switchroom 0.21.7 → 0.21.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/bin/tmp-reaper.sh +234 -0
  2. package/dist/agent-scheduler/index.js +1 -1
  3. package/dist/auth-broker/index.js +2 -2
  4. package/dist/cli/notion-write-pretool.mjs +1 -1
  5. package/dist/cli/switchroom.js +3520 -2782
  6. package/dist/host-control/main.js +177 -13
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +6 -5
  10. package/profiles/_base/start.sh.hbs +115 -0
  11. package/profiles/_shared/local-time.md.hbs +6 -0
  12. package/profiles/default/CLAUDE.md.hbs +0 -12
  13. package/skills/switchroom-architecture/telegram.md +12 -10
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/README.md +3 -1
  16. package/telegram-plugin/dist/gateway/gateway.js +1164 -547
  17. package/telegram-plugin/format.ts +12 -4
  18. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  19. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  20. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  21. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  23. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  24. package/telegram-plugin/gateway/gateway.ts +12 -10
  25. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  26. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  27. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  28. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  29. package/telegram-plugin/package.json +1 -1
  30. package/telegram-plugin/render/code-segments.ts +38 -4
  31. package/telegram-plugin/render/dollar-math-guard.ts +16 -1
  32. package/telegram-plugin/render/ir.ts +53 -3
  33. package/telegram-plugin/render/parse.ts +73 -14
  34. package/telegram-plugin/render/render.ts +53 -15
  35. package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
  36. package/telegram-plugin/rich-send.ts +22 -7
  37. package/telegram-plugin/shared/bot-runtime.ts +3 -2
  38. package/telegram-plugin/telegraph.ts +6 -4
  39. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  40. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  41. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  42. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  43. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  44. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  45. package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
  46. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  47. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  48. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  49. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
  50. package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
  51. package/telegram-plugin/tests/render/parse.test.ts +30 -5
  52. package/telegram-plugin/tests/render/render.test.ts +9 -4
  53. package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
  54. package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
  55. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
  56. package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
  57. package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
  58. package/telegram-plugin/tests/telegraph.test.ts +1 -1
  59. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Compile-time contract for the grammy 1.44.0 → 1.45.1 bump
3
+ * (`@grammyjs/types` 3.28.0 → 4.0.0).
4
+ *
5
+ * Why this file exists
6
+ * --------------------
7
+ * 4.0.0 turned `InputRichMessage` from a plain interface into a GENERIC one:
8
+ *
9
+ * 3.28.0 `export interface InputRichMessage {` (rich.d.ts:280)
10
+ * 4.0.0 `export interface InputRichMessage<F> {` (rich.d.ts:283)
11
+ *
12
+ * `F` is the file-payload type threaded through the new Bot API 10.2 `blocks`
13
+ * / `media` fields. Switchroom never uses either — every outbound message is
14
+ * the plain `{ markdown }` shape (`rich-send.ts:96`), and the plugin defines
15
+ * its OWN local `InputRichMessageMarkdown` (`rich-send.ts:28`) rather than
16
+ * importing grammy's. So the bump is only safe as long as a bare
17
+ * `{ markdown: string }` literal still satisfies the generic parameter at
18
+ * every call site.
19
+ *
20
+ * That invariant is invisible to the rest of the suite for two reasons, and
21
+ * both are why this test spends ~1s on a real compile instead of asserting on
22
+ * runtime values:
23
+ *
24
+ * 1. Every send in the regression suite is MOCKED — no mock can notice that
25
+ * a payload stopped typechecking.
26
+ * 2. The repo's `tsc --noEmit` does NOT cover this directory. The root
27
+ * `tsconfig.json` `include` is `["src/**\/*.ts", "bin/**\/*.ts",
28
+ * "scripts/**\/*.ts"]` — `telegram-plugin/` is absent, so a type error
29
+ * here is invisible to `npm run lint` (verified empirically: a deliberate
30
+ * `const x: number = "s"` in `rich-send.ts` leaves `tsc --noEmit` at exit
31
+ * 0). A plain `.ts` fixture would therefore be a NO-OP as a guard.
32
+ *
33
+ * So the check has to run the compiler itself. The negative controls below are
34
+ * load-bearing: if the harness ever silently stopped compiling (bad fixture
35
+ * path, unresolved `grammy`, swallowed diagnostics), the "must fail" cases
36
+ * would go green and this file goes red — it cannot rot into a vacuous pass.
37
+ */
38
+ import { describe, it, expect, afterAll } from 'vitest'
39
+ import ts from 'typescript'
40
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
41
+ import { fileURLToPath } from 'node:url'
42
+ import { dirname, join, resolve } from 'node:path'
43
+
44
+ const here = dirname(fileURLToPath(import.meta.url))
45
+
46
+ // Fixtures must live INSIDE the repo tree: they `import 'grammy'`, and module
47
+ // resolution walks up from the file to the hoisted root `node_modules`. A
48
+ // fixture in `os.tmpdir()` would fail to resolve grammy and every case would
49
+ // report a misleading "cannot find module" instead of the real answer.
50
+ const fixtureDir = mkdtempSync(join(here, 'grammy-types-fixture-'))
51
+ afterAll(() => rmSync(fixtureDir, { recursive: true, force: true }))
52
+
53
+ /** Mirrors the compiler settings the plugin is actually authored against. */
54
+ const COMPILER_OPTIONS: ts.CompilerOptions = {
55
+ target: ts.ScriptTarget.ES2022,
56
+ module: ts.ModuleKind.ESNext,
57
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
58
+ strict: true,
59
+ // Matches the root tsconfig: we are asserting on OUR call shapes, not
60
+ // auditing grammy's own .d.ts files.
61
+ skipLibCheck: true,
62
+ noEmit: true,
63
+ }
64
+
65
+ /**
66
+ * Typecheck one fixture source and return its syntactic + semantic errors,
67
+ * formatted `TSxxxx: message`. Empty array means "compiles clean".
68
+ */
69
+ function typecheck(name: string, source: string): string[] {
70
+ const file = resolve(fixtureDir, `${name}.ts`)
71
+ writeFileSync(file, source)
72
+ const program = ts.createProgram([file], COMPILER_OPTIONS)
73
+ return ts
74
+ .getPreEmitDiagnostics(program)
75
+ .filter((d) => d.file?.fileName === file.split('\\').join('/'))
76
+ .map((d) => `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`)
77
+ }
78
+
79
+ describe('grammy rich-message input still accepts the plain { markdown } shape', () => {
80
+ it('accepts a bare { markdown } literal on sendRichMessage and editMessageText', () => {
81
+ expect(
82
+ typecheck(
83
+ 'plain-literal',
84
+ `
85
+ import type { Bot } from 'grammy'
86
+ declare const bot: Bot
87
+ export async function send(): Promise<void> {
88
+ await bot.api.sendRichMessage(1, { markdown: 'hi' })
89
+ await bot.api.editMessageText(1, 2, { markdown: 'hi' })
90
+ }
91
+ `,
92
+ ),
93
+ ).toEqual([])
94
+ })
95
+
96
+ it("accepts the production helper's return type (rich-send.ts richMessage())", () => {
97
+ // The real seam: `richMessage()` returns the plugin's OWN local
98
+ // `InputRichMessageMarkdown`, which must stay structurally assignable to
99
+ // grammy's generic parameter. This is the assertion that would break if a
100
+ // future @grammyjs/types made `blocks`/`media` mandatory or constrained `F`.
101
+ expect(
102
+ typecheck(
103
+ 'production-helper',
104
+ `
105
+ import type { Bot } from 'grammy'
106
+ import { richMessage } from '${join(here, '..', 'rich-send.js').split('\\').join('/')}'
107
+ declare const bot: Bot
108
+ export async function send(): Promise<void> {
109
+ await bot.api.sendRichMessage(1, richMessage('hi'))
110
+ await bot.api.editMessageText(1, 2, richMessage('hi'))
111
+ }
112
+ `,
113
+ ),
114
+ ).toEqual([])
115
+ })
116
+
117
+ it('still accepts a { markdown } payload through bot.api.raw', () => {
118
+ // `installRichMarkdownGuard` (shared/bot-runtime.ts) inspects the RAW
119
+ // payload `{ chat_id, rich_message: { markdown } }`, so pin that shape too.
120
+ expect(
121
+ typecheck(
122
+ 'raw-payload',
123
+ `
124
+ import type { Bot } from 'grammy'
125
+ declare const bot: Bot
126
+ export async function send(): Promise<void> {
127
+ await bot.api.raw.sendRichMessage({ chat_id: 1, rich_message: { markdown: 'hi' } })
128
+ }
129
+ `,
130
+ ),
131
+ ).toEqual([])
132
+ })
133
+
134
+ // ── Negative controls: prove the harness has teeth ──────────────────────
135
+ // Without these, every assertion above would pass just as happily against a
136
+ // harness that had silently stopped compiling anything at all.
137
+
138
+ it('NEGATIVE CONTROL: rejects a wrongly-typed markdown field', () => {
139
+ const errors = typecheck(
140
+ 'wrong-type',
141
+ `
142
+ import type { Bot } from 'grammy'
143
+ declare const bot: Bot
144
+ export async function send(): Promise<void> {
145
+ await bot.api.sendRichMessage(1, { markdown: 123 })
146
+ }
147
+ `,
148
+ )
149
+ expect(errors.join('\n')).toContain('TS2322')
150
+ })
151
+
152
+ it('NEGATIVE CONTROL: rejects an unknown field on the rich-message literal', () => {
153
+ const errors = typecheck(
154
+ 'unknown-field',
155
+ `
156
+ import type { Bot } from 'grammy'
157
+ declare const bot: Bot
158
+ export async function send(): Promise<void> {
159
+ await bot.api.sendRichMessage(1, { markdwon: 'typo' })
160
+ }
161
+ `,
162
+ )
163
+ expect(errors.length).toBeGreaterThan(0)
164
+ })
165
+ })
166
+
167
+ describe('grammy supply-chain pin', () => {
168
+ // Assert on `bun.lock` rather than the resolved package: grammy's `exports`
169
+ // map deliberately hides `./package.json`, and the lockfile is the artifact
170
+ // CI actually installs from (`bun install --frozen-lockfile`), so it is both
171
+ // reachable and the more honest source of truth for what ships.
172
+ const lock = readFileSync(resolve(here, '..', '..', 'bun.lock'), 'utf8')
173
+
174
+ /** Pull the resolved version bun.lock pins for a package. */
175
+ function lockedVersion(pkg: string): string {
176
+ const escaped = pkg.replace('/', '\\/')
177
+ const m = new RegExp(`"${escaped}": \\["${escaped}@(\\d+\\.\\d+\\.\\d+)"`).exec(lock)
178
+ if (!m) throw new Error(`no bun.lock pin found for ${pkg}`)
179
+ return m[1]
180
+ }
181
+
182
+ // These assert a FLOOR, not an exact version: a later 1.46/5.x bump is a
183
+ // legitimate change and must not go red here for no substantive reason. What
184
+ // must never happen silently is a slide BACK below the floor — that would
185
+ // return @grammyjs/types to 3.28.0 and drop the Bot API 10.2 surface this
186
+ // bump exists to unlock, while every assertion above stayed green (the plain
187
+ // `{ markdown }` shape compiles under both).
188
+
189
+ it('keeps grammy at or above 1.45 (the floor that ships @grammyjs/types 4.x)', () => {
190
+ const [major, minor] = lockedVersion('grammy').split('.').map(Number)
191
+ expect(major).toBe(1)
192
+ expect(minor).toBeGreaterThanOrEqual(45)
193
+ })
194
+
195
+ it('keeps @grammyjs/types at or above the 4.x major grammy 1.45 depends on', () => {
196
+ const [major] = lockedVersion('@grammyjs/types').split('.').map(Number)
197
+ expect(major).toBeGreaterThanOrEqual(4)
198
+ })
199
+ })
@@ -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
+ });