@hellcoder/companion 0.109.0 → 0.109.1
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/package.json
CHANGED
|
@@ -81,9 +81,15 @@ describe("SessionStateMachine", () => {
|
|
|
81
81
|
it("initializing -> terminated", () =>
|
|
82
82
|
expectValidTransition("initializing", "terminated"));
|
|
83
83
|
|
|
84
|
-
// ready -> streaming, compacting, reconnecting, terminated
|
|
84
|
+
// ready -> streaming, awaiting_permission, compacting, reconnecting, terminated
|
|
85
85
|
it("ready -> streaming", () =>
|
|
86
86
|
expectValidTransition("ready", "streaming"));
|
|
87
|
+
// Reattach replays a pending permission while the session is idle: the CLI
|
|
88
|
+
// emits system_init (phase -> ready) and the resumed process immediately
|
|
89
|
+
// re-requests the permission it was already blocked on. This edge must be
|
|
90
|
+
// legal or the request is dropped and the phase stays "ready".
|
|
91
|
+
it("ready -> awaiting_permission", () =>
|
|
92
|
+
expectValidTransition("ready", "awaiting_permission"));
|
|
87
93
|
it("ready -> compacting", () =>
|
|
88
94
|
expectValidTransition("ready", "compacting"));
|
|
89
95
|
it("ready -> reconnecting", () =>
|
|
@@ -561,6 +567,46 @@ describe("SessionStateMachine", () => {
|
|
|
561
567
|
}
|
|
562
568
|
});
|
|
563
569
|
|
|
570
|
+
it("handles a permission replayed on reattach: reconnecting -> initializing -> ready -> awaiting_permission", () => {
|
|
571
|
+
// Regression: a session that drops while awaiting permission gets its
|
|
572
|
+
// pending request replayed by the resumed CLI. ws-bridge forces the phase
|
|
573
|
+
// to "ready" on system_init, so the replayed request arrives in "ready".
|
|
574
|
+
// If that edge is illegal the transition is blocked, which has two
|
|
575
|
+
// consequences beyond the dropped request:
|
|
576
|
+
// 1. No transition event fires, so no session_phase frame is broadcast
|
|
577
|
+
// and the client keeps showing the session as idle while it is in
|
|
578
|
+
// fact blocked on an approval dialog.
|
|
579
|
+
// 2. handleCLIClose derives interruptedMidTurn from the phase, so a
|
|
580
|
+
// second drop is logged as a clean idle disconnect and the in-flight
|
|
581
|
+
// turn is orphaned rather than flagged.
|
|
582
|
+
const reattached = new SessionStateMachine("reattach", "awaiting_permission");
|
|
583
|
+
const events: SessionTransitionEvent[] = [];
|
|
584
|
+
reattached.onTransition((e) => events.push(e));
|
|
585
|
+
|
|
586
|
+
// Socket drops while the approval dialog is still open.
|
|
587
|
+
expect(reattached.transition("reconnecting", "cli_ws_closed")).toBe(true);
|
|
588
|
+
// CLI reattaches and re-initializes.
|
|
589
|
+
expect(reattached.transition("initializing", "cli-ws-reconnected")).toBe(true);
|
|
590
|
+
expect(reattached.transition("ready", "system_init")).toBe(true);
|
|
591
|
+
|
|
592
|
+
// The resumed CLI immediately re-requests the still-pending permission.
|
|
593
|
+
expect(reattached.transition("awaiting_permission", "permission_requested")).toBe(true);
|
|
594
|
+
expect(reattached.phase).toBe("awaiting_permission");
|
|
595
|
+
|
|
596
|
+
// The transition must emit an event — this is what drives the
|
|
597
|
+
// session_phase broadcast that moves the client off "idle".
|
|
598
|
+
expect(events.map((e) => `${e.from}->${e.to}`)).toEqual([
|
|
599
|
+
"awaiting_permission->reconnecting",
|
|
600
|
+
"reconnecting->initializing",
|
|
601
|
+
"initializing->ready",
|
|
602
|
+
"ready->awaiting_permission",
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
// A subsequent drop is now correctly attributable to a mid-turn state.
|
|
606
|
+
expect(reattached.canRespondToPermission()).toBe(true);
|
|
607
|
+
expect(reattached.isIdle()).toBe(false);
|
|
608
|
+
});
|
|
609
|
+
|
|
564
610
|
it("handles early user message: starting -> streaming -> initializing -> ready", () => {
|
|
565
611
|
// When a user sends a message before the CLI connects, the session
|
|
566
612
|
// transitions starting -> streaming. When the CLI later connects,
|
|
@@ -55,6 +55,12 @@ export const VALID_TRANSITIONS: ReadonlyMap<
|
|
|
55
55
|
"ready",
|
|
56
56
|
new Set<SessionPhase>([
|
|
57
57
|
"streaming",
|
|
58
|
+
// A permission request can arrive while the session is idle: on reattach
|
|
59
|
+
// the CLI emits system_init (forcing phase -> ready), then the resumed
|
|
60
|
+
// process immediately re-requests its still-pending permission. Without
|
|
61
|
+
// this edge the transition is blocked and the phase stays "ready", so the
|
|
62
|
+
// session reads as idle while it is in fact awaiting approval.
|
|
63
|
+
"awaiting_permission",
|
|
58
64
|
"compacting",
|
|
59
65
|
"reconnecting",
|
|
60
66
|
"terminated",
|
package/server/ws-bridge.test.ts
CHANGED
|
@@ -1858,6 +1858,71 @@ describe("CLI message routing", () => {
|
|
|
1858
1858
|
expect(permBroadcast.request.tool_name).toBe("Bash");
|
|
1859
1859
|
});
|
|
1860
1860
|
|
|
1861
|
+
// Regression: a permission request arriving while the session is idle must
|
|
1862
|
+
// move the phase to awaiting_permission AND reach the browser as a
|
|
1863
|
+
// session_phase frame.
|
|
1864
|
+
//
|
|
1865
|
+
// This is the reattach path: a session drops while a permission dialog is
|
|
1866
|
+
// open, the CLI is resumed, it emits system.init (which forces phase ->
|
|
1867
|
+
// ready), and the resumed process immediately re-requests the permission it
|
|
1868
|
+
// was already blocked on. So the request lands in "ready", not "streaming".
|
|
1869
|
+
//
|
|
1870
|
+
// Before the ready -> awaiting_permission edge was added to VALID_TRANSITIONS
|
|
1871
|
+
// the transition was blocked. The permission itself still rendered (it is
|
|
1872
|
+
// stored and broadcast independently), but no transition event fired, so no
|
|
1873
|
+
// session_phase frame was emitted and the client stayed on the "idle" status
|
|
1874
|
+
// set by the preceding system.init — showing an idle session that was in fact
|
|
1875
|
+
// blocked on an approval dialog.
|
|
1876
|
+
it("control_request: broadcasts session_phase when a permission arrives in the ready phase", async () => {
|
|
1877
|
+
const cli = makeCliSocket("s1");
|
|
1878
|
+
bridge.handleCLIOpen(cli, "s1");
|
|
1879
|
+
|
|
1880
|
+
// system.init drives the session to "ready", as it does on every reattach.
|
|
1881
|
+
await bridge.handleCLIMessage(cli, makeInitMsg());
|
|
1882
|
+
const session = bridge.getSession("s1")!;
|
|
1883
|
+
expect(session.stateMachine.phase).toBe("ready");
|
|
1884
|
+
|
|
1885
|
+
const browser = makeBrowserSocket("s1");
|
|
1886
|
+
bridge.handleBrowserOpen(browser, "s1");
|
|
1887
|
+
// Drop the connect-time frames so we only assert on what the permission
|
|
1888
|
+
// request itself produces.
|
|
1889
|
+
browser.send.mockClear();
|
|
1890
|
+
|
|
1891
|
+
// The resumed CLI replays its pending permission while still idle.
|
|
1892
|
+
await bridge.handleCLIMessage(cli, JSON.stringify({
|
|
1893
|
+
type: "control_request",
|
|
1894
|
+
request_id: "req-reattach",
|
|
1895
|
+
request: {
|
|
1896
|
+
subtype: "can_use_tool",
|
|
1897
|
+
tool_name: "Edit",
|
|
1898
|
+
input: { file_path: "/test.ts" },
|
|
1899
|
+
tool_use_id: "tu-reattach",
|
|
1900
|
+
},
|
|
1901
|
+
}));
|
|
1902
|
+
|
|
1903
|
+
// The transition is legal, so the phase actually advances.
|
|
1904
|
+
expect(session.stateMachine.phase).toBe("awaiting_permission");
|
|
1905
|
+
|
|
1906
|
+
const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
|
|
1907
|
+
|
|
1908
|
+
// The frame that unsticks the client status. src/ws.ts maps
|
|
1909
|
+
// awaiting_permission -> "running"; without this frame the session renders
|
|
1910
|
+
// as idle.
|
|
1911
|
+
expect(calls).toContainEqual(
|
|
1912
|
+
expect.objectContaining({
|
|
1913
|
+
type: "session_phase",
|
|
1914
|
+
phase: "awaiting_permission",
|
|
1915
|
+
previousPhase: "ready",
|
|
1916
|
+
}),
|
|
1917
|
+
);
|
|
1918
|
+
|
|
1919
|
+
// The dialog still renders — this part was never broken, and asserting it
|
|
1920
|
+
// here guards against a fix that trades one for the other.
|
|
1921
|
+
const permBroadcast = calls.find((c: any) => c.type === "permission_request");
|
|
1922
|
+
expect(permBroadcast).toBeDefined();
|
|
1923
|
+
expect(permBroadcast.request.request_id).toBe("req-reattach");
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1861
1926
|
it("tool_progress: broadcasts", async () => {
|
|
1862
1927
|
const msg = JSON.stringify({
|
|
1863
1928
|
type: "tool_progress",
|