@blackbelt-technology/pi-agent-dashboard 0.5.3 → 0.5.4
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 +19 -30
- package/README.md +69 -1
- package/docs/architecture.md +89 -165
- package/package.json +10 -7
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-default-model-gate.test.ts +47 -0
- package/packages/extension/src/__tests__/bridge-followup-chat-order.test.ts +215 -0
- package/packages/extension/src/__tests__/bridge-followup-multi-entry.test.ts +202 -0
- package/packages/extension/src/__tests__/bridge-queue-update-forward.test.ts +77 -0
- package/packages/extension/src/__tests__/bridge-retry-ordering.test.ts +148 -0
- package/packages/extension/src/__tests__/bridge-shadow-queue-drain.test.ts +221 -0
- package/packages/extension/src/__tests__/bridge-shadow-queue-gate.test.ts +299 -0
- package/packages/extension/src/__tests__/bridge-shutdown-reset.test.ts +238 -0
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +127 -31
- package/packages/extension/src/__tests__/command-handler.test.ts +105 -3
- package/packages/extension/src/__tests__/fixtures/usage-limit-error-strings.ts +127 -0
- package/packages/extension/src/__tests__/source-detector.test.ts +15 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +12 -0
- package/packages/extension/src/bridge-default-model-gate.ts +32 -0
- package/packages/extension/src/bridge.ts +299 -20
- package/packages/extension/src/command-handler.ts +53 -7
- package/packages/extension/src/dashboard-default-adapter.ts +5 -0
- package/packages/extension/src/prompt-bus.ts +15 -0
- package/packages/extension/src/slash-dispatch.ts +30 -15
- package/packages/extension/src/source-detector.ts +13 -5
- package/packages/extension/src/usage-limit-orderer.ts +18 -1
- package/packages/server/bin/pi-dashboard.mjs +62 -14
- package/packages/server/package.json +9 -5
- package/packages/server/src/__tests__/browser-gateway-register-handler.test.ts +69 -0
- package/packages/server/src/__tests__/cli-env-no-clobber.test.ts +46 -0
- package/packages/server/src/__tests__/cli-no-bootstrap-references.test.ts +69 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +9 -10
- package/packages/server/src/__tests__/cli-version.test.ts +151 -0
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +9 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +9 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +9 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +9 -0
- package/packages/server/src/__tests__/directory-service.test.ts +9 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +53 -0
- package/packages/server/src/__tests__/event-wiring-queue-state.test.ts +156 -0
- package/packages/server/src/__tests__/event-wiring-resume-clear.test.ts +105 -0
- package/packages/server/src/__tests__/health-shape.test.ts +35 -12
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +12 -12
- package/packages/server/src/__tests__/is-activity-event.test.ts +4 -7
- package/packages/server/src/__tests__/package-routes.test.ts +6 -2
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +10 -13
- package/packages/server/src/__tests__/pi-core-checker.test.ts +2 -2
- package/packages/server/src/__tests__/pi-version-skew.test.ts +3 -2
- package/packages/server/src/__tests__/plugin-activation-routes.test.ts +267 -0
- package/packages/server/src/__tests__/plugin-intent-cache.test.ts +75 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +196 -0
- package/packages/server/src/__tests__/reattach-placement.test.ts +9 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/recovery-server.test.ts +203 -0
- package/packages/server/src/__tests__/session-action-handler-clear-queue.test.ts +153 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +43 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +9 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +9 -0
- package/packages/server/src/__tests__/session-ordering-integration.test.ts +9 -0
- package/packages/server/src/browser-gateway.ts +83 -5
- package/packages/server/src/browser-handlers/directory-handler.ts +69 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +89 -0
- package/packages/server/src/browser-handlers/subscription-handler.ts +23 -0
- package/packages/server/src/changelog-parser.ts +1 -1
- package/packages/server/src/cli.ts +68 -250
- package/packages/server/src/event-status-extraction.ts +14 -62
- package/packages/server/src/event-wiring.ts +23 -10
- package/packages/server/src/memory-session-manager.ts +4 -0
- package/packages/server/src/pi-core-checker.ts +1 -1
- package/packages/server/src/pi-dev-version-check.ts +1 -1
- package/packages/server/src/pi-version-skew.ts +24 -46
- package/packages/server/src/plugin-intent-cache.ts +67 -0
- package/packages/server/src/preferences-store.ts +199 -13
- package/packages/server/src/recovery-server.ts +366 -0
- package/packages/server/src/routes/__tests__/manifest-route.test.ts +138 -0
- package/packages/server/src/routes/doctor-routes.ts +26 -21
- package/packages/server/src/routes/manifest-route.ts +162 -0
- package/packages/server/src/routes/openspec-routes.ts +4 -25
- package/packages/server/src/routes/pi-changelog-routes.ts +5 -24
- package/packages/server/src/routes/pi-core-routes.ts +3 -23
- package/packages/server/src/routes/plugin-activation-routes.ts +193 -0
- package/packages/server/src/routes/recommended-routes.ts +21 -0
- package/packages/server/src/routes/system-routes.ts +73 -11
- package/packages/server/src/server.ts +105 -307
- package/packages/server/src/session-api.ts +5 -63
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +28 -0
- package/packages/shared/src/__tests__/binary-lookup-spawn-env.test.ts +61 -0
- package/packages/shared/src/__tests__/binary-lookup.test.ts +16 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +67 -0
- package/packages/shared/src/__tests__/ci-electron-no-side-effects.test.ts +129 -0
- package/packages/shared/src/__tests__/config.test.ts +40 -0
- package/packages/shared/src/__tests__/dashboard-paths.test.ts +81 -0
- package/packages/shared/src/__tests__/ensure-windows-path.test.ts +112 -0
- package/packages/shared/src/__tests__/intent-types.test.ts +120 -0
- package/packages/shared/src/__tests__/jiti-packages-parity.test.ts +85 -0
- package/packages/shared/src/__tests__/legacy-managed-dir.test.ts +59 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +12 -0
- package/packages/shared/src/__tests__/no-electron-execpath-spawn.test.ts +149 -0
- package/packages/shared/src/__tests__/no-flow-command-route-claims.test.ts +71 -0
- package/packages/shared/src/__tests__/no-flow-references-in-shell.test.ts +221 -0
- package/packages/shared/src/__tests__/no-managed-dir-reference.test.ts +134 -0
- package/packages/shared/src/__tests__/no-pi-dashboard-version-jiti-gate.test.ts +41 -0
- package/packages/shared/src/__tests__/no-primitive-direct-import.test.ts +235 -0
- package/packages/shared/src/__tests__/no-server-imports-in-resolver.test.ts +53 -0
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +54 -101
- package/packages/shared/src/__tests__/node-spawn.test.ts +29 -13
- package/packages/shared/src/__tests__/pi-package-resolver.test.ts +300 -0
- package/packages/shared/src/__tests__/plugin-activation-contracts.test.ts +74 -0
- package/packages/shared/src/__tests__/plugin-bridge-classify-source.test.ts +73 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +17 -5
- package/packages/shared/src/__tests__/plugin-bridge-register-packages.test.ts +233 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +19 -9
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +154 -15
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +28 -10
- package/packages/shared/src/__tests__/resolver-parity-with-scanner.test.ts +76 -0
- package/packages/shared/src/__tests__/server-identity.test.ts +127 -0
- package/packages/shared/src/__tests__/server-launcher.test.ts +35 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +5 -5
- package/packages/shared/src/__tests__/sync-versions-spec.test.ts +76 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +50 -2
- package/packages/shared/src/bridge-register.ts +35 -2
- package/packages/shared/src/browser-protocol.ts +176 -2
- package/packages/shared/src/config.ts +12 -0
- package/packages/shared/src/dashboard-paths.ts +69 -0
- package/packages/shared/src/dashboard-plugin/index.ts +2 -0
- package/packages/shared/src/dashboard-plugin/intent-types.ts +93 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +55 -1
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +82 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +11 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +16 -2
- package/packages/shared/src/dashboard-plugin/ui-primitives.ts +287 -0
- package/packages/shared/src/dashboard-starter.ts +22 -0
- package/packages/shared/src/doctor-core.ts +49 -27
- package/packages/shared/src/launch-source-types.ts +9 -9
- package/packages/shared/src/legacy-managed-dir.ts +97 -0
- package/packages/shared/src/mdns-discovery.ts +4 -1
- package/packages/shared/src/pi-package-resolver.ts +388 -0
- package/packages/shared/src/platform/binary-lookup.ts +27 -3
- package/packages/shared/src/platform/ensure-windows-path.ts +95 -0
- package/packages/shared/src/platform/exec.ts +22 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -41
- package/packages/shared/src/plugin-bridge-register.ts +275 -18
- package/packages/shared/src/protocol.ts +94 -2
- package/packages/shared/src/recommended-extensions.ts +34 -10
- package/packages/shared/src/server-identity.ts +74 -5
- package/packages/shared/src/server-launcher.ts +20 -0
- package/packages/shared/src/source-matching.ts +1 -1
- package/packages/shared/src/tool-registry/__tests__/node-script-toargv-fallback.test.ts +84 -0
- package/packages/shared/src/tool-registry/definitions.ts +91 -7
- package/packages/shared/src/types.ts +12 -8
- package/scripts/maybe-patch-package.cjs +44 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +0 -263
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +0 -120
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +0 -125
- package/packages/server/src/__tests__/bootstrap-state.test.ts +0 -119
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +0 -36
- package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +0 -55
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +0 -149
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +0 -180
- package/packages/server/src/__tests__/post-install-rescan.test.ts +0 -134
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +0 -91
- package/packages/server/src/bootstrap-install-from-list.ts +0 -232
- package/packages/server/src/bootstrap-queue.ts +0 -130
- package/packages/server/src/bootstrap-state.ts +0 -159
- package/packages/server/src/legacy-pi-cleanup.ts +0 -151
- package/packages/server/src/routes/bootstrap-routes.ts +0 -125
- package/packages/shared/src/__tests__/bootstrap/README.md +0 -133
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +0 -378
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +0 -136
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +0 -47
- package/packages/shared/src/__tests__/bootstrap/cube.ts +0 -66
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +0 -84
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +0 -90
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +0 -34
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +0 -20
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +0 -62
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +0 -34
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +0 -49
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +0 -12
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +0 -156
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +0 -157
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +0 -102
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +0 -76
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +0 -94
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +0 -87
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +0 -143
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +0 -64
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +0 -77
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +0 -19
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +0 -61
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +0 -50
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +0 -272
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +0 -58
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +0 -84
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +0 -9
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +0 -85
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +0 -122
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +0 -36
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +0 -39
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +0 -220
- package/packages/shared/src/__tests__/bootstrap/harness.ts +0 -413
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +0 -125
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +0 -132
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +0 -72
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +0 -68
- package/packages/shared/src/__tests__/install-managed-node.test.ts +0 -192
- package/packages/shared/src/__tests__/installable-list.test.ts +0 -130
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +0 -52
- package/packages/shared/src/bootstrap-install.ts +0 -406
- package/packages/shared/src/installable-list.ts +0 -152
- package/packages/shared/src/launch-source-flag.ts +0 -14
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge's per-entry shadow-queue drain on user message_start.
|
|
3
|
+
*
|
|
4
|
+
* Pi mirrors this exact algorithm internally (see
|
|
5
|
+
* `@earendil-works/pi-coding-agent/dist/core/agent-session.js`
|
|
6
|
+
* `_processAgentEvent`, around line 270-292):
|
|
7
|
+
*
|
|
8
|
+
* if (event.type === "message_start" && event.message.role === "user") {
|
|
9
|
+
* const text = _getUserMessageText(event.message);
|
|
10
|
+
* const steeringIdx = _steeringMessages.indexOf(text);
|
|
11
|
+
* if (steeringIdx !== -1) {
|
|
12
|
+
* _steeringMessages.splice(steeringIdx, 1);
|
|
13
|
+
* _emitQueueUpdate();
|
|
14
|
+
* } else {
|
|
15
|
+
* const followUpIdx = _followUpMessages.indexOf(text);
|
|
16
|
+
* if (followUpIdx !== -1) {
|
|
17
|
+
* _followUpMessages.splice(followUpIdx, 1);
|
|
18
|
+
* _emitQueueUpdate();
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Pre-fix bug: bridge bulk-cleared `bridgeFollowUp = []` at every `agent_end`
|
|
24
|
+
* and `bridgeSteering = []` at every `turn_end`. With pi's `mode:"all"`
|
|
25
|
+
* (default), pi drains all queued follow-ups across multiple turns before
|
|
26
|
+
* emitting the final `agent_end`. The dashboard saw the entire queue stay
|
|
27
|
+
* visible for the whole drain window, then disappear all at once at the
|
|
28
|
+
* end — instead of shrinking one entry per drain as the user observes them
|
|
29
|
+
* being processed.
|
|
30
|
+
*
|
|
31
|
+
* Fix: bridge mirrors pi's per-entry matcher on user `message_start`. Bulk
|
|
32
|
+
* clears at `agent_end` / `turn_end` are removed (would otherwise wipe
|
|
33
|
+
* entries the user added DURING a drain).
|
|
34
|
+
*
|
|
35
|
+
* See change: add-followup-edit-and-steer-cancel (per-entry-drain scenario).
|
|
36
|
+
*/
|
|
37
|
+
import { describe, it, expect } from "vitest";
|
|
38
|
+
|
|
39
|
+
interface ShadowQueue {
|
|
40
|
+
steering: string[];
|
|
41
|
+
followUp: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pure mirror of the per-entry drain matcher as it lives in bridge.ts'
|
|
46
|
+
* `message_start` handler. If production drifts from this shape, the
|
|
47
|
+
* test should drift in lockstep.
|
|
48
|
+
*/
|
|
49
|
+
function makeShadowDrainMatcher() {
|
|
50
|
+
const queue: ShadowQueue = { steering: [], followUp: [] };
|
|
51
|
+
const emits: ShadowQueue[] = [];
|
|
52
|
+
function emit() {
|
|
53
|
+
emits.push({ steering: [...queue.steering], followUp: [...queue.followUp] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mirrors pi's `_getUserMessageText` exactly. */
|
|
57
|
+
function getUserMessageText(message: { role: string; content: unknown }): string {
|
|
58
|
+
if (message.role !== "user") return "";
|
|
59
|
+
const content = message.content;
|
|
60
|
+
if (typeof content === "string") return content;
|
|
61
|
+
if (!Array.isArray(content)) return "";
|
|
62
|
+
return content
|
|
63
|
+
.filter((c: any) => c && c.type === "text")
|
|
64
|
+
.map((c: any) => c.text ?? "")
|
|
65
|
+
.join("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Call when pi emits a user `message_start`. Mirrors pi's internal logic. */
|
|
69
|
+
function onUserMessageStart(message: { role: string; content: unknown }): void {
|
|
70
|
+
const text = getUserMessageText(message);
|
|
71
|
+
if (!text) return;
|
|
72
|
+
const steeringIdx = queue.steering.indexOf(text);
|
|
73
|
+
if (steeringIdx !== -1) {
|
|
74
|
+
queue.steering.splice(steeringIdx, 1);
|
|
75
|
+
emit();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const followUpIdx = queue.followUp.indexOf(text);
|
|
79
|
+
if (followUpIdx !== -1) {
|
|
80
|
+
queue.followUp.splice(followUpIdx, 1);
|
|
81
|
+
emit();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
queue,
|
|
87
|
+
emits,
|
|
88
|
+
onUserMessageStart,
|
|
89
|
+
recordSteer: (t: string) => { queue.steering.push(t); emit(); },
|
|
90
|
+
recordFollowup: (t: string) => { queue.followUp.push(t); emit(); },
|
|
91
|
+
snapshotQueue: () => ({ steering: [...queue.steering], followUp: [...queue.followUp] }),
|
|
92
|
+
snapshotEmits: () => emits.map((e) => ({ steering: [...e.steering], followUp: [...e.followUp] })),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("Bridge shadow-queue per-entry drain on user message_start", () => {
|
|
97
|
+
it("removes the matching follow-up entry when pi drains it, leaves the rest", () => {
|
|
98
|
+
const m = makeShadowDrainMatcher();
|
|
99
|
+
m.recordFollowup("a");
|
|
100
|
+
m.recordFollowup("b");
|
|
101
|
+
m.recordFollowup("c");
|
|
102
|
+
expect(m.snapshotQueue().followUp).toEqual(["a", "b", "c"]);
|
|
103
|
+
|
|
104
|
+
// Pi drains "a" first.
|
|
105
|
+
m.onUserMessageStart({ role: "user", content: "a" });
|
|
106
|
+
expect(m.snapshotQueue().followUp).toEqual(["b", "c"]);
|
|
107
|
+
|
|
108
|
+
// Then "b".
|
|
109
|
+
m.onUserMessageStart({ role: "user", content: "b" });
|
|
110
|
+
expect(m.snapshotQueue().followUp).toEqual(["c"]);
|
|
111
|
+
|
|
112
|
+
// Then "c".
|
|
113
|
+
m.onUserMessageStart({ role: "user", content: "c" });
|
|
114
|
+
expect(m.snapshotQueue().followUp).toEqual([]);
|
|
115
|
+
|
|
116
|
+
// One emit per drain (plus the three initial record emits).
|
|
117
|
+
expect(m.emits).toHaveLength(6);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("removes the matching steering entry when pi drains it", () => {
|
|
121
|
+
const m = makeShadowDrainMatcher();
|
|
122
|
+
m.recordSteer("focus on X");
|
|
123
|
+
m.recordSteer("ignore Y");
|
|
124
|
+
expect(m.snapshotQueue().steering).toEqual(["focus on X", "ignore Y"]);
|
|
125
|
+
|
|
126
|
+
m.onUserMessageStart({ role: "user", content: "focus on X" });
|
|
127
|
+
expect(m.snapshotQueue().steering).toEqual(["ignore Y"]);
|
|
128
|
+
expect(m.snapshotQueue().followUp).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("steering queue checked BEFORE follow-up when same text is in both", () => {
|
|
132
|
+
const m = makeShadowDrainMatcher();
|
|
133
|
+
m.recordSteer("hello");
|
|
134
|
+
m.recordFollowup("hello");
|
|
135
|
+
|
|
136
|
+
m.onUserMessageStart({ role: "user", content: "hello" });
|
|
137
|
+
// Steering entry consumed, follow-up untouched.
|
|
138
|
+
expect(m.snapshotQueue().steering).toEqual([]);
|
|
139
|
+
expect(m.snapshotQueue().followUp).toEqual(["hello"]);
|
|
140
|
+
|
|
141
|
+
// Second drain consumes the follow-up.
|
|
142
|
+
m.onUserMessageStart({ role: "user", content: "hello" });
|
|
143
|
+
expect(m.snapshotQueue().followUp).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("non-matching user message_start is a no-op (no queue mutation, no emit)", () => {
|
|
147
|
+
const m = makeShadowDrainMatcher();
|
|
148
|
+
m.recordFollowup("queued");
|
|
149
|
+
const baselineEmits = m.emits.length;
|
|
150
|
+
|
|
151
|
+
// Fresh user send not in any queue (e.g., the user typed something
|
|
152
|
+
// new on an idle session, or a steer was added by a non-dashboard
|
|
153
|
+
// consumer).
|
|
154
|
+
m.onUserMessageStart({ role: "user", content: "fresh send" });
|
|
155
|
+
expect(m.snapshotQueue().followUp).toEqual(["queued"]);
|
|
156
|
+
expect(m.emits.length).toBe(baselineEmits);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("user message_start with array content joins text blocks (matches pi's _getUserMessageText)", () => {
|
|
160
|
+
const m = makeShadowDrainMatcher();
|
|
161
|
+
m.recordFollowup("describe this");
|
|
162
|
+
|
|
163
|
+
m.onUserMessageStart({
|
|
164
|
+
role: "user",
|
|
165
|
+
content: [
|
|
166
|
+
{ type: "text", text: "describe " },
|
|
167
|
+
{ type: "image", data: "<base64>", mimeType: "image/png" },
|
|
168
|
+
{ type: "text", text: "this" },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
expect(m.snapshotQueue().followUp).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("ignores non-user message_start (assistant role does not touch the queue)", () => {
|
|
175
|
+
const m = makeShadowDrainMatcher();
|
|
176
|
+
m.recordFollowup("a");
|
|
177
|
+
const baselineEmits = m.emits.length;
|
|
178
|
+
|
|
179
|
+
m.onUserMessageStart({ role: "assistant", content: "a" });
|
|
180
|
+
expect(m.snapshotQueue().followUp).toEqual(["a"]);
|
|
181
|
+
expect(m.emits.length).toBe(baselineEmits);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("removes only the FIRST occurrence on duplicate text (FIFO)", () => {
|
|
185
|
+
const m = makeShadowDrainMatcher();
|
|
186
|
+
m.recordFollowup("dup");
|
|
187
|
+
m.recordFollowup("other");
|
|
188
|
+
m.recordFollowup("dup");
|
|
189
|
+
expect(m.snapshotQueue().followUp).toEqual(["dup", "other", "dup"]);
|
|
190
|
+
|
|
191
|
+
m.onUserMessageStart({ role: "user", content: "dup" });
|
|
192
|
+
// First "dup" removed; second one still queued (FIFO).
|
|
193
|
+
expect(m.snapshotQueue().followUp).toEqual(["other", "dup"]);
|
|
194
|
+
|
|
195
|
+
m.onUserMessageStart({ role: "user", content: "dup" });
|
|
196
|
+
expect(m.snapshotQueue().followUp).toEqual(["other"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("entries added DURING drain are preserved (no bulk wipe)", () => {
|
|
200
|
+
const m = makeShadowDrainMatcher();
|
|
201
|
+
m.recordFollowup("a");
|
|
202
|
+
m.recordFollowup("b");
|
|
203
|
+
|
|
204
|
+
// Pi drains "a"
|
|
205
|
+
m.onUserMessageStart({ role: "user", content: "a" });
|
|
206
|
+
expect(m.snapshotQueue().followUp).toEqual(["b"]);
|
|
207
|
+
|
|
208
|
+
// User adds a new entry "c" while pi is still draining
|
|
209
|
+
m.recordFollowup("c");
|
|
210
|
+
expect(m.snapshotQueue().followUp).toEqual(["b", "c"]);
|
|
211
|
+
|
|
212
|
+
// Pi drains "b"
|
|
213
|
+
m.onUserMessageStart({ role: "user", content: "b" });
|
|
214
|
+
// "c" must still be present
|
|
215
|
+
expect(m.snapshotQueue().followUp).toEqual(["c"]);
|
|
216
|
+
|
|
217
|
+
// Eventually pi drains "c"
|
|
218
|
+
m.onUserMessageStart({ role: "user", content: "c" });
|
|
219
|
+
expect(m.snapshotQueue().followUp).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge's shadow-queue streaming gate.
|
|
3
|
+
*
|
|
4
|
+
* Repro for the bug "STEERING (1) appears on the very first message to an
|
|
5
|
+
* idle session". The fix has TWO layers:
|
|
6
|
+
*
|
|
7
|
+
* 1. Capture-before-send (primary gate): at the call site of
|
|
8
|
+
* `pi.sendUserMessage`, the caller MUST snapshot
|
|
9
|
+
* `isAgentStreaming` BEFORE invoking sendUserMessage. Pi flips
|
|
10
|
+
* idle→streaming synchronously inside sendUserMessage by emitting
|
|
11
|
+
* `agent_start`, whose handler in bridge.ts flips the flag in its
|
|
12
|
+
* first sync line. Checking the flag AFTER the send always reads
|
|
13
|
+
* true — the original bug. Tests `record*Sent + wasStreaming`
|
|
14
|
+
* encode that contract.
|
|
15
|
+
*
|
|
16
|
+
* 2. Internal gate (defense in depth): `recordSteerSent` /
|
|
17
|
+
* `recordFollowupSent` themselves re-check `isStreaming()` so a
|
|
18
|
+
* caller that forgets to capture pre-send still doesn't corrupt
|
|
19
|
+
* the shadow queue.
|
|
20
|
+
*
|
|
21
|
+
* See change: add-followup-edit-and-steer-cancel.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect, vi } from "vitest";
|
|
24
|
+
|
|
25
|
+
interface ShadowQueue {
|
|
26
|
+
steering: string[];
|
|
27
|
+
followUp: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pure version of the gate as it lives in bridge.ts. Mirrors the closure
|
|
32
|
+
* logic 1:1; if the production code drifts from this shape, this test
|
|
33
|
+
* should drift in lockstep.
|
|
34
|
+
*/
|
|
35
|
+
function makeShadowRecorder(opts: {
|
|
36
|
+
isStreaming: () => boolean;
|
|
37
|
+
onEmit: (snapshot: ShadowQueue) => void;
|
|
38
|
+
}) {
|
|
39
|
+
const queue: ShadowQueue = { steering: [], followUp: [] };
|
|
40
|
+
function emit() { opts.onEmit({ steering: [...queue.steering], followUp: [...queue.followUp] }); }
|
|
41
|
+
function recordSteer(text: string) {
|
|
42
|
+
if (!opts.isStreaming()) return;
|
|
43
|
+
queue.steering.push(text);
|
|
44
|
+
emit();
|
|
45
|
+
}
|
|
46
|
+
function recordFollowup(text: string) {
|
|
47
|
+
if (!opts.isStreaming()) return;
|
|
48
|
+
queue.followUp = [text];
|
|
49
|
+
emit();
|
|
50
|
+
}
|
|
51
|
+
function clearSteer() { queue.steering = []; emit(); }
|
|
52
|
+
function clearFollowup() { queue.followUp = []; emit(); }
|
|
53
|
+
function drainSteerOnTurnEnd() { if (queue.steering.length > 0) { queue.steering = []; emit(); } }
|
|
54
|
+
function drainFollowupOnAgentEnd() { if (queue.followUp.length > 0) { queue.followUp = []; emit(); } }
|
|
55
|
+
return { recordSteer, recordFollowup, clearSteer, clearFollowup, drainSteerOnTurnEnd, drainFollowupOnAgentEnd, snapshot: () => ({ ...queue, steering: [...queue.steering], followUp: [...queue.followUp] }) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("bridge shadow queue: streaming gate", () => {
|
|
59
|
+
it("recordSteer is a no-op when isStreaming === false (idle first message)", () => {
|
|
60
|
+
let streaming = false;
|
|
61
|
+
const onEmit = vi.fn();
|
|
62
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
63
|
+
|
|
64
|
+
r.recordSteer("hello");
|
|
65
|
+
|
|
66
|
+
expect(r.snapshot().steering).toEqual([]);
|
|
67
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("recordFollowup is a no-op when isStreaming === false (idle first message)", () => {
|
|
71
|
+
let streaming = false;
|
|
72
|
+
const onEmit = vi.fn();
|
|
73
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
74
|
+
|
|
75
|
+
r.recordFollowup("after done");
|
|
76
|
+
|
|
77
|
+
expect(r.snapshot().followUp).toEqual([]);
|
|
78
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("recordSteer appends + emits when streaming", () => {
|
|
82
|
+
let streaming = true;
|
|
83
|
+
const onEmit = vi.fn();
|
|
84
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
85
|
+
|
|
86
|
+
r.recordSteer("A");
|
|
87
|
+
r.recordSteer("B");
|
|
88
|
+
|
|
89
|
+
expect(r.snapshot().steering).toEqual(["A", "B"]);
|
|
90
|
+
expect(onEmit).toHaveBeenCalledTimes(2);
|
|
91
|
+
expect(onEmit.mock.calls[1][0]).toEqual({ steering: ["A", "B"], followUp: [] });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("recordFollowup replaces slot + emits when streaming (capacity 1)", () => {
|
|
95
|
+
let streaming = true;
|
|
96
|
+
const onEmit = vi.fn();
|
|
97
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
98
|
+
|
|
99
|
+
r.recordFollowup("first");
|
|
100
|
+
r.recordFollowup("second");
|
|
101
|
+
|
|
102
|
+
expect(r.snapshot().followUp).toEqual(["second"]);
|
|
103
|
+
expect(onEmit).toHaveBeenCalledTimes(2);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("bridge shadow queue: drain boundaries", () => {
|
|
108
|
+
it("turn_end drains steering only (followUp untouched)", () => {
|
|
109
|
+
let streaming = true;
|
|
110
|
+
const onEmit = vi.fn();
|
|
111
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
112
|
+
r.recordSteer("s1");
|
|
113
|
+
r.recordFollowup("f1");
|
|
114
|
+
onEmit.mockClear();
|
|
115
|
+
|
|
116
|
+
r.drainSteerOnTurnEnd();
|
|
117
|
+
|
|
118
|
+
expect(r.snapshot()).toEqual({ steering: [], followUp: ["f1"] });
|
|
119
|
+
expect(onEmit).toHaveBeenCalledTimes(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("agent_end drains followUp only (steering untouched)", () => {
|
|
123
|
+
let streaming = true;
|
|
124
|
+
const onEmit = vi.fn();
|
|
125
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
126
|
+
r.recordSteer("s1");
|
|
127
|
+
r.recordFollowup("f1");
|
|
128
|
+
onEmit.mockClear();
|
|
129
|
+
|
|
130
|
+
r.drainFollowupOnAgentEnd();
|
|
131
|
+
|
|
132
|
+
expect(r.snapshot()).toEqual({ steering: ["s1"], followUp: [] });
|
|
133
|
+
expect(onEmit).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("turn_end on empty steering does NOT emit (idempotent / no spurious broadcasts)", () => {
|
|
137
|
+
const onEmit = vi.fn();
|
|
138
|
+
const r = makeShadowRecorder({ isStreaming: () => true, onEmit });
|
|
139
|
+
|
|
140
|
+
r.drainSteerOnTurnEnd();
|
|
141
|
+
|
|
142
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("agent_end on empty followUp does NOT emit", () => {
|
|
146
|
+
const onEmit = vi.fn();
|
|
147
|
+
const r = makeShadowRecorder({ isStreaming: () => true, onEmit });
|
|
148
|
+
|
|
149
|
+
r.drainFollowupOnAgentEnd();
|
|
150
|
+
|
|
151
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("bridge shadow queue: clears", () => {
|
|
156
|
+
it("clearSteer wipes + emits regardless of streaming state", () => {
|
|
157
|
+
let streaming = false;
|
|
158
|
+
const onEmit = vi.fn();
|
|
159
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
160
|
+
// Populate via a streaming-on phase, then go idle and clear.
|
|
161
|
+
streaming = true; r.recordSteer("x"); streaming = false; onEmit.mockClear();
|
|
162
|
+
|
|
163
|
+
r.clearSteer();
|
|
164
|
+
|
|
165
|
+
expect(r.snapshot().steering).toEqual([]);
|
|
166
|
+
expect(onEmit).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("clearFollowup wipes + emits regardless of streaming state", () => {
|
|
170
|
+
let streaming = false;
|
|
171
|
+
const onEmit = vi.fn();
|
|
172
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
173
|
+
streaming = true; r.recordFollowup("y"); streaming = false; onEmit.mockClear();
|
|
174
|
+
|
|
175
|
+
r.clearFollowup();
|
|
176
|
+
|
|
177
|
+
expect(r.snapshot().followUp).toEqual([]);
|
|
178
|
+
expect(onEmit).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("bridge shadow queue: capture-before-send semantics (PRIMARY gate)", () => {
|
|
183
|
+
// Simulates the command-handler / sessionPrompt call sites where the bug
|
|
184
|
+
// originally lived. The fix: capture streaming state into a local var
|
|
185
|
+
// BEFORE calling pi.sendUserMessage. The internal recordX gate is a
|
|
186
|
+
// safety net only.
|
|
187
|
+
|
|
188
|
+
/** Stand-in for the simplified call-site logic in command-handler.ts. */
|
|
189
|
+
function send(opts: {
|
|
190
|
+
text: string;
|
|
191
|
+
delivery: "steer" | "followUp";
|
|
192
|
+
isStreaming: () => boolean;
|
|
193
|
+
piSendUserMessage: () => void; // simulates the synchronous agent_start flip
|
|
194
|
+
onSteer: (text: string) => void;
|
|
195
|
+
onFollowup: (text: string) => void;
|
|
196
|
+
}) {
|
|
197
|
+
const wasStreaming = opts.isStreaming();
|
|
198
|
+
opts.piSendUserMessage();
|
|
199
|
+
if (wasStreaming) {
|
|
200
|
+
if (opts.delivery === "steer") opts.onSteer(opts.text);
|
|
201
|
+
else opts.onFollowup(opts.text);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it("idle send DOES NOT record even when pi flips isStreaming synchronously inside sendUserMessage", () => {
|
|
206
|
+
let streaming = false;
|
|
207
|
+
const onEmit = vi.fn();
|
|
208
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
209
|
+
|
|
210
|
+
send({
|
|
211
|
+
text: "first message",
|
|
212
|
+
delivery: "steer",
|
|
213
|
+
isStreaming: () => streaming,
|
|
214
|
+
// Pi receives the message, fires agent_start synchronously, which
|
|
215
|
+
// flips `streaming` to true. This was the original bug.
|
|
216
|
+
piSendUserMessage: () => { streaming = true; },
|
|
217
|
+
onSteer: r.recordSteer,
|
|
218
|
+
onFollowup: r.recordFollowup,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(r.snapshot()).toEqual({ steering: [], followUp: [] });
|
|
222
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("streaming send DOES record (chip appears for mid-turn steer)", () => {
|
|
226
|
+
let streaming = true;
|
|
227
|
+
const onEmit = vi.fn();
|
|
228
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
229
|
+
|
|
230
|
+
send({
|
|
231
|
+
text: "redirect",
|
|
232
|
+
delivery: "steer",
|
|
233
|
+
isStreaming: () => streaming,
|
|
234
|
+
piSendUserMessage: () => { /* still streaming */ },
|
|
235
|
+
onSteer: r.recordSteer,
|
|
236
|
+
onFollowup: r.recordFollowup,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(r.snapshot().steering).toEqual(["redirect"]);
|
|
240
|
+
expect(onEmit).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("idle followUp also DOES NOT record (same race shape)", () => {
|
|
244
|
+
let streaming = false;
|
|
245
|
+
const onEmit = vi.fn();
|
|
246
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
247
|
+
|
|
248
|
+
send({
|
|
249
|
+
text: "after done",
|
|
250
|
+
delivery: "followUp",
|
|
251
|
+
isStreaming: () => streaming,
|
|
252
|
+
piSendUserMessage: () => { streaming = true; },
|
|
253
|
+
onSteer: r.recordSteer,
|
|
254
|
+
onFollowup: r.recordFollowup,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(r.snapshot()).toEqual({ steering: [], followUp: [] });
|
|
258
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("bridge shadow queue: realistic follow-up scenario", () => {
|
|
263
|
+
it("send followUp while idle (first message of session) shows no chip", () => {
|
|
264
|
+
let streaming = false;
|
|
265
|
+
const onEmit = vi.fn();
|
|
266
|
+
const r = makeShadowRecorder({ isStreaming: () => streaming, onEmit });
|
|
267
|
+
|
|
268
|
+
// User sends their initial prompt; pi starts a new turn directly,
|
|
269
|
+
// it doesn't queue. The bridge must NOT record a chip.
|
|
270
|
+
r.recordFollowup("kick off the task");
|
|
271
|
+
|
|
272
|
+
expect(r.snapshot().followUp).toEqual([]);
|
|
273
|
+
expect(onEmit).not.toHaveBeenCalled();
|
|
274
|
+
|
|
275
|
+
// Pi fires agent_start → streaming flips on.
|
|
276
|
+
streaming = true;
|
|
277
|
+
|
|
278
|
+
// User adds a follow-up mid-stream → chip appears.
|
|
279
|
+
r.recordFollowup("when you finish, run the tests");
|
|
280
|
+
expect(r.snapshot().followUp).toEqual(["when you finish, run the tests"]);
|
|
281
|
+
expect(onEmit).toHaveBeenCalledTimes(1);
|
|
282
|
+
|
|
283
|
+
// Agent finishes → followUp drains.
|
|
284
|
+
r.drainFollowupOnAgentEnd();
|
|
285
|
+
expect(r.snapshot().followUp).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("rapid edit (steering still active) replaces slot atomically", () => {
|
|
289
|
+
const onEmit = vi.fn();
|
|
290
|
+
const r = makeShadowRecorder({ isStreaming: () => true, onEmit });
|
|
291
|
+
|
|
292
|
+
r.recordFollowup("v1");
|
|
293
|
+
r.recordFollowup("v2");
|
|
294
|
+
r.recordFollowup("v3");
|
|
295
|
+
|
|
296
|
+
expect(r.snapshot().followUp).toEqual(["v3"]);
|
|
297
|
+
expect(onEmit).toHaveBeenCalledTimes(3);
|
|
298
|
+
});
|
|
299
|
+
});
|