switchroom 0.5.0 → 0.7.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.
Files changed (89) hide show
  1. package/README.md +142 -121
  2. package/bin/autoaccept.exp +29 -6
  3. package/dist/agent-scheduler/index.js +12261 -0
  4. package/dist/cli/autoaccept-poll.js +10 -0
  5. package/dist/cli/switchroom.js +27250 -25324
  6. package/dist/vault/approvals/kernel-server.js +12709 -0
  7. package/dist/vault/broker/server.js +15724 -0
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +133 -0
  10. package/profiles/_shared/telegram-style.md.hbs +3 -3
  11. package/profiles/default/CLAUDE.md +3 -3
  12. package/profiles/default/CLAUDE.md.hbs +2 -2
  13. package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
  14. package/skills/docx/VENDORED.md +1 -1
  15. package/skills/mcp-builder/VENDORED.md +1 -1
  16. package/skills/pdf/VENDORED.md +1 -1
  17. package/skills/pptx/VENDORED.md +1 -1
  18. package/skills/skill-creator/VENDORED.md +1 -1
  19. package/skills/switchroom-architecture/SKILL.md +8 -7
  20. package/skills/switchroom-cli/SKILL.md +23 -15
  21. package/skills/switchroom-health/SKILL.md +7 -7
  22. package/skills/switchroom-install/SKILL.md +36 -39
  23. package/skills/switchroom-manage/SKILL.md +4 -4
  24. package/skills/switchroom-status/SKILL.md +1 -1
  25. package/skills/webapp-testing/VENDORED.md +1 -1
  26. package/skills/xlsx/VENDORED.md +1 -1
  27. package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
  28. package/telegram-plugin/admin-commands/index.ts +71 -0
  29. package/telegram-plugin/ask-user.ts +1 -0
  30. package/telegram-plugin/card-event-log.ts +138 -0
  31. package/telegram-plugin/dist/bridge/bridge.js +178 -31
  32. package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
  33. package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
  34. package/telegram-plugin/dist/server.js +202 -40
  35. package/telegram-plugin/fleet-state.ts +25 -10
  36. package/telegram-plugin/foreman/foreman.ts +38 -3
  37. package/telegram-plugin/gateway/approval-callback.ts +126 -0
  38. package/telegram-plugin/gateway/approval-card.test.ts +90 -0
  39. package/telegram-plugin/gateway/approval-card.ts +127 -0
  40. package/telegram-plugin/gateway/approvals-commands.ts +126 -0
  41. package/telegram-plugin/gateway/boot-card.ts +31 -6
  42. package/telegram-plugin/gateway/boot-probes.ts +503 -72
  43. package/telegram-plugin/gateway/gateway.ts +822 -94
  44. package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
  45. package/telegram-plugin/gateway/ipc-server.ts +35 -0
  46. package/telegram-plugin/gateway/startup-mutex.ts +110 -2
  47. package/telegram-plugin/hooks/hooks.json +19 -0
  48. package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
  49. package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
  50. package/telegram-plugin/package.json +4 -1
  51. package/telegram-plugin/plugin-logger.ts +20 -1
  52. package/telegram-plugin/progress-card-driver.ts +202 -13
  53. package/telegram-plugin/progress-card.ts +2 -2
  54. package/telegram-plugin/quota-check.ts +1 -0
  55. package/telegram-plugin/registry/subagents-schema.ts +37 -0
  56. package/telegram-plugin/registry/subagents.test.ts +64 -0
  57. package/telegram-plugin/session-tail.ts +58 -5
  58. package/telegram-plugin/shared/bot-runtime.ts +48 -2
  59. package/telegram-plugin/subagent-watcher.ts +139 -7
  60. package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
  61. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
  62. package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
  63. package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
  64. package/telegram-plugin/tests/boot-probes.test.ts +558 -0
  65. package/telegram-plugin/tests/card-event-log.test.ts +145 -0
  66. package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
  67. package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
  68. package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
  69. package/telegram-plugin/tests/quota-check.test.ts +37 -1
  70. package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
  71. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
  72. package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
  73. package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
  74. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
  75. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
  76. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
  77. package/telegram-plugin/tests/welcome-text.test.ts +57 -0
  78. package/telegram-plugin/tool-label-sidecar.ts +140 -0
  79. package/telegram-plugin/tool-labels.ts +55 -0
  80. package/telegram-plugin/two-zone-card.ts +27 -7
  81. package/telegram-plugin/uat/SETUP.md +160 -0
  82. package/telegram-plugin/uat/assertions.ts +140 -0
  83. package/telegram-plugin/uat/driver.ts +174 -0
  84. package/telegram-plugin/uat/harness.ts +161 -0
  85. package/telegram-plugin/uat/login.ts +134 -0
  86. package/telegram-plugin/uat/port-allocator.ts +71 -0
  87. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
  88. package/telegram-plugin/welcome-text.ts +44 -2
  89. package/bin/bridge-watchdog.sh +0 -967
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tests for the structured card-event logger — the audit trail for the
3
+ * pinned progress card lifecycle. Mirrors `pin-event-log.test.ts`.
4
+ */
5
+
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
+ import { mkdtempSync, readFileSync, existsSync, rmSync } from 'fs'
8
+ import { tmpdir } from 'os'
9
+ import { join } from 'path'
10
+ import {
11
+ logCardEvent,
12
+ emitCardEvent,
13
+ resolveCardEventPath,
14
+ _resetForTests,
15
+ type CardEvent,
16
+ } from '../card-event-log.js'
17
+
18
+ let tmpDir: string
19
+ const prevStateDir = process.env.STATE_DIR
20
+
21
+ beforeEach(() => {
22
+ tmpDir = mkdtempSync(join(tmpdir(), 'card-event-log-'))
23
+ _resetForTests()
24
+ })
25
+
26
+ afterEach(() => {
27
+ _resetForTests()
28
+ if (prevStateDir === undefined) delete process.env.STATE_DIR
29
+ else process.env.STATE_DIR = prevStateDir
30
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
31
+ })
32
+
33
+ describe('logCardEvent (injected writer)', () => {
34
+ it('writes one JSON line per call', () => {
35
+ const lines: string[] = []
36
+ const ev: CardEvent = {
37
+ ts: 1700000000000,
38
+ agent: 'klanker',
39
+ chatId: '100',
40
+ turnKey: '100::1',
41
+ cardMessageId: 4242,
42
+ event: 'rendered',
43
+ htmlHash: 'abc123def456',
44
+ }
45
+ logCardEvent(ev, (l) => lines.push(l))
46
+ expect(lines).toHaveLength(1)
47
+ expect(lines[0].endsWith('\n')).toBe(true)
48
+ const payload = JSON.parse(lines[0].trimEnd())
49
+ expect(payload).toEqual(ev)
50
+ })
51
+
52
+ it('omits undefined optional fields cleanly', () => {
53
+ const lines: string[] = []
54
+ logCardEvent(
55
+ {
56
+ ts: 1,
57
+ agent: 'a',
58
+ chatId: 'c',
59
+ turnKey: 'c::1',
60
+ event: 'finalized',
61
+ },
62
+ (l) => lines.push(l),
63
+ )
64
+ const raw = lines[0].trimEnd()
65
+ expect(raw).not.toContain('undefined')
66
+ const payload = JSON.parse(raw)
67
+ expect(payload.cardMessageId).toBeUndefined()
68
+ expect(payload.reason).toBeUndefined()
69
+ expect(payload.subagents).toBeUndefined()
70
+ })
71
+
72
+ it('preserves subagents array and durationMs', () => {
73
+ const lines: string[] = []
74
+ logCardEvent(
75
+ {
76
+ ts: 2,
77
+ agent: 'a',
78
+ chatId: 'c',
79
+ turnKey: 'c::1',
80
+ event: 'deferred',
81
+ reason: 'in-flight-sub-agents',
82
+ subagents: ['agent-1', 'agent-2'],
83
+ durationMs: 12345,
84
+ },
85
+ (l) => lines.push(l),
86
+ )
87
+ const payload = JSON.parse(lines[0].trimEnd())
88
+ expect(payload.subagents).toEqual(['agent-1', 'agent-2'])
89
+ expect(payload.durationMs).toBe(12345)
90
+ expect(payload.reason).toBe('in-flight-sub-agents')
91
+ })
92
+ })
93
+
94
+ describe('emitCardEvent', () => {
95
+ it('fills ts when omitted', () => {
96
+ const lines: string[] = []
97
+ const before = Date.now()
98
+ emitCardEvent(
99
+ { agent: 'a', chatId: 'c', turnKey: 'c::1', event: 'edited' },
100
+ (l) => lines.push(l),
101
+ )
102
+ const after = Date.now()
103
+ const payload = JSON.parse(lines[0].trimEnd())
104
+ expect(payload.ts).toBeGreaterThanOrEqual(before)
105
+ expect(payload.ts).toBeLessThanOrEqual(after)
106
+ })
107
+
108
+ it('respects an explicit ts', () => {
109
+ const lines: string[] = []
110
+ emitCardEvent(
111
+ { ts: 999, agent: 'a', chatId: 'c', turnKey: 'c::1', event: 'edited' },
112
+ (l) => lines.push(l),
113
+ )
114
+ expect(JSON.parse(lines[0].trimEnd()).ts).toBe(999)
115
+ })
116
+ })
117
+
118
+ describe('resolveCardEventPath', () => {
119
+ it('returns <STATE_DIR>/card-events.jsonl when STATE_DIR is set', () => {
120
+ expect(resolveCardEventPath({ STATE_DIR: '/tmp/x' })).toBe('/tmp/x/card-events.jsonl')
121
+ })
122
+
123
+ it('returns null when STATE_DIR is unset', () => {
124
+ expect(resolveCardEventPath({})).toBeNull()
125
+ })
126
+
127
+ it('returns null when STATE_DIR is empty', () => {
128
+ expect(resolveCardEventPath({ STATE_DIR: '' })).toBeNull()
129
+ })
130
+ })
131
+
132
+ describe('default writer (filesystem)', () => {
133
+ it('appends to <STATE_DIR>/card-events.jsonl when STATE_DIR is set', () => {
134
+ process.env.STATE_DIR = tmpDir
135
+ _resetForTests()
136
+ emitCardEvent({ agent: 'a', chatId: 'c', turnKey: 'c::1', event: 'rendered' })
137
+ emitCardEvent({ agent: 'a', chatId: 'c', turnKey: 'c::1', event: 'finalized' })
138
+ const target = join(tmpDir, 'card-events.jsonl')
139
+ expect(existsSync(target)).toBe(true)
140
+ const contents = readFileSync(target, 'utf8').trimEnd().split('\n')
141
+ expect(contents).toHaveLength(2)
142
+ expect(JSON.parse(contents[0]).event).toBe('rendered')
143
+ expect(JSON.parse(contents[1]).event).toBe('finalized')
144
+ })
145
+ })
@@ -166,6 +166,108 @@ describe("acquireStartupLock", () => {
166
166
  expect(acquired).toContain(`pid=${ourPid}`);
167
167
  });
168
168
 
169
+ it("(c.boot-id) auto-recovers a lock from a different boot, even if the recorded PID number is alive (#884)", async () => {
170
+ // Reproduces the v0.7 docker container-restart bug: the previous
171
+ // gateway lived in a now-gone container's PID namespace and was
172
+ // PID 10. The new container's PID 10 is some unrelated process
173
+ // (autoaccept-poll, tini's child, etc.). isPidAlive(10) reports
174
+ // alive, but the holder isn't actually the gateway — it's a stale
175
+ // record from a different boot. Pre-#884 this caused the new
176
+ // gateway to report blocked, retry 10x, then exit non-zero with
177
+ // "another_gateway_is_live" — leaving the agent without a
178
+ // gateway daemon.
179
+ const { dir, path } = tmpLockPath();
180
+ cleanups.push(dir);
181
+ const collidingPid = 10;
182
+ const stale = { ...makeRecord(collidingPid, Date.now() - 5_000_000), bootId: "pid1:111111111" };
183
+ writeFileSync(path, JSON.stringify(stale), "utf-8");
184
+
185
+ const { lines, log } = noopLog();
186
+ const result = await acquireStartupLock({
187
+ path,
188
+ record: makeRecord(99999),
189
+ // Inject a DIFFERENT current bootId (this is what readCurrentBootId
190
+ // would return inside a fresh container).
191
+ currentBootId: "pid1:222222222",
192
+ // Crucially: report the holder PID as ALIVE — the bug was that the
193
+ // PID number is reused by an unrelated current-namespace process.
194
+ isPidAlive: () => true,
195
+ log,
196
+ agentName: "test-agent",
197
+ });
198
+
199
+ expect(result.status).toBe("acquired");
200
+ if (result.status !== "acquired") throw new Error("unreachable");
201
+ expect(result.recoveredFrom).toBeDefined();
202
+ expect(result.recoveredFrom?.pid).toBe(collidingPid);
203
+ expect(result.recoveredFrom?.bootId).toBe("pid1:111111111");
204
+
205
+ // Our record should now own the file AND carry the new bootId so
206
+ // future boots can apply the same gate.
207
+ const onDisk = JSON.parse(readFileSync(path, "utf-8")) as MutexRecord;
208
+ expect(onDisk.pid).toBe(99999);
209
+ expect(onDisk.bootId).toBe("pid1:222222222");
210
+
211
+ const recovered = lines.find((l) => l.includes("boot.lock_stale_recovered_boot_mismatch"));
212
+ expect(recovered).toBeDefined();
213
+ expect(recovered).toContain(`prior_pid=${collidingPid}`);
214
+ expect(recovered).toContain("prior_boot=pid1:111111111");
215
+ expect(recovered).toContain("current_boot=pid1:222222222");
216
+
217
+ // Should NOT have logged boot.lock_blocked — that's the bug we're
218
+ // pinning against.
219
+ const blocked = lines.find((l) => l.includes("boot.lock_blocked"));
220
+ expect(blocked).toBeUndefined();
221
+ });
222
+
223
+ it("(c.legacy-record) falls back to PID check when the existing record predates the bootId field", async () => {
224
+ // Records written by pre-#884 gateways have no bootId. We must
225
+ // preserve the legacy kill-based check for those — otherwise an
226
+ // upgrade would treat every legacy live holder as stale and
227
+ // potentially clobber a working sibling.
228
+ const { dir, path } = tmpLockPath();
229
+ cleanups.push(dir);
230
+ const livePid = 12345;
231
+ const legacy = makeRecord(livePid, Date.now() - 5000); // no bootId
232
+ writeFileSync(path, JSON.stringify(legacy), "utf-8");
233
+
234
+ const { log } = noopLog();
235
+ const result = await acquireStartupLock({
236
+ path,
237
+ record: makeRecord(99999),
238
+ currentBootId: "pid1:any",
239
+ isPidAlive: (pid) => pid === livePid,
240
+ log,
241
+ agentName: "test-agent",
242
+ });
243
+
244
+ expect(result.status).toBe("blocked");
245
+ });
246
+
247
+ it("(c.same-boot) treats matching bootId as same-boot and trusts the PID check", async () => {
248
+ // Within the same container/host boot, the PID-namespace recycling
249
+ // problem doesn't exist. A holder record with the same bootId as
250
+ // ours should be evaluated by isPidAlive verbatim — both for blocked
251
+ // (live holder) and stale-recovered (dead holder) outcomes.
252
+ const { dir, path } = tmpLockPath();
253
+ cleanups.push(dir);
254
+ const livePid = 12345;
255
+ const sameBoot = { ...makeRecord(livePid, Date.now() - 5000), bootId: "pid1:same" };
256
+ writeFileSync(path, JSON.stringify(sameBoot), "utf-8");
257
+
258
+ const { log } = noopLog();
259
+ const result = await acquireStartupLock({
260
+ path,
261
+ record: makeRecord(99999),
262
+ currentBootId: "pid1:same",
263
+ isPidAlive: (pid) => pid === livePid,
264
+ log,
265
+ agentName: "test-agent",
266
+ });
267
+
268
+ expect(result.status).toBe("blocked");
269
+ });
270
+
169
271
  it("(c.bonus) double-acquire by the SAME process does NOT corrupt state", async () => {
170
272
  // Defensive: if the boot path somehow runs acquireStartupLock twice
171
273
  // in the same process, we should detect ourselves as the holder and
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Validation contract for the Phase 2 cron-fold-in `inject_inbound`
3
+ * IPC message — the wire envelope the in-agent scheduler sibling
4
+ * uses to ask the gateway to forward a synthesized InboundMessage to
5
+ * a registered bridge.
6
+ *
7
+ * The gateway's `validateClientMessage` is the security boundary on
8
+ * the client→gateway direction. The wrapped `inbound` payload is
9
+ * forwarded verbatim to the bridge as a `type: "inbound"` envelope —
10
+ * the bridge's validateGatewayMessage runs on the other end and is
11
+ * lenient (only checks `chatId` + `text`), so this validator carries
12
+ * the structural checks the bridge silently relies on.
13
+ *
14
+ * Companion to ipc-server-validate-{operator,pty-partial,update-placeholder}.test.ts.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+ import { validateClientMessage } from '../gateway/ipc-server.js'
19
+
20
+ function baseInbound() {
21
+ return {
22
+ type: 'inbound' as const,
23
+ chatId: '-1001234567890',
24
+ messageId: 1_700_000_000_000,
25
+ user: 'cron',
26
+ userId: 0,
27
+ ts: 1_700_000_000_000,
28
+ text: 'Morning briefing',
29
+ meta: {
30
+ source: 'cron',
31
+ schedule_index: '0',
32
+ prompt_key: 'abcdef012345',
33
+ },
34
+ }
35
+ }
36
+
37
+ function base() {
38
+ return {
39
+ type: 'inject_inbound' as const,
40
+ agentName: 'klanker',
41
+ inbound: baseInbound(),
42
+ }
43
+ }
44
+
45
+ describe('validateClientMessage — inject_inbound', () => {
46
+ it('accepts a well-formed cron fire', () => {
47
+ expect(validateClientMessage(base())).toBe(true)
48
+ })
49
+
50
+ it('rejects when agentName is missing or malformed', () => {
51
+ const noName = { ...base() } as Record<string, unknown>
52
+ delete noName.agentName
53
+ expect(validateClientMessage(noName)).toBe(false)
54
+ expect(validateClientMessage({ ...base(), agentName: '' })).toBe(false)
55
+ // Same regex as register/heartbeat — uppercase rejected.
56
+ expect(validateClientMessage({ ...base(), agentName: 'Klanker' })).toBe(false)
57
+ // Path traversal / shell metacharacters rejected.
58
+ expect(validateClientMessage({ ...base(), agentName: '../etc/passwd' })).toBe(false)
59
+ expect(validateClientMessage({ ...base(), agentName: 'a$(rm -rf)' })).toBe(false)
60
+ expect(validateClientMessage({ ...base(), agentName: 42 })).toBe(false)
61
+ })
62
+
63
+ it('rejects when inbound is not an object', () => {
64
+ expect(validateClientMessage({ ...base(), inbound: null })).toBe(false)
65
+ expect(validateClientMessage({ ...base(), inbound: 'string' })).toBe(false)
66
+ expect(validateClientMessage({ ...base(), inbound: 42 })).toBe(false)
67
+ const noInbound = { ...base() } as Record<string, unknown>
68
+ delete noInbound.inbound
69
+ expect(validateClientMessage(noInbound)).toBe(false)
70
+ })
71
+
72
+ it("requires inbound.type === 'inbound'", () => {
73
+ expect(validateClientMessage({
74
+ ...base(),
75
+ inbound: { ...baseInbound(), type: 'permission' },
76
+ })).toBe(false)
77
+ expect(validateClientMessage({
78
+ ...base(),
79
+ inbound: { ...baseInbound(), type: 'Inbound' },
80
+ })).toBe(false)
81
+ })
82
+
83
+ it('requires non-empty inbound.chatId (string) and string inbound.text', () => {
84
+ expect(validateClientMessage({
85
+ ...base(),
86
+ inbound: { ...baseInbound(), chatId: '' },
87
+ })).toBe(false)
88
+ expect(validateClientMessage({
89
+ ...base(),
90
+ inbound: { ...baseInbound(), chatId: 42 },
91
+ })).toBe(false)
92
+ expect(validateClientMessage({
93
+ ...base(),
94
+ inbound: { ...baseInbound(), text: 42 },
95
+ })).toBe(false)
96
+ })
97
+
98
+ it('requires numeric messageId, userId, ts (the bridge does not coerce)', () => {
99
+ expect(validateClientMessage({
100
+ ...base(),
101
+ inbound: { ...baseInbound(), messageId: '0' },
102
+ })).toBe(false)
103
+ expect(validateClientMessage({
104
+ ...base(),
105
+ inbound: { ...baseInbound(), userId: '0' },
106
+ })).toBe(false)
107
+ expect(validateClientMessage({
108
+ ...base(),
109
+ inbound: { ...baseInbound(), ts: '0' },
110
+ })).toBe(false)
111
+ })
112
+
113
+ it('requires meta to be an object (Record<string, string> on the wire)', () => {
114
+ expect(validateClientMessage({
115
+ ...base(),
116
+ inbound: { ...baseInbound(), meta: null },
117
+ })).toBe(false)
118
+ const noMeta = { ...baseInbound() } as Record<string, unknown>
119
+ delete noMeta.meta
120
+ expect(validateClientMessage({ ...base(), inbound: noMeta })).toBe(false)
121
+ // Empty meta is acceptable — the gateway doesn't enforce specific keys
122
+ // here; meta.source filtering is policy that lives at the handler.
123
+ expect(validateClientMessage({
124
+ ...base(),
125
+ inbound: { ...baseInbound(), meta: {} },
126
+ })).toBe(true)
127
+ })
128
+
129
+ it('rejects unknown type aliases that look similar', () => {
130
+ expect(validateClientMessage({ ...base(), type: 'inject_inbounds' })).toBe(false)
131
+ expect(validateClientMessage({ ...base(), type: 'inject-inbound' })).toBe(false)
132
+ expect(validateClientMessage({ ...base(), type: 'inbound' })).toBe(false)
133
+ })
134
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * #842 — first-render delay (45s default) with explicit-background bypass.
3
+ *
4
+ * Behavioural contract:
5
+ * 1. Turn that ends BEFORE the threshold trips → no card emit at all.
6
+ * 2. Turn that runs PAST the threshold → exactly one card emit at the
7
+ * threshold, rendering the full buffered event stream (verified by
8
+ * checking the rendered HTML reflects accumulated state).
9
+ * 3. Explicit `Agent({ run_in_background: true })` dispatch with
10
+ * `delay_ms_background=0` → card emits immediately on the
11
+ * tool_use, regardless of the long `delay_ms` budget.
12
+ * 4. Threshold timer is cleared on early turn_end (no late phantom
13
+ * emit when wall-clock advances past the threshold afterwards).
14
+ * 5. Pre-threshold buffer matches post-threshold render — i.e. the
15
+ * first emit's HTML reflects every tool_use that landed during
16
+ * the suppression window (no events lost).
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest'
20
+ import { makeHarness, enqueue } from './_progress-card-harness.js'
21
+ import type { SessionEvent } from '../session-tail.js'
22
+
23
+ const tu = (
24
+ toolName: string,
25
+ toolUseId: string,
26
+ input: Record<string, unknown> = {},
27
+ ): SessionEvent => ({
28
+ kind: 'tool_use',
29
+ toolName,
30
+ toolUseId,
31
+ input,
32
+ })
33
+
34
+ const tr = (toolUseId: string): SessionEvent => ({
35
+ kind: 'tool_result',
36
+ toolUseId,
37
+ isError: false,
38
+ errorText: null,
39
+ })
40
+
41
+ describe('#842 progress-card first-render delay', () => {
42
+ it('AC2 + AC6: turn ends BEFORE the 45s threshold → no card is ever posted', () => {
43
+ const { driver, emits, advance } = makeHarness({ initialDelayMs: 45_000 })
44
+ driver.ingest(enqueue('chat-fast'), null)
45
+ advance(5_000)
46
+ driver.ingest(tu('Read', 'tu1', { file_path: '/tmp/a.ts' }), 'chat-fast')
47
+ advance(10_000)
48
+ driver.ingest(tr('tu1'), 'chat-fast')
49
+ advance(10_000)
50
+ driver.ingest({ kind: 'turn_end' }, 'chat-fast')
51
+ // Turn finished at t=25s — well before 45s. No card should have
52
+ // been emitted, and no late phantom emit when we keep the clock
53
+ // running.
54
+ advance(60_000)
55
+ expect(emits.length).toBe(0)
56
+ })
57
+
58
+ it('AC3 + AC6: turn that crosses 45s → one card emit at threshold, full backfill', () => {
59
+ const { driver, emits, advance } = makeHarness({ initialDelayMs: 45_000 })
60
+ driver.ingest(enqueue('chat-long'), null)
61
+ // Buffer some events through the suppression window.
62
+ advance(10_000)
63
+ driver.ingest(tu('Read', 'tu1', { file_path: '/tmp/a.ts' }), 'chat-long')
64
+ advance(10_000)
65
+ driver.ingest(tr('tu1'), 'chat-long')
66
+ advance(10_000)
67
+ driver.ingest(tu('Bash', 'tu2', { description: 'check commits' }), 'chat-long')
68
+ // No emits yet — still within the 45s window.
69
+ expect(emits.length).toBe(0)
70
+ // Cross the threshold.
71
+ advance(20_000) // total elapsed ~50s
72
+ // Exactly one initial emit at threshold, rendering the buffered
73
+ // state. The first emit must reflect the tool_use accumulation
74
+ // that happened during the suppression window — i.e. the renderer
75
+ // saw the buffer.
76
+ expect(emits.length).toBeGreaterThanOrEqual(1)
77
+ const first = emits[0]
78
+ expect(first.html.length).toBeGreaterThan(0)
79
+ // Buffer included a Bash with a human description — render must
80
+ // include the description text (non-trivial: proves the reducer
81
+ // ate the events before the first flush). This is the
82
+ // "pre-threshold buffer matches post-threshold render" assertion.
83
+ expect(first.html).toContain('check commits')
84
+ })
85
+
86
+ it('AC4: explicit Agent({run_in_background:true}) bypasses the long delay', () => {
87
+ const { driver, emits, advance } = makeHarness({
88
+ initialDelayMs: 45_000,
89
+ initialDelayMsBackground: 0,
90
+ })
91
+ driver.ingest(enqueue('chat-bg'), null)
92
+ advance(2_000)
93
+ driver.ingest(
94
+ tu('Agent', 'tu-bg', {
95
+ prompt: 'do bg work',
96
+ description: 'bg-job',
97
+ run_in_background: true,
98
+ }),
99
+ 'chat-bg',
100
+ )
101
+ // Card should emit immediately — no need to advance the clock.
102
+ expect(emits.length).toBeGreaterThanOrEqual(1)
103
+ expect(emits[0].html.length).toBeGreaterThan(0)
104
+ })
105
+
106
+ it('AC4 (foreground variant): non-background Agent does NOT bypass the delay', () => {
107
+ const { driver, emits, advance } = makeHarness({
108
+ initialDelayMs: 45_000,
109
+ initialDelayMsBackground: 0,
110
+ })
111
+ driver.ingest(enqueue('chat-fg'), null)
112
+ advance(2_000)
113
+ driver.ingest(
114
+ tu('Agent', 'tu-fg', { prompt: 'p', description: 'fg-job' }),
115
+ 'chat-fg',
116
+ )
117
+ // No emit yet — foreground Agent should follow the 45s rule.
118
+ // (`promoteOnSubAgent` only fires once `sub_agent_started` lands;
119
+ // this test stops at the parent tool_use to isolate the
120
+ // background-bypass branch.)
121
+ expect(emits.length).toBe(0)
122
+ })
123
+
124
+ it('AC4 (with positive background budget): timer rescheduled to short budget', () => {
125
+ const { driver, emits, advance } = makeHarness({
126
+ initialDelayMs: 45_000,
127
+ initialDelayMsBackground: 5_000,
128
+ })
129
+ driver.ingest(enqueue('chat-bg-short'), null)
130
+ advance(1_000)
131
+ driver.ingest(
132
+ tu('Agent', 'tu-bg2', {
133
+ prompt: 'p',
134
+ description: 'bg',
135
+ run_in_background: true,
136
+ }),
137
+ 'chat-bg-short',
138
+ )
139
+ // No immediate emit — budget is 5s.
140
+ expect(emits.length).toBe(0)
141
+ // Advance past 45s budget would emit, but we expect the
142
+ // background bypass to fire by 5s elapsed.
143
+ advance(5_000)
144
+ expect(emits.length).toBeGreaterThanOrEqual(1)
145
+ })
146
+
147
+ it('AC5: timer cleared on early turn_end — no phantom emit when clock keeps running', () => {
148
+ const { driver, emits, advance } = makeHarness({ initialDelayMs: 45_000 })
149
+ driver.ingest(enqueue('chat-fast2'), null)
150
+ advance(5_000)
151
+ driver.ingest(tu('Read', 'tu1', { file_path: '/x' }), 'chat-fast2')
152
+ advance(5_000)
153
+ driver.ingest({ kind: 'turn_end' }, 'chat-fast2')
154
+ expect(emits.length).toBe(0)
155
+ // Push the clock far past the original threshold. If the timer
156
+ // wasn't cleared, a phantom flush would land here.
157
+ advance(120_000)
158
+ expect(emits.length).toBe(0)
159
+ })
160
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
- import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs'
2
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync, existsSync, readFileSync } from 'fs'
3
3
  import { tmpdir } from 'os'
4
4
  import { join } from 'path'
5
5
  import {
@@ -379,6 +379,42 @@ describe('fetchAccountQuota — cache + token resolution', () => {
379
379
  rmSync(home, { recursive: true, force: true })
380
380
  }
381
381
  })
382
+
383
+ it('persists the snapshot under the supplied home, not the real homedir (issue #708 regression)', async () => {
384
+ const home = makeAccountHome({
385
+ 'work@example.com': { accessToken: 'tok' },
386
+ })
387
+ const fakeFetch = async () =>
388
+ new Response('{}', {
389
+ status: 200,
390
+ headers: {
391
+ 'anthropic-ratelimit-unified-5h-utilization': '0.42',
392
+ 'anthropic-ratelimit-unified-7d-utilization': '0.17',
393
+ },
394
+ })
395
+ try {
396
+ const r = await fetchAccountQuota('work@example.com', {
397
+ home,
398
+ fetchImpl: fakeFetch as typeof fetch,
399
+ })
400
+ expect(r.ok).toBe(true)
401
+ const snapPath = join(
402
+ home,
403
+ '.switchroom',
404
+ 'accounts',
405
+ 'work@example.com',
406
+ 'quota.json',
407
+ )
408
+ // The bug: writeAccountQuota was called without opts.home, so the
409
+ // snapshot landed under the real $HOME instead of the test home.
410
+ expect(existsSync(snapPath)).toBe(true)
411
+ const snap = JSON.parse(readFileSync(snapPath, 'utf-8'))
412
+ expect(snap.fiveHourPct).toBeCloseTo(42, 0)
413
+ expect(snap.sevenDayPct).toBeCloseTo(17, 0)
414
+ } finally {
415
+ rmSync(home, { recursive: true, force: true })
416
+ }
417
+ })
382
418
  })
383
419
 
384
420
  describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
@@ -219,6 +219,11 @@ function makeHarnessWithDb(opts: {
219
219
  agentDir,
220
220
  sendNotification: (text) => notifications.push(text),
221
221
  stallThresholdMs,
222
+ // Mirror the active-loop threshold for fixtures with toolCount=0;
223
+ // tests that need the silent-synthesis vs active-loop distinction
224
+ // pass a separate value explicitly. See subagent-watcher.ts adaptive
225
+ // threshold logic.
226
+ silentSynthesisStallThresholdMs: stallThresholdMs,
222
227
  rescanMs: 500,
223
228
  now: () => currentTime,
224
229
  setInterval: (fn, ms) => {