switchroom 0.21.6 → 0.21.8
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/bin/autoaccept.exp +21 -1
- package/bin/tmp-reaper.sh +234 -0
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +2 -2
- package/dist/cli/autoaccept-poll.js +95 -7
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +3432 -2751
- package/dist/host-control/main.js +177 -13
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +115 -0
- package/profiles/_shared/local-time.md.hbs +6 -0
- package/profiles/default/CLAUDE.md.hbs +0 -12
- package/telegram-plugin/bunfig.toml +12 -4
- package/telegram-plugin/dist/gateway/gateway.js +1017 -465
- package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
- package/telegram-plugin/gateway/approval-hold.ts +32 -1
- package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
- package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
- package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
- package/telegram-plugin/gateway/gateway.ts +12 -10
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
- package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
- package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
- package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
- package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
- package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
- package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
- package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +7 -1
- package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
- package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
- package/telegram-plugin/tests/parked-turn-start-preload.test.ts +65 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
- package/telegram-plugin/tests/queued-card-surface.test.ts +9 -1
- package/telegram-plugin/tests/stream-render-golden.test.ts +10 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
- package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +7 -1
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +7 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/sessions/:id/messages` — ordering and pagination, against a REAL
|
|
3
|
+
* turns DB.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `hermes-rest-parity.test.ts` for one reason: that fixture
|
|
6
|
+
* points `SWITCHROOM_AGENTS_DIR` at an empty tmpdir, so every session has zero
|
|
7
|
+
* messages and an assertion about ORDER or about which slice came back passes
|
|
8
|
+
* vacuously there. Ordering and offset windowing are exactly the properties
|
|
9
|
+
* that need real rows, and the turns DB is `bun:sqlite` — hence a bun test
|
|
10
|
+
* (vitest excludes it; see vitest.config.ts). It lives under
|
|
11
|
+
* telegram-plugin/tests/ rather than beside the adapter because that is the only
|
|
12
|
+
* tree the bun CI job walks — `bun test` runs with cwd telegram-plugin/, so a
|
|
13
|
+
* root-level src/ bun test is executed by NEITHER runner
|
|
14
|
+
* (scripts/check-test-runner-coverage.mjs, and the debt list it guards).
|
|
15
|
+
*
|
|
16
|
+
* Upstream contract being asserted (`hermes_cli/web_routers/sessions.py:601-651`
|
|
17
|
+
* at 9da6d455c9e1f2bf74bb9f47766ee9fc52e17bfb):
|
|
18
|
+
* - messages come back CHRONOLOGICAL within the page;
|
|
19
|
+
* - `order=oldest` windows from the start, `order=latest` from the end;
|
|
20
|
+
* - `pagination.returned` is the real row count of that window.
|
|
21
|
+
*
|
|
22
|
+
* The bug each case guards, stated so a future edit cannot weaken it into a
|
|
23
|
+
* code-path check: the adapter read `listTurnsForAgent` (newest-first) and
|
|
24
|
+
* projected it without reversing, so the transcript arrived backwards; and it
|
|
25
|
+
* emitted no `pagination`, which `getAllSessionMessages` (hermes.ts:713-750)
|
|
26
|
+
* reads as "legacy backend, that was everything" and stops paging on.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { test, expect, beforeEach, afterEach } from "bun:test";
|
|
30
|
+
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { openTurnsDb } from "../registry/turns-schema.js";
|
|
34
|
+
import { handleHermesRest, type HermesRestResult } from "../../src/web/hermes-adapter.js";
|
|
35
|
+
import type { SwitchroomConfig } from "../../src/config/schema.js";
|
|
36
|
+
|
|
37
|
+
const AGENT = "alpha";
|
|
38
|
+
let agentsDir = "";
|
|
39
|
+
|
|
40
|
+
const CONFIG = { agents: { [AGENT]: {} } } as unknown as SwitchroomConfig;
|
|
41
|
+
|
|
42
|
+
/** Seed `n` completed turns with explicit, strictly-increasing start times. */
|
|
43
|
+
function seedTurns(n: number) {
|
|
44
|
+
const dir = join(agentsDir, AGENT);
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
const db = openTurnsDb(dir);
|
|
47
|
+
try {
|
|
48
|
+
for (let i = 0; i < n; i++) {
|
|
49
|
+
const ts = 1_700_000_000_000 + i * 1000;
|
|
50
|
+
db.prepare(
|
|
51
|
+
`INSERT INTO turns
|
|
52
|
+
(turn_key, chat_id, thread_id, started_at, ended_at, ended_via,
|
|
53
|
+
user_prompt_preview, assistant_reply_preview, created_at, updated_at)
|
|
54
|
+
VALUES (?, ?, NULL, ?, ?, 'stop', ?, ?, ?, ?)`,
|
|
55
|
+
).run(`t${i}`, "chat", ts, ts + 500, `u${i}`, `a${i}`, ts, ts);
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
db.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function messages(search: string): Promise<Record<string, unknown>> {
|
|
63
|
+
const res = (await handleHermesRest(
|
|
64
|
+
"GET",
|
|
65
|
+
`/api/sessions/${AGENT}/messages`,
|
|
66
|
+
CONFIG,
|
|
67
|
+
search,
|
|
68
|
+
)) as HermesRestResult;
|
|
69
|
+
expect(res).not.toBeNull();
|
|
70
|
+
expect(res.status).toBe(200);
|
|
71
|
+
return res.body as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function contents(body: Record<string, unknown>): string[] {
|
|
75
|
+
return (body.messages as { content: string }[]).map((m) => m.content);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
agentsDir = mkdtempSync(join(tmpdir(), "sr-hermes-msgs-"));
|
|
80
|
+
process.env.SWITCHROOM_AGENTS_DIR = agentsDir;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
rmSync(agentsDir, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("messages are chronological, not the newest-first order the turns DB reads in", async () => {
|
|
88
|
+
seedTurns(3);
|
|
89
|
+
const body = await messages("?limit=500&offset=0&order=oldest");
|
|
90
|
+
// u0/a0 is the OLDEST turn. Before the fix this list started at u2.
|
|
91
|
+
expect(contents(body)).toEqual(["u0", "a0", "u1", "a1", "u2", "a2"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("order=oldest windows from the start and reports what it returned", async () => {
|
|
95
|
+
seedTurns(3);
|
|
96
|
+
const body = await messages("?limit=2&offset=2&order=oldest");
|
|
97
|
+
expect(contents(body)).toEqual(["u1", "a1"]);
|
|
98
|
+
expect(body.pagination).toEqual({ limit: 2, offset: 2, order: "oldest", returned: 2 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("order=latest windows from the END, still chronological inside the page", async () => {
|
|
102
|
+
seedTurns(3);
|
|
103
|
+
const body = await messages("?limit=3&order=latest");
|
|
104
|
+
expect(contents(body)).toEqual(["a1", "u2", "a2"]);
|
|
105
|
+
expect(body.pagination).toEqual({ limit: 3, offset: 0, order: "latest", returned: 3 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("paging with the desktop's own loop terminates having read every message once", async () => {
|
|
109
|
+
seedTurns(5); // 10 messages
|
|
110
|
+
// getAllSessionMessages (hermes.ts:713-750): limit=500, order=oldest, advance
|
|
111
|
+
// offset by the page length, stop when the page is short of `limit`.
|
|
112
|
+
const seen: string[] = [];
|
|
113
|
+
let offset = 0;
|
|
114
|
+
for (let guard = 0; guard < 10; guard++) {
|
|
115
|
+
const page = await messages(`?limit=4&offset=${offset}&order=oldest`);
|
|
116
|
+
const pagination = page.pagination as { limit: number };
|
|
117
|
+
seen.push(...contents(page));
|
|
118
|
+
if (contents(page).length === 0 || contents(page).length < pagination.limit) break;
|
|
119
|
+
offset += contents(page).length;
|
|
120
|
+
}
|
|
121
|
+
expect(seen).toEqual(["u0", "a0", "u1", "a1", "u2", "a2", "u3", "a3", "u4", "a4"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an omitted limit defaults to the latest 500, an explicit one defaults to oldest", async () => {
|
|
125
|
+
seedTurns(1);
|
|
126
|
+
expect((await messages("")).pagination).toEqual({
|
|
127
|
+
limit: 500,
|
|
128
|
+
offset: 0,
|
|
129
|
+
order: "latest",
|
|
130
|
+
returned: 2,
|
|
131
|
+
});
|
|
132
|
+
expect((await messages("?limit=10")).pagination).toEqual({
|
|
133
|
+
limit: 10,
|
|
134
|
+
offset: 0,
|
|
135
|
+
order: "oldest",
|
|
136
|
+
returned: 2,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("an unrecognised order is a 400, not a silently-reinterpreted page", async () => {
|
|
141
|
+
seedTurns(1);
|
|
142
|
+
const res = await handleHermesRest(
|
|
143
|
+
"GET",
|
|
144
|
+
`/api/sessions/${AGENT}/messages`,
|
|
145
|
+
CONFIG,
|
|
146
|
+
"?order=sideways",
|
|
147
|
+
);
|
|
148
|
+
expect(res?.status).toBe(400);
|
|
149
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/sessions/search` — that it actually SEARCHES.
|
|
3
|
+
*
|
|
4
|
+
* `hermes-rest-parity.test.ts` can only assert the contract floor here
|
|
5
|
+
* (`Array.isArray(results)`), because it points `SWITCHROOM_AGENTS_DIR` at an
|
|
6
|
+
* empty tmpdir: with no turns anywhere, "found the right rows", "found nothing
|
|
7
|
+
* for a miss" and "hardcoded `{results: []}`" are indistinguishable. A
|
|
8
|
+
* hardcoded empty list would pass that floor forever, and an always-200 empty
|
|
9
|
+
* body is the exact bug class the desktop cannot detect —
|
|
10
|
+
* `isEndpointMissingError` only matches 404s.
|
|
11
|
+
*
|
|
12
|
+
* So this file seeds a real turns registry (`bun:sqlite`, hence a bun test —
|
|
13
|
+
* vitest excludes it; it lives under telegram-plugin/tests/ because that is the
|
|
14
|
+
* only tree the bun CI job walks) and asserts the discriminating outcomes:
|
|
15
|
+
* a query that should hit, hits; a query that should miss, misses.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { test, expect, beforeEach, afterEach } from "bun:test";
|
|
19
|
+
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { openTurnsDb } from "../registry/turns-schema.js";
|
|
23
|
+
import { handleHermesRest, type HermesRestResult } from "../../src/web/hermes-adapter.js";
|
|
24
|
+
import type { SwitchroomConfig } from "../../src/config/schema.js";
|
|
25
|
+
|
|
26
|
+
const CONFIG = {
|
|
27
|
+
agents: { alpha: { model: "sonnet" }, beta: {} },
|
|
28
|
+
} as unknown as SwitchroomConfig;
|
|
29
|
+
|
|
30
|
+
let agentsDir = "";
|
|
31
|
+
|
|
32
|
+
function seed(agent: string, rows: { user: string; assistant: string }[]) {
|
|
33
|
+
const dir = join(agentsDir, agent);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
const db = openTurnsDb(dir);
|
|
36
|
+
try {
|
|
37
|
+
rows.forEach((r, i) => {
|
|
38
|
+
const ts = 1_700_000_000_000 + i * 1000;
|
|
39
|
+
db.prepare(
|
|
40
|
+
`INSERT INTO turns
|
|
41
|
+
(turn_key, chat_id, thread_id, started_at, ended_at, ended_via,
|
|
42
|
+
user_prompt_preview, assistant_reply_preview, created_at, updated_at)
|
|
43
|
+
VALUES (?, ?, NULL, ?, ?, 'stop', ?, ?, ?, ?)`,
|
|
44
|
+
).run(`${agent}-t${i}`, "chat", ts, ts + 500, r.user, r.assistant, ts, ts);
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
db.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Result = {
|
|
52
|
+
session_id: string;
|
|
53
|
+
lineage_root: string;
|
|
54
|
+
snippet: string;
|
|
55
|
+
role: string | null;
|
|
56
|
+
source: string | null;
|
|
57
|
+
model: string | null;
|
|
58
|
+
session_started: number | null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
async function search(query: string): Promise<Result[]> {
|
|
62
|
+
const res = (await handleHermesRest(
|
|
63
|
+
"GET",
|
|
64
|
+
"/api/sessions/search",
|
|
65
|
+
CONFIG,
|
|
66
|
+
query,
|
|
67
|
+
)) as HermesRestResult;
|
|
68
|
+
expect(res).not.toBeNull();
|
|
69
|
+
// The pre-fix behaviour: the /api/sessions/:id regex matched "search" as an
|
|
70
|
+
// id and answered 404 {error:'Unknown session'}, which threw the panel.
|
|
71
|
+
expect(res.status).toBe(200);
|
|
72
|
+
return (res.body as { results: Result[] }).results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
agentsDir = mkdtempSync(join(tmpdir(), "sr-hermes-search-"));
|
|
77
|
+
process.env.SWITCHROOM_AGENTS_DIR = agentsDir;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
rmSync(agentsDir, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a session-id match comes back, with role null and the id as its own lineage root", async () => {
|
|
85
|
+
const results = await search("?q=alph");
|
|
86
|
+
expect(results.map((r) => r.session_id)).toEqual(["alpha"]);
|
|
87
|
+
expect(results[0].role).toBeNull();
|
|
88
|
+
expect(results[0].lineage_root).toBe("alpha");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a message-content match comes back tagged with the role it matched on", async () => {
|
|
92
|
+
seed("alpha", [{ user: "deploy the gateway", assistant: "done" }]);
|
|
93
|
+
seed("beta", [{ user: "unrelated", assistant: "also unrelated" }]);
|
|
94
|
+
|
|
95
|
+
const onUser = await search("?q=deploy");
|
|
96
|
+
expect(onUser.map((r) => r.session_id)).toEqual(["alpha"]);
|
|
97
|
+
expect(onUser[0].role).toBe("user");
|
|
98
|
+
expect(onUser[0].snippet).toBe("deploy the gateway");
|
|
99
|
+
|
|
100
|
+
const onAssistant = await search("?q=also%20unrelated");
|
|
101
|
+
expect(onAssistant.map((r) => r.session_id)).toEqual(["beta"]);
|
|
102
|
+
expect(onAssistant[0].role).toBe("assistant");
|
|
103
|
+
expect(onAssistant[0].snippet).toBe("also unrelated");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a query that matches nothing returns nothing — not the whole fleet", async () => {
|
|
107
|
+
seed("alpha", [{ user: "deploy the gateway", assistant: "done" }]);
|
|
108
|
+
// The discriminating case: a stub returning every session, and a stub
|
|
109
|
+
// returning none, are told apart only by running both this and the hit above.
|
|
110
|
+
expect(await search("?q=zzz-no-such-token")).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an empty or whitespace query is short-circuited, not treated as match-everything", async () => {
|
|
114
|
+
seed("alpha", [{ user: "deploy", assistant: "done" }]);
|
|
115
|
+
expect(await search("?q=")).toEqual([]);
|
|
116
|
+
expect(await search("?q=%20%20")).toEqual([]);
|
|
117
|
+
expect(await search("")).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("one row per session even when several turns match", async () => {
|
|
121
|
+
seed("alpha", [
|
|
122
|
+
{ user: "deploy one", assistant: "ok" },
|
|
123
|
+
{ user: "deploy two", assistant: "ok" },
|
|
124
|
+
{ user: "deploy three", assistant: "ok" },
|
|
125
|
+
]);
|
|
126
|
+
const results = await search("?q=deploy");
|
|
127
|
+
expect(results.length).toBe(1);
|
|
128
|
+
expect(results[0].session_id).toBe("alpha");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("?limit= caps the result set", async () => {
|
|
132
|
+
seed("alpha", [{ user: "shared token", assistant: "ok" }]);
|
|
133
|
+
seed("beta", [{ user: "shared token", assistant: "ok" }]);
|
|
134
|
+
expect((await search("?q=shared&limit=1")).length).toBe(1);
|
|
135
|
+
expect((await search("?q=shared")).length).toBe(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("id matches are ordered ahead of content matches", async () => {
|
|
139
|
+
// beta matches by id; alpha only by content. Upstream surfaces direct
|
|
140
|
+
// session-id hits first (sessions.py:169-383) and the panel relies on it.
|
|
141
|
+
seed("alpha", [{ user: "beta is mentioned here", assistant: "ok" }]);
|
|
142
|
+
const results = await search("?q=beta");
|
|
143
|
+
expect(results.map((r) => r.session_id)).toEqual(["beta", "alpha"]);
|
|
144
|
+
expect(results[0].role).toBeNull();
|
|
145
|
+
expect(results[1].role).toBe("user");
|
|
146
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
handleSessionEvent,
|
|
4
|
+
__parkedTurnStartCountForTest,
|
|
5
|
+
__resetParkedTurnStartsForTest,
|
|
6
|
+
} from '../gateway/stream-render.js'
|
|
7
|
+
import { enqueue, makeHarness } from './turn-mint-harness.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runtime alarm for the parked-turn-start leak guard (#4611).
|
|
11
|
+
*
|
|
12
|
+
* `parkedTurnStarts` is module-scope by design (one CLI session, one queue) and
|
|
13
|
+
* `bun test` runs all ~657 telegram-plugin files in ONE process, so a file that
|
|
14
|
+
* exits mid-park changes global state for every file after it:
|
|
15
|
+
* `gateway/obligation-wiring.ts` folds the parked count into `sessionBusy`, and
|
|
16
|
+
* `tests/represent-guard.test.ts`'s idle case then asserts `toHaveLength(1)`
|
|
17
|
+
* against `0`. Two PRs were ejected from the merge queue that way, with
|
|
18
|
+
* byte-identical retries passing, because bun's file order is not stable.
|
|
19
|
+
*
|
|
20
|
+
* `npm run lint:parked-turn-start-hermeticity` pins the WIRING statically; this
|
|
21
|
+
* pins the EFFECT, so a bunfig that is present but no longer loading the guard
|
|
22
|
+
* (wrong relative path, bun config-discovery change) fails a test rather than
|
|
23
|
+
* silently un-protecting the runner.
|
|
24
|
+
*/
|
|
25
|
+
// This suite deliberately parks, so it follows the discipline the guard
|
|
26
|
+
// enforces for every other suite: reset on EXIT, not just on entry.
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
__resetParkedTurnStartsForTest()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('bun test runs with the parked-turn-start leak guard installed', () => {
|
|
32
|
+
it('the preload registered the global afterEach', () => {
|
|
33
|
+
const installed = (globalThis as { __switchroomParkedTurnStartGuard?: { hook: () => void } })
|
|
34
|
+
.__switchroomParkedTurnStartGuard
|
|
35
|
+
expect(
|
|
36
|
+
installed,
|
|
37
|
+
'guard absent — bunfig.toml `[test] preload` did not load parked-turn-start-guard.mjs',
|
|
38
|
+
).toBeTruthy()
|
|
39
|
+
expect(typeof installed!.hook).toBe('function')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('the hook throws on a leaked entry AND resets the store (one failure, not a cascade)', () => {
|
|
43
|
+
const { hook } = (globalThis as { __switchroomParkedTurnStartGuard: { hook: () => void } })
|
|
44
|
+
.__switchroomParkedTurnStartGuard
|
|
45
|
+
|
|
46
|
+
// Reproduce the #4611 leak shape: a mid-turn enqueue parks behind a live
|
|
47
|
+
// turn and is never dequeued — exactly queued-card-surface.test.ts:318.
|
|
48
|
+
const h = makeHarness()
|
|
49
|
+
handleSessionEvent(h.deps, enqueue('501'))
|
|
50
|
+
handleSessionEvent(h.deps, enqueue('502', 'real mid-turn message'))
|
|
51
|
+
expect(__parkedTurnStartCountForTest()).toBe(1)
|
|
52
|
+
|
|
53
|
+
expect(() => hook()).toThrow(/SWITCHROOM_PARKED_TURN_START_LEAK/)
|
|
54
|
+
// Reset-before-throw is what turns the ~1800-deep victim cascade into one
|
|
55
|
+
// attributable failure — and is why this test's own leak does not escape.
|
|
56
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
57
|
+
|
|
58
|
+
// A clean store is silent.
|
|
59
|
+
expect(() => hook()).not.toThrow()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leaves the store clean for the next file', () => {
|
|
63
|
+
expect(__parkedTurnStartCountForTest()).toBe(0)
|
|
64
|
+
})
|
|
65
|
+
})
|