@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.
Files changed (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. 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.0",
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.0",
70
- "@blackbelt-technology/pi-dashboard-server": "^0.4.0",
71
- "@blackbelt-technology/pi-dashboard-web": "^0.4.0"
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
- "@sinclair/typebox": "*"
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.0",
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.0",
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("@sinclair/typebox", () => ({
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
- multiselect: vi.fn().mockResolvedValue(["A"]),
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
- expect(ctx.ui.input).toHaveBeenCalledWith("Q", undefined, { message: "Details here" });
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("Pick", ["A", "B"], { message: "Context" });
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("passes message through opts for multiselect", async () => {
103
+ it("dispatches multiselect through the polyfill via ctx.ui.custom", async () => {
76
104
  const { tool, ctx } = getToolAndMockCtx();
77
- await tool.execute("id", { method: "multiselect", title: "Multi", message: "Info", options: ["A"] }, undefined, undefined, ctx);
78
- expect(ctx.ui.multiselect).toHaveBeenCalledWith("Multi", ["A"], { message: "Info" });
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("does not pass opts when message is undefined", async () => {
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
- expect(ctx.ui.input).toHaveBeenCalledWith("Q", undefined, undefined);
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("Detailed question", undefined, { message: "Detailed question" });
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
- expect(ctx.ui.confirm).toHaveBeenCalledWith("Question", "");
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
- expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], undefined);
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.multiselect).not.toHaveBeenCalled();
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
- multiselect: vi.fn().mockResolvedValue(["A"]),
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",