@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Tests for `openspec.enabled` gating in DirectoryService.
3
+ *
4
+ * Confirms:
5
+ * - `refreshOpenSpec` short-circuits (no CLI spawn, returns cleared shape).
6
+ * - `pollDirectoryGated` short-circuits.
7
+ * - `scheduleOpenSpecTick` short-circuits.
8
+ * - `reconfigurePolling({ enabled: false })` clears every cached cwd and
9
+ * broadcasts `openspec_update` via the onChange callback.
10
+ *
11
+ * See change: auto-hide-empty-session-subcards.
12
+ */
13
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
14
+ import { createDirectoryService, type DirectoryService } from "../directory-service.js";
15
+ import type { PreferencesStore } from "../preferences-store.js";
16
+ import type { SessionManager } from "../memory-session-manager.js";
17
+ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
18
+ import { DEFAULT_OPENSPEC_POLL } from "@blackbelt-technology/pi-dashboard-shared/config.js";
19
+
20
+ // Mock CLI entry points so we can spy on whether they get called.
21
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js", async (importOriginal) => {
22
+ const actual = await importOriginal<
23
+ typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js")
24
+ >();
25
+ return {
26
+ ...actual,
27
+ pollOpenSpecAsync: vi.fn(async () => ({ initialized: true, changes: [] })),
28
+ runOpenSpecList: vi.fn(async () => null),
29
+ runOpenSpecStatus: vi.fn(async () => null),
30
+ };
31
+ });
32
+
33
+ vi.mock("../pi-resource-scanner.js", () => ({
34
+ scanPiResources: vi.fn(async () => ({
35
+ local: { extensions: [], skills: [], prompts: [] },
36
+ global: { extensions: [], skills: [], prompts: [] },
37
+ packages: [],
38
+ })),
39
+ }));
40
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/state-replay.js", () => ({
41
+ replayEntriesAsEvents: vi.fn(() => []),
42
+ }));
43
+ vi.mock("../session-discovery.js", () => ({
44
+ discoverSessionsForCwd: vi.fn(() => []),
45
+ }));
46
+ vi.mock("../session-file-reader.js", () => ({
47
+ loadSessionEntries: vi.fn(() => []),
48
+ }));
49
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
50
+ SessionManager: {
51
+ list: vi.fn(async () => []),
52
+ open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
53
+ },
54
+ }));
55
+
56
+ function makePrefs(pinnedDirs: string[] = []): PreferencesStore {
57
+ return {
58
+ getPinnedDirectories: () => pinnedDirs,
59
+ getSessionOrder: () => ({}),
60
+ setSessionOrder: vi.fn(),
61
+ setPinnedDirectories: vi.fn(),
62
+ pinDirectory: vi.fn(),
63
+ unpinDirectory: vi.fn(),
64
+ reorderPinnedDirs: vi.fn(),
65
+ flush: vi.fn(),
66
+ dispose: vi.fn(),
67
+ };
68
+ }
69
+ function makeSessionMgr(sessions: DashboardSession[] = []): SessionManager {
70
+ const map = new Map<string, DashboardSession>();
71
+ for (const s of sessions) map.set(s.id, s);
72
+ return {
73
+ register: vi.fn(),
74
+ restore: vi.fn(),
75
+ unregister: vi.fn(),
76
+ update: vi.fn(),
77
+ get: (id: string) => map.get(id),
78
+ listActive: () => Array.from(map.values()).filter(s => s.status !== "ended"),
79
+ listAll: () => Array.from(map.values()),
80
+ };
81
+ }
82
+
83
+ describe("DirectoryService — openspec.enabled gate", () => {
84
+ let service: DirectoryService;
85
+
86
+ beforeEach(() => {
87
+ vi.clearAllMocks();
88
+ });
89
+ afterEach(() => {
90
+ service?.stopPolling();
91
+ });
92
+
93
+ it("refreshOpenSpec returns cleared shape and spawns no CLI when disabled", async () => {
94
+ const prefs = makePrefs(["/repo"]);
95
+ const sessMgr = makeSessionMgr();
96
+ service = createDirectoryService(prefs, sessMgr, { ...DEFAULT_OPENSPEC_POLL, enabled: false });
97
+
98
+ const { pollOpenSpecAsync, runOpenSpecList, runOpenSpecStatus } = await import(
99
+ "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js"
100
+ );
101
+
102
+ const data = await service.refreshOpenSpec("/repo");
103
+ expect(data).toEqual({
104
+ initialized: false,
105
+ pending: false,
106
+ changes: [],
107
+ hasOpenspecDir: false,
108
+ });
109
+ expect(pollOpenSpecAsync).not.toHaveBeenCalled();
110
+ expect(runOpenSpecList).not.toHaveBeenCalled();
111
+ expect(runOpenSpecStatus).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it("pollDirectoryGated returns cleared shape and spawns no CLI when disabled", async () => {
115
+ const prefs = makePrefs();
116
+ const sessMgr = makeSessionMgr();
117
+ service = createDirectoryService(prefs, sessMgr, { ...DEFAULT_OPENSPEC_POLL, enabled: false });
118
+
119
+ const { runOpenSpecList } = await import(
120
+ "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js"
121
+ );
122
+
123
+ const data = await service.pollDirectoryGated("/repo");
124
+ expect(data).toEqual({
125
+ initialized: false,
126
+ pending: false,
127
+ changes: [],
128
+ hasOpenspecDir: false,
129
+ });
130
+ expect(runOpenSpecList).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("reconfigurePolling({ enabled: false }) broadcasts cleared payload for every cached cwd", async () => {
134
+ const prefs = makePrefs(["/a", "/b"]);
135
+ const sessMgr = makeSessionMgr();
136
+ service = createDirectoryService(prefs, sessMgr); // starts enabled
137
+
138
+ // Seed the cache by calling refresh while enabled.
139
+ const { runOpenSpecList, runOpenSpecStatus } = await import(
140
+ "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js"
141
+ );
142
+ (runOpenSpecList as any).mockResolvedValue({
143
+ mtimeMs: 1,
144
+ result: { changes: [], specs: [] },
145
+ });
146
+ (runOpenSpecStatus as any).mockResolvedValue(null);
147
+ await service.refreshOpenSpec("/a");
148
+ await service.refreshOpenSpec("/b");
149
+
150
+ expect(service.getOpenSpecData("/a")).toBeDefined();
151
+ expect(service.getOpenSpecData("/b")).toBeDefined();
152
+
153
+ // Wire the broadcast callback then flip the master gate.
154
+ const broadcasts: Array<{ cwd: string; data: unknown }> = [];
155
+ service.startPolling((cwd, data) => broadcasts.push({ cwd, data }));
156
+
157
+ service.reconfigurePolling({ ...DEFAULT_OPENSPEC_POLL, enabled: false });
158
+
159
+ const cleared = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
160
+ const cwds = new Set(broadcasts.map(b => b.cwd));
161
+ expect(cwds.has("/a")).toBe(true);
162
+ expect(cwds.has("/b")).toBe(true);
163
+ for (const b of broadcasts) {
164
+ expect(b.data).toEqual(cleared);
165
+ }
166
+ expect(service.getOpenSpecData("/a")).toEqual(cleared);
167
+ expect(service.getOpenSpecData("/b")).toEqual(cleared);
168
+ });
169
+
170
+ it("no broadcast on disabled→disabled or enabled→enabled reconfiguration", async () => {
171
+ const prefs = makePrefs(["/a"]);
172
+ const sessMgr = makeSessionMgr();
173
+ service = createDirectoryService(prefs, sessMgr, { ...DEFAULT_OPENSPEC_POLL, enabled: false });
174
+
175
+ const broadcasts: Array<{ cwd: string }> = [];
176
+ service.startPolling((cwd) => broadcasts.push({ cwd }));
177
+
178
+ // disabled → disabled with new interval — should not trigger the
179
+ // disable-broadcast path.
180
+ service.reconfigurePolling({
181
+ ...DEFAULT_OPENSPEC_POLL,
182
+ enabled: false,
183
+ pollIntervalSeconds: 90,
184
+ });
185
+ expect(broadcasts).toHaveLength(0);
186
+ });
187
+ });
@@ -39,7 +39,7 @@ vi.mock("../session-file-reader.js", () => ({
39
39
  loadSessionEntries: vi.fn(() => []),
40
40
  }));
41
41
 
42
- vi.mock("@mariozechner/pi-coding-agent", () => ({
42
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
43
43
  SessionManager: {
44
44
  list: vi.fn(async () => []),
45
45
  open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
@@ -47,7 +47,7 @@ vi.mock("../session-file-reader.js", () => ({
47
47
  loadSessionEntries: vi.fn(() => []),
48
48
  }));
49
49
 
50
- vi.mock("@mariozechner/pi-coding-agent", () => ({
50
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
51
51
  SessionManager: {
52
52
  list: vi.fn(async () => []),
53
53
  open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
@@ -46,7 +46,7 @@ vi.mock("../session-file-reader.js", () => ({
46
46
  loadSessionEntries: vi.fn(() => []),
47
47
  }));
48
48
 
49
- vi.mock("@mariozechner/pi-coding-agent", () => ({
49
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
50
50
  SessionManager: {
51
51
  list: vi.fn(async () => []),
52
52
  open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
@@ -42,7 +42,7 @@ vi.mock("../session-file-reader.js", () => ({
42
42
  }));
43
43
 
44
44
  // Mock the pi-coding-agent SessionManager (legacy, kept for compatibility)
45
- vi.mock("@mariozechner/pi-coding-agent", () => ({
45
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
46
46
  SessionManager: {
47
47
  list: vi.fn(async () => []),
48
48
  open: vi.fn(() => ({
@@ -633,7 +633,7 @@ describe("DirectoryService", () => {
633
633
  const sessionManager = createMockSessionManager();
634
634
  service = createDirectoryService(stateStore, sessionManager);
635
635
  await service.refreshOpenSpec("/x");
636
- service.reconfigurePolling({ pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "mtime", jitterSeconds: 0 });
636
+ service.reconfigurePolling({ enabled: true, pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "mtime", jitterSeconds: 0 });
637
637
  expect(service.getOpenSpecData("/x")).toBeDefined();
638
638
  });
639
639
  });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * dispatch-router unit tests (Phase 8 / task 8.7).
3
+ *
4
+ * Drives `handleDispatchExtensionCommand` with a mock `headlessPidRegistry`
5
+ * + browser broadcaster; asserts the optimistic-completion contract from
6
+ * `extension-rpc-dispatch` Requirement "Server-side dispatch routing to keeper".
7
+ *
8
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
9
+ */
10
+ import { describe, it, expect, vi } from "vitest";
11
+ import {
12
+ buildPiRpcLine,
13
+ handleDispatchExtensionCommand,
14
+ type DispatchRouterContext,
15
+ } from "../rpc-keeper/dispatch-router.js";
16
+ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
17
+
18
+ // ── Mocks ────────────────────────────────────────────────────────────────────
19
+
20
+ interface FakeRegistryState {
21
+ writeRpcCalls: Array<{ sessionId: string; line: string }>;
22
+ writeRpcResult: boolean | Error;
23
+ }
24
+
25
+ function makeFakeRegistry(opts: { result: boolean | Error }): {
26
+ registry: HeadlessPidRegistry;
27
+ state: FakeRegistryState;
28
+ } {
29
+ const state: FakeRegistryState = {
30
+ writeRpcCalls: [],
31
+ writeRpcResult: opts.result,
32
+ };
33
+ const registry: Partial<HeadlessPidRegistry> = {
34
+ writeRpc: async (sessionId, line) => {
35
+ state.writeRpcCalls.push({ sessionId, line });
36
+ if (state.writeRpcResult instanceof Error) throw state.writeRpcResult;
37
+ return state.writeRpcResult;
38
+ },
39
+ };
40
+ return { registry: registry as HeadlessPidRegistry, state };
41
+ }
42
+
43
+ interface FeedbackBroadcast {
44
+ sessionId: string;
45
+ command: string;
46
+ status: "completed" | "error";
47
+ message?: string;
48
+ }
49
+
50
+ function makeContext(registry: HeadlessPidRegistry): {
51
+ ctx: DispatchRouterContext;
52
+ broadcasts: FeedbackBroadcast[];
53
+ } {
54
+ const broadcasts: FeedbackBroadcast[] = [];
55
+ return {
56
+ ctx: {
57
+ headlessPidRegistry: registry,
58
+ emitCommandFeedback: (sessionId, command, status, message) =>
59
+ broadcasts.push({ sessionId, command, status, message }),
60
+ },
61
+ broadcasts,
62
+ };
63
+ }
64
+
65
+ function feedbackData(b: FeedbackBroadcast): FeedbackBroadcast {
66
+ return b;
67
+ }
68
+
69
+ // ── Tests ────────────────────────────────────────────────────────────────────
70
+
71
+ describe("buildPiRpcLine", () => {
72
+ it("constructs the pi RPC prompt JSON with command and id", () => {
73
+ const line = buildPiRpcLine("/ctx-stats", "req-1");
74
+ expect(JSON.parse(line)).toEqual({
75
+ type: "prompt",
76
+ message: "/ctx-stats",
77
+ id: "req-1",
78
+ });
79
+ });
80
+
81
+ it("preserves command text verbatim (no quoting)", () => {
82
+ const line = buildPiRpcLine("/ctx-stats verbose=1", "req-2");
83
+ const parsed = JSON.parse(line);
84
+ expect(parsed.message).toBe("/ctx-stats verbose=1");
85
+ });
86
+ });
87
+
88
+ describe("handleDispatchExtensionCommand", () => {
89
+ it("success path: writeRpc invoked, optimistic 'completed' broadcast", async () => {
90
+ const { registry, state } = makeFakeRegistry({ result: true });
91
+ const { ctx, broadcasts } = makeContext(registry);
92
+
93
+ await handleDispatchExtensionCommand(
94
+ { type: "dispatch_extension_command", sessionId: "S1", command: "/ctx-stats", requestId: "r1" },
95
+ ctx,
96
+ );
97
+
98
+ expect(state.writeRpcCalls).toHaveLength(1);
99
+ expect(state.writeRpcCalls[0].sessionId).toBe("S1");
100
+ expect(JSON.parse(state.writeRpcCalls[0].line)).toEqual({
101
+ type: "prompt",
102
+ message: "/ctx-stats",
103
+ id: "r1",
104
+ });
105
+
106
+ expect(broadcasts).toHaveLength(1);
107
+ expect(broadcasts[0].sessionId).toBe("S1");
108
+ expect(broadcasts[0].command).toBe("/ctx-stats");
109
+ expect(broadcasts[0].status).toBe("completed");
110
+ expect(broadcasts[0].message).toBeUndefined();
111
+ });
112
+
113
+ it("no-keeper path: writeRpc returns false \u2192 'error' with keeper-unavailable message", async () => {
114
+ const { registry } = makeFakeRegistry({ result: false });
115
+ const { ctx, broadcasts } = makeContext(registry);
116
+
117
+ await handleDispatchExtensionCommand(
118
+ { type: "dispatch_extension_command", sessionId: "S2", command: "/curator", requestId: "r2" },
119
+ ctx,
120
+ );
121
+
122
+ expect(broadcasts).toHaveLength(1);
123
+ expect(broadcasts[0].status).toBe("error");
124
+ expect(broadcasts[0].command).toBe("/curator");
125
+ expect(broadcasts[0].message).toMatch(/RPC keeper unavailable/);
126
+ });
127
+
128
+ it("write-fails path: writeRpc throws \u2192 'error' with reason-prefixed message", async () => {
129
+ const { registry } = makeFakeRegistry({ result: new Error("EPIPE") });
130
+ const { ctx, broadcasts } = makeContext(registry);
131
+
132
+ await handleDispatchExtensionCommand(
133
+ { type: "dispatch_extension_command", sessionId: "S3", command: "/agents", requestId: "r3" },
134
+ ctx,
135
+ );
136
+
137
+ expect(broadcasts).toHaveLength(1);
138
+ expect(broadcasts[0].status).toBe("error");
139
+ expect(broadcasts[0].message).toMatch(/Failed to write RPC line/);
140
+ expect(broadcasts[0].message).toMatch(/EPIPE/);
141
+ });
142
+
143
+ it("never throws even on registry failures", async () => {
144
+ const { registry } = makeFakeRegistry({ result: new Error("boom") });
145
+ const { ctx } = makeContext(registry);
146
+
147
+ await expect(
148
+ handleDispatchExtensionCommand(
149
+ { type: "dispatch_extension_command", sessionId: "S4", command: "/x", requestId: "r4" },
150
+ ctx,
151
+ ),
152
+ ).resolves.toBeUndefined();
153
+ });
154
+
155
+ it("emits exactly one broadcast per dispatch (success)", async () => {
156
+ const { registry } = makeFakeRegistry({ result: true });
157
+ const { ctx, broadcasts } = makeContext(registry);
158
+
159
+ await handleDispatchExtensionCommand(
160
+ { type: "dispatch_extension_command", sessionId: "S5", command: "/x", requestId: "r5" },
161
+ ctx,
162
+ );
163
+
164
+ expect(broadcasts).toHaveLength(1);
165
+ });
166
+
167
+ it("emits exactly one broadcast per dispatch (failure)", async () => {
168
+ const { registry } = makeFakeRegistry({ result: false });
169
+ const { ctx, broadcasts } = makeContext(registry);
170
+
171
+ await handleDispatchExtensionCommand(
172
+ { type: "dispatch_extension_command", sessionId: "S6", command: "/x", requestId: "r6" },
173
+ ctx,
174
+ );
175
+
176
+ expect(broadcasts).toHaveLength(1);
177
+ });
178
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * End-to-end smoke test for the model proxy using Google Gemini Flash (task 16.1).
3
+ *
4
+ * Skipped by default in CI. Enable with:
5
+ * E2E_MODEL_PROXY=1 GEMINI_API_KEY=<key> npm test -- model-proxy-google-flash
6
+ *
7
+ * Steps:
8
+ * 1. Boot dashboard server on a random port
9
+ * 2. POST /api/model-proxy/api-keys → get a proxy key
10
+ * 3. GET /v1/models with the key → expect ≥1 model
11
+ * 4. If google/gemini-2.5-flash* model present:
12
+ * a. POST /v1/chat/completions non-streaming → 200 + non-empty assistant text
13
+ * b. POST /v1/chat/completions streaming → SSE chunks with delta.content
14
+ * c. POST /v1/messages (Anthropic shape) → 200
15
+ * 5. Delete the API key → re-use → expect 401
16
+ * 6. Shutdown
17
+ *
18
+ * See change: add-dashboard-model-proxy, task 16.2.
19
+ */
20
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
21
+ import { createTestServer, type TestServerHandle } from "../../test-support/test-server.js";
22
+
23
+ const ENABLED = process.env["E2E_MODEL_PROXY"] === "1";
24
+
25
+ let handle: TestServerHandle | null = null;
26
+ let httpPort: number;
27
+ let proxyKey: string;
28
+
29
+ beforeAll(async () => {
30
+ if (!ENABLED) return;
31
+ handle = await createTestServer();
32
+ httpPort = handle.httpPort;
33
+ });
34
+
35
+ afterAll(async () => {
36
+ if (handle) {
37
+ try { await handle.stop(); } catch {}
38
+ handle = null;
39
+ }
40
+ });
41
+
42
+ describe.skipIf(!ENABLED)("model-proxy e2e: google gemini flash", () => {
43
+ it("creates a proxy API key", async () => {
44
+ const res = await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ label: "e2e-test" }),
48
+ });
49
+ expect(res.ok).toBe(true);
50
+ const body = await res.json() as any;
51
+ proxyKey = body.data.key;
52
+ expect(proxyKey).toMatch(/^pi-proxy-/);
53
+ });
54
+
55
+ it("GET /v1/models returns at least 1 model", async () => {
56
+ const res = await fetch(`http://localhost:${httpPort}/v1/models`, {
57
+ headers: { authorization: `Bearer ${proxyKey}` },
58
+ });
59
+ expect(res.ok).toBe(true);
60
+ const body = await res.json() as any;
61
+ expect(body.object).toBe("list");
62
+ expect(body.data.length).toBeGreaterThanOrEqual(1);
63
+ });
64
+
65
+ it("POST /v1/chat/completions non-streaming with google flash", async () => {
66
+ const modelsRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
67
+ headers: { authorization: `Bearer ${proxyKey}` },
68
+ });
69
+ const modelsBody = await modelsRes.json() as any;
70
+ const flashModel = modelsBody.data.find((m: any) =>
71
+ m.id.includes("google/gemini-2.5-flash") || m.id.includes("gemini-2.5-flash"),
72
+ );
73
+
74
+ if (!flashModel) {
75
+ console.warn("No google/gemini-2.5-flash model available — skipping");
76
+ return;
77
+ }
78
+
79
+ const res = await fetch(`http://localhost:${httpPort}/v1/chat/completions`, {
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ authorization: `Bearer ${proxyKey}`,
84
+ },
85
+ body: JSON.stringify({
86
+ model: flashModel.id,
87
+ messages: [{ role: "user", content: "Reply with just the word: ok" }],
88
+ stream: false,
89
+ max_tokens: 20,
90
+ }),
91
+ });
92
+
93
+ expect(res.ok).toBe(true);
94
+ const body = await res.json() as any;
95
+ const content = body.choices?.[0]?.message?.content ?? "";
96
+ expect(content.length).toBeGreaterThan(0);
97
+ });
98
+
99
+ it("POST /v1/chat/completions streaming with google flash", async () => {
100
+ const modelsRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
101
+ headers: { authorization: `Bearer ${proxyKey}` },
102
+ });
103
+ const modelsBody = await modelsRes.json() as any;
104
+ const flashModel = modelsBody.data.find((m: any) =>
105
+ m.id.includes("google/gemini-2.5-flash") || m.id.includes("gemini-2.5-flash"),
106
+ );
107
+
108
+ if (!flashModel) {
109
+ console.warn("No google/gemini-2.5-flash model available — skipping");
110
+ return;
111
+ }
112
+
113
+ const res = await fetch(`http://localhost:${httpPort}/v1/chat/completions`, {
114
+ method: "POST",
115
+ headers: {
116
+ "Content-Type": "application/json",
117
+ authorization: `Bearer ${proxyKey}`,
118
+ },
119
+ body: JSON.stringify({
120
+ model: flashModel.id,
121
+ messages: [{ role: "user", content: "Reply with just the word: ok" }],
122
+ stream: true,
123
+ max_tokens: 20,
124
+ }),
125
+ });
126
+
127
+ expect(res.ok).toBe(true);
128
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
129
+
130
+ const text = await res.text();
131
+ // Should contain at least one data chunk
132
+ expect(text).toContain("data:");
133
+ // Should end with [DONE]
134
+ expect(text).toContain("[DONE]");
135
+ });
136
+
137
+ it("POST /v1/messages (Anthropic shape) with google flash", async () => {
138
+ const modelsRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
139
+ headers: { authorization: `Bearer ${proxyKey}` },
140
+ });
141
+ const modelsBody = await modelsRes.json() as any;
142
+ const flashModel = modelsBody.data.find((m: any) =>
143
+ m.id.includes("google/gemini-2.5-flash") || m.id.includes("gemini-2.5-flash"),
144
+ );
145
+
146
+ if (!flashModel) {
147
+ console.warn("No google/gemini-2.5-flash model available — skipping");
148
+ return;
149
+ }
150
+
151
+ const res = await fetch(`http://localhost:${httpPort}/v1/messages`, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ authorization: `Bearer ${proxyKey}`,
156
+ },
157
+ body: JSON.stringify({
158
+ model: flashModel.id,
159
+ messages: [{ role: "user", content: "Reply with just the word: ok" }],
160
+ max_tokens: 20,
161
+ }),
162
+ });
163
+
164
+ expect(res.ok).toBe(true);
165
+ });
166
+
167
+ it("deleted API key returns 401 on re-use", async () => {
168
+ // Revoke the key first
169
+ const keysRes = await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys`);
170
+ const keysBody = await keysRes.json() as any;
171
+ const keyId = keysBody.data?.keys?.[0]?.id;
172
+ if (!keyId) return;
173
+
174
+ await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys/${keyId}/revoke`, {
175
+ method: "POST",
176
+ });
177
+
178
+ // Re-use should now fail
179
+ const res = await fetch(`http://localhost:${httpPort}/v1/models`, {
180
+ headers: { authorization: `Bearer ${proxyKey}` },
181
+ });
182
+ expect(res.status).toBe(401);
183
+ });
184
+ });
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * End-to-end test: `providers_list` arriving from a (fake) bridge updates
3
3
  * the provider-catalogue cache, and `getAuthStatus()` reflects it.
4
- * See change: replace-hardcoded-provider-lists.
4
+ * Pins the contract that the server emits NO `models_refreshed` broadcast
5
+ * on `providers_list` arrival — the catalogue is a pure read consumer for
6
+ * the Settings UI, the model-selector dropdown lives on the independent
7
+ * `models_list` channel which is per-session-broadcast already.
8
+ * See changes: replace-hardcoded-provider-lists,
9
+ * fix-providers-list-spurious-models-refreshed,
10
+ * simplify-model-selection-channels.
5
11
  */
6
12
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
13
  import { WebSocket } from "ws";
@@ -84,4 +90,65 @@ describe("providers_list — server wiring", () => {
84
90
 
85
91
  piWs.close();
86
92
  });
93
+
94
+ // Regression — see change: simplify-model-selection-channels.
95
+ // The server MUST NOT emit `models_refreshed` on routine providers_list
96
+ // arrivals. The previous implementation broadcast on every push (or, in
97
+ // the interim fix, on content change), which globally wiped browsers'
98
+ // modelsMap and left previously-visited sessions with empty model
99
+ // selectors. Per-session `models_list` updates are now the sole signal
100
+ // for dropdown contents.
101
+ it("never broadcasts models_refreshed on providers_list arrival (any flavour)", async () => {
102
+ const piWs = await connectSession(piPort, "p1");
103
+ const browserWs = new WebSocket(`ws://localhost:${browserPort}/ws`);
104
+ const browserMessages: any[] = [];
105
+ await new Promise<void>((resolve) => {
106
+ browserWs.on("open", () => resolve());
107
+ });
108
+ browserWs.on("message", (raw) => {
109
+ try {
110
+ const m = JSON.parse(raw.toString());
111
+ browserMessages.push(m);
112
+ } catch { /* ignore */ }
113
+ });
114
+ // Drain initial snapshot/handshake messages.
115
+ await wait(80);
116
+ browserMessages.length = 0;
117
+
118
+ const cat1 = [
119
+ { id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false },
120
+ { id: "fireworks", displayName: "Fireworks", hasOAuth: false, configured: false, envVar: "FIREWORKS_API_KEY" },
121
+ ];
122
+ const cat2 = [
123
+ { id: "deepseek", displayName: "DeepSeek", hasOAuth: false, configured: false, custom: true },
124
+ { id: "fireworks", displayName: "Fireworks", hasOAuth: false, configured: false, envVar: "FIREWORKS_API_KEY" },
125
+ ];
126
+
127
+ // First push — no broadcast.
128
+ piWs.send(JSON.stringify({ type: "providers_list", sessionId: "p1", providers: cat1 }));
129
+ await wait(80);
130
+ expect(browserMessages.filter((m) => m.type === "models_refreshed").length).toBe(0);
131
+
132
+ // Identical re-push — no broadcast.
133
+ piWs.send(JSON.stringify({ type: "providers_list", sessionId: "p1", providers: cat1 }));
134
+ await wait(80);
135
+ expect(browserMessages.filter((m) => m.type === "models_refreshed").length).toBe(0);
136
+
137
+ // Content change (custom flag flip) — still no broadcast.
138
+ piWs.send(JSON.stringify({ type: "providers_list", sessionId: "p1", providers: cat2 }));
139
+ await wait(80);
140
+ expect(browserMessages.filter((m) => m.type === "models_refreshed").length).toBe(0);
141
+
142
+ // New session sending its first push — still no broadcast (this was the
143
+ // exact scenario that defeated the per-session `changed` gate from the
144
+ // previous fix).
145
+ const piWs2 = await connectSession(piPort, "p2");
146
+ piWs2.send(JSON.stringify({ type: "providers_list", sessionId: "p2", providers: cat1 }));
147
+ await wait(80);
148
+ expect(browserMessages.filter((m) => m.type === "models_refreshed").length).toBe(0);
149
+
150
+ piWs.close();
151
+ piWs2.close();
152
+ browserWs.close();
153
+ });
87
154
  });