@baique/pi-web-sky 0.1.35 → 0.1.36
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +16 -16
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
- package/.next/server/app/api/agent/[id]/events/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
- package/.next/server/app/api/cwd/validate/route.js +1 -1
- package/.next/server/app/api/default-cwd/route.js +1 -1
- package/.next/server/app/api/file-index/route.js +1 -1
- package/.next/server/app/api/files/[...path]/route.js +1 -1
- package/.next/server/app/api/mcp/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/search/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js +1 -1
- package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/install/route.js +1 -1
- package/.next/server/app/api/skills/route.js +2 -31
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/update/route.js +1 -1
- package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +16 -16
- package/.next/server/chunks/2404.js +1 -1
- package/.next/server/chunks/3500.js +30 -0
- package/.next/server/chunks/4226.js +2 -2
- package/.next/server/chunks/542.js +10 -5
- package/.next/server/chunks/5794.js +1 -1
- package/.next/server/chunks/6429.js +1 -1
- package/.next/server/chunks/898.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
- package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
- package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
- package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
- package/.next/static/css/422c7dc0faa04d89.css +1 -0
- package/.next/trace +8 -9
- package/.next/trace-build +1 -1
- package/bin/pi-web-sky.js +15 -20
- package/lib/agent-client.test.mjs +64 -0
- package/lib/agent-client.ts +53 -0
- package/lib/agent-event-connection.test.mjs +193 -0
- package/lib/agent-event-connection.ts +202 -0
- package/lib/agent-event-stream.test.mjs +155 -0
- package/lib/agent-event-stream.ts +132 -0
- package/lib/agent-event-wire.test.mjs +278 -0
- package/lib/agent-event-wire.ts +107 -0
- package/lib/agent-phase.ts +24 -0
- package/lib/allowed-roots.ts +31 -0
- package/lib/ansi.test.mjs +52 -0
- package/lib/ansi.ts +211 -0
- package/lib/api-types.ts +139 -0
- package/lib/app-update.test.mjs +27 -0
- package/lib/app-update.ts +28 -0
- package/lib/atomic-file.test.mjs +37 -0
- package/lib/atomic-file.ts +34 -0
- package/lib/audit-session.ts +235 -0
- package/lib/bash-output.test.mjs +63 -0
- package/lib/bash-output.ts +53 -0
- package/lib/bg-image.ts +129 -0
- package/lib/board-events.ts +83 -0
- package/lib/board-purge.test.mjs +66 -0
- package/lib/board-purge.ts +65 -0
- package/lib/board-search.test.mjs +110 -0
- package/lib/board-search.ts +63 -0
- package/lib/board-store.test.mjs +542 -0
- package/lib/board-store.ts +794 -0
- package/lib/board-types.ts +96 -0
- package/lib/board-utils.ts +17 -0
- package/lib/bounded-form-data.test.mjs +54 -0
- package/lib/bounded-form-data.ts +49 -0
- package/lib/browser-notifications.test.mjs +167 -0
- package/lib/browser-notifications.ts +104 -0
- package/lib/chat-lazy-load.test.mjs +140 -0
- package/lib/chat-lazy-load.ts +64 -0
- package/lib/clipboard-paths.test.mjs +140 -0
- package/lib/clipboard-paths.ts +192 -0
- package/lib/clipboard.ts +18 -0
- package/lib/compaction-summary.test.mjs +44 -0
- package/lib/compaction-summary.ts +34 -0
- package/lib/custom-ui-terminal.test.mjs +32 -0
- package/lib/custom-ui-terminal.ts +27 -0
- package/lib/deepseek-pricing.test.mjs +120 -0
- package/lib/deepseek-pricing.ts +71 -0
- package/lib/directory-browser.test.mjs +60 -0
- package/lib/directory-browser.ts +83 -0
- package/lib/draft-stash.test.mjs +91 -0
- package/lib/draft-stash.ts +102 -0
- package/lib/draft-store.ts +105 -0
- package/lib/dropdown-direction.ts +27 -0
- package/lib/extension-command.test.mjs +63 -0
- package/lib/extension-command.ts +81 -0
- package/lib/file-access.test.mjs +31 -0
- package/lib/file-access.ts +59 -0
- package/lib/file-dirent.test.mjs +56 -0
- package/lib/file-dirent.ts +17 -0
- package/lib/file-explorer-state.test.mjs +63 -0
- package/lib/file-explorer-state.ts +36 -0
- package/lib/file-fuzzy.test.mjs +29 -0
- package/lib/file-fuzzy.ts +189 -0
- package/lib/file-links.test.mjs +92 -0
- package/lib/file-links.ts +148 -0
- package/lib/file-paths.ts +43 -0
- package/lib/file-types.test.mjs +34 -0
- package/lib/file-types.ts +73 -0
- package/lib/file-upload.test.mjs +70 -0
- package/lib/file-upload.ts +59 -0
- package/lib/file-viewer-state.test.mjs +20 -0
- package/lib/file-viewer-state.ts +15 -0
- package/lib/frontmatter.test.mjs +55 -0
- package/lib/frontmatter.ts +87 -0
- package/lib/git-changes.test.mjs +52 -0
- package/lib/git-changes.ts +232 -0
- package/lib/git-status.ts +47 -0
- package/lib/git-types.ts +29 -0
- package/lib/http-dispatcher.test.mjs +83 -0
- package/lib/http-dispatcher.ts +86 -0
- package/lib/i18n/format.test.mjs +19 -0
- package/lib/i18n/format.ts +60 -0
- package/lib/i18n/messages/en.ts +662 -0
- package/lib/i18n/messages/zh-CN.ts +661 -0
- package/lib/i18n/registry.test.mjs +32 -0
- package/lib/i18n/registry.ts +43 -0
- package/lib/i18n/types.ts +15 -0
- package/lib/image-attachments.test.mjs +27 -0
- package/lib/image-attachments.ts +56 -0
- package/lib/initial-navigation.test.mjs +65 -0
- package/lib/initial-navigation.ts +15 -0
- package/lib/markdown.test.mjs +104 -0
- package/lib/markdown.ts +347 -0
- package/lib/message-display.test.mjs +130 -0
- package/lib/message-display.ts +47 -0
- package/lib/model-catalog.test.mjs +327 -0
- package/lib/model-catalog.ts +414 -0
- package/lib/model-discovery-auth.ts +58 -0
- package/lib/model-discovery.test.mjs +50 -0
- package/lib/model-discovery.ts +75 -0
- package/lib/model-id-preference.ts +46 -0
- package/lib/model-scope.test.mjs +193 -0
- package/lib/model-scope.ts +198 -0
- package/lib/models-cache.test.mjs +122 -0
- package/lib/models-cache.ts +90 -0
- package/lib/models-config-store.test.mjs +200 -0
- package/lib/models-config-store.ts +84 -0
- package/lib/next-config-esm.test.mjs +14 -0
- package/lib/next-config.test.mjs +13 -0
- package/lib/node-version.test.mjs +33 -0
- package/lib/normalize.test.mjs +54 -0
- package/lib/normalize.ts +63 -0
- package/lib/npx.ts +62 -0
- package/lib/panel-layout.test.mjs +56 -0
- package/lib/panel-layout.ts +52 -0
- package/lib/patch.test.mjs +110 -0
- package/lib/patch.ts +134 -0
- package/lib/path-security.ts +42 -0
- package/lib/paths.test.mjs +58 -0
- package/lib/paths.ts +71 -0
- package/lib/pi-types.ts +183 -0
- package/lib/pi-web-options.test.mjs +63 -0
- package/lib/process-lifecycle.test.mjs +83 -0
- package/lib/project-command-env.test.mjs +298 -0
- package/lib/project-command-env.ts +129 -0
- package/lib/project-groups.test.mjs +60 -0
- package/lib/project-groups.ts +53 -0
- package/lib/project-identity.test.mjs +37 -0
- package/lib/project-identity.ts +26 -0
- package/lib/project-tree.test.mjs +115 -0
- package/lib/project-tree.ts +176 -0
- package/lib/project-trust.test.mjs +117 -0
- package/lib/project-trust.ts +48 -0
- package/lib/prompt-recovery.test.mjs +33 -0
- package/lib/prompt-recovery.ts +47 -0
- package/lib/provider-api-key-route.test.mjs +12 -0
- package/lib/provider-credential-store.test.mjs +126 -0
- package/lib/provider-credential-store.ts +114 -0
- package/lib/provider-listing-runtime.ts +37 -0
- package/lib/provider-listing.test.mjs +114 -0
- package/lib/provider-listing.ts +118 -0
- package/lib/provider-services.ts +64 -0
- package/lib/request-security.test.mjs +256 -0
- package/lib/request-security.ts +136 -0
- package/lib/rpc-manager-shutdown.test.mjs +372 -0
- package/lib/rpc-manager-widgets.test.mjs +342 -0
- package/lib/rpc-manager.test.mjs +139 -0
- package/lib/rpc-manager.ts +1703 -0
- package/lib/rpc-session-info.test.mjs +93 -0
- package/lib/session-delete.test.mjs +53 -0
- package/lib/session-delete.ts +86 -0
- package/lib/session-file-references-core.ts +82 -0
- package/lib/session-file-references.test.mjs +91 -0
- package/lib/session-file-references.ts +29 -0
- package/lib/session-path.test.mjs +24 -0
- package/lib/session-path.ts +11 -0
- package/lib/session-reader.pagination.test.mjs +115 -0
- package/lib/session-reader.test.mjs +446 -0
- package/lib/session-reader.ts +548 -0
- package/lib/session-resolve.test.mjs +65 -0
- package/lib/session-row-context-menu.test.mjs +46 -0
- package/lib/session-row-context-menu.ts +29 -0
- package/lib/session-scanner.test.mjs +151 -0
- package/lib/session-scanner.ts +238 -0
- package/lib/session-search.test.mjs +173 -0
- package/lib/session-search.ts +284 -0
- package/lib/session-stats.ts +26 -0
- package/lib/session-timing.test.mjs +107 -0
- package/lib/session-timing.ts +45 -0
- package/lib/session-title.test.mjs +284 -0
- package/lib/session-title.ts +255 -0
- package/lib/skill-frontmatter.test.mjs +75 -0
- package/lib/skill-frontmatter.ts +40 -0
- package/lib/skill-lock.test.mjs +182 -0
- package/lib/skill-lock.ts +146 -0
- package/lib/skill-updates.test.mjs +182 -0
- package/lib/skill-updates.ts +263 -0
- package/lib/skills-service.ts +16 -0
- package/lib/slash-display.test.mjs +122 -0
- package/lib/slash-display.ts +30 -0
- package/lib/sqlite-db.test.mjs +140 -0
- package/lib/sqlite-db.ts +205 -0
- package/lib/startup-preferences.test.mjs +130 -0
- package/lib/startup-preferences.ts +65 -0
- package/lib/streaming-message.test.mjs +212 -0
- package/lib/streaming-message.ts +151 -0
- package/lib/subagent-widget.test.mjs +87 -0
- package/lib/subagent-widget.ts +109 -0
- package/lib/sync-room-server.mjs +137 -0
- package/lib/task-card-store.test.mjs +309 -0
- package/lib/task-card-store.ts +559 -0
- package/lib/task-scheduler.ts +498 -0
- package/lib/task-store.test.mjs +216 -0
- package/lib/task-store.ts +271 -0
- package/lib/terminal-input.test.mjs +46 -0
- package/lib/terminal-input.ts +63 -0
- package/lib/terminal-manager.test.mjs +71 -0
- package/lib/terminal-manager.ts +245 -0
- package/lib/tool-execution-progress.test.mjs +32 -0
- package/lib/tool-execution-progress.ts +29 -0
- package/lib/tool-names.ts +25 -0
- package/lib/tool-preset-preference.test.mjs +72 -0
- package/lib/tool-preset-preference.ts +41 -0
- package/lib/tool-presets.test.mjs +56 -0
- package/lib/tool-presets.ts +42 -0
- package/lib/turn-written-files.test.mjs +137 -0
- package/lib/turn-written-files.ts +60 -0
- package/lib/types.ts +349 -0
- package/lib/wallpaper-settings.ts +191 -0
- package/lib/web-auth.test.mjs +50 -0
- package/lib/web-auth.ts +45 -0
- package/lib/workspace-memory.test.mjs +129 -0
- package/lib/workspace-memory.ts +98 -0
- package/lib/worktree.test.mjs +50 -0
- package/lib/worktree.ts +250 -0
- package/package.json +3 -1
- package/server.mjs +34 -0
- package/.next/server/chunks/7513.js +0 -30
- package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
- package/.next/static/css/cf25164cb7c04d95.css +0 -1
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
- /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
const { MAX_PROJECTED_TREE_DEPTH, projectTreeForResponse } = await import("./project-tree.ts");
|
|
5
|
+
|
|
6
|
+
const msg = (id, role, content) => ({ type: "message", id, parentId: null, timestamp: "t", message: { role, content } });
|
|
7
|
+
const info = (id) => ({ type: "session_info", id, parentId: null, timestamp: "t", name: "x" });
|
|
8
|
+
const model = (id) => ({ type: "model_change", id, parentId: null, timestamp: "t", provider: "test", modelId: "test" });
|
|
9
|
+
const node = (entry, children = []) => ({ entry, children });
|
|
10
|
+
|
|
11
|
+
function findProjectedNode(nodes, id) {
|
|
12
|
+
const pending = [...nodes];
|
|
13
|
+
while (pending.length > 0) {
|
|
14
|
+
const current = pending.pop();
|
|
15
|
+
if (current.entry.id === id) return current;
|
|
16
|
+
pending.push(...current.children);
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("attaches the first branch message preview to the contracted representative", () => {
|
|
22
|
+
const arm1Leaf = node(msg("a2", "assistant", "答一"));
|
|
23
|
+
const arm1 = node(info("s1"), [node(msg("u2", "user", [
|
|
24
|
+
{ type: "text", text: "分支一的问题" },
|
|
25
|
+
{ type: "image", data: "base64-secret", mimeType: "image/png" },
|
|
26
|
+
]), [arm1Leaf])]);
|
|
27
|
+
const arm2 = node(msg("u2b", "user", "分支二的问题"), [node(msg("a2b", "assistant", "答二"))]);
|
|
28
|
+
const a1 = node(msg("a1", "assistant", "答"), [arm1, arm2]);
|
|
29
|
+
const root = node(msg("u1", "user", "第一问"), [a1]);
|
|
30
|
+
|
|
31
|
+
const [projectedRoot] = projectTreeForResponse([root]);
|
|
32
|
+
const projectedA1 = projectedRoot.children[0];
|
|
33
|
+
assert.equal(projectedA1.entry.id, "a1");
|
|
34
|
+
assert.equal(projectedA1.children[0].entry.id, "a2");
|
|
35
|
+
assert.deepEqual(projectedA1.children[0].compressedEntryIds, ["s1", "u2"]);
|
|
36
|
+
assert.deepEqual(projectedA1.children[0].branchPreview, { role: "user", text: "分支一的问题" });
|
|
37
|
+
assert.equal(projectedA1.children[1].entry.id, "a2b");
|
|
38
|
+
assert.deepEqual(projectedA1.children[1].compressedEntryIds, ["u2b"]);
|
|
39
|
+
assert.deepEqual(projectedA1.children[1].branchPreview, { role: "user", text: "分支二的问题" });
|
|
40
|
+
assert.equal(JSON.stringify(projectedRoot).includes("base64-secret"), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("does not copy thinking or tool payloads from a compressed assistant label source", () => {
|
|
44
|
+
const assistant = node(msg("a1", "assistant", [
|
|
45
|
+
{ type: "thinking", thinking: "thinking-secret" },
|
|
46
|
+
{ type: "text", text: "可见回答" },
|
|
47
|
+
{ type: "toolCall", id: "tc1", name: "read", arguments: { value: "tool-secret" } },
|
|
48
|
+
]), [node(info("leaf1"))]);
|
|
49
|
+
const sibling = node(msg("u2", "user", "另一个分支"), [node(info("leaf2"))]);
|
|
50
|
+
const projected = projectTreeForResponse([node(info("root"), [assistant, sibling])]);
|
|
51
|
+
const leaf = findProjectedNode(projected, "leaf1");
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(leaf.branchPreview, { role: "assistant", text: "可见回答" });
|
|
54
|
+
const serialized = JSON.stringify(projected);
|
|
55
|
+
assert.equal(serialized.includes("thinking-secret"), false);
|
|
56
|
+
assert.equal(serialized.includes("tool-secret"), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("carries previews through non-message roots in multi-root trees", () => {
|
|
60
|
+
const root1 = node(model("m1"), [node(msg("u1", "user", "第一个问题"), [node(msg("a1", "assistant", "回答一"))])]);
|
|
61
|
+
const root2 = node(info("s2"), [node(msg("u2", "user", "第二个问题"), [node(msg("a2", "assistant", "回答二"))])]);
|
|
62
|
+
const projected = projectTreeForResponse([root1, root2]);
|
|
63
|
+
|
|
64
|
+
assert.deepEqual(projected[0].children[0].branchPreview, { role: "user", text: "第一个问题" });
|
|
65
|
+
assert.deepEqual(projected[1].children[0].branchPreview, { role: "user", text: "第二个问题" });
|
|
66
|
+
assert.deepEqual(projected[0].children[0].compressedEntryIds, ["u1"]);
|
|
67
|
+
assert.deepEqual(projected[1].children[0].compressedEntryIds, ["u2"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("normalizes and bounds preview text and labels image-only messages", () => {
|
|
71
|
+
const longText = ` 第一行\n\n第二行 ${"x".repeat(80)}`;
|
|
72
|
+
const textArm = node(msg("u1", "user", longText), [node(info("leaf1"))]);
|
|
73
|
+
const imageArm = node(msg("u2", "user", [{ type: "image", data: "secret-image", mimeType: "image/png" }]), [node(info("leaf2"))]);
|
|
74
|
+
const projected = projectTreeForResponse([node(info("root"), [textArm, imageArm])]);
|
|
75
|
+
const textPreview = findProjectedNode(projected, "leaf1").branchPreview;
|
|
76
|
+
const imagePreview = findProjectedNode(projected, "leaf2").branchPreview;
|
|
77
|
+
|
|
78
|
+
assert.equal(textPreview.text.startsWith("第一行 第二行 "), true);
|
|
79
|
+
assert.equal(textPreview.text.length, 41);
|
|
80
|
+
assert.equal(textPreview.text.endsWith("…"), true);
|
|
81
|
+
assert.deepEqual(imagePreview, { role: "user", text: "[image]" });
|
|
82
|
+
assert.equal(JSON.stringify(projected).includes("secret-image"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("does not copy unknown message roles into previews", () => {
|
|
86
|
+
const unknownRole = `unknown-role-${"x".repeat(80)}`;
|
|
87
|
+
const arm = node(msg("m1", unknownRole, "可见内容"), [node(info("leaf1"))]);
|
|
88
|
+
const sibling = node(msg("u2", "user", "另一个分支"), [node(info("leaf2"))]);
|
|
89
|
+
const projected = projectTreeForResponse([node(info("root"), [arm, sibling])]);
|
|
90
|
+
const preview = findProjectedNode(projected, "leaf1").branchPreview;
|
|
91
|
+
|
|
92
|
+
assert.deepEqual(preview, { text: "可见内容" });
|
|
93
|
+
assert.equal(JSON.stringify(projected).includes("unknown-role-"), false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("carries previews through the depth-limit flattening path", () => {
|
|
97
|
+
let deepArm = node(info("prefix"), [node(msg("deep-user", "user", "深层问题"), [node(info("deep-leaf"))])]);
|
|
98
|
+
for (let i = 0; i < MAX_PROJECTED_TREE_DEPTH + 2; i++) {
|
|
99
|
+
deepArm = node(info(`branch-${i}`), [deepArm, node(info(`side-${i}`))]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const projected = projectTreeForResponse([deepArm]);
|
|
103
|
+
const leaf = findProjectedNode(projected, "deep-leaf");
|
|
104
|
+
assert.deepEqual(leaf.branchPreview, { role: "user", text: "深层问题" });
|
|
105
|
+
assert.deepEqual(leaf.compressedEntryIds, ["prefix", "deep-user"]);
|
|
106
|
+
assert.equal(findProjectedNode(projected, "deep-user"), undefined);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("linear sessions still project to root + leaf only", () => {
|
|
110
|
+
const root = node(msg("u1", "user", "第一问"), [node(msg("a1", "assistant", "答"))]);
|
|
111
|
+
const [projected] = projectTreeForResponse([root]);
|
|
112
|
+
assert.equal(projected.entry.id, "u1");
|
|
113
|
+
assert.equal(projected.children.length, 1);
|
|
114
|
+
assert.equal(projected.children[0].entry.id, "a1");
|
|
115
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { BranchPreview } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
// BranchNavigator still traverses recursively, so keep the response tree shallow.
|
|
4
|
+
export const MAX_PROJECTED_TREE_DEPTH = 200;
|
|
5
|
+
const MAX_BRANCH_PREVIEW_LENGTH = 40;
|
|
6
|
+
|
|
7
|
+
type ProjectableEntry = {
|
|
8
|
+
id: string;
|
|
9
|
+
type: string;
|
|
10
|
+
message?: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ProjectableTreeNode<T> = {
|
|
14
|
+
entry: ProjectableEntry;
|
|
15
|
+
children: T[];
|
|
16
|
+
compressedEntryIds?: string[];
|
|
17
|
+
branchPreview?: BranchPreview;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function appendPreviewText(current: string, value: unknown): string {
|
|
25
|
+
if (typeof value !== "string" || current.length > MAX_BRANCH_PREVIEW_LENGTH) return current;
|
|
26
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
27
|
+
if (!normalized) return current;
|
|
28
|
+
const separator = current ? " " : "";
|
|
29
|
+
const prefix = current + separator;
|
|
30
|
+
if (prefix.length >= MAX_BRANCH_PREVIEW_LENGTH + 1) {
|
|
31
|
+
return prefix.slice(0, MAX_BRANCH_PREVIEW_LENGTH + 1);
|
|
32
|
+
}
|
|
33
|
+
const remaining = MAX_BRANCH_PREVIEW_LENGTH + 1 - prefix.length;
|
|
34
|
+
return prefix + normalized.slice(0, remaining);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function previewForEntry(entry: ProjectableEntry): BranchPreview | undefined {
|
|
38
|
+
if (entry.type !== "message" || !isRecord(entry.message) || typeof entry.message.role !== "string") {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const content = entry.message.content;
|
|
43
|
+
let text = "";
|
|
44
|
+
let hasImage = false;
|
|
45
|
+
if (typeof content === "string") {
|
|
46
|
+
text = appendPreviewText(text, content);
|
|
47
|
+
} else if (Array.isArray(content)) {
|
|
48
|
+
for (const block of content) {
|
|
49
|
+
if (!isRecord(block)) continue;
|
|
50
|
+
if (block.type === "image") hasImage = true;
|
|
51
|
+
if (block.type === "text") text = appendPreviewText(text, block.text);
|
|
52
|
+
if (text.length > MAX_BRANCH_PREVIEW_LENGTH) break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (text.length > MAX_BRANCH_PREVIEW_LENGTH) {
|
|
57
|
+
text = text.slice(0, MAX_BRANCH_PREVIEW_LENGTH) + "…";
|
|
58
|
+
} else if (!text) {
|
|
59
|
+
text = hasImage
|
|
60
|
+
? "[image]"
|
|
61
|
+
: entry.message.role === "assistant"
|
|
62
|
+
? "[assistant]"
|
|
63
|
+
: "message";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const role = entry.message.role === "user" || entry.message.role === "assistant"
|
|
67
|
+
? entry.message.role
|
|
68
|
+
: undefined;
|
|
69
|
+
return { ...(role ? { role } : {}), text };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Project the session tree into the shallow navigation tree sent to the client.
|
|
74
|
+
* Keeps roots, branch points, and leaves while contracting single-child chains
|
|
75
|
+
* without recursive traversal. Contracted entry IDs are attached to the next
|
|
76
|
+
* visible node so the UI can still recognize an active leaf inside the chain.
|
|
77
|
+
*/
|
|
78
|
+
export function projectTreeForResponse<T extends ProjectableTreeNode<T>>(
|
|
79
|
+
nodes: T[]
|
|
80
|
+
): T[] {
|
|
81
|
+
const keep = new Set<T>();
|
|
82
|
+
const roots = new Set(nodes);
|
|
83
|
+
const seen = new Set<T>();
|
|
84
|
+
const stack = [...nodes];
|
|
85
|
+
|
|
86
|
+
while (stack.length > 0) {
|
|
87
|
+
const node = stack.pop()!;
|
|
88
|
+
if (seen.has(node)) continue;
|
|
89
|
+
seen.add(node);
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
roots.has(node) ||
|
|
93
|
+
node.children.length !== 1
|
|
94
|
+
) {
|
|
95
|
+
keep.add(node);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const child of node.children) {
|
|
99
|
+
stack.push(child);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cloneNode = (node: T, compressedEntryIds?: string[], branchPreview?: BranchPreview): T => ({
|
|
104
|
+
...node,
|
|
105
|
+
children: [],
|
|
106
|
+
...(compressedEntryIds?.length ? { compressedEntryIds } : {}),
|
|
107
|
+
...(branchPreview ? { branchPreview } : {}),
|
|
108
|
+
});
|
|
109
|
+
const projectedRoots = nodes.map((node) => cloneNode(node, undefined, previewForEntry(node.entry)));
|
|
110
|
+
const tasks = nodes.map((source, index) => ({
|
|
111
|
+
source,
|
|
112
|
+
projected: projectedRoots[index],
|
|
113
|
+
depth: 1,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
const appendFlattenedKeptDescendants = (source: T, projectedParent: T) => {
|
|
117
|
+
const pending = [{
|
|
118
|
+
node: source,
|
|
119
|
+
compressedEntryIds: [] as string[],
|
|
120
|
+
branchPreview: undefined as BranchPreview | undefined,
|
|
121
|
+
}];
|
|
122
|
+
const flattenedSeen = new Set<T>();
|
|
123
|
+
|
|
124
|
+
while (pending.length > 0) {
|
|
125
|
+
const { node, compressedEntryIds, branchPreview } = pending.pop()!;
|
|
126
|
+
if (flattenedSeen.has(node)) continue;
|
|
127
|
+
flattenedSeen.add(node);
|
|
128
|
+
const nextPreview = branchPreview ?? previewForEntry(node.entry);
|
|
129
|
+
|
|
130
|
+
if (keep.has(node)) {
|
|
131
|
+
projectedParent.children.push(cloneNode(node, compressedEntryIds, nextPreview));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
135
|
+
pending.push({
|
|
136
|
+
node: node.children[i],
|
|
137
|
+
compressedEntryIds: keep.has(node)
|
|
138
|
+
? []
|
|
139
|
+
: [...compressedEntryIds, node.entry.id],
|
|
140
|
+
branchPreview: keep.has(node) ? undefined : nextPreview,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
while (tasks.length > 0) {
|
|
147
|
+
const { source, projected, depth } = tasks.pop()!;
|
|
148
|
+
|
|
149
|
+
for (const sourceChild of source.children) {
|
|
150
|
+
let child = sourceChild;
|
|
151
|
+
|
|
152
|
+
if (depth >= MAX_PROJECTED_TREE_DEPTH) {
|
|
153
|
+
appendFlattenedKeptDescendants(child, projected);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const compressedEntryIds: string[] = [];
|
|
158
|
+
let branchPreview = previewForEntry(child.entry);
|
|
159
|
+
while (!keep.has(child) && child.children.length === 1) {
|
|
160
|
+
compressedEntryIds.push(child.entry.id);
|
|
161
|
+
child = child.children[0];
|
|
162
|
+
branchPreview ??= previewForEntry(child.entry);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!keep.has(child)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const projectedChild = cloneNode(child, compressedEntryIds, branchPreview);
|
|
170
|
+
projected.children.push(projectedChild);
|
|
171
|
+
tasks.push({ source: child, projected: projectedChild, depth: depth + 1 });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return projectedRoots;
|
|
176
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
getProjectTrustStatus,
|
|
10
|
+
projectTrustReloadOptions,
|
|
11
|
+
trustProject,
|
|
12
|
+
} from "./project-trust.ts";
|
|
13
|
+
|
|
14
|
+
async function createProjectFixture(t) {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), "pi-web-project-trust-"));
|
|
16
|
+
const cwd = join(root, "project");
|
|
17
|
+
const agentDir = join(root, "agent");
|
|
18
|
+
await mkdir(cwd, { recursive: true });
|
|
19
|
+
await mkdir(agentDir, { recursive: true });
|
|
20
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
21
|
+
return { root, cwd, agentDir };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("clean projects stay on the normal trusted load path", async (t) => {
|
|
25
|
+
const { cwd, agentDir } = await createProjectFixture(t);
|
|
26
|
+
|
|
27
|
+
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
|
|
28
|
+
requiresTrust: false,
|
|
29
|
+
trusted: true,
|
|
30
|
+
});
|
|
31
|
+
assert.equal(projectTrustReloadOptions(cwd, agentDir), undefined);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("project extensions execute only after the project is trusted", async (t) => {
|
|
35
|
+
const { root, cwd, agentDir } = await createProjectFixture(t);
|
|
36
|
+
const extensionDir = join(cwd, ".pi", "extensions");
|
|
37
|
+
const marker = join(root, "extension-executed");
|
|
38
|
+
await mkdir(extensionDir, { recursive: true });
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(extensionDir, "probe.js"),
|
|
41
|
+
`import { writeFileSync } from "node:fs";\nexport default () => { writeFileSync(${JSON.stringify(marker)}, "executed"); };\n`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(getProjectTrustStatus(cwd, agentDir), {
|
|
45
|
+
requiresTrust: true,
|
|
46
|
+
trusted: false,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const restrictedLoader = new DefaultResourceLoader({ cwd, agentDir });
|
|
50
|
+
await restrictedLoader.reload(projectTrustReloadOptions(cwd, agentDir));
|
|
51
|
+
assert.equal(existsSync(marker), false);
|
|
52
|
+
assert.equal(restrictedLoader.getExtensions().extensions.length, 0);
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(trustProject(cwd, agentDir), {
|
|
55
|
+
requiresTrust: true,
|
|
56
|
+
trusted: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const trustedLoader = new DefaultResourceLoader({ cwd, agentDir });
|
|
60
|
+
await trustedLoader.reload(projectTrustReloadOptions(cwd, agentDir));
|
|
61
|
+
assert.equal(existsSync(marker), true);
|
|
62
|
+
assert.equal(trustedLoader.getExtensions().extensions.length, 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the reload resolver reads the latest persisted trust decision", async (t) => {
|
|
66
|
+
const { cwd, agentDir } = await createProjectFixture(t);
|
|
67
|
+
await mkdir(join(cwd, ".pi", "extensions"), { recursive: true });
|
|
68
|
+
|
|
69
|
+
const reloadOptions = projectTrustReloadOptions(cwd, agentDir);
|
|
70
|
+
assert.ok(reloadOptions);
|
|
71
|
+
assert.equal(await reloadOptions.resolveProjectTrust(), false);
|
|
72
|
+
|
|
73
|
+
trustProject(cwd, agentDir);
|
|
74
|
+
assert.equal(await reloadOptions.resolveProjectTrust(), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("all project resource loaders and reloads enforce project trust", async () => {
|
|
78
|
+
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
|
79
|
+
const modelsSource = await readFile(new URL("../app/api/models/route.ts", import.meta.url), "utf8");
|
|
80
|
+
const skillsSource = await readFile(new URL("./skills-service.ts", import.meta.url), "utf8");
|
|
81
|
+
const skillsInstallSource = await readFile(new URL("../app/api/skills/install/route.ts", import.meta.url), "utf8");
|
|
82
|
+
const pluginsSource = await readFile(new URL("../app/api/plugins/route.ts", import.meta.url), "utf8");
|
|
83
|
+
|
|
84
|
+
assert.match(rpcSource, /const sessionCwd = sessionManager\.getCwd\(\)/);
|
|
85
|
+
assert.match(rpcSource, /projectTrustReloadOptions\(sessionCwd, agentDir\)/);
|
|
86
|
+
assert.match(rpcSource, /resourceLoaderReloadOptions: trustReloadOptions/);
|
|
87
|
+
assert.equal(
|
|
88
|
+
Array.from(rpcSource.matchAll(/this\.syncProjectTrust\(\);\s*await this\.inner\.reload/g)).length,
|
|
89
|
+
2,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
assert.match(modelsSource, /projectTrustReloadOptions\(cwd, agentDir\)/);
|
|
93
|
+
assert.match(modelsSource, /resourceLoaderReloadOptions: trustReloadOptions/);
|
|
94
|
+
assert.match(skillsSource, /loader\.reload\(projectTrustReloadOptions\(cwd, agentDir\)\)/);
|
|
95
|
+
assert.match(pluginsSource, /projectTrusted: projectTrust\.trusted/);
|
|
96
|
+
assert.match(
|
|
97
|
+
skillsInstallSource,
|
|
98
|
+
/getProjectTrustStatus\(cwd, getAgentDir\(\)\)\.trusted/,
|
|
99
|
+
);
|
|
100
|
+
assert.equal(
|
|
101
|
+
Array.from(pluginsSource.matchAll(/projectTrusted: projectTrust\.trusted/g)).length,
|
|
102
|
+
2,
|
|
103
|
+
);
|
|
104
|
+
assert.match(pluginsSource, /scope === "project" && !projectTrust\.trusted/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("the trust API invalidates cached models and restricted runtimes", async () => {
|
|
108
|
+
const source = await readFile(new URL("../app/api/project-trust/route.ts", import.meta.url), "utf8");
|
|
109
|
+
const rpcSource = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
|
|
110
|
+
|
|
111
|
+
assert.match(source, /trustProject\(result\.cwd, agentDir\)/);
|
|
112
|
+
assert.match(source, /invalidateModelsCache\(\)/);
|
|
113
|
+
assert.match(source, /destroyRpcSessionsForCwd\(result\.cwd\)/);
|
|
114
|
+
assert.match(source, /hasBusyRpcSessionForCwd\(result\.cwd\)/);
|
|
115
|
+
assert.match(rpcSource, /trackStartingSession\(sessionCwd\)/);
|
|
116
|
+
assert.match(rpcSource, /realpathSync\(resolvedCwd\)/);
|
|
117
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ProjectTrustStatus } from "./api-types";
|
|
3
|
+
|
|
4
|
+
export function getProjectTrustStatus(cwd: string, agentDir: string): ProjectTrustStatus {
|
|
5
|
+
const requiresTrust = Boolean(cwd) && hasTrustRequiringProjectResources(cwd);
|
|
6
|
+
if (!requiresTrust) return { requiresTrust: false, trusted: true };
|
|
7
|
+
|
|
8
|
+
const trustStore = new ProjectTrustStore(agentDir);
|
|
9
|
+
return {
|
|
10
|
+
requiresTrust: true,
|
|
11
|
+
trusted: trustStore.get(cwd) === true,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function trustProject(cwd: string, agentDir: string): ProjectTrustStatus {
|
|
16
|
+
const status = getProjectTrustStatus(cwd, agentDir);
|
|
17
|
+
if (!status.requiresTrust) return status;
|
|
18
|
+
|
|
19
|
+
new ProjectTrustStore(agentDir).set(cwd, true);
|
|
20
|
+
return { requiresTrust: true, trusted: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reload options that gate project-local, trust-requiring resources — a
|
|
25
|
+
* repository's `.pi/extensions`, project `.pi/settings.json` extension
|
|
26
|
+
* entries, and `.agents/skills` — behind the SDK's project-trust store.
|
|
27
|
+
*
|
|
28
|
+
* Pi Web *executes* project extensions when it builds session services: their
|
|
29
|
+
* factory runs on import and their `session_start` handlers run on startup.
|
|
30
|
+
* Without a trust gate, merely opening an untrusted repository in Pi Web runs
|
|
31
|
+
* repository-controlled code locally (issue #236). The SDK's resource loader
|
|
32
|
+
* only imports project extensions once `resolveProjectTrust` resolves true, so
|
|
33
|
+
* denying trust keeps them dormant.
|
|
34
|
+
*
|
|
35
|
+
* Pi Web and the `pi` CLI share the same trust store. Projects with gated
|
|
36
|
+
* resources default to untrusted until either client records a trust decision.
|
|
37
|
+
* Returns `undefined` when the project has no trust-requiring resources,
|
|
38
|
+
* leaving ordinary projects on their existing load path.
|
|
39
|
+
*/
|
|
40
|
+
export function projectTrustReloadOptions(
|
|
41
|
+
cwd: string,
|
|
42
|
+
agentDir: string,
|
|
43
|
+
): { resolveProjectTrust: () => Promise<boolean> } | undefined {
|
|
44
|
+
const status = getProjectTrustStatus(cwd, agentDir);
|
|
45
|
+
if (!status.requiresTrust) return undefined;
|
|
46
|
+
const trustStore = new ProjectTrustStore(agentDir);
|
|
47
|
+
return { resolveProjectTrust: async () => trustStore.get(cwd) === true };
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
const { userMessageKey } = await createJiti(import.meta.url).import("./prompt-recovery.ts");
|
|
6
|
+
|
|
7
|
+
function textMessage(content) {
|
|
8
|
+
return { role: "user", content, timestamp: 1 };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test("builds stable keys for matching optimistic text messages", () => {
|
|
12
|
+
assert.equal(userMessageKey(textMessage("repeat this")), userMessageKey(textMessage("repeat this")));
|
|
13
|
+
assert.notEqual(userMessageKey(textMessage("first")), userMessageKey(textMessage("second")));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("includes attached images in optimistic message keys", () => {
|
|
17
|
+
const submitted = {
|
|
18
|
+
role: "user",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "text", text: "inspect" },
|
|
21
|
+
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AQID" } },
|
|
22
|
+
],
|
|
23
|
+
timestamp: 1,
|
|
24
|
+
};
|
|
25
|
+
const differentImage = {
|
|
26
|
+
...submitted,
|
|
27
|
+
content: [
|
|
28
|
+
{ type: "text", text: "inspect" },
|
|
29
|
+
{ type: "image", source: { type: "base64", media_type: "image/png", data: "BAUG" } },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
assert.notEqual(userMessageKey(submitted), userMessageKey(differentImage));
|
|
33
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AgentMessage } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
function extractMessageText(message: Partial<AgentMessage>): string {
|
|
4
|
+
const content = (message as { content?: unknown }).content;
|
|
5
|
+
if (typeof content === "string") return content;
|
|
6
|
+
if (!Array.isArray(content)) return "";
|
|
7
|
+
return content
|
|
8
|
+
.map((block) =>
|
|
9
|
+
block && typeof block === "object"
|
|
10
|
+
&& (block as { type?: string }).type === "text"
|
|
11
|
+
&& typeof (block as { text?: unknown }).text === "string"
|
|
12
|
+
? (block as { text: string }).text
|
|
13
|
+
: "")
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function imageSignature(block: unknown): string {
|
|
19
|
+
if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "image") return "";
|
|
20
|
+
const source = (block as { source?: unknown }).source;
|
|
21
|
+
if (source && typeof source === "object") {
|
|
22
|
+
const src = source as { type?: unknown; media_type?: unknown; data?: unknown; url?: unknown };
|
|
23
|
+
return [
|
|
24
|
+
src.type === "url" ? "url" : "base64",
|
|
25
|
+
typeof src.media_type === "string" ? src.media_type : "",
|
|
26
|
+
typeof src.data === "string" ? src.data : "",
|
|
27
|
+
typeof src.url === "string" ? src.url : "",
|
|
28
|
+
].join(":");
|
|
29
|
+
}
|
|
30
|
+
const flat = block as { data?: unknown; mimeType?: unknown };
|
|
31
|
+
return [
|
|
32
|
+
"base64",
|
|
33
|
+
typeof flat.mimeType === "string" ? flat.mimeType : "",
|
|
34
|
+
typeof flat.data === "string" ? flat.data : "",
|
|
35
|
+
"",
|
|
36
|
+
].join(":");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function userMessageKey(message: Partial<AgentMessage>): string {
|
|
40
|
+
const content = (message as { content?: unknown }).content;
|
|
41
|
+
if (typeof content === "string") return JSON.stringify({ text: content, images: [] });
|
|
42
|
+
if (!Array.isArray(content)) return JSON.stringify({ text: "", images: [] });
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
text: extractMessageText(message),
|
|
45
|
+
images: content.map(imageSignature).filter(Boolean),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
|
|
5
|
+
test("API key saves do not use ModelRuntime.login's network refresh", async () => {
|
|
6
|
+
const source = await readFile(new URL("../app/api/auth/api-key/[provider]/route.ts", import.meta.url), "utf-8");
|
|
7
|
+
|
|
8
|
+
assert.doesNotMatch(source, /modelRuntime\.login\(/);
|
|
9
|
+
assert.match(source, /apiKeyAuth\.login\(/);
|
|
10
|
+
assert.match(source, /signal:\s*req\.signal/);
|
|
11
|
+
assert.match(source, /storeProviderCredential\(provider, credential\)/);
|
|
12
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createJiti } from "jiti";
|
|
7
|
+
import lockfile from "proper-lockfile";
|
|
8
|
+
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
const { removeStoredCredentialIfType, storeProviderCredential } = await createJiti(import.meta.url)
|
|
11
|
+
.import("./provider-credential-store.ts");
|
|
12
|
+
|
|
13
|
+
async function withAuthFile(data, run) {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-web-provider-auth-"));
|
|
15
|
+
const authPath = join(dir, "auth.json");
|
|
16
|
+
await writeFile(authPath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
17
|
+
try {
|
|
18
|
+
await run(authPath);
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readAuthFile(authPath) {
|
|
25
|
+
return JSON.parse(await readFile(authPath, "utf-8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("removes only the expected credential type and preserves other providers", async () => {
|
|
29
|
+
await withAuthFile({
|
|
30
|
+
anthropic: { type: "api_key", key: "secret" },
|
|
31
|
+
openai: { type: "api_key", key: "other" },
|
|
32
|
+
}, async (authPath) => {
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
await removeStoredCredentialIfType("anthropic", "api_key", authPath),
|
|
35
|
+
{ status: "removed" },
|
|
36
|
+
);
|
|
37
|
+
assert.deepEqual(await readAuthFile(authPath), {
|
|
38
|
+
openai: { type: "api_key", key: "other" },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("stores a provider credential while preserving other providers", async () => {
|
|
44
|
+
await withAuthFile({
|
|
45
|
+
anthropic: { type: "oauth", access: "old-token" },
|
|
46
|
+
openai: { type: "api_key", key: "other" },
|
|
47
|
+
}, async (authPath) => {
|
|
48
|
+
await storeProviderCredential(
|
|
49
|
+
"anthropic",
|
|
50
|
+
{ type: "api_key", key: "replacement" },
|
|
51
|
+
authPath,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
assert.deepEqual(await readAuthFile(authPath), {
|
|
55
|
+
anthropic: { type: "api_key", key: "replacement" },
|
|
56
|
+
openai: { type: "api_key", key: "other" },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a stale API-key delete cannot remove a newly stored OAuth credential", async () => {
|
|
62
|
+
await withAuthFile({
|
|
63
|
+
anthropic: { type: "api_key", key: "old" },
|
|
64
|
+
}, async (authPath) => {
|
|
65
|
+
const release = await lockfile.lock(authPath, { stale: 30_000 });
|
|
66
|
+
const removal = removeStoredCredentialIfType("anthropic", "api_key", authPath);
|
|
67
|
+
|
|
68
|
+
await writeFile(authPath, JSON.stringify({
|
|
69
|
+
anthropic: { type: "oauth", access: "new-token", expires: Date.now() + 60_000 },
|
|
70
|
+
}, null, 2), { mode: 0o600 });
|
|
71
|
+
await release();
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(await removal, {
|
|
74
|
+
status: "type_mismatch",
|
|
75
|
+
storedType: "oauth",
|
|
76
|
+
});
|
|
77
|
+
assert.equal((await readAuthFile(authPath)).anthropic.access, "new-token");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("missing credentials are a successful no-op", async () => {
|
|
82
|
+
await withAuthFile({}, async (authPath) => {
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
await removeStoredCredentialIfType("anthropic", "oauth", authPath),
|
|
85
|
+
{ status: "not_found" },
|
|
86
|
+
);
|
|
87
|
+
assert.deepEqual(await readAuthFile(authPath), {});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("saving and removing an API key updates the SDK authentication status", async () => {
|
|
92
|
+
await withAuthFile({}, async (authPath) => {
|
|
93
|
+
const providerId = "pi-web-auth-status-test";
|
|
94
|
+
|
|
95
|
+
await storeProviderCredential(
|
|
96
|
+
providerId,
|
|
97
|
+
{ type: "api_key", key: "test-secret" },
|
|
98
|
+
authPath,
|
|
99
|
+
);
|
|
100
|
+
let runtime = await ModelRuntime.create({ authPath, modelsPath: null });
|
|
101
|
+
assert.deepEqual(runtime.getProviderAuthStatus(providerId), {
|
|
102
|
+
configured: true,
|
|
103
|
+
source: "stored",
|
|
104
|
+
});
|
|
105
|
+
assert.deepEqual(await runtime.listCredentials(), [
|
|
106
|
+
{ providerId, type: "api_key" },
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
await storeProviderCredential(
|
|
110
|
+
providerId,
|
|
111
|
+
{ type: "api_key", key: "replacement-secret" },
|
|
112
|
+
authPath,
|
|
113
|
+
);
|
|
114
|
+
runtime = await ModelRuntime.create({ authPath, modelsPath: null });
|
|
115
|
+
assert.equal(runtime.getProviderAuthStatus(providerId).configured, true);
|
|
116
|
+
assert.equal((await readAuthFile(authPath))[providerId].key, "replacement-secret");
|
|
117
|
+
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
await removeStoredCredentialIfType(providerId, "api_key", authPath),
|
|
120
|
+
{ status: "removed" },
|
|
121
|
+
);
|
|
122
|
+
runtime = await ModelRuntime.create({ authPath, modelsPath: null });
|
|
123
|
+
assert.deepEqual(runtime.getProviderAuthStatus(providerId), { configured: false });
|
|
124
|
+
assert.deepEqual(await runtime.listCredentials(), []);
|
|
125
|
+
});
|
|
126
|
+
});
|