@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.
- package/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- 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
|
+
});
|