@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.
- package/AGENTS.md +26 -5
- package/README.md +49 -7
- package/docs/architecture.md +129 -1
- package/package.json +15 -15
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +68 -4
- package/packages/extension/src/bridge.ts +79 -11
- package/packages/extension/src/command-handler.ts +95 -15
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +8 -7
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +2 -2
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +27 -10
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +62 -82
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +90 -6
- package/packages/server/src/headless-pid-registry.ts +344 -37
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +182 -11
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +8 -1
- package/packages/server/src/routes/provider-routes.ts +28 -5
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +254 -60
- package/packages/server/src/session-api.ts +63 -4
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +70 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +71 -26
- package/packages/shared/src/protocol.ts +27 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +62 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
- 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("@
|
|
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("@
|
|
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("@
|
|
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("@
|
|
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
|
-
*
|
|
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
|
});
|