@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +61 -15
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-agent-dashboard",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Web dashboard for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"postinstall": "node packages/server/scripts/fix-pty-permissions.cjs",
|
|
50
50
|
"dev": "npm run dev --workspace=@blackbelt-technology/pi-dashboard-web",
|
|
51
51
|
"build": "npm run build --workspace=@blackbelt-technology/pi-dashboard-web",
|
|
52
|
-
"test": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run",
|
|
53
|
-
"test:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest",
|
|
54
|
-
"test:bootstrap": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run packages/shared/src/__tests__/bootstrap",
|
|
52
|
+
"test": "HOME=$(mktemp -d -t pi-test-XXXXXX) NODE_OPTIONS=\"--localstorage-file=$(mktemp -t pi-test-ls-XXXXXX)\" vitest run",
|
|
53
|
+
"test:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) NODE_OPTIONS=\"--localstorage-file=$(mktemp -t pi-test-ls-XXXXXX)\" vitest",
|
|
54
|
+
"test:bootstrap": "HOME=$(mktemp -d -t pi-test-XXXXXX) NODE_OPTIONS=\"--localstorage-file=$(mktemp -t pi-test-ls-XXXXXX)\" vitest run packages/shared/src/__tests__/bootstrap",
|
|
55
55
|
"test:bootstrap:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest packages/shared/src/__tests__/bootstrap",
|
|
56
56
|
"lint": "tsc --noEmit",
|
|
57
57
|
"reload": "./scripts/reload-all.sh",
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"screenshots": "npm --prefix site run screenshots"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.4.
|
|
70
|
-
"@blackbelt-technology/pi-dashboard-server": "^0.4.
|
|
71
|
-
"@blackbelt-technology/pi-dashboard-web": "^0.4.
|
|
69
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.4.2",
|
|
70
|
+
"@blackbelt-technology/pi-dashboard-server": "^0.4.2",
|
|
71
|
+
"@blackbelt-technology/pi-dashboard-web": "^0.4.2"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"jsdom": "^29.0.2",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"@oh-my-pi/pi-ai": "*",
|
|
84
84
|
"@oh-my-pi/pi-coding-agent": "*",
|
|
85
85
|
"@oh-my-pi/pi-tui": "*",
|
|
86
|
-
"
|
|
86
|
+
"typebox": "*"
|
|
87
87
|
},
|
|
88
88
|
"peerDependenciesMeta": {
|
|
89
89
|
"@mariozechner/pi-coding-agent": {
|
|
@@ -103,6 +103,9 @@
|
|
|
103
103
|
},
|
|
104
104
|
"@oh-my-pi/pi-tui": {
|
|
105
105
|
"optional": true
|
|
106
|
+
},
|
|
107
|
+
"typebox": {
|
|
108
|
+
"optional": true
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-extension",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Pi bridge extension for pi-dashboard",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/BlackBeltTechnology/pi-agent-dashboard",
|
|
9
|
+
"directory": "packages/extension"
|
|
10
|
+
},
|
|
6
11
|
"publishConfig": {
|
|
7
12
|
"access": "public"
|
|
8
13
|
},
|
|
@@ -19,12 +24,13 @@
|
|
|
19
24
|
".pi/skills/pi-dashboard/"
|
|
20
25
|
],
|
|
21
26
|
"dependencies": {
|
|
22
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.4.
|
|
27
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.4.2",
|
|
23
28
|
"ws": "^8.18.0"
|
|
24
29
|
},
|
|
25
30
|
"peerDependencies": {
|
|
26
31
|
"@mariozechner/pi-coding-agent": "*",
|
|
27
|
-
"@mariozechner/pi-tui": "*"
|
|
32
|
+
"@mariozechner/pi-tui": "*",
|
|
33
|
+
"typebox": "*"
|
|
28
34
|
},
|
|
29
35
|
"peerDependenciesMeta": {
|
|
30
36
|
"@mariozechner/pi-coding-agent": {
|
|
@@ -36,6 +42,7 @@
|
|
|
36
42
|
},
|
|
37
43
|
"devDependencies": {
|
|
38
44
|
"@mariozechner/pi-tui": "*",
|
|
39
|
-
"@types/ws": "^8.18.1"
|
|
45
|
+
"@types/ws": "^8.18.1",
|
|
46
|
+
"typebox": "^1.1.33"
|
|
40
47
|
}
|
|
41
48
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for the `ask_user` tool's parameters JSON Schema shape.
|
|
3
|
+
*
|
|
4
|
+
* History:
|
|
5
|
+
* 1. Pre-a53933f — schema was `Type.Union` of per-method `Type.Object`
|
|
6
|
+
* arms (root-level `anyOf`). Anthropic loved it; OpenAI rejected it
|
|
7
|
+
* ("schema must be a JSON Schema of 'type: \"object\"', got
|
|
8
|
+
* 'type: \"None\"'").
|
|
9
|
+
*
|
|
10
|
+
* 2. a53933f — collapsed to a single flat `Type.Object` (root
|
|
11
|
+
* `type: "object"`, all fields optional). OpenAI happy. Anthropic
|
|
12
|
+
* lost its per-method strictness; Claude started emitting
|
|
13
|
+
* multiselect calls without `options` and the dashboard auto-
|
|
14
|
+
* cancelled them — the user-visible bug behind
|
|
15
|
+
* fix-multiselect-auto-cancel-on-dashboard.
|
|
16
|
+
*
|
|
17
|
+
* 3. Layer-2 attempt of fix-multiselect-auto-cancel-on-dashboard —
|
|
18
|
+
* tried to keep root `type: "object"` AND attach a body-level
|
|
19
|
+
* `oneOf` discriminator over `method`. Anthropic worked. Real
|
|
20
|
+
* OpenAI gpt-5 rejected it (verified 2026-04-30) with: "schema
|
|
21
|
+
* must have type 'object' and not have 'oneOf' / 'anyOf' / 'allOf'
|
|
22
|
+
* / 'enum' / 'not' at the top level." The fallback documented in
|
|
23
|
+
* tasks.md §9.7 was taken: Layer 2 dropped, Layer 1 (multiselect
|
|
24
|
+
* dashboard routing) ships alone — that's what actually fixes the
|
|
25
|
+
* user bug.
|
|
26
|
+
*
|
|
27
|
+
* This test pins the post-fallback shape so it cannot regress in
|
|
28
|
+
* either direction:
|
|
29
|
+
* • Root MUST be `type: "object"` (OpenAI rule).
|
|
30
|
+
* • Root MUST NOT have `oneOf`, `anyOf`, `allOf`, `enum`, `not`.
|
|
31
|
+
* • The same five forbidden keys MUST NOT appear on `SubQuestionSchema`
|
|
32
|
+
* either — OpenAI applies the rule recursively per the error message.
|
|
33
|
+
*
|
|
34
|
+
* Per-method strictness is enforced by `prepareArguments` rescue + the
|
|
35
|
+
* `execute` runtime switch (covered by `ask-user-tool.test.ts`).
|
|
36
|
+
*
|
|
37
|
+
* See change: fix-multiselect-auto-cancel-on-dashboard.
|
|
38
|
+
*/
|
|
39
|
+
import { describe, it, expect, vi } from "vitest";
|
|
40
|
+
import { registerAskUserTool } from "../ask-user-tool.js";
|
|
41
|
+
|
|
42
|
+
const FORBIDDEN_TOP_LEVEL_KEYS = ["oneOf", "anyOf", "allOf", "enum", "not"] as const;
|
|
43
|
+
|
|
44
|
+
interface PiSchema {
|
|
45
|
+
type?: string;
|
|
46
|
+
properties?: Record<string, any>;
|
|
47
|
+
required?: string[];
|
|
48
|
+
description?: string;
|
|
49
|
+
oneOf?: unknown;
|
|
50
|
+
anyOf?: unknown;
|
|
51
|
+
allOf?: unknown;
|
|
52
|
+
enum?: unknown;
|
|
53
|
+
not?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function captureRegisteredTool() {
|
|
57
|
+
const calls: any[] = [];
|
|
58
|
+
const pi = {
|
|
59
|
+
registerTool: vi.fn((def: any) => calls.push(def)),
|
|
60
|
+
};
|
|
61
|
+
registerAskUserTool(pi as any);
|
|
62
|
+
expect(calls.length).toBe(1);
|
|
63
|
+
return calls[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getSubQuestionSchema(parameters: PiSchema): PiSchema {
|
|
67
|
+
const items = parameters.properties?.questions?.items;
|
|
68
|
+
expect(items).toBeDefined();
|
|
69
|
+
return items as PiSchema;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Walk a Union<Literal> schema (typebox emits it as `anyOf` of `{const: ...}`)
|
|
73
|
+
// into a flat list of literal values.
|
|
74
|
+
function flattenUnionLiterals(s: any): string[] {
|
|
75
|
+
if (!s) return [];
|
|
76
|
+
if (Array.isArray(s.anyOf)) return s.anyOf.flatMap(flattenUnionLiterals);
|
|
77
|
+
if (s.const !== undefined) return [String(s.const)];
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("ask_user parameters schema — OpenAI strict-mode shape", () => {
|
|
82
|
+
it("root is type:object", () => {
|
|
83
|
+
const tool = captureRegisteredTool();
|
|
84
|
+
const params = tool.parameters as PiSchema;
|
|
85
|
+
expect(params.type).toBe("object");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it.each(FORBIDDEN_TOP_LEVEL_KEYS)(
|
|
89
|
+
"root has NO `%s` (OpenAI strict mode forbids it at the top level)",
|
|
90
|
+
(key) => {
|
|
91
|
+
const tool = captureRegisteredTool();
|
|
92
|
+
const params = tool.parameters as Record<string, unknown>;
|
|
93
|
+
expect(
|
|
94
|
+
params[key],
|
|
95
|
+
`parameters.${key} would break OpenAI gpt-5: "schema must have type 'object' and not have 'oneOf' / 'anyOf' / 'allOf' / 'enum' / 'not' at the top level."`,
|
|
96
|
+
).toBeUndefined();
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
it("declares the five method literals (regression guard for MethodEnum)", () => {
|
|
101
|
+
const tool = captureRegisteredTool();
|
|
102
|
+
const methodSchema = (tool.parameters as PiSchema).properties?.method;
|
|
103
|
+
expect(methodSchema).toBeDefined();
|
|
104
|
+
// typebox emits a Union<Literal> as `anyOf` UNDER `properties.method`
|
|
105
|
+
// (NOT at the schema root). That is OpenAI-legal because it isn't at
|
|
106
|
+
// the top level. We only assert the literal set is preserved.
|
|
107
|
+
const methods = flattenUnionLiterals(methodSchema);
|
|
108
|
+
expect(methods).toEqual(
|
|
109
|
+
expect.arrayContaining(["confirm", "select", "multiselect", "input", "batch"]),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("ask_user SubQuestionSchema — OpenAI strict-mode shape", () => {
|
|
115
|
+
it("sub-question schema is type:object", () => {
|
|
116
|
+
const tool = captureRegisteredTool();
|
|
117
|
+
const sq = getSubQuestionSchema(tool.parameters);
|
|
118
|
+
expect(sq.type).toBe("object");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it.each(FORBIDDEN_TOP_LEVEL_KEYS)(
|
|
122
|
+
"sub-question schema has NO `%s` at top level (OpenAI rule applies recursively)",
|
|
123
|
+
(key) => {
|
|
124
|
+
const tool = captureRegisteredTool();
|
|
125
|
+
const sq = getSubQuestionSchema(tool.parameters) as Record<string, unknown>;
|
|
126
|
+
expect(sq[key]).toBeUndefined();
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
it("sub-question excludes 'batch' from method literals (no nesting)", () => {
|
|
131
|
+
const tool = captureRegisteredTool();
|
|
132
|
+
const sq = getSubQuestionSchema(tool.parameters);
|
|
133
|
+
const methodSchema = sq.properties?.method;
|
|
134
|
+
expect(methodSchema).toBeDefined();
|
|
135
|
+
const methods = flattenUnionLiterals(methodSchema);
|
|
136
|
+
expect(methods).toEqual(
|
|
137
|
+
expect.arrayContaining(["confirm", "select", "multiselect", "input"]),
|
|
138
|
+
);
|
|
139
|
+
expect(methods).not.toContain("batch");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
// Mock modules before importing
|
|
4
|
-
vi.mock("
|
|
4
|
+
vi.mock("typebox", () => ({
|
|
5
5
|
Type: {
|
|
6
6
|
Object: vi.fn(() => ({})),
|
|
7
7
|
String: vi.fn(() => ({})),
|
|
@@ -44,62 +44,119 @@ describe("registerAskUserTool", () => {
|
|
|
44
44
|
expect(tool.promptGuidelines.length).toBeGreaterThan(0);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it("description instructs agents not to add a Select all option", () => {
|
|
48
|
+
const pi = createMockPi();
|
|
49
|
+
registerAskUserTool(pi as any);
|
|
50
|
+
const tool = pi.registerTool.mock.calls[0][0];
|
|
51
|
+
expect(tool.description).toMatch(/UI provides a Select all/i);
|
|
52
|
+
});
|
|
53
|
+
|
|
47
54
|
describe("message passthrough", () => {
|
|
48
55
|
function getToolAndMockCtx() {
|
|
49
56
|
const pi = createMockPi();
|
|
50
57
|
registerAskUserTool(pi as any);
|
|
51
58
|
const tool = pi.registerTool.mock.calls[0][0];
|
|
59
|
+
// `custom` stands in for the multiselect polyfill: it invokes the factory
|
|
60
|
+
// with a `done` callback; the factory-returned component exposes
|
|
61
|
+
// onConfirm/onCancel. We auto-confirm with ["A"] to preserve the legacy
|
|
62
|
+
// mock return value that the multiselect assertions expected.
|
|
63
|
+
const custom = vi.fn().mockImplementation(async (factory: any) => {
|
|
64
|
+
return await new Promise<unknown>((resolve) => {
|
|
65
|
+
const component: any = factory({}, {}, {}, (r: unknown) => resolve(r));
|
|
66
|
+
component?.onConfirm?.(["A"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
52
69
|
const ctx = {
|
|
53
70
|
ui: {
|
|
54
71
|
confirm: vi.fn().mockResolvedValue(true),
|
|
55
72
|
select: vi.fn().mockResolvedValue("A"),
|
|
56
73
|
input: vi.fn().mockResolvedValue("hello"),
|
|
57
|
-
|
|
74
|
+
custom,
|
|
58
75
|
},
|
|
59
76
|
};
|
|
60
|
-
return { tool, ctx };
|
|
77
|
+
return { tool, ctx, custom };
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
it("passes message through opts for input", async () => {
|
|
64
81
|
const { tool, ctx } = getToolAndMockCtx();
|
|
65
82
|
await tool.execute("id", { method: "input", title: "Q", message: "Details here" }, undefined, undefined, ctx);
|
|
66
|
-
|
|
83
|
+
// toolCallId is also threaded through opts since change
|
|
84
|
+
// `fix-interactive-ui-reorder`. Asserts both fields without
|
|
85
|
+
// pinning property order.
|
|
86
|
+
expect(ctx.ui.input).toHaveBeenCalledWith(
|
|
87
|
+
"Q",
|
|
88
|
+
undefined,
|
|
89
|
+
expect.objectContaining({ message: "Details here", toolCallId: "id" }),
|
|
90
|
+
);
|
|
67
91
|
});
|
|
68
92
|
|
|
69
93
|
it("passes message through opts for select", async () => {
|
|
70
94
|
const { tool, ctx } = getToolAndMockCtx();
|
|
71
95
|
await tool.execute("id", { method: "select", title: "Pick", message: "Context", options: ["A", "B"] }, undefined, undefined, ctx);
|
|
72
|
-
expect(ctx.ui.select).toHaveBeenCalledWith(
|
|
96
|
+
expect(ctx.ui.select).toHaveBeenCalledWith(
|
|
97
|
+
"Pick",
|
|
98
|
+
["A", "B"],
|
|
99
|
+
expect.objectContaining({ message: "Context", toolCallId: "id" }),
|
|
100
|
+
);
|
|
73
101
|
});
|
|
74
102
|
|
|
75
|
-
it("
|
|
103
|
+
it("dispatches multiselect through the polyfill via ctx.ui.custom", async () => {
|
|
76
104
|
const { tool, ctx } = getToolAndMockCtx();
|
|
77
|
-
await tool.execute(
|
|
78
|
-
|
|
105
|
+
const result = await tool.execute(
|
|
106
|
+
"id",
|
|
107
|
+
{ method: "multiselect", title: "Multi", message: "Info", options: ["A"] },
|
|
108
|
+
undefined,
|
|
109
|
+
undefined,
|
|
110
|
+
ctx,
|
|
111
|
+
);
|
|
112
|
+
// Polyfill routes via custom(factory); multiselect is not called directly.
|
|
113
|
+
expect(ctx.ui.custom).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(result.details.method).toBe("multiselect");
|
|
115
|
+
expect(result.details.result).toEqual(["A"]);
|
|
79
116
|
});
|
|
80
117
|
|
|
81
|
-
it("
|
|
118
|
+
it("passes only toolCallId through opts when message is undefined", async () => {
|
|
82
119
|
const { tool, ctx } = getToolAndMockCtx();
|
|
83
120
|
await tool.execute("id", { method: "input", title: "Q" }, undefined, undefined, ctx);
|
|
84
|
-
|
|
121
|
+
// Even with no `message`, the wrapper still attaches toolCallId so
|
|
122
|
+
// the resulting prompt_request can be paired by the client reducer.
|
|
123
|
+
expect(ctx.ui.input).toHaveBeenCalledWith(
|
|
124
|
+
"Q",
|
|
125
|
+
undefined,
|
|
126
|
+
expect.objectContaining({ toolCallId: "id" }),
|
|
127
|
+
);
|
|
85
128
|
});
|
|
86
129
|
|
|
87
130
|
it("falls back to message when title is missing", async () => {
|
|
88
131
|
const { tool, ctx } = getToolAndMockCtx();
|
|
89
132
|
await tool.execute("id", { method: "input", message: "Detailed question" }, undefined, undefined, ctx);
|
|
90
|
-
expect(ctx.ui.input).toHaveBeenCalledWith(
|
|
133
|
+
expect(ctx.ui.input).toHaveBeenCalledWith(
|
|
134
|
+
"Detailed question",
|
|
135
|
+
undefined,
|
|
136
|
+
expect.objectContaining({ message: "Detailed question", toolCallId: "id" }),
|
|
137
|
+
);
|
|
91
138
|
});
|
|
92
139
|
|
|
93
140
|
it("falls back to 'Question' when both title and message are missing", async () => {
|
|
94
141
|
const { tool, ctx } = getToolAndMockCtx();
|
|
95
142
|
await tool.execute("id", { method: "confirm" }, undefined, undefined, ctx);
|
|
96
|
-
|
|
143
|
+
// confirm now also threads toolCallId via 3rd arg.
|
|
144
|
+
expect(ctx.ui.confirm).toHaveBeenCalledWith(
|
|
145
|
+
"Question",
|
|
146
|
+
"",
|
|
147
|
+
expect.objectContaining({ toolCallId: "id" }),
|
|
148
|
+
);
|
|
97
149
|
});
|
|
98
150
|
|
|
99
151
|
it("parses options from JSON string", async () => {
|
|
100
152
|
const { tool, ctx } = getToolAndMockCtx();
|
|
101
153
|
await tool.execute("id", { method: "select", title: "Pick", options: '["A", "B"]' }, undefined, undefined, ctx);
|
|
102
|
-
|
|
154
|
+
// No message, no other opts — only toolCallId.
|
|
155
|
+
expect(ctx.ui.select).toHaveBeenCalledWith(
|
|
156
|
+
"Pick",
|
|
157
|
+
["A", "B"],
|
|
158
|
+
expect.objectContaining({ toolCallId: "id" }),
|
|
159
|
+
);
|
|
103
160
|
});
|
|
104
161
|
|
|
105
162
|
it("throws when select reaches execute with unparseable options string", async () => {
|
|
@@ -123,7 +180,7 @@ describe("registerAskUserTool", () => {
|
|
|
123
180
|
await expect(
|
|
124
181
|
tool.execute("id", { method: "multiselect", title: "Pick" }, undefined, undefined, ctx),
|
|
125
182
|
).rejects.toThrow(/options/i);
|
|
126
|
-
expect(ctx.ui.
|
|
183
|
+
expect(ctx.ui.custom).not.toHaveBeenCalled();
|
|
127
184
|
});
|
|
128
185
|
});
|
|
129
186
|
|
|
@@ -134,6 +191,19 @@ describe("registerAskUserTool", () => {
|
|
|
134
191
|
return pi.registerTool.mock.calls[0][0];
|
|
135
192
|
}
|
|
136
193
|
|
|
194
|
+
it("leaves empty {} args untouched (no synthetic method) so schema rejection still fires", () => {
|
|
195
|
+
// Regression test for the Opus-emits-empty-args bug seen in session 019dd05c.
|
|
196
|
+
// Our rescue layer must NOT silently fabricate a method/title when there is
|
|
197
|
+
// nothing to rescue — the framework's schema validator must still reject {}
|
|
198
|
+
// so the model is forced to retry with valid args.
|
|
199
|
+
const tool = getTool();
|
|
200
|
+
const result = tool.prepareArguments({});
|
|
201
|
+
expect(result.method).toBeUndefined();
|
|
202
|
+
expect(result.title).toBeUndefined();
|
|
203
|
+
expect(result.questions).toBeUndefined();
|
|
204
|
+
expect(Object.keys(result).filter((k) => k !== "__normalizations")).toHaveLength(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
137
207
|
it("parses stringified options array", () => {
|
|
138
208
|
const tool = getTool();
|
|
139
209
|
const result = tool.prepareArguments({ method: "select", title: "Pick", options: '["A", "B"]' });
|
|
@@ -311,12 +381,18 @@ describe("registerAskUserTool", () => {
|
|
|
311
381
|
const pi = createMockPi();
|
|
312
382
|
registerAskUserTool(pi as any);
|
|
313
383
|
const tool = pi.registerTool.mock.calls[0][0];
|
|
384
|
+
const custom = vi.fn().mockImplementation(async (factory: any) => {
|
|
385
|
+
return await new Promise<unknown>((resolve) => {
|
|
386
|
+
const component: any = factory({}, {}, {}, (r: unknown) => resolve(r));
|
|
387
|
+
component?.onConfirm?.(["A"]);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
314
390
|
const ctx = {
|
|
315
391
|
ui: {
|
|
316
392
|
confirm: vi.fn().mockResolvedValue(true),
|
|
317
393
|
select: vi.fn().mockResolvedValue("A"),
|
|
318
394
|
input: vi.fn().mockResolvedValue("hello"),
|
|
319
|
-
|
|
395
|
+
custom,
|
|
320
396
|
},
|
|
321
397
|
};
|
|
322
398
|
return { tool, ctx };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge entryId stamping under pi 0.70.x's emit-then-await-then-append
|
|
3
|
+
* ordering. Pi 0.70.x's _processAgentEvent does (paraphrased):
|
|
4
|
+
*
|
|
5
|
+
* await this._emitExtensionEvent(event); // <-- bridge runs here, awaited
|
|
6
|
+
* this._emit(event); // sync legacy listeners
|
|
7
|
+
* if (event.type === "message_end") {
|
|
8
|
+
* sessionManager.appendMessage(event.message); // <-- entry id GENERATED HERE
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* The bridge's old `queueMicrotask` deferral resolves INSIDE the awaited dispatcher,
|
|
12
|
+
* before appendMessage runs — so getLeafId() still returns the previous leaf. The fix
|
|
13
|
+
* is `setTimeout(0)` (macrotask) so the entire await chain unwinds and appendMessage
|
|
14
|
+
* runs first; OR reading `event.message.id` after pi mutates it in-place.
|
|
15
|
+
*
|
|
16
|
+
* This test simulates that ordering and asserts the correct mechanisms.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
|
|
20
|
+
interface SimMessage {
|
|
21
|
+
role: string;
|
|
22
|
+
content: string;
|
|
23
|
+
id?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Simulate pi 0.70.x's _processAgentEvent ordering. Returns a promise that
|
|
28
|
+
* resolves when the entire event has been processed (including appendMessage).
|
|
29
|
+
*
|
|
30
|
+
* The `bridgeHandler` is registered as an "extension handler" — runs awaited
|
|
31
|
+
* inside _emitExtensionEvent. It receives the event and a pseudo-ctx with
|
|
32
|
+
* sessionManager.getLeafId().
|
|
33
|
+
*/
|
|
34
|
+
async function simulatePi070Emit(opts: {
|
|
35
|
+
event: { type: string; message: SimMessage };
|
|
36
|
+
state: { leafId: string; nextId: string };
|
|
37
|
+
appendMessage: (msg: SimMessage) => string; // returns the new id
|
|
38
|
+
bridgeHandler: (event: any, ctx: any) => Promise<void> | void;
|
|
39
|
+
}): Promise<void> {
|
|
40
|
+
const ctx = {
|
|
41
|
+
sessionManager: { getLeafId: () => opts.state.leafId },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Step 1: await _emitExtensionEvent — runs the bridge handler awaited.
|
|
45
|
+
await opts.bridgeHandler(opts.event, ctx);
|
|
46
|
+
|
|
47
|
+
// Step 2: _emit (sync legacy listeners) — no-op in this simulation.
|
|
48
|
+
|
|
49
|
+
// Step 3: persistence on message_end.
|
|
50
|
+
if (opts.event.type === "message_end") {
|
|
51
|
+
const id = opts.appendMessage(opts.event.message);
|
|
52
|
+
opts.state.leafId = id;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("pi 0.70 emit/append ordering", () => {
|
|
57
|
+
it("queueMicrotask deferral DOES NOT capture the post-persist id (the bug)", async () => {
|
|
58
|
+
const state = { leafId: "prev", nextId: "new-id-42" };
|
|
59
|
+
let captured: string | undefined;
|
|
60
|
+
|
|
61
|
+
const buggyBridge = async (event: any, ctx: any) => {
|
|
62
|
+
// What the OLD bridge does today:
|
|
63
|
+
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
64
|
+
captured = ctx.sessionManager.getLeafId();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await simulatePi070Emit({
|
|
68
|
+
event: { type: "message_end", message: { role: "assistant", content: "hi" } },
|
|
69
|
+
state,
|
|
70
|
+
appendMessage: (m) => {
|
|
71
|
+
m.id = state.nextId;
|
|
72
|
+
return state.nextId;
|
|
73
|
+
},
|
|
74
|
+
bridgeHandler: buggyBridge,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Bug: captured is the previous leaf, NOT the just-appended id.
|
|
78
|
+
expect(captured).toBe("prev");
|
|
79
|
+
expect(captured).not.toBe("new-id-42");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("setTimeout(0) deferral DOES capture the post-persist id (the fix)", async () => {
|
|
83
|
+
const state = { leafId: "prev", nextId: "new-id-42" };
|
|
84
|
+
let capturedFromGetLeafId: string | undefined;
|
|
85
|
+
let capturedFromMessageId: string | undefined;
|
|
86
|
+
let sendDone!: () => void;
|
|
87
|
+
const sendCompleted = new Promise<void>((r) => { sendDone = r; });
|
|
88
|
+
|
|
89
|
+
// The bridge schedules and returns synchronously — the only way the
|
|
90
|
+
// awaited dispatcher can unwind so appendMessage runs before the timeout.
|
|
91
|
+
const fixedBridge = (event: any, ctx: any) => {
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
capturedFromMessageId = (event.message as any).id;
|
|
94
|
+
capturedFromGetLeafId = ctx.sessionManager.getLeafId();
|
|
95
|
+
sendDone();
|
|
96
|
+
}, 0);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await simulatePi070Emit({
|
|
100
|
+
event: { type: "message_end", message: { role: "assistant", content: "hi" } },
|
|
101
|
+
state,
|
|
102
|
+
appendMessage: (m) => {
|
|
103
|
+
m.id = state.nextId;
|
|
104
|
+
return state.nextId;
|
|
105
|
+
},
|
|
106
|
+
bridgeHandler: fixedBridge,
|
|
107
|
+
});
|
|
108
|
+
await sendCompleted;
|
|
109
|
+
|
|
110
|
+
// Both signals should now point at the just-persisted entry.
|
|
111
|
+
expect(capturedFromMessageId).toBe("new-id-42");
|
|
112
|
+
expect(capturedFromGetLeafId).toBe("new-id-42");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("WeakMap-on-appendMessage captures the id even before the macrotask", async () => {
|
|
116
|
+
const state = { leafId: "prev", nextId: "new-id-77" };
|
|
117
|
+
const idByMessage = new WeakMap<object, string>();
|
|
118
|
+
const wrappedAppend = (m: SimMessage): string => {
|
|
119
|
+
m.id = state.nextId;
|
|
120
|
+
idByMessage.set(m as object, m.id);
|
|
121
|
+
return m.id;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
let viaWeakMap: string | undefined;
|
|
125
|
+
let viaMutation: string | undefined;
|
|
126
|
+
let sendDone!: () => void;
|
|
127
|
+
const sentP = new Promise<void>((r) => { sendDone = r; });
|
|
128
|
+
|
|
129
|
+
// CRITICAL: bridge SCHEDULES the send and RETURNS IMMEDIATELY.
|
|
130
|
+
// It does NOT await its own setTimeout — that would keep the
|
|
131
|
+
// outer dispatcher awaiting and we'd be back to the queueMicrotask
|
|
132
|
+
// bug (timeout fires before appendMessage).
|
|
133
|
+
const fixedBridge = (event: any) => {
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
viaMutation = (event.message as any).id;
|
|
136
|
+
viaWeakMap = idByMessage.get(event.message as object);
|
|
137
|
+
sendDone();
|
|
138
|
+
}, 0);
|
|
139
|
+
// Return synchronously — let the awaited dispatcher unwind.
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
await simulatePi070Emit({
|
|
143
|
+
event: { type: "message_end", message: { role: "assistant", content: "hi" } },
|
|
144
|
+
state,
|
|
145
|
+
appendMessage: wrappedAppend,
|
|
146
|
+
bridgeHandler: fixedBridge,
|
|
147
|
+
});
|
|
148
|
+
await sentP;
|
|
149
|
+
|
|
150
|
+
expect(viaMutation).toBe("new-id-77");
|
|
151
|
+
expect(viaWeakMap).toBe("new-id-77");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("user message_start has NO id (pi defers user persistence to message_end)", async () => {
|
|
155
|
+
const state = { leafId: "prev-assistant", nextId: "new-user-id" };
|
|
156
|
+
let captured: string | undefined;
|
|
157
|
+
|
|
158
|
+
const fixedBridge = async (event: any) => {
|
|
159
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
160
|
+
captured = (event.message as any).id; // still undefined for message_start
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await simulatePi070Emit({
|
|
164
|
+
event: { type: "message_start", message: { role: "user", content: "hello" } },
|
|
165
|
+
state,
|
|
166
|
+
appendMessage: () => state.nextId, // not called for message_start
|
|
167
|
+
bridgeHandler: fixedBridge,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// No id available at message_start time — must rely on entry_persisted
|
|
171
|
+
// back-fill (delivered when the message_end of the SAME message fires later).
|
|
172
|
+
expect(captured).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -74,6 +74,36 @@ describe("mapEventToProtocol", () => {
|
|
|
74
74
|
expect(result.event.data).toEqual(piEvent);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
it("should map an entry_persisted event (per fix-per-message-fork)", () => {
|
|
78
|
+
const piEvent = {
|
|
79
|
+
type: "entry_persisted",
|
|
80
|
+
entryId: "abc-123",
|
|
81
|
+
nonce: "n-7",
|
|
82
|
+
};
|
|
83
|
+
const result = mapEventToProtocol(sessionId, piEvent);
|
|
84
|
+
expect(result.type).toBe("event_forward");
|
|
85
|
+
expect(result.sessionId).toBe(sessionId);
|
|
86
|
+
expect(result.event.eventType).toBe("entry_persisted");
|
|
87
|
+
expect(result.event.data).toMatchObject({
|
|
88
|
+
type: "entry_persisted",
|
|
89
|
+
entryId: "abc-123",
|
|
90
|
+
nonce: "n-7",
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should map a message_end event with nonce (per fix-per-message-fork)", () => {
|
|
95
|
+
const piEvent = {
|
|
96
|
+
type: "message_end",
|
|
97
|
+
message: { role: "assistant", content: "hi", id: "asst-9" },
|
|
98
|
+
entryId: "asst-9",
|
|
99
|
+
nonce: "n-7",
|
|
100
|
+
};
|
|
101
|
+
const result = mapEventToProtocol(sessionId, piEvent);
|
|
102
|
+
expect(result.event.eventType).toBe("message_end");
|
|
103
|
+
expect((result.event.data as any).nonce).toBe("n-7");
|
|
104
|
+
expect((result.event.data as any).entryId).toBe("asst-9");
|
|
105
|
+
});
|
|
106
|
+
|
|
77
107
|
it("should strip non-serializable fields", () => {
|
|
78
108
|
const piEvent = {
|
|
79
109
|
type: "test_event",
|