@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2

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 (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for the user-resume-intent gate that protects sessionOrder from
3
+ * spurious mutation during bridge auto-reattach on dashboard reboot.
4
+ *
5
+ * The actual gate lives inside the `sessionManager.onChange` closure in
6
+ * `server.ts`. To keep this test focused and avoid spinning up a full
7
+ * server, we replicate the exact algorithm the closure runs and assert
8
+ * the four scenarios from `specs/session-filtering/spec.md`.
9
+ *
10
+ * The algorithm tested here is intentionally a verbatim copy of the
11
+ * server.ts implementation \u2014 if one drifts from the other, both this
12
+ * test and the production code are out of sync.
13
+ *
14
+ * See change: preserve-session-order-on-reboot.
15
+ */
16
+ import { describe, it, expect, beforeEach } from "vitest";
17
+ import { createPendingResumeIntentRegistry } from "../pending-resume-intent-registry.js";
18
+ import { createSessionOrderManager } from "../session-order-manager.js";
19
+ import type { PreferencesStore } from "../preferences-store.js";
20
+
21
+ // In-memory PreferencesStore mock matching the slice of the interface
22
+ // `sessionOrderManager` consumes.
23
+ function makePrefs(): PreferencesStore {
24
+ let order: Record<string, string[]> = {};
25
+ return {
26
+ getSessionOrder: () => order,
27
+ setSessionOrder: (o) => { order = o; },
28
+ getPinnedDirectories: () => [],
29
+ setPinnedDirectories: () => {},
30
+ pinDirectory: () => {},
31
+ unpinDirectory: () => {},
32
+ reorderPinnedDirs: () => {},
33
+ flush: () => {},
34
+ dispose: () => {},
35
+ };
36
+ }
37
+
38
+ interface BroadcastEvent {
39
+ type: "sessions_reordered";
40
+ cwd: string;
41
+ sessionIds: string[];
42
+ }
43
+
44
+ /**
45
+ * Executes the exact ended\u2192alive branch from `server.ts`'s onChange hook
46
+ * against the supplied state. Returns the broadcast that would have been
47
+ * emitted (or null when the branch returned early).
48
+ *
49
+ * Mirrors the post-`top-of-tier-on-status-change` semantics: user-intent
50
+ * resume calls `moveToFront` (always brings the id to the top) instead
51
+ * of insert-if-absent.
52
+ */
53
+ function endedToAlive(
54
+ sessionId: string,
55
+ cwd: string,
56
+ endedSessionIds: Set<string>,
57
+ pendingResumeIntents: ReturnType<typeof createPendingResumeIntentRegistry>,
58
+ sessionOrderManager: ReturnType<typeof createSessionOrderManager>,
59
+ ): BroadcastEvent | null {
60
+ // Mirror server.ts:onChange ended\u2192alive branch verbatim.
61
+ // Post-`differentiate-resume-intent-by-trigger`: 3-way switch on the
62
+ // consumed intent ("front" | "keep" | null).
63
+ endedSessionIds.delete(sessionId);
64
+ const intent = pendingResumeIntents.consume(sessionId);
65
+ if (intent === null) return null;
66
+ if (intent === "keep") return null; // dropped slot wins; no mutation, no broadcast
67
+ sessionOrderManager.moveToFront(cwd, sessionId);
68
+ const next = sessionOrderManager.getOrder(cwd) ?? [];
69
+ return { type: "sessions_reordered", cwd, sessionIds: next };
70
+ }
71
+
72
+ describe("ended\u2192alive sessionOrder gate", () => {
73
+ let endedSessionIds: Set<string>;
74
+ let pendingResumeIntents: ReturnType<typeof createPendingResumeIntentRegistry>;
75
+ let sessionOrderManager: ReturnType<typeof createSessionOrderManager>;
76
+ const cwd = "/project";
77
+
78
+ beforeEach(() => {
79
+ endedSessionIds = new Set();
80
+ pendingResumeIntents = createPendingResumeIntentRegistry();
81
+ sessionOrderManager = createSessionOrderManager(makePrefs());
82
+ });
83
+
84
+ it("user Resume click prepends id and emits broadcast", () => {
85
+ // Pre-state: existing alive order [B, A, C], session X is ended.
86
+ sessionOrderManager.insert(cwd, "C");
87
+ sessionOrderManager.insert(cwd, "A");
88
+ sessionOrderManager.insert(cwd, "B");
89
+ endedSessionIds.add("X");
90
+
91
+ // User-initiated resume tags the intent before spawn.
92
+ pendingResumeIntents.record("X", "front");
93
+
94
+ const broadcast = endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
95
+
96
+ expect(broadcast).not.toBeNull();
97
+ expect(broadcast!.sessionIds[0]).toBe("X"); // prepended
98
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["X", "B", "A", "C"]);
99
+ });
100
+
101
+ it("drag-to-resume preserves dropped slot (intent: \"keep\")", () => {
102
+ // Pre-state: alive [A, C], ended id "B" was just dragged into slot 1
103
+ // via reorder_sessions which writes the order BEFORE resume_session
104
+ // fires.
105
+ //
106
+ // Post change `differentiate-resume-intent-by-trigger`, drag-to-resume
107
+ // tags `keep` so the dropped slot survives the resume round-trip.
108
+ sessionOrderManager.reorder(cwd, ["A", "B", "C"]);
109
+ endedSessionIds.add("B");
110
+
111
+ // resume_session handler with placement: "keep" tags accordingly.
112
+ pendingResumeIntents.record("B", "keep");
113
+
114
+ const broadcast = endedToAlive("B", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
115
+
116
+ // No broadcast — the earlier reorder_sessions already broadcast,
117
+ // and the order didn't change here.
118
+ expect(broadcast).toBeNull();
119
+ // B stays at the dropped slot.
120
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["A", "B", "C"]);
121
+ });
122
+
123
+ it("button resume after drag (last-write-wins) moves id to front", () => {
124
+ // User drags X into the middle (tags "keep"), then before the bridge
125
+ // re-registers, clicks Resume on X (tags "front"). Last-write-wins:
126
+ // X surfaces at the top.
127
+ sessionOrderManager.reorder(cwd, ["A", "X", "B"]);
128
+ endedSessionIds.add("X");
129
+
130
+ pendingResumeIntents.record("X", "keep");
131
+ pendingResumeIntents.record("X", "front");
132
+
133
+ const broadcast = endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
134
+
135
+ expect(broadcast).not.toBeNull();
136
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["X", "A", "B"]);
137
+ });
138
+
139
+ it("bridge auto-reattach on reboot leaves order untouched and emits no broadcast", () => {
140
+ // Pre-state: user had reordered the alive tier to [B, A, C]; B was
141
+ // running, dashboard rebooted, scan classified all as ended, but B's
142
+ // pi process was still alive and just reattached.
143
+ sessionOrderManager.reorder(cwd, ["B", "A", "C"]);
144
+ endedSessionIds.add("B");
145
+
146
+ // No record() call \u2014 nothing tagged the intent.
147
+ const broadcast = endedToAlive("B", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
148
+
149
+ expect(broadcast).toBeNull();
150
+ // Order is preserved exactly.
151
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["B", "A", "C"]);
152
+ // endedSessionIds was still cleared so the next alive\u2192ended fires.
153
+ expect(endedSessionIds.has("B")).toBe(false);
154
+ });
155
+
156
+ it("multiple bridge reattaches on reboot emit zero broadcasts", () => {
157
+ sessionOrderManager.reorder(cwd, ["A", "B", "C", "D", "E"]);
158
+ for (const id of ["A", "B", "C", "D", "E"]) endedSessionIds.add(id);
159
+
160
+ const broadcasts: BroadcastEvent[] = [];
161
+ for (const id of ["A", "B", "C", "D", "E"]) {
162
+ const b = endedToAlive(id, cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
163
+ if (b) broadcasts.push(b);
164
+ }
165
+
166
+ expect(broadcasts).toEqual([]);
167
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["A", "B", "C", "D", "E"]);
168
+ });
169
+
170
+ it("stale intent is discarded; reattach classified as non-user", () => {
171
+ let nowMs = 1_000_000;
172
+ const clock = () => nowMs;
173
+ const r = createPendingResumeIntentRegistry({ ttlMs: 100, now: clock });
174
+
175
+ sessionOrderManager.reorder(cwd, ["A", "B", "C"]);
176
+ endedSessionIds.add("X");
177
+
178
+ r.record("X", "front"); // user clicked Resume but spawn failed
179
+ nowMs += 200; // 200 ms later, well past the 100 ms TTL
180
+
181
+ // Now a legitimate bridge reattach happens for the same id.
182
+ const broadcast = endedToAlive("X", cwd, endedSessionIds, r, sessionOrderManager);
183
+
184
+ expect(broadcast).toBeNull();
185
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["A", "B", "C"]);
186
+ });
187
+
188
+ it("intent is single-use \u2014 a second reattach after the same record() is treated as auto", () => {
189
+ // Edge case: bridge sends two session_register messages back-to-back
190
+ // (e.g. a bridge reload mid-resume). First consume() wins; second
191
+ // sees no intent.
192
+ sessionOrderManager.insert(cwd, "A");
193
+ endedSessionIds.add("X");
194
+
195
+ pendingResumeIntents.record("X", "front");
196
+
197
+ const first = endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
198
+ expect(first).not.toBeNull();
199
+ expect(first!.sessionIds[0]).toBe("X");
200
+
201
+ // Simulate a second transition for the same id.
202
+ endedSessionIds.add("X");
203
+ const second = endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
204
+
205
+ expect(second).toBeNull();
206
+ // X is still in the order from the first call \u2014 no further mutation.
207
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["X", "A"]);
208
+ });
209
+
210
+ it("end → resume → end → resume cycle always lands id at index 0", () => {
211
+ // Regression for `top-of-tier-on-status-change`: pre-fix the
212
+ // ended→alive branch used insert-if-absent, so on the second resume
213
+ // the id was still in the order list and stayed at its previous
214
+ // position. With moveToFront, every user-intent resume re-prepends.
215
+ sessionOrderManager.reorder(cwd, ["A", "B", "X", "C"]);
216
+
217
+ // Cycle 1: X ends, X resumes.
218
+ sessionOrderManager.remove(cwd, "X");
219
+ endedSessionIds.add("X");
220
+ pendingResumeIntents.record("X", "front");
221
+ const r1 = endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
222
+ expect(r1).not.toBeNull();
223
+ expect(sessionOrderManager.getOrder(cwd)[0]).toBe("X");
224
+
225
+ // Cycle 2: X ends again, X resumes. With insert-if-absent this
226
+ // would no-op (X is already in the list); with moveToFront X jumps
227
+ // to the top regardless.
228
+ sessionOrderManager.remove(cwd, "X");
229
+ endedSessionIds.add("X");
230
+ pendingResumeIntents.record("X", "front");
231
+ const r2 = endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
232
+ expect(r2).not.toBeNull();
233
+ expect(sessionOrderManager.getOrder(cwd)[0]).toBe("X");
234
+
235
+ // Cycle 3.
236
+ sessionOrderManager.remove(cwd, "X");
237
+ endedSessionIds.add("X");
238
+ pendingResumeIntents.record("X", "front");
239
+ endedToAlive("X", cwd, endedSessionIds, pendingResumeIntents, sessionOrderManager);
240
+ expect(sessionOrderManager.getOrder(cwd)[0]).toBe("X");
241
+ });
242
+ });
@@ -184,6 +184,50 @@ describe("session-scanner", () => {
184
184
  expect(meta.cwd).toBe("/test");
185
185
  });
186
186
 
187
+ it("should preserve persisted contextWindow over inferred stats value when model unchanged", () => {
188
+ // Regression: pi's JSONL has no turn_end/contextUsage events, so
189
+ // extractSessionStats falls back to inferContextWindow(model) which
190
+ // hardcodes Claude → 200_000. The persisted .meta.json value (written
191
+ // from a live turn_end carrying e.g. 1_000_000 for Sonnet 1M) must win.
192
+ const dir = createSessionDir("--test-cwd--");
193
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_ctx-id.jsonl", { id: "ctx-id", cwd: "/ctx" });
194
+
195
+ writeSessionMeta(sf, {
196
+ cwd: "/ctx",
197
+ model: "anthropic/claude-sonnet-4-20250514",
198
+ contextWindow: 1_000_000, // truth from a live turn_end
199
+ cachedAt: 1000, // stale — forces re-extract
200
+ });
201
+ fs.utimesSync(sf, new Date(), new Date());
202
+
203
+ const result = scanAllSessions(tmpDir);
204
+ expect(result.sessions[0].contextWindow).toBe(1_000_000);
205
+
206
+ // Should also persist the preserved value, not the inferred 200k.
207
+ const meta = JSON.parse(fs.readFileSync(metaPath(sf), "utf-8"));
208
+ expect(meta.contextWindow).toBe(1_000_000);
209
+ });
210
+
211
+ it("should adopt inferred contextWindow when model changes", () => {
212
+ // If the user switched models, the persisted contextWindow no longer
213
+ // applies — fall back to whatever stats reports for the new model.
214
+ const dir = createSessionDir("--test-cwd--");
215
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_chg-id.jsonl", { id: "chg-id", cwd: "/chg" });
216
+
217
+ writeSessionMeta(sf, {
218
+ cwd: "/chg",
219
+ model: "openai/gpt-4o", // different model from the mock's anthropic/claude-...
220
+ contextWindow: 128_000,
221
+ cachedAt: 1000,
222
+ });
223
+ fs.utimesSync(sf, new Date(), new Date());
224
+
225
+ const result = scanAllSessions(tmpDir);
226
+ // Mock returns model=anthropic/claude-..., contextWindow=200000 → adopt it
227
+ expect(result.sessions[0].model).toBe("anthropic/claude-sonnet-4-20250514");
228
+ expect(result.sessions[0].contextWindow).toBe(200_000);
229
+ });
230
+
187
231
  it("should set hidden from meta", () => {
188
232
  const dir = createSessionDir("--test-cwd--");
189
233
  const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_hidden-id.jsonl", { id: "hidden-id", cwd: "/test" });
@@ -135,6 +135,46 @@ describe("handleSubscribe — stale lastSeq detection", () => {
135
135
  expect(clearReplaying).toHaveBeenCalledWith(ctx.ws, "s1", 3);
136
136
  });
137
137
 
138
+ it("forwards session.contextWindow into directoryService.loadSessionEvents on lazy load", async () => {
139
+ // Regression: ended sessions opened from disk must replay with the
140
+ // persisted contextWindow (e.g. 1M Sonnet beta) instead of the legacy
141
+ // 200k Claude inference. The wiring lives in subscription-handler:160 —
142
+ // this test pins that loadSessionEvents is invoked with session.contextWindow
143
+ // as its 3rd argument so future refactors cannot silently drop it.
144
+ // See change: fix-context-window-reload.
145
+ const loadSessionEvents = vi.fn(async () => ({ success: true, events: [] }));
146
+ const directoryService = { loadSessionEvents } as any;
147
+ const ctx = createMockContext({ directoryService });
148
+
149
+ // Restore an ENDED session with sessionFile + persisted contextWindow.
150
+ // No events in the store → falls into the lazy-load branch.
151
+ // (`restore()` takes the full DashboardSession; `register()` does not
152
+ // accept contextWindow as a registration param.)
153
+ ctx.sessionManager.restore({
154
+ id: "s-ctx",
155
+ cwd: "/test",
156
+ source: "tui",
157
+ status: "ended",
158
+ startedAt: 1000,
159
+ endedAt: 2000,
160
+ tokensIn: 0,
161
+ tokensOut: 0,
162
+ cost: 0,
163
+ contextWindow: 1_000_000,
164
+ sessionFile: "/sessions/s-ctx.jsonl",
165
+ sessionDir: "/sessions",
166
+ hidden: false,
167
+ } as any);
168
+
169
+ const subs = new Set<string>();
170
+ handleSubscribe({ type: "subscribe", sessionId: "s-ctx" }, subs, ctx);
171
+
172
+ await new Promise((r) => setTimeout(r, 20));
173
+
174
+ expect(loadSessionEvents).toHaveBeenCalledTimes(1);
175
+ expect(loadSessionEvents).toHaveBeenCalledWith("s-ctx", "/sessions/s-ctx.jsonl", 1_000_000);
176
+ });
177
+
138
178
  it("does full replay when lastSeq is 0", async () => {
139
179
  const ctx = createMockContext();
140
180
  for (let i = 0; i < 3; i++) ctx.eventStore.insertEvent("s1", makeEvent());
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import path from "node:path";
3
+ import { translatePathSource } from "../package-manager-wrapper.js";
4
+
5
+ describe("translatePathSource", () => {
6
+ const fromLocal = "/abs/project/.pi";
7
+ const toGlobal = "/Users/u/.pi/agent";
8
+ const toLocal = "/abs/other/.pi";
9
+
10
+ it("rel-path → global resolves to absolute against fromSettingsDir", () => {
11
+ expect(
12
+ translatePathSource({
13
+ originalSource: "..",
14
+ fromSettingsDir: fromLocal,
15
+ toSettingsDir: toGlobal,
16
+ toScope: "global",
17
+ }),
18
+ ).toBe(path.resolve("/abs/project/.pi/.."));
19
+ });
20
+
21
+ it("./foo → global resolves to absolute", () => {
22
+ expect(
23
+ translatePathSource({
24
+ originalSource: "./foo",
25
+ fromSettingsDir: fromLocal,
26
+ toSettingsDir: toGlobal,
27
+ toScope: "global",
28
+ }),
29
+ ).toBe(path.resolve("/abs/project/.pi/foo"));
30
+ });
31
+
32
+ it("abs-path → local stays absolute when relative form escapes >2 levels", () => {
33
+ // /abs/project from /abs/other/.pi → ../../project (2 ups, OK)
34
+ // /tmp/foo from /abs/other/.pi → ../../../tmp/foo (3 ups, escape)
35
+ expect(
36
+ translatePathSource({
37
+ originalSource: "/tmp/foo",
38
+ fromSettingsDir: fromLocal,
39
+ toSettingsDir: toLocal,
40
+ toScope: "local",
41
+ }),
42
+ ).toBe("/tmp/foo");
43
+ });
44
+
45
+ it("abs-path → local goes relative when within 2 levels", () => {
46
+ expect(
47
+ translatePathSource({
48
+ originalSource: "/abs/project",
49
+ fromSettingsDir: fromLocal,
50
+ toSettingsDir: toLocal,
51
+ toScope: "local",
52
+ }),
53
+ ).toBe("../../project");
54
+ });
55
+
56
+ it("abs-path equal to toSettingsDir collapses to '.'", () => {
57
+ expect(
58
+ translatePathSource({
59
+ originalSource: "/abs/other/.pi",
60
+ fromSettingsDir: fromLocal,
61
+ toSettingsDir: toLocal,
62
+ toScope: "local",
63
+ }),
64
+ ).toBe(".");
65
+ });
66
+
67
+ it("abs-path → global stays absolute", () => {
68
+ expect(
69
+ translatePathSource({
70
+ originalSource: "/abs/project/vendor/x",
71
+ fromSettingsDir: fromLocal,
72
+ toSettingsDir: toGlobal,
73
+ toScope: "global",
74
+ }),
75
+ ).toBe("/abs/project/vendor/x");
76
+ });
77
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { handleSubscribe, replayUiState } from "../browser-handlers/subscription-handler.js";
3
+ import { createMemoryEventStore } from "../memory-event-store.js";
4
+ import { createMemorySessionManager } from "../memory-session-manager.js";
5
+ import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
6
+ import type { DecoratorDescriptor, ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js";
7
+
8
+ /**
9
+ * Phase-2 (`add-extension-ui-decorations`) server contract:
10
+ *
11
+ * - `replayUiState` sends `ui_modules_list` + `ui_data_list`* (Phase 1) THEN
12
+ * one `ext_ui_decorator` per cached `Session.uiDecorators` entry.
13
+ * - Replay decorators NEVER carry `removed: true`.
14
+ * - Replay ordering: events → pending UI requests → ui_modules_list →
15
+ * ui_data_list → ext_ui_decorator.
16
+ * - Cache write/delete semantics: upsert under `${kind}:${namespace}:${id}`;
17
+ * `removed: true` deletes the entry; deleting an absent key is a no-op
18
+ * but still broadcasts.
19
+ */
20
+
21
+ function module1(): ExtensionUiModule {
22
+ return {
23
+ kind: "management-modal",
24
+ id: "m1",
25
+ command: "/m1",
26
+ title: "M1",
27
+ view: { kind: "table", dataEvent: "m1:rows", fields: [{ key: "id", label: "ID", kind: "text" }] },
28
+ };
29
+ }
30
+
31
+ function footer(namespace: string, id: string, text: string): DecoratorDescriptor {
32
+ return { kind: "footer-segment", namespace, id, payload: { text } };
33
+ }
34
+
35
+ function gate(namespace: string, id: string, flowId: string, available: boolean): DecoratorDescriptor {
36
+ return { kind: "gate", namespace, id, payload: { flowId, available } };
37
+ }
38
+
39
+ function createCtx(overrides: Partial<BrowserHandlerContext> = {}): BrowserHandlerContext {
40
+ return {
41
+ ws: { readyState: 1, OPEN: 1, bufferedAmount: 0 } as any,
42
+ sessionManager: createMemorySessionManager(),
43
+ eventStore: createMemoryEventStore(() => false),
44
+ piGateway: { sendToSession: vi.fn() } as any,
45
+ headlessPidRegistry: {} as any,
46
+ pendingResumeRegistry: {} as any,
47
+ sendTo: vi.fn(),
48
+ broadcast: vi.fn(),
49
+ getSubscribers: () => [],
50
+ trackUiRequest: vi.fn(),
51
+ replayPendingUiRequests: vi.fn(),
52
+ markReplaying: vi.fn(),
53
+ clearReplaying: vi.fn(),
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ describe("replayUiState — Phase-2 decorator replay", () => {
59
+ it("sends one ext_ui_decorator per uiDecorators entry after the Phase-1 batches", () => {
60
+ const ctx = createCtx();
61
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
62
+ const fA = footer("judo", "model-state", "3 mut");
63
+ const fB = footer("flows", "progress", "step 2/5");
64
+ const gC = gate("judo", "save", "judo:save", false);
65
+ ctx.sessionManager.update("s1", {
66
+ uiModules: [module1()],
67
+ uiDataMap: { "m1:rows": [{ id: 1 }] },
68
+ uiDecorators: {
69
+ [`footer-segment:judo:model-state`]: fA,
70
+ [`footer-segment:flows:progress`]: fB,
71
+ [`gate:judo:save`]: gC,
72
+ },
73
+ });
74
+
75
+ replayUiState(ctx.ws, "s1", ctx);
76
+
77
+ const calls = (ctx.sendTo as any).mock.calls.map(([, m]: any) => m);
78
+ // 1 modules + 1 data + 3 decorators
79
+ expect(calls).toHaveLength(5);
80
+ expect(calls[0]).toMatchObject({ type: "ui_modules_list" });
81
+ expect(calls[1]).toMatchObject({ type: "ui_data_list", event: "m1:rows" });
82
+ const decoratorCalls = calls.slice(2);
83
+ for (const m of decoratorCalls) {
84
+ expect(m.type).toBe("ext_ui_decorator");
85
+ expect(m.sessionId).toBe("s1");
86
+ // Replay NEVER sets removed: true — only live entries are replayed.
87
+ expect(m.removed).toBeUndefined();
88
+ }
89
+ const keys = decoratorCalls.map((m: any) => `${m.descriptor.kind}:${m.descriptor.namespace}:${m.descriptor.id}`).sort();
90
+ expect(keys).toEqual([
91
+ "footer-segment:flows:progress",
92
+ "footer-segment:judo:model-state",
93
+ "gate:judo:save",
94
+ ]);
95
+ });
96
+
97
+ it("does not send any ext_ui_decorator when uiDecorators is missing or empty", () => {
98
+ const ctx = createCtx();
99
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
100
+ ctx.sessionManager.update("s1", { uiModules: [module1()] });
101
+ replayUiState(ctx.ws, "s1", ctx);
102
+ const calls = (ctx.sendTo as any).mock.calls.map(([, m]: any) => m);
103
+ expect(calls.some((m: any) => m.type === "ext_ui_decorator")).toBe(false);
104
+
105
+ (ctx.sendTo as any).mockClear();
106
+ ctx.sessionManager.update("s1", { uiDecorators: {} });
107
+ replayUiState(ctx.ws, "s1", ctx);
108
+ const calls2 = (ctx.sendTo as any).mock.calls.map(([, m]: any) => m);
109
+ expect(calls2.some((m: any) => m.type === "ext_ui_decorator")).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe("handleSubscribe — replay ordering with decorators", () => {
114
+ it("replay order is events → pending UI → ui_modules_list → ui_data_list → ext_ui_decorator", async () => {
115
+ const ctx = createCtx();
116
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
117
+ ctx.sessionManager.update("s1", {
118
+ uiModules: [module1()],
119
+ uiDataMap: { "m1:rows": [{ id: 1 }] },
120
+ uiDecorators: { [`footer-segment:judo:x`]: footer("judo", "x", "live") },
121
+ });
122
+ ctx.eventStore.insertEvent("s1", { eventType: "x", timestamp: Date.now(), data: {} });
123
+
124
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, new Set(), ctx);
125
+ await new Promise((r) => setTimeout(r, 30));
126
+
127
+ const calls = (ctx.sendTo as any).mock.calls.map(([, m]: any) => m);
128
+ const eventReplayIdx = calls.findIndex((m: any) => m.type === "event_replay");
129
+ const modulesIdx = calls.findIndex((m: any) => m.type === "ui_modules_list");
130
+ const dataIdx = calls.findIndex((m: any) => m.type === "ui_data_list");
131
+ const decoratorIdx = calls.findIndex((m: any) => m.type === "ext_ui_decorator");
132
+
133
+ expect(eventReplayIdx).toBeGreaterThanOrEqual(0);
134
+ expect(modulesIdx).toBeGreaterThan(eventReplayIdx);
135
+ expect(dataIdx).toBeGreaterThan(modulesIdx);
136
+ expect(decoratorIdx).toBeGreaterThan(dataIdx);
137
+ // replayPendingUiRequests is called between event replay and replayUiState.
138
+ expect((ctx.replayPendingUiRequests as any).mock.calls.length).toBeGreaterThan(0);
139
+ });
140
+ });
141
+
142
+ /**
143
+ * The cache upsert/delete contract is implemented inside `event-wiring.ts`'s
144
+ * `ext_ui_decorator` switch arm. We exercise it through a thin reducer
145
+ * mirroring the production code; the same logic is exercised end-to-end via
146
+ * `replayUiState` (which reads `Session.uiDecorators` directly).
147
+ */
148
+ describe("ext_ui_decorator cache reducer (mirrors event-wiring contract)", () => {
149
+ type DecoratorMsg = {
150
+ type: "ext_ui_decorator";
151
+ sessionId: string;
152
+ descriptor: DecoratorDescriptor;
153
+ removed?: boolean;
154
+ };
155
+
156
+ function applyDecoratorMsg(
157
+ sessionMgr: ReturnType<typeof createMemorySessionManager>,
158
+ msg: DecoratorMsg,
159
+ ): void {
160
+ const session = sessionMgr.get(msg.sessionId);
161
+ if (!session) return;
162
+ const key = `${msg.descriptor.kind}:${msg.descriptor.namespace}:${msg.descriptor.id}`;
163
+ const next = { ...(session.uiDecorators ?? {}) };
164
+ if (msg.removed) delete next[key];
165
+ else next[key] = msg.descriptor;
166
+ sessionMgr.update(msg.sessionId, { uiDecorators: next });
167
+ }
168
+
169
+ it("upserts under composite key", () => {
170
+ const mgr = createMemorySessionManager();
171
+ mgr.register({ id: "s1", cwd: "/tmp", source: "tui" });
172
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("judo", "x", "v1") });
173
+ expect(mgr.get("s1")?.uiDecorators).toEqual({
174
+ "footer-segment:judo:x": expect.objectContaining({ payload: { text: "v1" } }),
175
+ });
176
+
177
+ // Upsert overwrites the value at the same key.
178
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("judo", "x", "v2") });
179
+ expect((mgr.get("s1")!.uiDecorators!["footer-segment:judo:x"].payload as any).text).toBe("v2");
180
+ });
181
+
182
+ it("removed: true deletes the entry", () => {
183
+ const mgr = createMemorySessionManager();
184
+ mgr.register({ id: "s1", cwd: "/tmp", source: "tui" });
185
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("judo", "x", "v1") });
186
+ expect(mgr.get("s1")?.uiDecorators).toHaveProperty("footer-segment:judo:x");
187
+
188
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("judo", "x", "v1"), removed: true });
189
+ expect(mgr.get("s1")?.uiDecorators).not.toHaveProperty("footer-segment:judo:x");
190
+ });
191
+
192
+ it("removed: true on an absent key is a no-op", () => {
193
+ const mgr = createMemorySessionManager();
194
+ mgr.register({ id: "s1", cwd: "/tmp", source: "tui" });
195
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("judo", "absent", "v"), removed: true });
196
+ expect(mgr.get("s1")?.uiDecorators).toEqual({});
197
+ });
198
+
199
+ it("different namespaces are stored independently", () => {
200
+ const mgr = createMemorySessionManager();
201
+ mgr.register({ id: "s1", cwd: "/tmp", source: "tui" });
202
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("judo", "ms", "j") });
203
+ applyDecoratorMsg(mgr, { type: "ext_ui_decorator", sessionId: "s1", descriptor: footer("flows", "ms", "f") });
204
+ expect(Object.keys(mgr.get("s1")!.uiDecorators!).sort()).toEqual([
205
+ "footer-segment:flows:ms",
206
+ "footer-segment:judo:ms",
207
+ ]);
208
+ });
209
+ });