@hellcoder/companion 0.116.0 → 0.116.1-preview.20260814041931.d191f06

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/assets/{AgentsPage-BBi92OuO.js → AgentsPage-BAalV1v6.js} +5 -5
  2. package/dist/assets/CronManager-B2Vo1YmB.js +1 -0
  3. package/dist/assets/{DashboardPage-BvuDoRlp.js → DashboardPage-BOvDiP1W.js} +1 -1
  4. package/dist/assets/{IntegrationsPage-CK_ThzWg.js → IntegrationsPage-kOXLH5KU.js} +1 -1
  5. package/dist/assets/{LinearOAuthSettingsPage-InTf-E7u.js → LinearOAuthSettingsPage-H-uUJ-ub.js} +1 -1
  6. package/dist/assets/{LinearSettingsPage-BwjC9Vd-.js → LinearSettingsPage-CD_x4AGr.js} +1 -1
  7. package/dist/assets/MagicUIDashboard-9Jgiv4rb.js +1119 -0
  8. package/dist/assets/MagicUIView-BLEcS9XO.js +1 -0
  9. package/dist/assets/{Playground-DlzomEOu.js → Playground-QLrd9q16.js} +10 -9
  10. package/dist/assets/{PromptsPage-hRZhLUYR.js → PromptsPage-11eJ6ak7.js} +1 -1
  11. package/dist/assets/{RunsPage-zsVmbFoi.js → RunsPage-Bz3udIL2.js} +1 -1
  12. package/dist/assets/{SandboxManager-Bq_aXA1z.js → SandboxManager-Dv47pbC6.js} +1 -1
  13. package/dist/assets/SettingsPage-C_2ftG-C.js +1 -0
  14. package/dist/assets/{TailscalePage-fE7Z9uiF.js → TailscalePage-DzMX_7XA.js} +1 -1
  15. package/dist/assets/index-DSCRqqpK.css +1 -0
  16. package/dist/assets/index-DspbwgSL.js +140 -0
  17. package/dist/assets/{sw-register-Cjt2BQTW.js → sw-register-CarYQEeI.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/dist/sw.js +1 -1
  20. package/package.json +4 -1
  21. package/server/async-message-queue.ts +38 -0
  22. package/server/auto-namer.test.ts +8 -0
  23. package/server/claude-sdk-adapter.ts +1 -36
  24. package/server/event-bus-types.ts +32 -1
  25. package/server/index.ts +7 -0
  26. package/server/linear-agent-bridge.ts +6 -72
  27. package/server/linear-connections.test.ts +8 -0
  28. package/server/magic-ui-ops.test.ts +239 -0
  29. package/server/magic-ui-ops.ts +365 -0
  30. package/server/magic-ui-settings.test.ts +56 -0
  31. package/server/magic-ui-settings.ts +38 -0
  32. package/server/magic-ui-store.ts +94 -0
  33. package/server/magic-ui-types.ts +138 -0
  34. package/server/magic-ui-watcher.test.ts +288 -0
  35. package/server/magic-ui-watcher.ts +623 -0
  36. package/server/message-extract.ts +79 -0
  37. package/server/routes/settings-routes.ts +22 -0
  38. package/server/routes.test.ts +59 -0
  39. package/server/session-types.ts +15 -1
  40. package/server/settings-manager.test.ts +6 -0
  41. package/server/settings-manager.ts +24 -1
  42. package/server/ws-bridge-browser-ingest.ts +1 -0
  43. package/server/ws-bridge-codex.test.ts +12 -0
  44. package/server/ws-bridge-controls.ts +9 -0
  45. package/server/ws-bridge.ts +65 -0
  46. package/dist/assets/CronManager-DxLaNh9T.js +0 -1
  47. package/dist/assets/SettingsPage-BvrrlEKj.js +0 -1
  48. package/dist/assets/index--SnXduR0.js +0 -139
  49. package/dist/assets/index-DcQxHfE7.css +0 -1
@@ -0,0 +1,38 @@
1
+ import { getSettings } from "./settings-manager.js";
2
+ import { isClaudeCliAvailable } from "./claude-cli-runner.js";
3
+ import type { SessionState } from "./session-types.js";
4
+
5
+ export interface EffectiveMagicUiSettings {
6
+ /**
7
+ * Whether MagicUI is effectively on for this session: the per-session
8
+ * opt-in wins, falling back to the global default. Always false when the
9
+ * Claude CLI is unavailable — the watcher rides the CLI subscription
10
+ * login and cannot run without it (never the Anthropic API key).
11
+ */
12
+ enabled: boolean;
13
+ /** Model for the watcher session. Cheap-by-default (Haiku). */
14
+ model: string;
15
+ }
16
+
17
+ /**
18
+ * Resolve effective MagicUI settings for a session.
19
+ *
20
+ * Unlike AI validation (where the global setting is a default that sessions
21
+ * inherit), MagicUI is deliberately availability-gated AND opt-in:
22
+ * the global toggle makes the feature available to users, but a session only
23
+ * runs the watcher when the user explicitly enabled it for that session.
24
+ * (A global default-on would silently start a Haiku watcher for every
25
+ * session — surprise token burn.)
26
+ */
27
+ export function getEffectiveMagicUi(
28
+ sessionState: SessionState,
29
+ ): EffectiveMagicUiSettings {
30
+ const global = getSettings();
31
+ return {
32
+ enabled:
33
+ global.magicUiEnabled
34
+ && sessionState.magicUiActive === true
35
+ && isClaudeCliAvailable(),
36
+ model: global.magicUiModel,
37
+ };
38
+ }
@@ -0,0 +1,94 @@
1
+ // ─── MagicUI dashboard state persistence ─────────────────────────────────────
2
+ //
3
+ // One JSON file per session under ~/.companion/magic-ui/. Debounced writes
4
+ // (same 150ms pattern as session-store.ts) because the watcher can emit
5
+ // several patches in quick succession. State survives server restarts so a
6
+ // reload / late-joining browser instantly sees the current dashboard.
7
+
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { COMPANION_HOME } from "./paths.js";
11
+ import { emptyMagicUiState, type MagicUiDashboardState } from "./magic-ui-types.js";
12
+
13
+ const WRITE_DEBOUNCE_MS = 150;
14
+
15
+ export class MagicUiStore {
16
+ private readonly dir: string;
17
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
18
+ private pending = new Map<string, MagicUiDashboardState>();
19
+
20
+ constructor(dir?: string) {
21
+ this.dir = dir || join(COMPANION_HOME, "magic-ui");
22
+ }
23
+
24
+ private fileFor(sessionId: string): string {
25
+ // Session ids are UUIDs, but never trust them as path components.
26
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
27
+ return join(this.dir, `${safe}.json`);
28
+ }
29
+
30
+ load(sessionId: string, now: number): MagicUiDashboardState {
31
+ try {
32
+ const file = this.fileFor(sessionId);
33
+ if (existsSync(file)) {
34
+ const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial<MagicUiDashboardState>;
35
+ if (raw && typeof raw.version === "number" && raw.slots && typeof raw.slots === "object") {
36
+ return {
37
+ ...emptyMagicUiState(now),
38
+ ...raw,
39
+ // A freshly loaded dashboard is not being driven yet.
40
+ status: "stopped",
41
+ } as MagicUiDashboardState;
42
+ }
43
+ }
44
+ } catch {
45
+ // Corrupt state file → start fresh; the watcher rebuilds from its seed.
46
+ }
47
+ return emptyMagicUiState(now);
48
+ }
49
+
50
+ save(sessionId: string, state: MagicUiDashboardState): void {
51
+ this.pending.set(sessionId, state);
52
+ if (this.timers.has(sessionId)) return;
53
+ this.timers.set(
54
+ sessionId,
55
+ setTimeout(() => {
56
+ this.timers.delete(sessionId);
57
+ this.flush(sessionId);
58
+ }, WRITE_DEBOUNCE_MS),
59
+ );
60
+ }
61
+
62
+ flush(sessionId: string): void {
63
+ const state = this.pending.get(sessionId);
64
+ if (!state) return;
65
+ this.pending.delete(sessionId);
66
+ try {
67
+ mkdirSync(this.dir, { recursive: true });
68
+ writeFileSync(this.fileFor(sessionId), JSON.stringify(state), "utf-8");
69
+ } catch (err) {
70
+ console.warn(`[magic-ui] Failed to persist dashboard for ${sessionId}:`, err);
71
+ }
72
+ }
73
+
74
+ flushAll(): void {
75
+ for (const sessionId of [...this.pending.keys()]) {
76
+ const timer = this.timers.get(sessionId);
77
+ if (timer) clearTimeout(timer);
78
+ this.timers.delete(sessionId);
79
+ this.flush(sessionId);
80
+ }
81
+ }
82
+
83
+ remove(sessionId: string): void {
84
+ const timer = this.timers.get(sessionId);
85
+ if (timer) clearTimeout(timer);
86
+ this.timers.delete(sessionId);
87
+ this.pending.delete(sessionId);
88
+ try {
89
+ rmSync(this.fileFor(sessionId), { force: true });
90
+ } catch {
91
+ // best-effort
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,138 @@
1
+ // ─── MagicUI types ───────────────────────────────────────────────────────────
2
+ //
3
+ // Shared vocabulary between the server-side Haiku watcher, the canonical
4
+ // dashboard state it maintains, and the browser/iframe runtime that renders
5
+ // it. The watcher never authors whole documents: it emits small patch
6
+ // operations (MagicUiOp) that the server validates, sanitizes and folds into
7
+ // a MagicUiDashboardState — the single source of truth that late-joining
8
+ // browsers receive as a snapshot.
9
+ //
10
+ // Imported by both web/server and web/src (same pattern as session-types.ts).
11
+
12
+ export type MagicUiArea = "hero" | "main" | "side" | "footer";
13
+
14
+ export type MagicUiTrend = "up" | "down" | "flat";
15
+
16
+ export interface MagicChartSpec {
17
+ kind: "bar" | "line" | "donut" | "sparkline";
18
+ title?: string;
19
+ labels?: string[];
20
+ series: Array<{ label: string; data: number[] }>;
21
+ }
22
+
23
+ export interface MagicUiStat {
24
+ label: string;
25
+ value: string;
26
+ trend?: MagicUiTrend;
27
+ }
28
+
29
+ /** A copyable/expandable script or command the user is asked to run. */
30
+ export interface MagicUiSnippet {
31
+ title: string;
32
+ language?: string;
33
+ code: string;
34
+ }
35
+
36
+ /**
37
+ * An unresolved point where the main agent is waiting on the user —
38
+ * a question, a manual step ("run this and report back"), or a blocker.
39
+ * Rendered prominently in the runtime-owned Open Points panel until a
40
+ * resolve_item op clears it.
41
+ */
42
+ export interface MagicUiOpenItem {
43
+ id: string;
44
+ ts: number;
45
+ text: string;
46
+ kind: "action" | "question" | "blocker";
47
+ }
48
+
49
+ export interface MagicUiDecisionEntry {
50
+ id: string;
51
+ ts: number;
52
+ /**
53
+ * user — the user answered a permission prompt / question
54
+ * ai_auto — AI validation auto-resolved a permission
55
+ * agent — a notable choice the main agent made (watcher-observed)
56
+ */
57
+ source: "user" | "ai_auto" | "agent";
58
+ title: string;
59
+ detail: string;
60
+ behavior?: "allow" | "deny";
61
+ }
62
+
63
+ /** One named region of the dashboard grid. Exactly one content kind is set. */
64
+ export interface MagicUiSlot {
65
+ title?: string;
66
+ html?: string;
67
+ chart?: MagicChartSpec;
68
+ stat?: MagicUiStat;
69
+ snippet?: MagicUiSnippet;
70
+ updatedAt: number;
71
+ }
72
+
73
+ export interface MagicUiLayoutEntry {
74
+ slot: string;
75
+ area: MagicUiArea;
76
+ span?: 1 | 2 | 3;
77
+ }
78
+
79
+ export type MagicUiStatus = "live" | "degraded" | "stopped";
80
+
81
+ /** Patch operations the watcher model may emit (one JSON array per turn). */
82
+ export type MagicUiOp =
83
+ | { op: "set_slot"; slot: string; html: string; title?: string; area?: MagicUiArea; span?: 1 | 2 | 3 }
84
+ | { op: "remove_slot"; slot: string }
85
+ | { op: "layout"; slots: MagicUiLayoutEntry[] }
86
+ | { op: "chart"; slot: string; spec: MagicChartSpec; title?: string; area?: MagicUiArea; span?: 1 | 2 | 3 }
87
+ | { op: "stat"; slot: string; label: string; value: string; trend?: MagicUiTrend; area?: MagicUiArea; span?: 1 | 2 | 3 }
88
+ | { op: "snippet"; slot: string; title: string; code: string; language?: string; area?: MagicUiArea; span?: 1 | 2 | 3 }
89
+ | { op: "open_item"; id: string; text: string; kind?: "action" | "question" | "blocker" }
90
+ | { op: "resolve_item"; id: string }
91
+ | { op: "decision_log"; title: string; detail: string }
92
+ | { op: "session_summary"; text: string };
93
+
94
+ export interface MagicUiDashboardState {
95
+ /** Monotonic version, bumped once per applied patch batch. */
96
+ version: number;
97
+ slots: Record<string, MagicUiSlot>;
98
+ layout: MagicUiLayoutEntry[];
99
+ /** Newest first; server-owned entries (user/ai_auto) + watcher-observed agent ones. */
100
+ decisionLog: MagicUiDecisionEntry[];
101
+ /** Unresolved user-facing points, newest first. */
102
+ openItems: MagicUiOpenItem[];
103
+ /** Rolling watcher-maintained summary; the seed for watcher restarts. Not rendered. */
104
+ sessionSummary: string;
105
+ status: MagicUiStatus;
106
+ updatedAt: number;
107
+ }
108
+
109
+ export function emptyMagicUiState(now: number): MagicUiDashboardState {
110
+ return {
111
+ version: 0,
112
+ slots: {},
113
+ layout: [],
114
+ decisionLog: [],
115
+ openItems: [],
116
+ sessionSummary: "",
117
+ status: "live",
118
+ updatedAt: now,
119
+ };
120
+ }
121
+
122
+ // ── Budgets (enforced server-side in magic-ui-ops.ts) ────────────────────────
123
+
124
+ /** Hard cap on named slots; the prompt asks the model to stay well under. */
125
+ export const MAGIC_UI_MAX_SLOTS = 24;
126
+ /** Per-slot sanitized HTML budget (chars). */
127
+ export const MAGIC_UI_MAX_HTML_CHARS = 8_192;
128
+ /** Per-snippet code budget (chars). */
129
+ export const MAGIC_UI_MAX_SNIPPET_CHARS = 8_192;
130
+ /** Max ops applied from a single watcher turn; the rest is dropped. */
131
+ export const MAGIC_UI_MAX_OPS_PER_TURN = 30;
132
+ /** Decision log retention (newest first). */
133
+ export const MAGIC_UI_MAX_DECISIONS = 50;
134
+ /** Open point retention (newest first). */
135
+ export const MAGIC_UI_MAX_OPEN_ITEMS = 30;
136
+ /** Chart size guards. */
137
+ export const MAGIC_UI_MAX_CHART_SERIES = 6;
138
+ export const MAGIC_UI_MAX_CHART_POINTS = 100;
@@ -0,0 +1,288 @@
1
+ // Tests for the MagicUI watcher: digest building, flush triggers, the
2
+ // one-turn-in-flight merge discipline, server-generated decision log
3
+ // entries, and manager opt-in reconciliation.
4
+ //
5
+ // The Agent SDK query() is replaced by an injectable fake that consumes the
6
+ // watcher's streaming input queue and replies per test script — no network,
7
+ // no subprocess.
8
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
9
+ import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
10
+ import {
11
+ buildDigest,
12
+ MagicUiWatcher,
13
+ MagicUiWatcherManager,
14
+ } from "./magic-ui-watcher.js";
15
+ import { emptyMagicUiState, type MagicUiDashboardState } from "./magic-ui-types.js";
16
+ import type { MagicUiStore } from "./magic-ui-store.js";
17
+ import type { BrowserIncomingMessage, SessionState } from "./session-types.js";
18
+ import { companionBus } from "./event-bus.js";
19
+
20
+ vi.mock("./path-resolver.js", () => ({
21
+ resolveBinary: () => "/usr/bin/claude-fake",
22
+ getEnrichedPath: () => process.env.PATH ?? "",
23
+ }));
24
+
25
+ // Settings used by the manager's reconcile path.
26
+ const mockSettings = { magicUiEnabled: true, magicUiModel: "claude-haiku-4-5" };
27
+ vi.mock("./settings-manager.js", async (importOriginal) => {
28
+ const actual = await importOriginal<typeof import("./settings-manager.js")>();
29
+ return { ...actual, getSettings: () => ({ ...actual.getSettings(), ...mockSettings }) };
30
+ });
31
+ vi.mock("./claude-cli-runner.js", () => ({
32
+ isClaudeCliAvailable: () => true,
33
+ runClaudePrompt: vi.fn(),
34
+ }));
35
+
36
+ function fakeStore(initial?: MagicUiDashboardState) {
37
+ return {
38
+ saved: [] as MagicUiDashboardState[],
39
+ load: vi.fn(() => initial ?? emptyMagicUiState(0)),
40
+ save: vi.fn(function (this: unknown, _id: string, state: MagicUiDashboardState) {
41
+ store.saved.push(state);
42
+ }),
43
+ flush: vi.fn(),
44
+ flushAll: vi.fn(),
45
+ remove: vi.fn(),
46
+ };
47
+ }
48
+ let store: ReturnType<typeof fakeStore>;
49
+
50
+ /** Fake SDK query: consumes the prompt queue; for each user message calls
51
+ * the responder and yields an assistant + result frame. */
52
+ function makeFakeQuery(responder: (text: string) => string | Promise<string>) {
53
+ const received: string[] = [];
54
+ const fn = (({ prompt }: { prompt: AsyncIterable<SDKUserMessage> }) => {
55
+ async function* gen() {
56
+ for await (const m of prompt) {
57
+ const text = typeof m.message.content === "string" ? m.message.content : "";
58
+ received.push(text);
59
+ const reply = await responder(text);
60
+ yield { type: "assistant", message: { role: "assistant", content: [{ type: "text", text: reply }] }, parent_tool_use_id: null };
61
+ yield { type: "result", subtype: "success" };
62
+ }
63
+ }
64
+ return gen();
65
+ }) as unknown as typeof import("@anthropic-ai/claude-agent-sdk").query;
66
+ return { fn, received };
67
+ }
68
+
69
+ function assistantMsg(text: string, tools: Array<{ name: string; input: Record<string, unknown> }> = []): BrowserIncomingMessage {
70
+ return {
71
+ type: "assistant",
72
+ message: {
73
+ role: "assistant",
74
+ content: [
75
+ ...(text ? [{ type: "text", text }] : []),
76
+ ...tools.map((t, i) => ({ type: "tool_use", id: `t${i}`, name: t.name, input: t.input })),
77
+ ],
78
+ },
79
+ parent_tool_use_id: null,
80
+ } as unknown as BrowserIncomingMessage;
81
+ }
82
+
83
+ const OPS_REPLY = '```json\n[{"op":"set_slot","slot":"hero","html":"<p>Refactoring auth</p>","area":"hero"}]\n```';
84
+
85
+ let broadcasts: Array<{ sessionId: string; msg: BrowserIncomingMessage }>;
86
+ function makeWatcher(responder: (text: string) => string | Promise<string>) {
87
+ const { fn, received } = makeFakeQuery(responder);
88
+ const watcher = new MagicUiWatcher("sess-1", {
89
+ broadcast: (sessionId, msg) => broadcasts.push({ sessionId, msg }),
90
+ store: store as unknown as MagicUiStore,
91
+ model: "claude-haiku-4-5",
92
+ queryFn: fn,
93
+ });
94
+ return { watcher, received };
95
+ }
96
+
97
+ async function waitFor(cond: () => boolean, ms = 2000): Promise<void> {
98
+ const start = Date.now();
99
+ while (!cond()) {
100
+ if (Date.now() - start > ms) throw new Error("waitFor timed out");
101
+ await new Promise((r) => setTimeout(r, 5));
102
+ }
103
+ }
104
+
105
+ beforeEach(() => {
106
+ broadcasts = [];
107
+ store = fakeStore();
108
+ mockSettings.magicUiEnabled = true;
109
+ });
110
+
111
+ afterEach(() => {
112
+ vi.restoreAllMocks();
113
+ });
114
+
115
+ describe("buildDigest", () => {
116
+ it("assembles a compact digest and enforces the size budget", () => {
117
+ const digest = buildDigest({
118
+ texts: ["Refactored the auth middleware", "x".repeat(1_000)],
119
+ tools: [{ name: "Edit", summary: "Edit(src/auth.ts)" }],
120
+ files: new Set(["src/auth.ts"]),
121
+ permissionLines: ["PENDING PERMISSION wanted: Bash — rm -rf dist"],
122
+ resultLine: "92s, 6 turns",
123
+ firstEventAt: 1,
124
+ }, 7);
125
+ expect(digest).toContain("TURN DIGEST #7");
126
+ expect(digest).toContain("Refactored the auth middleware");
127
+ expect(digest).toContain("Edit(src/auth.ts)");
128
+ expect(digest).toContain("files touched: src/auth.ts");
129
+ expect(digest).toContain("PENDING PERMISSION");
130
+ expect(digest).toContain("result: 92s, 6 turns");
131
+ // Long assistant text is truncated per-snippet
132
+ expect(digest).toContain("…");
133
+ expect(digest.length).toBeLessThanOrEqual(4_100);
134
+ });
135
+ });
136
+
137
+ describe("MagicUiWatcher", () => {
138
+ it("broadcasts the initial snapshot on start", () => {
139
+ const { watcher } = makeWatcher(() => "[]");
140
+ watcher.start();
141
+ expect(broadcasts.some((b) => b.msg.type === "magic_ui_state")).toBe(true);
142
+ watcher.stop();
143
+ });
144
+
145
+ it("flushes a digest on turn end and applies the reply ops", async () => {
146
+ const { watcher, received } = makeWatcher(() => OPS_REPLY);
147
+ watcher.start();
148
+ watcher.onAssistantMessage(assistantMsg("Working on auth", [{ name: "Edit", input: { file_path: "src/auth.ts" } }]));
149
+ watcher.onResult({ type: "result", data: { duration_ms: 5000, num_turns: 2 } } as unknown as BrowserIncomingMessage);
150
+
151
+ await waitFor(() => watcher.state.version > 0 && !!watcher.state.slots.hero);
152
+ expect(received[0]).toContain("Working on auth");
153
+ expect(received[0]).toContain("Edit(src/auth.ts)");
154
+ expect(watcher.state.slots.hero.html).toContain("Refactoring auth");
155
+ // persisted + broadcast
156
+ expect(store.save).toHaveBeenCalled();
157
+ expect(broadcasts.filter((b) => b.msg.type === "magic_ui_state").length).toBeGreaterThan(1);
158
+ watcher.stop();
159
+ });
160
+
161
+ it("flushes immediately when a permission arrives", async () => {
162
+ const { watcher, received } = makeWatcher(() => "[]");
163
+ watcher.start();
164
+ watcher.onPermissionRequested({
165
+ request_id: "r1",
166
+ tool_name: "Bash",
167
+ input: { command: "rm -rf dist" },
168
+ tool_use_id: "t1",
169
+ timestamp: 1,
170
+ });
171
+ await waitFor(() => received.length === 1);
172
+ expect(received[0]).toContain("PENDING PERMISSION wanted: Bash");
173
+ expect(received[0]).toContain("rm -rf dist");
174
+ watcher.stop();
175
+ });
176
+
177
+ it("keeps one turn in flight and merges digests queued behind it", async () => {
178
+ let releaseFirst: (v: string) => void = () => {};
179
+ let call = 0;
180
+ const { watcher, received } = makeWatcher((text) => {
181
+ call += 1;
182
+ if (call === 1) return new Promise<string>((resolve) => { releaseFirst = () => resolve("[]"); void text; });
183
+ return "[]";
184
+ });
185
+ watcher.start();
186
+ // First flush → in flight (responder blocks)
187
+ watcher.onAssistantMessage(assistantMsg("first chunk"));
188
+ watcher.onResult({ type: "result" } as unknown as BrowserIncomingMessage);
189
+ await waitFor(() => received.length === 1);
190
+ // Two more flushes while in flight → merged into ONE pending digest
191
+ watcher.onAssistantMessage(assistantMsg("second chunk"));
192
+ watcher.onResult({ type: "result" } as unknown as BrowserIncomingMessage);
193
+ watcher.onAssistantMessage(assistantMsg("third chunk"));
194
+ watcher.onResult({ type: "result" } as unknown as BrowserIncomingMessage);
195
+ expect(received.length).toBe(1);
196
+ releaseFirst("[]");
197
+ await waitFor(() => received.length === 2);
198
+ expect(received[1]).toContain("second chunk");
199
+ expect(received[1]).toContain("third chunk");
200
+ watcher.stop();
201
+ });
202
+
203
+ it("appends server-generated decision entries without involving the model", () => {
204
+ const { watcher } = makeWatcher(() => "[]");
205
+ watcher.start();
206
+ watcher.onPermissionResolved({
207
+ behavior: "allow",
208
+ resolvedBy: "user",
209
+ toolName: "AskUserQuestion",
210
+ answers: { "Which DB?": "Postgres" },
211
+ });
212
+ expect(watcher.state.decisionLog[0]).toMatchObject({
213
+ source: "user",
214
+ title: "Answered the agent's question",
215
+ behavior: "allow",
216
+ });
217
+ expect(watcher.state.decisionLog[0].detail).toContain("Which DB? → Postgres");
218
+
219
+ watcher.onPermissionResolved({ behavior: "deny", resolvedBy: "ai", toolName: "Bash", reason: "dangerous" });
220
+ expect(watcher.state.decisionLog[0]).toMatchObject({ source: "ai_auto", title: "Bash", behavior: "deny" });
221
+ watcher.stop();
222
+ });
223
+
224
+ it("ignores unparseable replies without corrupting state", async () => {
225
+ const { watcher, received } = makeWatcher(() => "sorry, I cannot do that");
226
+ watcher.start();
227
+ const versionBefore = watcher.state.version;
228
+ watcher.onAssistantMessage(assistantMsg("hello"));
229
+ watcher.onResult({ type: "result" } as unknown as BrowserIncomingMessage);
230
+ await waitFor(() => received.length === 1);
231
+ await new Promise((r) => setTimeout(r, 20));
232
+ expect(watcher.state.version).toBe(versionBefore);
233
+ watcher.stop();
234
+ });
235
+ });
236
+
237
+ describe("MagicUiWatcherManager", () => {
238
+ function makeManager(sessionState: SessionState | undefined) {
239
+ const { fn } = makeFakeQuery(() => "[]");
240
+ return new MagicUiWatcherManager({
241
+ broadcast: (sessionId, msg) => broadcasts.push({ sessionId, msg }),
242
+ getSessionState: () => sessionState,
243
+ store: store as unknown as MagicUiStore,
244
+ queryFn: fn,
245
+ });
246
+ }
247
+
248
+ const activeSession = { session_id: "sess-1", magicUiActive: true } as SessionState;
249
+
250
+ it("starts a watcher when the session opts in and stops it on opt-out", () => {
251
+ const manager = makeManager(activeSession);
252
+ companionBus.emit("magic-ui:setting-changed", { sessionId: "sess-1", magicUiActive: true });
253
+ expect(broadcasts.some((b) => b.msg.type === "magic_ui_state")).toBe(true);
254
+
255
+ broadcasts = [];
256
+ activeSession.magicUiActive = false;
257
+ companionBus.emit("magic-ui:setting-changed", { sessionId: "sess-1", magicUiActive: false });
258
+ // stop() broadcasts a final stopped-status snapshot
259
+ const last = broadcasts.filter((b) => b.msg.type === "magic_ui_state").pop();
260
+ expect((last?.msg as { state: MagicUiDashboardState }).state.status).toBe("stopped");
261
+ activeSession.magicUiActive = true;
262
+ manager.shutdown();
263
+ });
264
+
265
+ it("lazily starts a watcher when messages arrive for an opted-in session", async () => {
266
+ const manager = makeManager({ session_id: "sess-1", magicUiActive: true } as SessionState);
267
+ companionBus.emit("message:assistant", { sessionId: "sess-1", message: assistantMsg("hi") });
268
+ expect(broadcasts.some((b) => b.msg.type === "magic_ui_state")).toBe(true);
269
+ manager.shutdown();
270
+ });
271
+
272
+ it("does not start watchers for sessions that did not opt in", () => {
273
+ const manager = makeManager({ session_id: "sess-1", magicUiActive: null } as SessionState);
274
+ companionBus.emit("message:assistant", { sessionId: "sess-1", message: assistantMsg("hi") });
275
+ expect(broadcasts.length).toBe(0);
276
+ manager.shutdown();
277
+ });
278
+
279
+ it("serves persisted snapshots on sync requests even without an active watcher", () => {
280
+ const persisted: MagicUiDashboardState = { ...emptyMagicUiState(1), version: 4 };
281
+ store = fakeStore(persisted);
282
+ const manager = makeManager({ session_id: "sess-1", magicUiActive: null } as SessionState);
283
+ companionBus.emit("magic-ui:sync-requested", { sessionId: "sess-1" });
284
+ const snap = broadcasts.find((b) => b.msg.type === "magic_ui_state");
285
+ expect((snap?.msg as { state: MagicUiDashboardState }).state.version).toBe(4);
286
+ manager.shutdown();
287
+ });
288
+ });