switchroom 0.5.0 → 0.7.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.
- package/README.md +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +510 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +564 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- 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) => {
|