返回 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 STREAM_EVENT_NAMES,
7 applyRuntimeEvent,
8 applySnapshot,
9 createThreadState,
10 eventStreamUrl,
11 restoreDraft,
12 runtimeEventContinuity,
13 saveDraft,
14 setSafeText,
15 snapshotThenSubscribe,
16 } from "../src/runtime_web/app.mjs";
17
18 function snapshot(threadId = "thread-a", latestSeq = 7) {
19 return {
20 thread: { id: threadId, title: "Test", model: "test", mode: "agent" },
21 turns: [{ id: "turn-1", status: "in_progress" }],
22 items: [
23 {
24 id: "item-1",
25 turn_id: "turn-1",
26 kind: "agent_message",
27 status: "in_progress",
28 summary: "",
29 detail: "Hello",
30 },
31 ],
32 latest_seq: latestSeq,
33 };
34 }
35
36 function runtimeEvent(sequence, event, payload = {}, overrides = {}) {
37 return {
38 schema_version: 1,
39 seq: sequence,
40 event,
41 kind: event,
42 thread_id: "thread-a",
43 turn_id: "turn-1",
44 item_id: null,
45 payload,
46 ...overrides,
47 };
48 }
49
50 function cssDeclarations(styles, selectorPattern) {
51 const match = styles.match(new RegExp(`${selectorPattern}\\s*\\{([^}]*)\\}`));
52 assert.ok(match, `missing CSS rule matching ${selectorPattern}`);
53 return match[1];
54 }
55
56 test("embedded web client uses the Blue Stage semantic palette", async () => {
57 const [styles, html] = await Promise.all([
58 readFile(new URL("../src/runtime_web/styles.css", import.meta.url), "utf8"),
59 readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"),
60 ]);
61
62 for (const token of [
63 "--ink-0: #03070d",
64 "--ink-1: #08111c",
65 "--ink-2: #0e1729",
66 "--text: #f6f2e8",
67 "--action: #6aaef2",
68 "--human: #f6c453",
69 "--live: #4fd1c5",
70 "--warning: #ff7a59",
71 "--danger: #ff86b2",
72 "--ok: #9bd66f",
73 ]) {
74 assert.match(styles, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
75 }
76 assert.match(
77 cssDeclarations(styles, "\\.primary-button,\\s*\\.send-button"),
78 /background: var\(--action\)/,
79 );
80 assert.match(
81 cssDeclarations(styles, "\\.status-pip\\.running"),
82 /background: var\(--live\)/,
83 );
84 assert.match(
85 cssDeclarations(styles, "\\.message\\.user \\.message-body"),
86 /background: rgba\(246, 196, 83/,
87 );
88 assert.match(
89 cssDeclarations(styles, "\\.attention-card"),
90 /border: 1px solid rgba\(246, 196, 83/,
91 );
92 assert.match(
93 cssDeclarations(styles, "\\.status-banner"),
94 /color: var\(--warning\)/,
95 );
96 assert.match(
97 cssDeclarations(styles, "\\.connection-dot\\.ready"),
98 /background: var\(--ok\)/,
99 );
100 assert.match(html, /name="theme-color" content="#03070d"/);
101 });
102
103 test("loads a consistent snapshot before subscribing from latest_seq", async () => {
104 const state = createThreadState("thread-a");
105 const order = [];
106 const subscribed = await snapshotThenSubscribe({
107 state,
108 threadId: "thread-a",
109 loadSnapshot: async () => {
110 order.push("snapshot");
111 return snapshot("thread-a", 42);
112 },
113 subscribe: (threadId, sequence) => order.push(`subscribe:${threadId}:${sequence}`),
114 });
115
116 assert.equal(subscribed, true);
117 assert.deepEqual(order, ["snapshot", "subscribe:thread-a:42"]);
118 assert.equal(state.latestSeq, 42);
119 });
120
121 test("drops a stale snapshot selection without opening an event stream", async () => {
122 const state = createThreadState("thread-a");
123 let current = true;
124 let subscribed = false;
125 const result = await snapshotThenSubscribe({
126 state,
127 threadId: "thread-a",
128 loadSnapshot: async () => {
129 current = false;
130 return snapshot();
131 },
132 subscribe: () => {
133 subscribed = true;
134 },
135 isCurrent: () => current,
136 });
137 assert.equal(result, false);
138 assert.equal(subscribed, false);
139 });
140
141 test("reconnect cursor advances monotonically and duplicate or stale-thread events are ignored", () => {
142 const state = createThreadState("thread-a");
143 assert.equal(applySnapshot(state, snapshot("thread-a", 7)), true);
144
145 assert.equal(
146 applyRuntimeEvent(
147 state,
148 runtimeEvent(8, "item.delta", { delta: " world", kind: "agent_message" }, { item_id: "item-1" }),
149 ),
150 true,
151 );
152 assert.equal(
153 applyRuntimeEvent(
154 state,
155 runtimeEvent(8, "item.delta", { delta: " duplicate", kind: "agent_message" }, { item_id: "item-1" }),
156 ),
157 false,
158 );
159 assert.equal(
160 applyRuntimeEvent(state, runtimeEvent(99, "turn.completed", {}, { thread_id: "thread-b" })),
161 false,
162 );
163 assert.equal(state.items.get("item-1").detail, "Hello world");
164 assert.equal(state.latestSeq, 8);
165 assert.equal(eventStreamUrl("thread-a", state.latestSeq), "/v1/threads/thread-a/events?since_seq=8");
166 });
167
168 test("uses the stream predecessor cursor to detect real gaps without assuming global sequences are contiguous", () => {
169 const state = createThreadState("thread-a");
170 applySnapshot(state, snapshot("thread-a", 7));
171
172 const interleaved = runtimeEvent(
173 12,
174 "item.delta",
175 { delta: " after other threads", kind: "agent_message" },
176 { item_id: "item-1", previous_seq: 7 },
177 );
178 assert.equal(runtimeEventContinuity(state, interleaved), "next");
179 assert.equal(applyRuntimeEvent(state, interleaved), true);
180 assert.equal(state.latestSeq, 12);
181
182 const gap = runtimeEvent(
183 15,
184 "approval.required",
185 { approval_id: "approval-missed", tool_name: "exec_shell" },
186 { previous_seq: 14 },
187 );
188 assert.equal(runtimeEventContinuity(state, gap), "gap");
189 assert.equal(applyRuntimeEvent(state, gap), false);
190 assert.equal(state.latestSeq, 12);
191 assert.equal(state.approvals.has("approval-missed"), false);
192 });
193
194 test("registers the full emitted Runtime vocabulary and advances continuity for every event", async () => {
195 const runtimeSource = await readFile(
196 new URL("../src/runtime_threads.rs", import.meta.url),
197 "utf8",
198 );
199 const emittedNames = new Set(
200 [...runtimeSource.matchAll(
201 /"((?:thread|turn|item|approval|user_input|sandbox|agent|tool_call)\.[a-z_]+)"/g,
202 )].map((match) => match[1]),
203 );
204 assert.deepEqual(new Set(STREAM_EVENT_NAMES), emittedNames);
205 assert.equal(STREAM_EVENT_NAMES.includes("thread.created"), false);
206
207 const state = createThreadState("thread-a");
208 applySnapshot(state, snapshot("thread-a", 7));
209 let previousSeq = 7;
210 for (const eventName of STREAM_EVENT_NAMES) {
211 const sequence = previousSeq + 2;
212 const envelope = runtimeEvent(sequence, eventName, {}, { previous_seq: previousSeq });
213 assert.equal(runtimeEventContinuity(state, envelope), "next", eventName);
214 assert.equal(applyRuntimeEvent(state, envelope), true, eventName);
215 assert.equal(state.latestSeq, sequence, eventName);
216 previousSeq = sequence;
217 }
218 });
219
220 test("gap recovery snapshot restores approval and user-input attention before resubscribing", async () => {
221 const state = createThreadState("thread-a");
222 applySnapshot(state, snapshot("thread-a", 7));
223 const subscriptions = [];
224
225 const recovered = await snapshotThenSubscribe({
226 state,
227 threadId: "thread-a",
228 loadSnapshot: async () => ({
229 ...snapshot("thread-a", 15),
230 pending_approvals: [{
231 id: "approval-recovered",
232 turn_id: "turn-1",
233 tool_name: "exec_command",
234 description: "Run a local check",
235 }],
236 pending_user_inputs: [{
237 id: "input-recovered",
238 turn_id: "turn-1",
239 request: { questions: [{ id: "choice", question: "Continue?", options: [] }] },
240 }],
241 pending_dynamic_tool_calls: [{
242 thread_id: "thread-a",
243 turn_id: "turn-1",
244 call_id: "call-recovered",
245 namespace: "bench",
246 tool: "lookup",
247 arguments: { id: "7" },
248 }],
249 }),
250 subscribe: (threadId, sequence) => subscriptions.push([threadId, sequence]),
251 });
252
253 assert.equal(recovered, true);
254 assert.equal(state.approvals.size, 1);
255 assert.equal(state.approvals.has("approval-recovered"), true);
256 assert.equal(state.userInputs.size, 1);
257 assert.equal(state.userInputs.has("input-recovered"), true);
258 assert.equal(state.dynamicToolCalls.size, 1);
259 assert.equal(state.dynamicToolCalls.get("call-recovered").tool, "lookup");
260 assert.deepEqual(subscriptions, [["thread-a", 15]]);
261
262 const duplicate = runtimeEvent(
263 15,
264 "approval.required",
265 { approval_id: "approval-recovered", tool_name: "exec_command" },
266 { previous_seq: 14 },
267 );
268 assert.equal(applyRuntimeEvent(state, duplicate), false);
269 assert.equal(state.approvals.size, 1);
270 });
271
272 test("assembles deltas and replaces the live item with its settled receipt", () => {
273 const state = createThreadState("thread-a");
274 applySnapshot(state, { ...snapshot(), items: [], latest_seq: 1 });
275 applyRuntimeEvent(
276 state,
277 runtimeEvent(2, "item.delta", { delta: "one", kind: "agent_message" }, { item_id: "item-new" }),
278 );
279 applyRuntimeEvent(
280 state,
281 runtimeEvent(3, "item.delta", { delta: " two", kind: "agent_message" }, { item_id: "item-new" }),
282 );
283 assert.equal(state.items.get("item-new").detail, "one two");
284
285 applyRuntimeEvent(
286 state,
287 runtimeEvent(
288 4,
289 "item.completed",
290 {
291 item: {
292 id: "item-new",
293 turn_id: "turn-1",
294 kind: "agent_message",
295 status: "completed",
296 summary: "one two",
297 detail: "one two",
298 },
299 },
300 { item_id: "item-new" },
301 ),
302 );
303 assert.equal(state.items.get("item-new").status, "completed");
304 assert.deepEqual(state.itemOrder, ["item-new"]);
305
306 applyRuntimeEvent(
307 state,
308 runtimeEvent(5, "item.delta", { delta: "partial", kind: "tool_call" }, { item_id: "item-stop" }),
309 );
310 applyRuntimeEvent(
311 state,
312 runtimeEvent(
313 6,
314 "item.interrupted",
315 {
316 item: {
317 id: "item-stop",
318 turn_id: "turn-1",
319 kind: "tool_call",
320 status: "interrupted",
321 summary: "Interrupted",
322 detail: "partial",
323 },
324 },
325 { item_id: "item-stop" },
326 ),
327 );
328 assert.equal(state.items.get("item-stop").status, "interrupted");
329 assert.deepEqual(state.itemOrder, ["item-new", "item-stop"]);
330 });
331
332 test("projects agent lifecycle receipts live and settles them without a snapshot reload", () => {
333 const state = createThreadState("thread-a");
334 applySnapshot(state, { ...snapshot(), items: [], latest_seq: 1 });
335
336 const agentItem = (status, summary) => ({
337 id: "item-agent",
338 turn_id: "turn-1",
339 kind: "status",
340 status,
341 summary,
342 detail: summary,
343 });
344 applyRuntimeEvent(
345 state,
346 runtimeEvent(2, "agent.spawned", { item: agentItem("in_progress", "Agent spawned") }),
347 );
348 assert.equal(state.items.get("item-agent").status, "in_progress");
349 assert.deepEqual(state.itemOrder, ["item-agent"]);
350
351 applyRuntimeEvent(
352 state,
353 runtimeEvent(3, "agent.progress", { item: agentItem("in_progress", "Agent checking") }),
354 );
355 applyRuntimeEvent(
356 state,
357 runtimeEvent(4, "agent.completed", { item: agentItem("completed", "Agent completed") }),
358 );
359 assert.equal(state.items.get("item-agent").status, "completed");
360 assert.equal(state.items.get("item-agent").summary, "Agent completed");
361 assert.deepEqual(state.itemOrder, ["item-agent"]);
362
363 applyRuntimeEvent(
364 state,
365 runtimeEvent(5, "agent.list", {
366 item: {
367 id: "item-agent-list",
368 turn_id: "turn-1",
369 kind: "status",
370 status: "completed",
371 summary: "Agent list refreshed",
372 detail: "Agent list refreshed",
373 },
374 }),
375 );
376 assert.equal(state.items.get("item-agent-list").status, "completed");
377 assert.deepEqual(state.itemOrder, ["item-agent", "item-agent-list"]);
378 assert.equal(state.latestSeq, 5);
379 });
380
381 test("tracks approval and user-input attention until each is resolved", () => {
382 const state = createThreadState("thread-a");
383 applySnapshot(state, snapshot());
384 applyRuntimeEvent(
385 state,
386 runtimeEvent(8, "approval.required", { approval_id: "approval-1", tool_name: "exec_shell" }),
387 );
388 applyRuntimeEvent(
389 state,
390 runtimeEvent(9, "user_input.required", {
391 id: "input-1",
392 request: { questions: [{ id: "choice", question: "Choose?", options: [] }] },
393 }),
394 );
395 assert.equal(state.approvals.has("approval-1"), true);
396 assert.equal(state.userInputs.has("input-1"), true);
397
398 applyRuntimeEvent(
399 state,
400 runtimeEvent(10, "approval.decided", { approval_id: "approval-1", decision: "allow" }),
401 );
402 assert.equal(state.approvals.has("approval-1"), false);
403 assert.equal(state.userInputs.has("input-1"), true);
404
405 applyRuntimeEvent(
406 state,
407 runtimeEvent(11, "user_input.answered", { input_id: "input-1" }),
408 );
409 assert.equal(state.userInputs.has("input-1"), false);
410 });
411
412 test("hydrates pending attention from a reload snapshot and clears cancellation events", () => {
413 const state = createThreadState("thread-a");
414 const detail = {
415 ...snapshot(),
416 pending_approvals: [{
417 id: "approval-reload",
418 turn_id: "turn-1",
419 tool_name: "exec_command",
420 description: "Run a local check",
421 }],
422 pending_user_inputs: [{
423 id: "input-reload",
424 turn_id: "turn-1",
425 request: { questions: [{ id: "choice", question: "Continue?", options: [] }] },
426 }],
427 };
428
429 assert.equal(applySnapshot(state, detail), true);
430 assert.equal(state.approvals.get("approval-reload").tool_name, "exec_command");
431 assert.equal(state.userInputs.get("input-reload").turn_id, "turn-1");
432
433 applyRuntimeEvent(
434 state,
435 runtimeEvent(8, "user_input.canceled", { id: "input-reload", terminal: true }),
436 );
437 assert.equal(state.userInputs.has("input-reload"), false);
438 });
439
440 test("turn completion defensively clears attention owned by that turn", () => {
441 const state = createThreadState("thread-a");
442 assert.equal(applySnapshot(state, {
443 ...snapshot(),
444 pending_approvals: [{ id: "approval-terminal", turn_id: "turn-1" }],
445 pending_user_inputs: [{ id: "input-terminal", turn_id: "turn-1", request: { questions: [] } }],
446 pending_dynamic_tool_calls: [{ call_id: "call-terminal", turn_id: "turn-1", tool: "lookup" }],
447 }), true);
448 state.approvals.set("approval-other", { id: "approval-other", turn_id: "turn-other" });
449 state.userInputs.set("input-other", { id: "input-other", turn_id: "turn-other" });
450 state.dynamicToolCalls.set("call-other", { call_id: "call-other", turn_id: "turn-other" });
451
452 assert.equal(applyRuntimeEvent(
453 state,
454 runtimeEvent(8, "turn.completed", { turn: { id: "turn-1", status: "completed" } }),
455 ), true);
456 assert.equal(state.approvals.has("approval-terminal"), false);
457 assert.equal(state.userInputs.has("input-terminal"), false);
458 assert.equal(state.dynamicToolCalls.has("call-terminal"), false);
459 assert.equal(state.approvals.has("approval-other"), true);
460 assert.equal(state.userInputs.has("input-other"), true);
461 assert.equal(state.dynamicToolCalls.has("call-other"), true);
462 });
463
464 test("dynamic tool calls hydrate and disappear exactly once across terminal variants", () => {
465 const state = createThreadState("thread-a");
466 assert.equal(applySnapshot(state, {
467 ...snapshot(),
468 pending_dynamic_tool_calls: [{
469 thread_id: "thread-a",
470 turn_id: "turn-1",
471 call_id: "call-snapshot",
472 tool: "snapshot_lookup",
473 arguments: { id: "snapshot" },
474 }],
475 }), true);
476 assert.equal(state.dynamicToolCalls.get("call-snapshot").tool, "snapshot_lookup");
477
478 assert.equal(applyRuntimeEvent(
479 state,
480 runtimeEvent(8, "tool_call.requested", {
481 thread_id: "thread-a",
482 turn_id: "turn-1",
483 call_id: "call-live",
484 tool: "live_lookup",
485 arguments: { id: "live" },
486 }),
487 ), true);
488 assert.equal(state.dynamicToolCalls.size, 2);
489
490 assert.equal(applyRuntimeEvent(
491 state,
492 runtimeEvent(9, "tool_call.resolved", { call_id: "call-snapshot", status: "resolved" }),
493 ), true);
494 assert.equal(state.dynamicToolCalls.has("call-snapshot"), false);
495 assert.equal(applyRuntimeEvent(
496 state,
497 runtimeEvent(9, "tool_call.resolved", { call_id: "call-snapshot", status: "resolved" }),
498 ), false);
499 assert.equal(state.dynamicToolCalls.size, 1);
500
501 assert.equal(applyRuntimeEvent(
502 state,
503 runtimeEvent(10, "tool_call.canceled", { call_id: "call-live", status: "canceled" }),
504 ), true);
505 assert.equal(state.dynamicToolCalls.size, 0);
506
507 assert.equal(applyRuntimeEvent(
508 state,
509 runtimeEvent(11, "tool_call.requested", {
510 call_id: "call-timeout",
511 tool: "slow_lookup",
512 arguments: {},
513 }),
514 ), true);
515 assert.equal(state.dynamicToolCalls.has("call-timeout"), true);
516 assert.equal(applyRuntimeEvent(
517 state,
518 runtimeEvent(12, "tool_call.timeout", { call_id: "call-timeout", status: "timeout" }),
519 ), true);
520 assert.equal(state.dynamicToolCalls.size, 0);
521 });
522
523 test("preserves drafts per thread without browser storage", () => {
524 const drafts = new Map();
525 saveDraft(drafts, "thread-a", "draft A");
526 saveDraft(drafts, "thread-b", "draft B");
527 assert.equal(restoreDraft(drafts, "thread-a"), "draft A");
528 assert.equal(restoreDraft(drafts, "thread-b"), "draft B");
529 saveDraft(drafts, "thread-a", "");
530 assert.equal(restoreDraft(drafts, "thread-a"), "");
531 });
532
533 test("renders hostile Runtime text only through the textContent sink", async () => {
534 const hostile = `<img src=x onerror=alert(1)><script>alert(2)</script>`;
535 const fakeElement = { textContent: "" };
536 setSafeText(fakeElement, hostile);
537 assert.equal(fakeElement.textContent, hostile);
538
539 const source = await readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8");
540 assert.equal(source.includes("inner" + "HTML"), false);
541 assert.equal(source.includes("insertAdjacent" + "HTML"), false);
542 assert.equal(source.includes("local" + "Storage"), false);
543 assert.equal(source.includes("session" + "Storage"), false);
544 });
545
545 lines Plain Text