@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1
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 +339 -190
- package/README.md +50 -7
- package/docs/architecture.md +238 -23
- package/package.json +23 -13
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- 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.test.ts +24 -1
- 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 +1 -1
- package/packages/extension/src/bridge.ts +169 -4
- package/packages/extension/src/command-handler.ts +65 -2
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- 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 +50 -2
- package/packages/extension/src/provider-register.ts +132 -8
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +15 -1
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/package.json +6 -6
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -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 +22 -4
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -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 +1 -1
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
- 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__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -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-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 +236 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- 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-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +70 -24
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- 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 +23 -2
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +105 -5
- package/packages/server/src/headless-pid-registry.ts +54 -5
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- 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 +81 -15
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +10 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +115 -21
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +47 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -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 +14 -1
- package/packages/server/src/routes/provider-routes.ts +4 -4
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +85 -66
- package/packages/server/src/session-api.ts +54 -3
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +11 -4
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +291 -0
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -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 +6 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +197 -3
- package/packages/shared/src/browser-protocol.ts +155 -1
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/platform/node-spawn.ts +29 -21
- package/packages/shared/src/protocol.ts +54 -2
- package/packages/shared/src/resolve-jiti.ts +62 -9
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +33 -8
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +64 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `_buildProviderCatalogue` — the bridge-side pure helper that
|
|
3
|
+
* derives ProviderInfo[] from a captured ModelRegistry.
|
|
4
|
+
* See change: replace-hardcoded-provider-lists.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { _buildProviderCatalogue } from "../provider-register.js";
|
|
8
|
+
|
|
9
|
+
function makeRegistry(opts: {
|
|
10
|
+
oauthIds?: string[];
|
|
11
|
+
models?: Array<{ provider: string; id: string }>;
|
|
12
|
+
authStatus?: Record<string, { configured: boolean; source?: string }>;
|
|
13
|
+
credentials?: Record<string, { type: "oauth" | "api_key"; expires?: number; key?: string }>;
|
|
14
|
+
displayNames?: Record<string, string>;
|
|
15
|
+
}): any {
|
|
16
|
+
return {
|
|
17
|
+
authStorage: {
|
|
18
|
+
getOAuthProviders: () => (opts.oauthIds ?? []).map((id) => ({ id, name: id })),
|
|
19
|
+
getAuthStatus: (id: string) => opts.authStatus?.[id] ?? { configured: false },
|
|
20
|
+
get: (id: string) => opts.credentials?.[id],
|
|
21
|
+
},
|
|
22
|
+
getAll: () => opts.models ?? [],
|
|
23
|
+
getProviderDisplayName: (id: string) => opts.displayNames?.[id] ?? id,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("_buildProviderCatalogue", () => {
|
|
28
|
+
it("returns [] when registry is null", () => {
|
|
29
|
+
expect(_buildProviderCatalogue(null, {})).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("includes every OAuth id and every distinct model.provider, deduplicated", () => {
|
|
33
|
+
const reg = makeRegistry({
|
|
34
|
+
oauthIds: ["anthropic", "openai-codex"],
|
|
35
|
+
models: [
|
|
36
|
+
{ provider: "anthropic", id: "claude-4" },
|
|
37
|
+
{ provider: "deepseek", id: "deepseek-chat" },
|
|
38
|
+
{ provider: "deepseek", id: "deepseek-coder" },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
42
|
+
const ids = cat.map((c) => c.id).sort();
|
|
43
|
+
expect(ids).toEqual(["anthropic", "deepseek", "openai-codex"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("sets hasOAuth true only for ids in the OAuth provider set", () => {
|
|
47
|
+
const reg = makeRegistry({
|
|
48
|
+
oauthIds: ["anthropic"],
|
|
49
|
+
models: [{ provider: "anthropic", id: "x" }, { provider: "deepseek", id: "y" }],
|
|
50
|
+
});
|
|
51
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
52
|
+
expect(cat.find((c) => c.id === "anthropic")?.hasOAuth).toBe(true);
|
|
53
|
+
expect(cat.find((c) => c.id === "deepseek")?.hasOAuth).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("populates displayName from getProviderDisplayName, falling back to id", () => {
|
|
57
|
+
const reg = makeRegistry({
|
|
58
|
+
models: [{ provider: "deepseek", id: "x" }, { provider: "custom-llm", id: "y" }],
|
|
59
|
+
displayNames: { deepseek: "DeepSeek" },
|
|
60
|
+
});
|
|
61
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
62
|
+
expect(cat.find((c) => c.id === "deepseek")?.displayName).toBe("DeepSeek");
|
|
63
|
+
expect(cat.find((c) => c.id === "custom-llm")?.displayName).toBe("custom-llm");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("populates configured + source from authStorage.getAuthStatus", () => {
|
|
67
|
+
const reg = makeRegistry({
|
|
68
|
+
models: [{ provider: "openai", id: "gpt-4" }],
|
|
69
|
+
authStatus: { openai: { configured: false, source: "environment" } },
|
|
70
|
+
});
|
|
71
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
72
|
+
const row = cat.find((c) => c.id === "openai")!;
|
|
73
|
+
expect(row.configured).toBe(false);
|
|
74
|
+
expect(row.source).toBe("environment");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("populates expires for OAuth credentials in auth.json", () => {
|
|
78
|
+
const reg = makeRegistry({
|
|
79
|
+
oauthIds: ["anthropic"],
|
|
80
|
+
models: [{ provider: "anthropic", id: "x" }],
|
|
81
|
+
credentials: { anthropic: { type: "oauth", expires: 1234567890 } },
|
|
82
|
+
});
|
|
83
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
84
|
+
expect(cat.find((c) => c.id === "anthropic")?.expires).toBe(1234567890);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("api_key credentials do not surface expires", () => {
|
|
88
|
+
const reg = makeRegistry({
|
|
89
|
+
models: [{ provider: "openai", id: "x" }],
|
|
90
|
+
credentials: { openai: { type: "api_key", key: "sk-..." } },
|
|
91
|
+
});
|
|
92
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
93
|
+
expect(cat.find((c) => c.id === "openai")?.expires).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("populates envVar from piAi.findEnvKeys (first entry)", () => {
|
|
97
|
+
const reg = makeRegistry({ models: [{ provider: "openai", id: "x" }] });
|
|
98
|
+
const cat = _buildProviderCatalogue(reg, {
|
|
99
|
+
findEnvKeys: (id) => (id === "openai" ? ["OPENAI_API_KEY"] : undefined),
|
|
100
|
+
});
|
|
101
|
+
expect(cat.find((c) => c.id === "openai")?.envVar).toBe("OPENAI_API_KEY");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("sets ambient when piAi.getEnvApiKey returns '<authenticated>'", () => {
|
|
105
|
+
const reg = makeRegistry({ models: [{ provider: "google-vertex", id: "x" }] });
|
|
106
|
+
const cat = _buildProviderCatalogue(reg, {
|
|
107
|
+
getEnvApiKey: (id) => (id === "google-vertex" ? "<authenticated>" : undefined),
|
|
108
|
+
});
|
|
109
|
+
expect(cat.find((c) => c.id === "google-vertex")?.ambient).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("ambient stays undefined for ordinary api keys", () => {
|
|
113
|
+
const reg = makeRegistry({ models: [{ provider: "openai", id: "x" }] });
|
|
114
|
+
const cat = _buildProviderCatalogue(reg, {
|
|
115
|
+
getEnvApiKey: () => "sk-real-key",
|
|
116
|
+
});
|
|
117
|
+
expect(cat.find((c) => c.id === "openai")?.ambient).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not throw when pi-ai helpers are missing", () => {
|
|
121
|
+
const reg = makeRegistry({ models: [{ provider: "openai", id: "x" }] });
|
|
122
|
+
expect(() => _buildProviderCatalogue(reg, {})).not.toThrow();
|
|
123
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
124
|
+
expect(cat[0].envVar).toBeUndefined();
|
|
125
|
+
expect(cat[0].ambient).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("marks ids in customIds set with custom:true; catalogue stays complete", () => {
|
|
129
|
+
const reg = makeRegistry({
|
|
130
|
+
models: [
|
|
131
|
+
{ provider: "deepseek", id: "deepseek-chat" },
|
|
132
|
+
{ provider: "proxy", id: "opus-4" }, // custom (registered via providers.json)
|
|
133
|
+
{ provider: "your-llmproxy", id: "foo" }, // custom
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
const customIds = new Set(["proxy", "your-llmproxy"]);
|
|
137
|
+
const cat = _buildProviderCatalogue(reg, {}, customIds);
|
|
138
|
+
// ALL providers present — the catalogue does not filter; consumers do.
|
|
139
|
+
const ids = cat.map((c) => c.id).sort();
|
|
140
|
+
expect(ids).toEqual(["deepseek", "proxy", "your-llmproxy"]);
|
|
141
|
+
// Custom flag is set only for custom providers.
|
|
142
|
+
expect(cat.find((c) => c.id === "deepseek")?.custom).toBeUndefined();
|
|
143
|
+
expect(cat.find((c) => c.id === "proxy")?.custom).toBe(true);
|
|
144
|
+
expect(cat.find((c) => c.id === "your-llmproxy")?.custom).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("OAuth-handler ids carry custom:true when in customIds (custom OAuth provider)", () => {
|
|
148
|
+
const reg = makeRegistry({
|
|
149
|
+
oauthIds: ["corporate-sso"],
|
|
150
|
+
models: [{ provider: "corporate-sso", id: "x" }, { provider: "deepseek", id: "y" }],
|
|
151
|
+
});
|
|
152
|
+
const cat = _buildProviderCatalogue(reg, {}, new Set(["corporate-sso"]));
|
|
153
|
+
expect(cat.find((c) => c.id === "corporate-sso")?.hasOAuth).toBe(true);
|
|
154
|
+
expect(cat.find((c) => c.id === "corporate-sso")?.custom).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("empty customIds = no provider marked custom (default behaviour)", () => {
|
|
158
|
+
const reg = makeRegistry({
|
|
159
|
+
models: [{ provider: "deepseek", id: "x" }, { provider: "proxy", id: "y" }],
|
|
160
|
+
});
|
|
161
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
162
|
+
const ids = cat.map((c) => c.id).sort();
|
|
163
|
+
expect(ids).toEqual(["deepseek", "proxy"]);
|
|
164
|
+
expect(cat.every((c) => !c.custom)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not throw when getProviderDisplayName throws", () => {
|
|
168
|
+
const reg = {
|
|
169
|
+
authStorage: { getOAuthProviders: () => [], getAuthStatus: () => ({ configured: false }), get: () => undefined },
|
|
170
|
+
getAll: () => [{ provider: "weird", id: "x" }],
|
|
171
|
+
getProviderDisplayName: () => { throw new Error("boom"); },
|
|
172
|
+
};
|
|
173
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
174
|
+
expect(cat[0].displayName).toBe("weird");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -201,6 +201,74 @@ describe("CommandHandler", () => {
|
|
|
201
201
|
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
it("abort schedules persistent-abort retries until isIdle returns true", async () => {
|
|
205
|
+
// See change: fix-provider-retry-infinite-loop.
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const pi = createMockPi();
|
|
208
|
+
const abort = vi.fn();
|
|
209
|
+
let idleAfter = 3; // become idle after 3 polls
|
|
210
|
+
const isIdle = vi.fn(() => --idleAfter <= 0);
|
|
211
|
+
const handler = createCommandHandler(pi as any, "s1", { abort, isIdle, eventSink: vi.fn() });
|
|
212
|
+
|
|
213
|
+
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
214
|
+
expect(abort).toHaveBeenCalledOnce();
|
|
215
|
+
|
|
216
|
+
// Advance through the persistent-abort schedule. Each 200ms tick
|
|
217
|
+
// checks isIdle first, then calls abort if not idle.
|
|
218
|
+
vi.advanceTimersByTime(200); // tick 1: idleAfter 3→2, abort
|
|
219
|
+
vi.advanceTimersByTime(200); // tick 2: idleAfter 2→1, abort
|
|
220
|
+
vi.advanceTimersByTime(200); // tick 3: idleAfter 1→0, isIdle true, no abort, scheduler stops
|
|
221
|
+
vi.advanceTimersByTime(1000); // no more aborts
|
|
222
|
+
|
|
223
|
+
expect(abort.mock.calls.length).toBe(3); // initial + 2 retries
|
|
224
|
+
vi.useRealTimers();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("persistent-abort scheduler stops after 2 seconds even if never idle", async () => {
|
|
228
|
+
vi.useFakeTimers();
|
|
229
|
+
const pi = createMockPi();
|
|
230
|
+
const abort = vi.fn();
|
|
231
|
+
const isIdle = vi.fn(() => false); // never idle
|
|
232
|
+
const handler = createCommandHandler(pi as any, "s1", { abort, isIdle, eventSink: vi.fn() });
|
|
233
|
+
|
|
234
|
+
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
235
|
+
|
|
236
|
+
vi.advanceTimersByTime(2500); // safely past 2s cap
|
|
237
|
+
// initial + ~10 retries (2000ms / 200ms)
|
|
238
|
+
const calls = abort.mock.calls.length;
|
|
239
|
+
expect(calls).toBeGreaterThanOrEqual(10);
|
|
240
|
+
expect(calls).toBeLessThanOrEqual(11);
|
|
241
|
+
|
|
242
|
+
// Past cap, no more calls
|
|
243
|
+
const before = abort.mock.calls.length;
|
|
244
|
+
vi.advanceTimersByTime(1000);
|
|
245
|
+
expect(abort.mock.calls.length).toBe(before);
|
|
246
|
+
vi.useRealTimers();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("abort synthesizes auto_retry_end event after invoking abort callback (provider-retry-state)", async () => {
|
|
250
|
+
// See change: fix-provider-retry-infinite-loop.
|
|
251
|
+
const pi = createMockPi();
|
|
252
|
+
const calls: Array<{ name: string; arg?: unknown }> = [];
|
|
253
|
+
const abort = vi.fn(() => calls.push({ name: "abort" }));
|
|
254
|
+
const eventSink = vi.fn((m: unknown) => calls.push({ name: "eventSink", arg: m }));
|
|
255
|
+
const handler = createCommandHandler(pi as any, "s1", { abort, eventSink });
|
|
256
|
+
|
|
257
|
+
await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
|
|
258
|
+
|
|
259
|
+
expect(abort).toHaveBeenCalledOnce();
|
|
260
|
+
expect(eventSink).toHaveBeenCalledOnce();
|
|
261
|
+
// Order: abort() first, then synthesized event
|
|
262
|
+
expect(calls[0]!.name).toBe("abort");
|
|
263
|
+
expect(calls[1]!.name).toBe("eventSink");
|
|
264
|
+
const evt = (calls[1]!.arg as any);
|
|
265
|
+
expect(evt.type).toBe("event_forward");
|
|
266
|
+
expect(evt.sessionId).toBe("s1");
|
|
267
|
+
expect(evt.event.eventType).toBe("auto_retry_end");
|
|
268
|
+
expect(evt.event.data).toEqual({ success: false, attempt: -1, finalError: "Aborted by user" });
|
|
269
|
+
expect(typeof evt.event.timestamp).toBe("number");
|
|
270
|
+
});
|
|
271
|
+
|
|
204
272
|
it("should handle request_commands message", async () => {
|
|
205
273
|
const pi = createMockPi();
|
|
206
274
|
const handler = createCommandHandler(pi as any, "s1");
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* defaults when the probe has no match.
|
|
6
6
|
*
|
|
7
7
|
* The helper takes an optional `probe` parameter so unit tests can supply a
|
|
8
|
-
* fake catalog without needing `@
|
|
8
|
+
* fake catalog without needing `@earendil-works/pi-ai` installed — in
|
|
9
9
|
* production, registerEntry() injects `modelRegistry.find` as the probe.
|
|
10
10
|
*
|
|
11
11
|
* Spec: openspec/changes/enrich-custom-provider-model-metadata/specs/provider-auth-bridge/spec.md
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge's markdown image inliner.
|
|
3
|
+
* See change: chat-markdown-local-images-and-math.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
inlineMessageText,
|
|
8
|
+
parseImageTokens,
|
|
9
|
+
isLocalSrc,
|
|
10
|
+
mimeFromExtension,
|
|
11
|
+
hashBytes,
|
|
12
|
+
resolveLocalPath,
|
|
13
|
+
type ReadFileOutcome,
|
|
14
|
+
} from "../markdown-image-inliner.js";
|
|
15
|
+
|
|
16
|
+
/** Build a fake `readFile` from an in-memory map. Missing keys → ENOENT. */
|
|
17
|
+
function fakeReader(files: Record<string, Buffer | "EACCES" | "EISDIR" | "EOTHER">) {
|
|
18
|
+
return (absolutePath: string): ReadFileOutcome => {
|
|
19
|
+
const v = files[absolutePath];
|
|
20
|
+
if (v === undefined) return { ok: false, kind: "ENOENT" };
|
|
21
|
+
if (v === "EACCES") return { ok: false, kind: "EACCES" };
|
|
22
|
+
if (v === "EISDIR") return { ok: false, kind: "EISDIR" };
|
|
23
|
+
if (v === "EOTHER") return { ok: false, kind: "EOTHER" };
|
|
24
|
+
return { ok: true, bytes: v };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PNG = Buffer.from("png-bytes");
|
|
29
|
+
const PNG_2 = Buffer.from("different-png-bytes");
|
|
30
|
+
const SVG = Buffer.from("<svg/>");
|
|
31
|
+
|
|
32
|
+
describe("parseImageTokens", () => {
|
|
33
|
+
it("matches a single image token", () => {
|
|
34
|
+
const tokens = parseImageTokens("Hello  world");
|
|
35
|
+
expect(tokens).toHaveLength(1);
|
|
36
|
+
expect(tokens[0].alt).toBe("pic");
|
|
37
|
+
expect(tokens[0].src).toBe("/abs/path.png");
|
|
38
|
+
expect(tokens[0].token).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("matches multiple image tokens", () => {
|
|
42
|
+
const tokens = parseImageTokens(" and ");
|
|
43
|
+
expect(tokens.map((t) => t.src)).toEqual(["/x.png", "/y.png"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not match partial token without closing paren", () => {
|
|
47
|
+
const tokens = parseImageTokens("Hello ;
|
|
48
|
+
expect(tokens).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not match non-image link with single bracket", () => {
|
|
52
|
+
const tokens = parseImageTokens("[click](/x.png)");
|
|
53
|
+
expect(tokens).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("isLocalSrc", () => {
|
|
58
|
+
it("treats data: / blob: / http(s): / pi-asset: / # as non-local", () => {
|
|
59
|
+
expect(isLocalSrc("data:image/png;base64,XXX")).toBe(false);
|
|
60
|
+
expect(isLocalSrc("blob:abc-123")).toBe(false);
|
|
61
|
+
expect(isLocalSrc("http://x/y.png")).toBe(false);
|
|
62
|
+
expect(isLocalSrc("https://x/y.png")).toBe(false);
|
|
63
|
+
expect(isLocalSrc("pi-asset:abc1234567890123")).toBe(false);
|
|
64
|
+
expect(isLocalSrc("#anchor")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("treats absolute and relative paths as local", () => {
|
|
68
|
+
expect(isLocalSrc("/abs/x.png")).toBe(true);
|
|
69
|
+
expect(isLocalSrc("./rel.png")).toBe(true);
|
|
70
|
+
expect(isLocalSrc("../up.png")).toBe(true);
|
|
71
|
+
expect(isLocalSrc("file:///a/b.png")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("mimeFromExtension", () => {
|
|
76
|
+
it("matches case-insensitively", () => {
|
|
77
|
+
expect(mimeFromExtension("/x/y.PNG")).toBe("image/png");
|
|
78
|
+
expect(mimeFromExtension("/x/y.JpEg")).toBe("image/jpeg");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns null for non-image extensions", () => {
|
|
82
|
+
expect(mimeFromExtension("/x/y.txt")).toBe(null);
|
|
83
|
+
expect(mimeFromExtension("/x/y")).toBe(null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("recognizes svg", () => {
|
|
87
|
+
expect(mimeFromExtension("/x/y.svg")).toBe("image/svg+xml");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("hashBytes", () => {
|
|
92
|
+
it("produces 16 hex chars", () => {
|
|
93
|
+
const h = hashBytes(Buffer.from("hello"));
|
|
94
|
+
expect(h).toHaveLength(16);
|
|
95
|
+
expect(h).toMatch(/^[0-9a-f]{16}$/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is deterministic", () => {
|
|
99
|
+
expect(hashBytes(Buffer.from("hello"))).toBe(hashBytes(Buffer.from("hello")));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("differs for different bytes", () => {
|
|
103
|
+
expect(hashBytes(Buffer.from("a"))).not.toBe(hashBytes(Buffer.from("b")));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("resolveLocalPath", () => {
|
|
108
|
+
it("strips file:// prefix", () => {
|
|
109
|
+
expect(resolveLocalPath("file:///abs/x.png", "/cwd")).toBe("/abs/x.png");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns absolute paths as-is", () => {
|
|
113
|
+
expect(resolveLocalPath("/abs/x.png", "/cwd")).toBe("/abs/x.png");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("resolves relative paths against cwd", () => {
|
|
117
|
+
expect(resolveLocalPath("./x.png", "/cwd")).toBe("/cwd/x.png");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("inlineMessageText — pass-through cases", () => {
|
|
122
|
+
it("external https URL passes through unchanged", () => {
|
|
123
|
+
const r = inlineMessageText("", {
|
|
124
|
+
readFile: fakeReader({}),
|
|
125
|
+
cwd: "/c",
|
|
126
|
+
alreadyEmitted: new Set(),
|
|
127
|
+
});
|
|
128
|
+
expect(r.rewritten).toBe("");
|
|
129
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("data: URL passes through unchanged", () => {
|
|
133
|
+
const r = inlineMessageText("", {
|
|
134
|
+
readFile: fakeReader({}),
|
|
135
|
+
cwd: "/c",
|
|
136
|
+
alreadyEmitted: new Set(),
|
|
137
|
+
});
|
|
138
|
+
expect(r.rewritten).toBe("");
|
|
139
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("blob: URL passes through unchanged", () => {
|
|
143
|
+
const r = inlineMessageText("", {
|
|
144
|
+
readFile: fakeReader({}),
|
|
145
|
+
cwd: "/c",
|
|
146
|
+
alreadyEmitted: new Set(),
|
|
147
|
+
});
|
|
148
|
+
expect(r.rewritten).toBe("");
|
|
149
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("partially-formed token (no closing paren) passes through unchanged", () => {
|
|
153
|
+
const text = "streaming chunk ,
|
|
156
|
+
cwd: "/c",
|
|
157
|
+
alreadyEmitted: new Set(),
|
|
158
|
+
});
|
|
159
|
+
expect(r.rewritten).toBe(text);
|
|
160
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("idempotent on text containing pi-asset tokens", () => {
|
|
164
|
+
const text = "Here is ";
|
|
165
|
+
const alreadyEmitted = new Set<string>(["abc1234567890123"]);
|
|
166
|
+
const r1 = inlineMessageText(text, {
|
|
167
|
+
readFile: fakeReader({}),
|
|
168
|
+
cwd: "/c",
|
|
169
|
+
alreadyEmitted,
|
|
170
|
+
});
|
|
171
|
+
const r2 = inlineMessageText(r1.rewritten, {
|
|
172
|
+
readFile: fakeReader({}),
|
|
173
|
+
cwd: "/c",
|
|
174
|
+
alreadyEmitted,
|
|
175
|
+
});
|
|
176
|
+
expect(r1.rewritten).toBe(text);
|
|
177
|
+
expect(r2.rewritten).toBe(text);
|
|
178
|
+
expect(r1.assetsToEmit).toEqual([]);
|
|
179
|
+
expect(r2.assetsToEmit).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("inlineMessageText — happy path", () => {
|
|
184
|
+
it("inlines a single absolute-path local image", () => {
|
|
185
|
+
const r = inlineMessageText("Here is  end", {
|
|
186
|
+
readFile: fakeReader({ "/home/me/shot.png": PNG }),
|
|
187
|
+
cwd: "/c",
|
|
188
|
+
alreadyEmitted: new Set(),
|
|
189
|
+
});
|
|
190
|
+
const expectedHash = hashBytes(PNG);
|
|
191
|
+
expect(r.rewritten).toBe(`Here is  end`);
|
|
192
|
+
expect(r.assetsToEmit).toEqual([
|
|
193
|
+
{ hash: expectedHash, mimeType: "image/png", data: PNG.toString("base64") },
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("inlines a relative-path image resolved against cwd", () => {
|
|
198
|
+
const r = inlineMessageText("", {
|
|
199
|
+
readFile: fakeReader({ "/work/shot.png": PNG }),
|
|
200
|
+
cwd: "/work",
|
|
201
|
+
alreadyEmitted: new Set(),
|
|
202
|
+
});
|
|
203
|
+
const expectedHash = hashBytes(PNG);
|
|
204
|
+
expect(r.rewritten).toBe(``);
|
|
205
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("inlines an SVG as image/svg+xml", () => {
|
|
209
|
+
const r = inlineMessageText("", {
|
|
210
|
+
readFile: fakeReader({ "/d.svg": SVG }),
|
|
211
|
+
cwd: "/c",
|
|
212
|
+
alreadyEmitted: new Set(),
|
|
213
|
+
});
|
|
214
|
+
expect(r.assetsToEmit[0].mimeType).toBe("image/svg+xml");
|
|
215
|
+
expect(r.rewritten).toContain("pi-asset:");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("matches extension case-insensitively", () => {
|
|
219
|
+
const r = inlineMessageText("", {
|
|
220
|
+
readFile: fakeReader({ "/x/SHOT.PNG": PNG }),
|
|
221
|
+
cwd: "/c",
|
|
222
|
+
alreadyEmitted: new Set(),
|
|
223
|
+
});
|
|
224
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
225
|
+
expect(r.assetsToEmit[0].mimeType).toBe("image/png");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("inlineMessageText — dedup", () => {
|
|
230
|
+
it("emits one asset for two refs to the same file in one message", () => {
|
|
231
|
+
const r = inlineMessageText(" and ", {
|
|
232
|
+
readFile: fakeReader({ "/same.png": PNG }),
|
|
233
|
+
cwd: "/c",
|
|
234
|
+
alreadyEmitted: new Set(),
|
|
235
|
+
});
|
|
236
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
237
|
+
const hash = hashBytes(PNG);
|
|
238
|
+
expect(r.rewritten).toBe(` and `);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("emits zero assets for refs to a hash already in alreadyEmitted", () => {
|
|
242
|
+
const hash = hashBytes(PNG);
|
|
243
|
+
const set = new Set<string>([hash]);
|
|
244
|
+
const r = inlineMessageText("", {
|
|
245
|
+
readFile: fakeReader({ "/same.png": PNG }),
|
|
246
|
+
cwd: "/c",
|
|
247
|
+
alreadyEmitted: set,
|
|
248
|
+
});
|
|
249
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
250
|
+
expect(r.rewritten).toBe(``);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("dedup carries across multiple inliner invocations sharing the same Set", () => {
|
|
254
|
+
const set = new Set<string>();
|
|
255
|
+
const r1 = inlineMessageText("", {
|
|
256
|
+
readFile: fakeReader({ "/x.png": PNG }),
|
|
257
|
+
cwd: "/c",
|
|
258
|
+
alreadyEmitted: set,
|
|
259
|
+
});
|
|
260
|
+
expect(r1.assetsToEmit).toHaveLength(1);
|
|
261
|
+
const r2 = inlineMessageText("", {
|
|
262
|
+
readFile: fakeReader({ "/x.png": PNG }),
|
|
263
|
+
cwd: "/c",
|
|
264
|
+
alreadyEmitted: set,
|
|
265
|
+
});
|
|
266
|
+
expect(r2.assetsToEmit).toEqual([]);
|
|
267
|
+
expect(r2.rewritten).toBe(r1.rewritten.replace("![a]", "![b]"));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("inlineMessageText — placeholder branches", () => {
|
|
272
|
+
it("non-image extension yields [unsupported image type: ...]", () => {
|
|
273
|
+
const r = inlineMessageText("", {
|
|
274
|
+
readFile: fakeReader({ "/notes.txt": Buffer.from("hi") }),
|
|
275
|
+
cwd: "/c",
|
|
276
|
+
alreadyEmitted: new Set(),
|
|
277
|
+
});
|
|
278
|
+
expect(r.rewritten).toBe("[unsupported image type: /notes.txt]");
|
|
279
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("ENOENT yields [image not found: ...]", () => {
|
|
283
|
+
const r = inlineMessageText("", {
|
|
284
|
+
readFile: fakeReader({}),
|
|
285
|
+
cwd: "/c",
|
|
286
|
+
alreadyEmitted: new Set(),
|
|
287
|
+
});
|
|
288
|
+
expect(r.rewritten).toBe("[image not found: /no/such.png]");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("EACCES is folded into [image not found: ...] (no permission leak)", () => {
|
|
292
|
+
const r = inlineMessageText("", {
|
|
293
|
+
readFile: fakeReader({ "/root/private.png": "EACCES" }),
|
|
294
|
+
cwd: "/c",
|
|
295
|
+
alreadyEmitted: new Set(),
|
|
296
|
+
});
|
|
297
|
+
expect(r.rewritten).toBe("[image not found: /root/private.png]");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("EISDIR yields [image read failed: ...]", () => {
|
|
301
|
+
const r = inlineMessageText("", {
|
|
302
|
+
readFile: fakeReader({ "/home/me": "EISDIR" }),
|
|
303
|
+
cwd: "/c",
|
|
304
|
+
alreadyEmitted: new Set(),
|
|
305
|
+
});
|
|
306
|
+
expect(r.rewritten).toBe("[image read failed: /home/me]");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("oversized image yields [image too large: ...]", () => {
|
|
310
|
+
const big = Buffer.alloc(6 * 1024 * 1024, 0xab);
|
|
311
|
+
const r = inlineMessageText("", {
|
|
312
|
+
readFile: fakeReader({ "/big.png": big }),
|
|
313
|
+
cwd: "/c",
|
|
314
|
+
alreadyEmitted: new Set(),
|
|
315
|
+
});
|
|
316
|
+
expect(r.rewritten).toMatch(/^\[image too large: \/big\.png \(6\.0 MB\)\]$/);
|
|
317
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("per-message budget exhausted after caps reached", () => {
|
|
321
|
+
// Five distinct 4.5 MB images. First four fit (18 MB ≤ 20 MB cap), fifth
|
|
322
|
+
// pushes total to 22.5 MB and is rejected.
|
|
323
|
+
const make = (i: number) => Buffer.alloc(4.5 * 1024 * 1024, i);
|
|
324
|
+
const files: Record<string, Buffer> = {};
|
|
325
|
+
let text = "";
|
|
326
|
+
for (let i = 0; i < 5; i++) {
|
|
327
|
+
const p = `/img${i}.png`;
|
|
328
|
+
files[p] = make(i);
|
|
329
|
+
text += ` `;
|
|
330
|
+
}
|
|
331
|
+
const r = inlineMessageText(text, {
|
|
332
|
+
readFile: fakeReader(files),
|
|
333
|
+
cwd: "/c",
|
|
334
|
+
alreadyEmitted: new Set(),
|
|
335
|
+
});
|
|
336
|
+
expect(r.assetsToEmit).toHaveLength(4);
|
|
337
|
+
expect(r.rewritten).toContain("[message asset budget exhausted: /img4.png]");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("already-registered asset does not count against budget", () => {
|
|
341
|
+
// 4 MB new + 18 MB already-registered → cumulative new bytes = 4 MB
|
|
342
|
+
const newImg = Buffer.alloc(4 * 1024 * 1024, 1);
|
|
343
|
+
const oldImg = Buffer.alloc(18 * 1024 * 1024, 2);
|
|
344
|
+
const oldHash = hashBytes(oldImg);
|
|
345
|
+
const r = inlineMessageText(" ", {
|
|
346
|
+
readFile: fakeReader({ "/new.png": newImg, "/old.png": oldImg }),
|
|
347
|
+
cwd: "/c",
|
|
348
|
+
alreadyEmitted: new Set([oldHash]),
|
|
349
|
+
});
|
|
350
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
351
|
+
expect(r.assetsToEmit[0].hash).toBe(hashBytes(newImg));
|
|
352
|
+
expect(r.rewritten).toContain(`pi-asset:${hashBytes(newImg)}`);
|
|
353
|
+
expect(r.rewritten).toContain(`pi-asset:${oldHash}`);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* pi 0.70's RPC mode (the only mode dashboard headless sessions run
|
|
12
12
|
* under) defines `ExtensionUIContext.custom` as an unconditional no-op
|
|
13
13
|
* (`async custom() { return undefined; }`, see
|
|
14
|
-
* `~/.nvm/.../@
|
|
14
|
+
* `~/.nvm/.../@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.js`
|
|
15
15
|
* lines 150-153). Awaiting that primitive resolves to `undefined`
|
|
16
16
|
* synchronously, and the TUI arm's `bus.respond({ cancelled: true,
|
|
17
17
|
* source: "tui" })` triggers the PromptBus's first-response-wins
|