@blackbelt-technology/pi-agent-dashboard 0.5.2 → 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 +11 -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,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge wire-ordering invariant for drained queued user messages.
|
|
3
|
+
*
|
|
4
|
+
* Applies to BOTH drain boundaries:
|
|
5
|
+
* - Steer drain at `turn_end` (Enter while streaming)
|
|
6
|
+
* - Follow-up drain at `agent_end` (Alt+Enter while streaming)
|
|
7
|
+
*
|
|
8
|
+
* Pi emits four events synchronously back-to-back at the drain boundary:
|
|
9
|
+
*
|
|
10
|
+
* 1. message_end (assistant — the final response of the just-completed turn)
|
|
11
|
+
* 2. turn_end OR agent_end (the drain boundary)
|
|
12
|
+
* 3. message_start (user — the drained steer or follow-up text, e.g. "asd")
|
|
13
|
+
* 4. message_end (user — same text)
|
|
14
|
+
*
|
|
15
|
+
* Pre-fix bug: the bridge defers `message_end` sends via `setTimeout(0)`
|
|
16
|
+
* (for entryId capture per `fix-per-message-fork`) but sends `message_start`
|
|
17
|
+
* synchronously. The drained user `message_start` therefore lands on the
|
|
18
|
+
* wire BEFORE the preceding assistant `message_end`. The client's reducer
|
|
19
|
+
* appends the user message to `state.messages[]` at `message_start`, then
|
|
20
|
+
* the assistant message at the (later) `message_end` — so the chat shows
|
|
21
|
+
* the follow-up "asd" ABOVE the assistant's final response.
|
|
22
|
+
*
|
|
23
|
+
* Fix: defer user `message_start` sends via the same `setTimeout(0)`. All
|
|
24
|
+
* user-role messages are queued in the timer FIFO behind any pending
|
|
25
|
+
* `message_end` deferrals, preserving pi's emit order on the wire.
|
|
26
|
+
*
|
|
27
|
+
* Assistant `message_start` MUST stay sync — `message_update` events fire
|
|
28
|
+
* sync and depend on the reducer having seen `message_start` first (to
|
|
29
|
+
* reset `streamingTextFlushed`).
|
|
30
|
+
*
|
|
31
|
+
* See change: add-followup-edit-and-steer-cancel (chat-order scenario).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from "vitest";
|
|
35
|
+
|
|
36
|
+
interface WireEvent {
|
|
37
|
+
eventType: string;
|
|
38
|
+
role?: "user" | "assistant";
|
|
39
|
+
content?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Simulates the bridge's event-forwarding pipeline with the new
|
|
44
|
+
* deferral rule applied to USER message_start. Runs everything in
|
|
45
|
+
* a single synchronous tick to mirror pi's emit cadence, then drains
|
|
46
|
+
* the macrotask queue to capture the final wire order.
|
|
47
|
+
*/
|
|
48
|
+
class BridgeSim {
|
|
49
|
+
readonly wire: WireEvent[] = [];
|
|
50
|
+
|
|
51
|
+
onMessageStart(role: "user" | "assistant", content: string): void {
|
|
52
|
+
if (role === "user") {
|
|
53
|
+
// FIX: defer user message_start to match message_end's deferral.
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
this.wire.push({ eventType: "message_start", role, content });
|
|
56
|
+
}, 0);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Assistant message_start sent sync (message_update depends on it).
|
|
60
|
+
this.wire.push({ eventType: "message_start", role, content });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onMessageEnd(role: "user" | "assistant", content: string): void {
|
|
64
|
+
// Existing behaviour: ALL message_end sends are deferred via setTimeout(0)
|
|
65
|
+
// for entryId capture (fix-per-message-fork).
|
|
66
|
+
setTimeout(() => {
|
|
67
|
+
this.wire.push({ eventType: "message_end", role, content });
|
|
68
|
+
}, 0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onAgentEnd(): void {
|
|
72
|
+
// Sent sync.
|
|
73
|
+
this.wire.push({ eventType: "agent_end" });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onTurnEnd(): void {
|
|
77
|
+
// Sent sync (mirrors agent_end).
|
|
78
|
+
this.wire.push({ eventType: "turn_end" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Flush pending setTimeout(0) callbacks. */
|
|
82
|
+
async flush(): Promise<void> {
|
|
83
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
84
|
+
// Two extra ticks in case any callback re-queues.
|
|
85
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("Bridge drained-followup chat-order invariant", () => {
|
|
90
|
+
it("user message_start for drained follow-up lands AFTER preceding assistant message_end", async () => {
|
|
91
|
+
const sim = new BridgeSim();
|
|
92
|
+
|
|
93
|
+
// Pi's sync emit order at agent_end with a queued follow-up:
|
|
94
|
+
// 1. assistant message_end (the weather report)
|
|
95
|
+
// 2. agent_end
|
|
96
|
+
// 3. user message_start ("asd" — the drained follow-up)
|
|
97
|
+
// 4. user message_end ("asd")
|
|
98
|
+
sim.onMessageEnd("assistant", "weather report");
|
|
99
|
+
sim.onAgentEnd();
|
|
100
|
+
sim.onMessageStart("user", "asd");
|
|
101
|
+
sim.onMessageEnd("user", "asd");
|
|
102
|
+
|
|
103
|
+
await sim.flush();
|
|
104
|
+
|
|
105
|
+
const types = sim.wire.map((e) => `${e.eventType}:${e.role ?? "-"}`);
|
|
106
|
+
// Expected wire order after the fix:
|
|
107
|
+
// 1. agent_end (sync, fires first)
|
|
108
|
+
// 2. assistant message_end (deferred, FIFO #1)
|
|
109
|
+
// 3. user message_start (deferred, FIFO #2 — after the fix)
|
|
110
|
+
// 4. user message_end (deferred, FIFO #3)
|
|
111
|
+
expect(types).toEqual([
|
|
112
|
+
"agent_end:-",
|
|
113
|
+
"message_end:assistant",
|
|
114
|
+
"message_start:user",
|
|
115
|
+
"message_end:user",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// Critical invariant: the drained user message_start MUST NOT come
|
|
119
|
+
// before the preceding assistant message_end.
|
|
120
|
+
const userStartIdx = sim.wire.findIndex(
|
|
121
|
+
(e) => e.eventType === "message_start" && e.role === "user"
|
|
122
|
+
);
|
|
123
|
+
const assistantEndIdx = sim.wire.findIndex(
|
|
124
|
+
(e) => e.eventType === "message_end" && e.role === "assistant"
|
|
125
|
+
);
|
|
126
|
+
expect(assistantEndIdx).toBeGreaterThanOrEqual(0);
|
|
127
|
+
expect(userStartIdx).toBeGreaterThan(assistantEndIdx);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("assistant message_start stays SYNC (message_update relies on reducer seeing it first)", () => {
|
|
131
|
+
const sim = new BridgeSim();
|
|
132
|
+
sim.onMessageStart("assistant", "hello");
|
|
133
|
+
// No flush — the event must already be on the wire.
|
|
134
|
+
expect(sim.wire).toEqual([
|
|
135
|
+
{ eventType: "message_start", role: "assistant", content: "hello" },
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("multiple drained follow-ups preserve their relative pi emit order", async () => {
|
|
140
|
+
const sim = new BridgeSim();
|
|
141
|
+
|
|
142
|
+
// Pi delivers two queued follow-ups in order ["a", "b"].
|
|
143
|
+
sim.onMessageEnd("assistant", "final");
|
|
144
|
+
sim.onAgentEnd();
|
|
145
|
+
sim.onMessageStart("user", "a");
|
|
146
|
+
sim.onMessageEnd("user", "a");
|
|
147
|
+
sim.onMessageStart("user", "b");
|
|
148
|
+
sim.onMessageEnd("user", "b");
|
|
149
|
+
|
|
150
|
+
await sim.flush();
|
|
151
|
+
|
|
152
|
+
const summary = sim.wire.map((e) =>
|
|
153
|
+
e.eventType === "agent_end"
|
|
154
|
+
? "agent_end"
|
|
155
|
+
: `${e.eventType}:${e.role}:${e.content}`
|
|
156
|
+
);
|
|
157
|
+
expect(summary).toEqual([
|
|
158
|
+
"agent_end",
|
|
159
|
+
"message_end:assistant:final",
|
|
160
|
+
"message_start:user:a",
|
|
161
|
+
"message_end:user:a",
|
|
162
|
+
"message_start:user:b",
|
|
163
|
+
"message_end:user:b",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("drained STEER at turn_end lands AFTER preceding assistant message_end (same bug, different drain boundary)", async () => {
|
|
168
|
+
const sim = new BridgeSim();
|
|
169
|
+
|
|
170
|
+
// Pi's sync emit order at turn_end with a queued steer:
|
|
171
|
+
// 1. assistant message_end (the weather report)
|
|
172
|
+
// 2. turn_end
|
|
173
|
+
// 3. user message_start ("asd" — the drained steer)
|
|
174
|
+
// 4. user message_end ("asd")
|
|
175
|
+
// (Identical to the follow-up case but the drain boundary is turn_end
|
|
176
|
+
// instead of agent_end. The fix is uniform: defer USER message_start.)
|
|
177
|
+
sim.onMessageEnd("assistant", "weather report");
|
|
178
|
+
sim.onTurnEnd();
|
|
179
|
+
sim.onMessageStart("user", "asd");
|
|
180
|
+
sim.onMessageEnd("user", "asd");
|
|
181
|
+
|
|
182
|
+
await sim.flush();
|
|
183
|
+
|
|
184
|
+
const types = sim.wire.map((e) => `${e.eventType}:${e.role ?? "-"}`);
|
|
185
|
+
expect(types).toEqual([
|
|
186
|
+
"turn_end:-",
|
|
187
|
+
"message_end:assistant",
|
|
188
|
+
"message_start:user",
|
|
189
|
+
"message_end:user",
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const userStartIdx = sim.wire.findIndex(
|
|
193
|
+
(e) => e.eventType === "message_start" && e.role === "user"
|
|
194
|
+
);
|
|
195
|
+
const assistantEndIdx = sim.wire.findIndex(
|
|
196
|
+
(e) => e.eventType === "message_end" && e.role === "assistant"
|
|
197
|
+
);
|
|
198
|
+
expect(userStartIdx).toBeGreaterThan(assistantEndIdx);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("idle user send (no preceding deferred message_end) still arrives intact", async () => {
|
|
202
|
+
const sim = new BridgeSim();
|
|
203
|
+
|
|
204
|
+
// No pending deferrals in flight — a fresh user prompt.
|
|
205
|
+
sim.onMessageStart("user", "hi");
|
|
206
|
+
sim.onMessageEnd("user", "hi");
|
|
207
|
+
|
|
208
|
+
await sim.flush();
|
|
209
|
+
|
|
210
|
+
expect(sim.wire.map((e) => `${e.eventType}:${e.role}`)).toEqual([
|
|
211
|
+
"message_start:user",
|
|
212
|
+
"message_end:user",
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 bridge behavior tests for the multi-entry follow-up queue.
|
|
3
|
+
*
|
|
4
|
+
* Encodes the contract of the bridge's `rewriteFollowupQueue` helper and the
|
|
5
|
+
* three new browser-message handlers (promote / remove / edit_entry). Tests
|
|
6
|
+
* use a pure-helper reproduction (same shape as the production code) so the
|
|
7
|
+
* contract is verifiable without instantiating the full bridge.
|
|
8
|
+
*
|
|
9
|
+
* See change: add-followup-edit-and-steer-cancel (tasks 13.6, 13.7, 13.13).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
const FOLLOWUP_QUEUE_CAP = 20;
|
|
14
|
+
|
|
15
|
+
/** Pure reproduction of the bridge's follow-up shadow + rewrite logic. */
|
|
16
|
+
function makeShadow() {
|
|
17
|
+
const calls: Array<{ kind: "clear" } | { kind: "send"; text: string }> = [];
|
|
18
|
+
let bridgeFollowUp: string[] = [];
|
|
19
|
+
const fakePiClearFollowUpQueue = () => { calls.push({ kind: "clear" }); };
|
|
20
|
+
const fakePiSendUserMessage = (text: string, opts: { deliverAs: string }) => {
|
|
21
|
+
calls.push({ kind: "send", text });
|
|
22
|
+
expect(opts).toEqual({ deliverAs: "followUp" });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function rewriteFollowupQueue(newEntries: string[]) {
|
|
26
|
+
const capped = newEntries.slice(0, FOLLOWUP_QUEUE_CAP);
|
|
27
|
+
fakePiClearFollowUpQueue();
|
|
28
|
+
for (const t of capped) fakePiSendUserMessage(t, { deliverAs: "followUp" });
|
|
29
|
+
bridgeFollowUp = [...capped];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recordFollowupSent(text: string, isStreaming: boolean) {
|
|
33
|
+
if (!isStreaming) return;
|
|
34
|
+
if (bridgeFollowUp.length >= FOLLOWUP_QUEUE_CAP) return;
|
|
35
|
+
bridgeFollowUp.push(text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Browser-message handlers (mirror bridge.ts shape)
|
|
39
|
+
function handlePromoteEntry(index: number) {
|
|
40
|
+
if (index < 0 || index >= bridgeFollowUp.length) return;
|
|
41
|
+
const head = bridgeFollowUp[index];
|
|
42
|
+
const rest = bridgeFollowUp.filter((_, i) => i !== index);
|
|
43
|
+
rewriteFollowupQueue([head, ...rest]);
|
|
44
|
+
}
|
|
45
|
+
function handleRemoveEntry(index: number) {
|
|
46
|
+
if (index < 0 || index >= bridgeFollowUp.length) return;
|
|
47
|
+
const surviving = bridgeFollowUp.filter((_, i) => i !== index);
|
|
48
|
+
rewriteFollowupQueue(surviving);
|
|
49
|
+
}
|
|
50
|
+
function handleEditEntry(index: number, text: string) {
|
|
51
|
+
if (index < 0 || index >= bridgeFollowUp.length) return;
|
|
52
|
+
const next = bridgeFollowUp.map((t, i) => (i === index ? text : t));
|
|
53
|
+
rewriteFollowupQueue(next);
|
|
54
|
+
}
|
|
55
|
+
function handleEditFollowupSlotV1Compat(text: string) {
|
|
56
|
+
rewriteFollowupQueue([text]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
snapshot: () => [...bridgeFollowUp],
|
|
61
|
+
calls,
|
|
62
|
+
rewriteFollowupQueue,
|
|
63
|
+
recordFollowupSent,
|
|
64
|
+
handlePromoteEntry,
|
|
65
|
+
handleRemoveEntry,
|
|
66
|
+
handleEditEntry,
|
|
67
|
+
handleEditFollowupSlotV1Compat,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("bridge follow-up multi-entry queue: rewrite helper", () => {
|
|
72
|
+
it("clears pi + sends each entry in new order", () => {
|
|
73
|
+
const s = makeShadow();
|
|
74
|
+
s.rewriteFollowupQueue(["a", "b", "c"]);
|
|
75
|
+
expect(s.calls).toEqual([
|
|
76
|
+
{ kind: "clear" },
|
|
77
|
+
{ kind: "send", text: "a" },
|
|
78
|
+
{ kind: "send", text: "b" },
|
|
79
|
+
{ kind: "send", text: "c" },
|
|
80
|
+
]);
|
|
81
|
+
expect(s.snapshot()).toEqual(["a", "b", "c"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("clears + sends nothing for empty rewrite (queue drained)", () => {
|
|
85
|
+
const s = makeShadow();
|
|
86
|
+
s.rewriteFollowupQueue([]);
|
|
87
|
+
expect(s.calls).toEqual([{ kind: "clear" }]);
|
|
88
|
+
expect(s.snapshot()).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("caps at FOLLOWUP_QUEUE_CAP (20)", () => {
|
|
92
|
+
const s = makeShadow();
|
|
93
|
+
const big = Array.from({ length: 25 }, (_, i) => `entry-${i}`);
|
|
94
|
+
s.rewriteFollowupQueue(big);
|
|
95
|
+
const sends = s.calls.filter((c) => c.kind === "send");
|
|
96
|
+
expect(sends).toHaveLength(20);
|
|
97
|
+
expect(s.snapshot()).toHaveLength(20);
|
|
98
|
+
expect(s.snapshot()[0]).toBe("entry-0");
|
|
99
|
+
expect(s.snapshot()[19]).toBe("entry-19");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("bridge follow-up multi-entry queue: recordFollowupSent (append)", () => {
|
|
104
|
+
it("appends when streaming", () => {
|
|
105
|
+
const s = makeShadow();
|
|
106
|
+
s.recordFollowupSent("a", true);
|
|
107
|
+
s.recordFollowupSent("b", true);
|
|
108
|
+
expect(s.snapshot()).toEqual(["a", "b"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does NOT append when idle (race fix)", () => {
|
|
112
|
+
const s = makeShadow();
|
|
113
|
+
s.recordFollowupSent("a", false);
|
|
114
|
+
expect(s.snapshot()).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("drops silently at soft cap", () => {
|
|
118
|
+
const s = makeShadow();
|
|
119
|
+
for (let i = 0; i < FOLLOWUP_QUEUE_CAP; i++) {
|
|
120
|
+
s.recordFollowupSent(`e${i}`, true);
|
|
121
|
+
}
|
|
122
|
+
expect(s.snapshot()).toHaveLength(FOLLOWUP_QUEUE_CAP);
|
|
123
|
+
s.recordFollowupSent("over-cap", true);
|
|
124
|
+
expect(s.snapshot()).toHaveLength(FOLLOWUP_QUEUE_CAP);
|
|
125
|
+
expect(s.snapshot()).not.toContain("over-cap");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("bridge follow-up multi-entry queue: promote handler", () => {
|
|
130
|
+
it("moves entry at index N to position 0", () => {
|
|
131
|
+
const s = makeShadow();
|
|
132
|
+
s.rewriteFollowupQueue(["a", "b", "c"]);
|
|
133
|
+
s.handlePromoteEntry(2); // promote "c" to head
|
|
134
|
+
expect(s.snapshot()).toEqual(["c", "a", "b"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("promoting index 0 is a no-op (already at head)", () => {
|
|
138
|
+
const s = makeShadow();
|
|
139
|
+
s.rewriteFollowupQueue(["a", "b", "c"]);
|
|
140
|
+
s.handlePromoteEntry(0);
|
|
141
|
+
expect(s.snapshot()).toEqual(["a", "b", "c"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("out-of-bounds index is ignored", () => {
|
|
145
|
+
const s = makeShadow();
|
|
146
|
+
s.rewriteFollowupQueue(["a"]);
|
|
147
|
+
const before = s.snapshot();
|
|
148
|
+
s.handlePromoteEntry(5);
|
|
149
|
+
s.handlePromoteEntry(-1);
|
|
150
|
+
expect(s.snapshot()).toEqual(before);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("bridge follow-up multi-entry queue: remove handler", () => {
|
|
155
|
+
it("removes entry at index N", () => {
|
|
156
|
+
const s = makeShadow();
|
|
157
|
+
s.rewriteFollowupQueue(["a", "b", "c"]);
|
|
158
|
+
s.handleRemoveEntry(1);
|
|
159
|
+
expect(s.snapshot()).toEqual(["a", "c"]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("removes last entry to leave empty queue", () => {
|
|
163
|
+
const s = makeShadow();
|
|
164
|
+
s.rewriteFollowupQueue(["only"]);
|
|
165
|
+
s.handleRemoveEntry(0);
|
|
166
|
+
expect(s.snapshot()).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("out-of-bounds index is ignored", () => {
|
|
170
|
+
const s = makeShadow();
|
|
171
|
+
s.rewriteFollowupQueue(["a", "b"]);
|
|
172
|
+
const before = s.snapshot();
|
|
173
|
+
s.handleRemoveEntry(99);
|
|
174
|
+
expect(s.snapshot()).toEqual(before);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("bridge follow-up multi-entry queue: edit handler", () => {
|
|
179
|
+
it("replaces entry at index N with new text", () => {
|
|
180
|
+
const s = makeShadow();
|
|
181
|
+
s.rewriteFollowupQueue(["a", "b", "c"]);
|
|
182
|
+
s.handleEditEntry(1, "b-revised");
|
|
183
|
+
expect(s.snapshot()).toEqual(["a", "b-revised", "c"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("out-of-bounds index is ignored", () => {
|
|
187
|
+
const s = makeShadow();
|
|
188
|
+
s.rewriteFollowupQueue(["only"]);
|
|
189
|
+
const before = s.snapshot();
|
|
190
|
+
s.handleEditEntry(99, "nope");
|
|
191
|
+
expect(s.snapshot()).toEqual(before);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("bridge follow-up multi-entry queue: v1 edit_followup_slot back-compat", () => {
|
|
196
|
+
it("replaces the ENTIRE queue with a single entry (v1 semantic)", () => {
|
|
197
|
+
const s = makeShadow();
|
|
198
|
+
s.rewriteFollowupQueue(["a", "b", "c"]);
|
|
199
|
+
s.handleEditFollowupSlotV1Compat("replacement");
|
|
200
|
+
expect(s.snapshot()).toEqual(["replacement"]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that the bridge forwards pi's `queue_update` event as a typed
|
|
3
|
+
* QueueUpdateToServerMessage. Also covers idempotent listener registration
|
|
4
|
+
* via pi.on("queue_update", ...).
|
|
5
|
+
*
|
|
6
|
+
* See change: add-followup-edit-and-steer-cancel.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
// We don't test the full bridge here (too much wiring) — we drive the
|
|
11
|
+
// listener-registration-and-forward shape directly with a fake pi.
|
|
12
|
+
|
|
13
|
+
describe("bridge queue_update forwarding (shape contract)", () => {
|
|
14
|
+
it("registered queue_update listener emits a typed QueueUpdateToServerMessage on event", () => {
|
|
15
|
+
// Simulate the listener registration the bridge performs.
|
|
16
|
+
const listeners: Record<string, (event: any) => void> = {};
|
|
17
|
+
const fakePi = {
|
|
18
|
+
on: vi.fn((eventType: string, handler: any) => { listeners[eventType] = handler; }),
|
|
19
|
+
};
|
|
20
|
+
const sent: any[] = [];
|
|
21
|
+
const fakeConnection = { send: (m: any) => sent.push(m) };
|
|
22
|
+
const sessionId = "S1";
|
|
23
|
+
|
|
24
|
+
// Equivalent of the bridge's pi.on("queue_update", ...) registration.
|
|
25
|
+
fakePi.on("queue_update", (event: any) => {
|
|
26
|
+
const steering = Array.isArray(event?.steering) ? Array.from(event.steering as readonly string[]) : [];
|
|
27
|
+
const followUp = Array.isArray(event?.followUp) ? Array.from(event.followUp as readonly string[]) : [];
|
|
28
|
+
fakeConnection.send({ type: "queue_update", sessionId, steering, followUp });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Fire pi's queue_update event.
|
|
32
|
+
listeners["queue_update"]({ type: "queue_update", steering: ["a", "b"], followUp: ["c"] });
|
|
33
|
+
|
|
34
|
+
expect(sent).toHaveLength(1);
|
|
35
|
+
expect(sent[0]).toEqual({
|
|
36
|
+
type: "queue_update",
|
|
37
|
+
sessionId: "S1",
|
|
38
|
+
steering: ["a", "b"],
|
|
39
|
+
followUp: ["c"],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("forwards empty arrays when pi reports empty queues", () => {
|
|
44
|
+
const listeners: Record<string, (event: any) => void> = {};
|
|
45
|
+
const fakePi = { on: vi.fn((t: string, h: any) => { listeners[t] = h; }) };
|
|
46
|
+
const sent: any[] = [];
|
|
47
|
+
const sessionId = "S2";
|
|
48
|
+
|
|
49
|
+
fakePi.on("queue_update", (event: any) => {
|
|
50
|
+
const steering = Array.isArray(event?.steering) ? Array.from(event.steering as readonly string[]) : [];
|
|
51
|
+
const followUp = Array.isArray(event?.followUp) ? Array.from(event.followUp as readonly string[]) : [];
|
|
52
|
+
sent.push({ type: "queue_update", sessionId, steering, followUp });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
listeners["queue_update"]({ type: "queue_update", steering: [], followUp: [] });
|
|
56
|
+
|
|
57
|
+
expect(sent).toEqual([{ type: "queue_update", sessionId: "S2", steering: [], followUp: [] }]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("defends against malformed event payloads (missing arrays)", () => {
|
|
61
|
+
const listeners: Record<string, (event: any) => void> = {};
|
|
62
|
+
const fakePi = { on: vi.fn((t: string, h: any) => { listeners[t] = h; }) };
|
|
63
|
+
const sent: any[] = [];
|
|
64
|
+
const sessionId = "S3";
|
|
65
|
+
|
|
66
|
+
fakePi.on("queue_update", (event: any) => {
|
|
67
|
+
const steering = Array.isArray(event?.steering) ? Array.from(event.steering as readonly string[]) : [];
|
|
68
|
+
const followUp = Array.isArray(event?.followUp) ? Array.from(event.followUp as readonly string[]) : [];
|
|
69
|
+
sent.push({ type: "queue_update", sessionId, steering, followUp });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Pi returns object missing the expected fields.
|
|
73
|
+
listeners["queue_update"]({ type: "queue_update" });
|
|
74
|
+
|
|
75
|
+
expect(sent).toEqual([{ type: "queue_update", sessionId: "S3", steering: [], followUp: [] }]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge wire-ordering invariant for synthesized retry events.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the bridge updates `RetryTracker` + `UsageLimitOrderer` state
|
|
5
|
+
* SYNCHRONOUSLY when handling `message_end`, so that a back-to-back `agent_end`
|
|
6
|
+
* (fired in the same event-loop tick by pi-coding-agent) observes the
|
|
7
|
+
* up-to-date state.
|
|
8
|
+
*
|
|
9
|
+
* Pre-fix bug: the synthesizer state lived inside `setTimeout(0)` (intended
|
|
10
|
+
* for entryId capture per `fix-per-message-fork`), so `agent_end` was
|
|
11
|
+
* processed BEFORE the trackers had been updated, the orderer's pending
|
|
12
|
+
* flag was never set, and `auto_retry_start` shipped on the wire AFTER
|
|
13
|
+
* `agent_end` — leaving the dashboard's `retryState` stuck (yellow + red
|
|
14
|
+
* banners both visible).
|
|
15
|
+
*
|
|
16
|
+
* See change: fix-retry-banner-stuck-on-limit-exceeded.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from "vitest";
|
|
20
|
+
import { RetryTracker } from "../retry-tracker.js";
|
|
21
|
+
import { UsageLimitOrderer } from "../usage-limit-orderer.js";
|
|
22
|
+
|
|
23
|
+
interface WireEvent {
|
|
24
|
+
eventType: string;
|
|
25
|
+
data?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Simulates the bridge's synthesizer pipeline as it runs synchronously
|
|
30
|
+
* inside the message_end / agent_end handlers, capturing all wire sends
|
|
31
|
+
* in order.
|
|
32
|
+
*/
|
|
33
|
+
class BridgeSim {
|
|
34
|
+
readonly wire: WireEvent[] = [];
|
|
35
|
+
private tracker = new RetryTracker();
|
|
36
|
+
private orderer = new UsageLimitOrderer();
|
|
37
|
+
|
|
38
|
+
/** Mirrors bridge.ts message_end handler synthesizer block. */
|
|
39
|
+
onMessageEnd(sessionId: string, message: { role: string; stopReason?: string; errorMessage?: string }): void {
|
|
40
|
+
const synthetic = this.tracker.observeMessageEnd(sessionId, message);
|
|
41
|
+
if (synthetic) {
|
|
42
|
+
if (synthetic.eventType === "auto_retry_start") {
|
|
43
|
+
this.orderer.noteRetryStart(sessionId);
|
|
44
|
+
} else {
|
|
45
|
+
this.orderer.noteRetryEnd(sessionId);
|
|
46
|
+
}
|
|
47
|
+
this.wire.push({ eventType: synthetic.eventType, data: synthetic.data });
|
|
48
|
+
}
|
|
49
|
+
// The actual message_end body send is deferred via setTimeout(0) in the
|
|
50
|
+
// real bridge for entryId capture; for ordering tests we only care about
|
|
51
|
+
// the synthetic events relative to agent_end.
|
|
52
|
+
this.wire.push({ eventType: "message_end" });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Mirrors bridge.ts agent_end handler synthesizer block. */
|
|
56
|
+
onAgentEnd(sessionId: string, agentEnd: { messages?: Array<Record<string, unknown>> }): void {
|
|
57
|
+
const orderedSynth = this.orderer.maybeSynthesize(sessionId, agentEnd);
|
|
58
|
+
if (orderedSynth) {
|
|
59
|
+
this.wire.push({ eventType: orderedSynth.eventType, data: orderedSynth.data });
|
|
60
|
+
this.tracker.noteAbort(sessionId);
|
|
61
|
+
} else {
|
|
62
|
+
const trackerSynth = this.tracker.observeAgentEnd(sessionId, agentEnd);
|
|
63
|
+
if (trackerSynth) {
|
|
64
|
+
this.wire.push({ eventType: trackerSynth.eventType, data: trackerSynth.data });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
this.wire.push({ eventType: "agent_end" });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("Bridge retry-event wire ordering", () => {
|
|
72
|
+
it("agent_end fired back-to-back after retryable message_end observes pending retry", () => {
|
|
73
|
+
const sim = new BridgeSim();
|
|
74
|
+
const sessionId = "s1";
|
|
75
|
+
const errorMsg = "429 too many requests";
|
|
76
|
+
|
|
77
|
+
// Pi fires both events synchronously back-to-back.
|
|
78
|
+
sim.onMessageEnd(sessionId, { role: "assistant", stopReason: "error", errorMessage: errorMsg });
|
|
79
|
+
sim.onAgentEnd(sessionId, {
|
|
80
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: errorMsg }],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const types = sim.wire.map((e) => e.eventType);
|
|
84
|
+
// auto_retry_start must precede message_end, which must precede the
|
|
85
|
+
// agent_end-side synthesis. Since the same retryable error is the
|
|
86
|
+
// terminal message, retryTracker.observeAgentEnd surfaces a final
|
|
87
|
+
// auto_retry_end{success:false, finalError:errorMsg} BEFORE agent_end.
|
|
88
|
+
expect(types).toEqual([
|
|
89
|
+
"auto_retry_start",
|
|
90
|
+
"message_end",
|
|
91
|
+
"auto_retry_end",
|
|
92
|
+
"agent_end",
|
|
93
|
+
]);
|
|
94
|
+
// auto_retry_start MUST come before agent_end on the wire.
|
|
95
|
+
const startIdx = types.indexOf("auto_retry_start");
|
|
96
|
+
const agentEndIdx = types.indexOf("agent_end");
|
|
97
|
+
expect(startIdx).toBeLessThan(agentEndIdx);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("Gemini monthly-spending-cap error orders auto_retry_end before agent_end", () => {
|
|
101
|
+
const sim = new BridgeSim();
|
|
102
|
+
const sessionId = "s2";
|
|
103
|
+
// Real fixture from ~/.pi/agent/sessions/...BME-szakdoga.../*.jsonl line 363
|
|
104
|
+
const errorMsg = JSON.stringify({
|
|
105
|
+
error: {
|
|
106
|
+
message:
|
|
107
|
+
"Your project has exceeded its monthly spending cap. Please go to AI Studio at https://ai.studio/spend to manage your project spend cap.",
|
|
108
|
+
status: "RESOURCE_EXHAUSTED",
|
|
109
|
+
},
|
|
110
|
+
code: 429,
|
|
111
|
+
status: "Too Many Requests",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
sim.onMessageEnd(sessionId, { role: "assistant", stopReason: "error", errorMessage: errorMsg });
|
|
115
|
+
sim.onAgentEnd(sessionId, {
|
|
116
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: errorMsg }],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const types = sim.wire.map((e) => e.eventType);
|
|
120
|
+
expect(types).toEqual([
|
|
121
|
+
"auto_retry_start",
|
|
122
|
+
"message_end",
|
|
123
|
+
"auto_retry_end",
|
|
124
|
+
"agent_end",
|
|
125
|
+
]);
|
|
126
|
+
// The synthetic auto_retry_end MUST come from the usage-limit orderer
|
|
127
|
+
// (not the retry-tracker fallback) because the broadened
|
|
128
|
+
// USAGE_LIMIT_PATTERN matches "monthly spending cap" / RESOURCE_EXHAUSTED.
|
|
129
|
+
const retryEnd = sim.wire.find((e) => e.eventType === "auto_retry_end")!;
|
|
130
|
+
expect(retryEnd.data).toMatchObject({ success: false, finalError: errorMsg });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("non-retryable message_end produces no synthesis (only message_end on wire)", () => {
|
|
134
|
+
const sim = new BridgeSim();
|
|
135
|
+
sim.onMessageEnd("s3", {
|
|
136
|
+
role: "assistant",
|
|
137
|
+
stopReason: "error",
|
|
138
|
+
errorMessage: "prompt is too long: 300000 tokens > 200000 maximum",
|
|
139
|
+
});
|
|
140
|
+
expect(sim.wire.map((e) => e.eventType)).toEqual(["message_end"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("successful message_end with no prior retry produces no synthesis", () => {
|
|
144
|
+
const sim = new BridgeSim();
|
|
145
|
+
sim.onMessageEnd("s4", { role: "assistant", stopReason: "end_turn" });
|
|
146
|
+
expect(sim.wire.map((e) => e.eventType)).toEqual(["message_end"]);
|
|
147
|
+
});
|
|
148
|
+
});
|