switchroom 0.18.7 → 0.18.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 +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the chunk-boundary cap bug (fix/rich-render-chunk-boundary-cap).
|
|
3
|
+
*
|
|
4
|
+
* THE BUG: the outbound safe renderer (`renderSafe`) re-escapes GFM-special
|
|
5
|
+
* characters, which GROWS a body. A raw chunk sized just under the wire cap can
|
|
6
|
+
* escape PAST it. `renderSafe`'s only oversize recourse is to degrade the WHOLE
|
|
7
|
+
* document to `mode:"plain"` (raw source, no rich wrapper) — it never re-splits
|
|
8
|
+
* a multi-block body. The send path then ships that plain body through the
|
|
9
|
+
* 4096-char plain `sendMessage` endpoint, so a ~32k plain body is rejected by
|
|
10
|
+
* Telegram (`message is too long`) and the answer is dropped.
|
|
11
|
+
*
|
|
12
|
+
* `renderOutboundChunks` closes the gap: every returned piece fits its own wire
|
|
13
|
+
* cap (rich `<= maxLen`, plain `<= plainMax`) and is cut only at
|
|
14
|
+
* `splitMarkdownChunks`' safe boundaries (never bisecting a fence / table row).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect } from "vitest";
|
|
17
|
+
import { renderOutboundChunks, PLAIN_TEXT_MAX_CHARS } from "../../render/rich-render.js";
|
|
18
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../../format.js";
|
|
19
|
+
|
|
20
|
+
const ON = { SWITCHROOM_RICH_RENDER: "1" } as NodeJS.ProcessEnv;
|
|
21
|
+
const OFF = {} as NodeJS.ProcessEnv;
|
|
22
|
+
|
|
23
|
+
/** Count fenced-code delimiter lines (```) in a body. A piece that bisects a
|
|
24
|
+
* fenced block has an ODD count. */
|
|
25
|
+
function fenceCount(s: string): number {
|
|
26
|
+
return (s.match(/^```/gm) ?? []).length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("renderOutboundChunks", () => {
|
|
30
|
+
it("flag OFF is a single passthrough piece (byte-for-byte)", () => {
|
|
31
|
+
const raw = "**bold** and _italic_ | a | table |";
|
|
32
|
+
const pieces = renderOutboundChunks(raw, OFF);
|
|
33
|
+
expect(pieces).toHaveLength(1);
|
|
34
|
+
expect(pieces[0].text).toBe(raw);
|
|
35
|
+
expect(pieces[0].mode).toBe("markdown");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("flag ON, body that fits is a single piece (common case)", () => {
|
|
39
|
+
const pieces = renderOutboundChunks("just some plain prose", ON);
|
|
40
|
+
expect(pieces).toHaveLength(1);
|
|
41
|
+
expect(pieces[0].text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("REGRESSION: a near-cap escapable body splits into cap-respecting pieces", () => {
|
|
45
|
+
// Prose whose escaping (`_ * |` each gain a leading `\`) grows it past the
|
|
46
|
+
// cap. Use small caps so the test is fast; the invariant is cap-agnostic.
|
|
47
|
+
const maxLen = 200;
|
|
48
|
+
const plainMax = 80;
|
|
49
|
+
const unit = "a_b*c|d ";
|
|
50
|
+
const raw = unit.repeat(40); // 320 raw chars, ~1.5x after escaping
|
|
51
|
+
const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
|
|
52
|
+
|
|
53
|
+
// The whole body did NOT fit as one message — it was re-split.
|
|
54
|
+
expect(pieces.length).toBeGreaterThan(1);
|
|
55
|
+
for (const p of pieces) {
|
|
56
|
+
if (p.mode === "plain") {
|
|
57
|
+
// A plain piece rides the plain `sendMessage` endpoint — must fit its cap.
|
|
58
|
+
expect(p.text.length).toBeLessThanOrEqual(plainMax);
|
|
59
|
+
} else {
|
|
60
|
+
expect(p.text.length).toBeLessThanOrEqual(maxLen);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("REGRESSION: never bisects a fenced code block when splitting", () => {
|
|
66
|
+
const maxLen = 300;
|
|
67
|
+
const plainMax = 250; // >= the fence size, so a fence never needs a hard slice.
|
|
68
|
+
// A fenced block big enough that a naive length cut would land inside it,
|
|
69
|
+
// wrapped in prose so the whole document overflows and must be re-split.
|
|
70
|
+
const fence = "```\n" + "code line here\n".repeat(8) + "```";
|
|
71
|
+
const prose = "word ".repeat(40);
|
|
72
|
+
const raw = `${prose}\n\n${fence}\n\n${prose}`;
|
|
73
|
+
const pieces = renderOutboundChunks(raw, ON, maxLen, plainMax);
|
|
74
|
+
|
|
75
|
+
expect(pieces.length).toBeGreaterThan(1);
|
|
76
|
+
for (const p of pieces) {
|
|
77
|
+
// Every emitted piece has BALANCED fence delimiters — no piece opens a
|
|
78
|
+
// fence it doesn't close (which would swallow the next piece's text).
|
|
79
|
+
expect(fenceCount(p.text) % 2).toBe(0);
|
|
80
|
+
const cap = p.mode === "plain" ? plainMax : maxLen;
|
|
81
|
+
expect(p.text.length).toBeLessThanOrEqual(cap);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("REAL-CAP: a ~32k escapable body never yields an over-4096 plain piece", () => {
|
|
86
|
+
// The production failure: raw just under RICH_MESSAGE_MAX_CHARS, escaping
|
|
87
|
+
// pushes the rendered form over it. On the buggy path this became ONE
|
|
88
|
+
// ~32k plain body sent through the 4096 plain endpoint. Here every plain
|
|
89
|
+
// piece is <= 4096 and every rich piece <= 32768.
|
|
90
|
+
const unit = "a_b*c|d ";
|
|
91
|
+
const raw = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
92
|
+
const pieces = renderOutboundChunks(raw, ON);
|
|
93
|
+
for (const p of pieces) {
|
|
94
|
+
const cap = p.mode === "plain" ? PLAIN_TEXT_MAX_CHARS : RICH_MESSAGE_MAX_CHARS;
|
|
95
|
+
expect(p.text.length).toBeLessThanOrEqual(cap);
|
|
96
|
+
}
|
|
97
|
+
}, 30000);
|
|
98
|
+
});
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
createSwallowingRetryApiCall,
|
|
16
16
|
retryWithThreadFallback,
|
|
17
17
|
isHtmlParseRejectError,
|
|
18
|
+
isLocalResourceError,
|
|
19
|
+
LOCAL_RESOURCE_EXHAUSTED,
|
|
18
20
|
type RetryObserver,
|
|
19
21
|
} from '../retry-api-call.js'
|
|
20
22
|
import { errors, makeGrammyError } from './fake-bot-api.js'
|
|
@@ -512,3 +514,60 @@ describe('isHtmlParseRejectError', () => {
|
|
|
512
514
|
).toBe(true)
|
|
513
515
|
})
|
|
514
516
|
})
|
|
517
|
+
|
|
518
|
+
describe('#2923 — LOCAL resource exhaustion is NOT retried (avoids flood ban)', () => {
|
|
519
|
+
it('classifies ENOSPC / EDQUOT / EIO / ENOMEM by errno code', () => {
|
|
520
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOSPC' }))).toBe(true)
|
|
521
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EDQUOT' }))).toBe(true)
|
|
522
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'EIO' }))).toBe(true)
|
|
523
|
+
expect(isLocalResourceError(Object.assign(new Error('x'), { code: 'ENOMEM' }))).toBe(true)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('classifies by message when no code is present (incl. EIO, word-boundaried)', () => {
|
|
527
|
+
expect(isLocalResourceError(new Error('ENOSPC: no space left on device, write'))).toBe(true)
|
|
528
|
+
expect(isLocalResourceError(new Error('disk quota exceeded'))).toBe(true)
|
|
529
|
+
expect(isLocalResourceError(new Error('EIO: i/o error, write'))).toBe(true)
|
|
530
|
+
// No false match on a substring (e.g. a word containing the letters).
|
|
531
|
+
expect(isLocalResourceError(new Error('DENOSPCX not a real code'))).toBe(false)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it('does NOT classify a remote GrammyError or ordinary error', () => {
|
|
535
|
+
expect(isLocalResourceError(errors.floodWait(10))).toBe(false)
|
|
536
|
+
expect(isLocalResourceError(new Error('fetch failed'))).toBe(false)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('throws LOCAL_RESOURCE_EXHAUSTED immediately without retrying', async () => {
|
|
540
|
+
// Before the fix: an ENOSPC thrown by the send-staging step fell through
|
|
541
|
+
// to the network-retry branch pattern OR was rethrown but only after the
|
|
542
|
+
// caller kept re-driving sends — the storm that tripped the flood ban.
|
|
543
|
+
// Now it must fail FAST on the first attempt with a distinct marker.
|
|
544
|
+
const sleep = vi.fn(async () => {})
|
|
545
|
+
let calls = 0
|
|
546
|
+
const retry = createRetryApiCall({ maxRetries: 3, sleep })
|
|
547
|
+
await expect(
|
|
548
|
+
retry(async () => {
|
|
549
|
+
calls++
|
|
550
|
+
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' })
|
|
551
|
+
}),
|
|
552
|
+
).rejects.toThrow(LOCAL_RESOURCE_EXHAUSTED)
|
|
553
|
+
expect(calls).toBe(1) // no retry
|
|
554
|
+
expect(sleep).not.toHaveBeenCalled() // no backoff-into-flood
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('fires onFloodWait with the retry_after when a 429 is seen', async () => {
|
|
558
|
+
const seen: number[] = []
|
|
559
|
+
const sleep = vi.fn(async () => {})
|
|
560
|
+
let n = 0
|
|
561
|
+
const retry = createRetryApiCall({
|
|
562
|
+
maxRetries: 3,
|
|
563
|
+
sleep,
|
|
564
|
+
onFloodWait: (s) => seen.push(s),
|
|
565
|
+
})
|
|
566
|
+
const out = await retry(async () => {
|
|
567
|
+
if (n++ === 0) throw errors.floodWait(42)
|
|
568
|
+
return 'ok'
|
|
569
|
+
})
|
|
570
|
+
expect(out).toBe('ok')
|
|
571
|
+
expect(seen).toEqual([42])
|
|
572
|
+
})
|
|
573
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* #2555 — run-hook.sh must tolerate a Node exit-134 (uv_thread_create abort
|
|
10
|
+
* under memory pressure): retry once, and if it still aborts, skip cleanly
|
|
11
|
+
* (exit 0) rather than propagating 134. Real non-134 statuses pass through.
|
|
12
|
+
*
|
|
13
|
+
* We drive the wrapper with a fake command (a tiny sh script) whose exit code
|
|
14
|
+
* is scripted via a counter file, so we exercise the exact control flow
|
|
15
|
+
* without needing a real memory-pressured Node.
|
|
16
|
+
*/
|
|
17
|
+
const wrapper = resolve(
|
|
18
|
+
fileURLToPath(new URL('../hooks/run-hook.sh', import.meta.url)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
describe('#2555 run-hook.sh exit-134 tolerance', () => {
|
|
22
|
+
let dir: string
|
|
23
|
+
let fake: string
|
|
24
|
+
let counter: string
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
dir = mkdtempSync(join(tmpdir(), 'run-hook-'))
|
|
28
|
+
fake = join(dir, 'fake.sh')
|
|
29
|
+
counter = join(dir, 'n')
|
|
30
|
+
writeFileSync(counter, '0')
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
33
|
+
|
|
34
|
+
/** Fake command: exits `codes[attemptIndex]`, records each invocation. */
|
|
35
|
+
function writeFake(codes: number[]): void {
|
|
36
|
+
writeFileSync(
|
|
37
|
+
fake,
|
|
38
|
+
[
|
|
39
|
+
'#!/bin/sh',
|
|
40
|
+
`n=$(cat "${counter}")`,
|
|
41
|
+
`echo "$((n + 1))" > "${counter}"`,
|
|
42
|
+
'case "$n" in',
|
|
43
|
+
...codes.map((c, i) => ` ${i}) exit ${c} ;;`),
|
|
44
|
+
` *) exit ${codes[codes.length - 1]} ;;`,
|
|
45
|
+
'esac',
|
|
46
|
+
].join('\n'),
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const run = (input = '') =>
|
|
51
|
+
spawnSync('sh', [wrapper, 'sh', fake], { encoding: 'utf-8', input })
|
|
52
|
+
|
|
53
|
+
const attempts = () => Number(readFileSync(counter, 'utf-8').trim())
|
|
54
|
+
|
|
55
|
+
it('passes a clean exit 0 through without retrying', () => {
|
|
56
|
+
writeFake([0])
|
|
57
|
+
const r = run()
|
|
58
|
+
expect(r.status).toBe(0)
|
|
59
|
+
expect(attempts()).toBe(1)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('passes a real non-134 failure through unchanged (no retry, not masked)', () => {
|
|
63
|
+
writeFake([2])
|
|
64
|
+
const r = run()
|
|
65
|
+
expect(r.status).toBe(2)
|
|
66
|
+
expect(attempts()).toBe(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('retries ONCE on a 134 abort and succeeds on the second attempt', () => {
|
|
70
|
+
writeFake([134, 0])
|
|
71
|
+
const r = run()
|
|
72
|
+
expect(r.status).toBe(0)
|
|
73
|
+
expect(attempts()).toBe(2)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('skips cleanly (exit 0) when it aborts 134 twice — no crash card', () => {
|
|
77
|
+
writeFake([134, 134])
|
|
78
|
+
const r = run()
|
|
79
|
+
expect(r.status).toBe(0) // skipped cleanly, NOT 134
|
|
80
|
+
expect(attempts()).toBe(2)
|
|
81
|
+
expect(r.stderr).toMatch(/skipping hook cleanly/)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('exports a shrunk UV_THREADPOOL_SIZE to the child', () => {
|
|
85
|
+
writeFileSync(fake, `#!/bin/sh\necho "$UV_THREADPOOL_SIZE"\nexit 0\n`)
|
|
86
|
+
const r = run()
|
|
87
|
+
expect(r.stdout.trim()).toBe('1')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('replays the SAME stdin payload on the retry (scanner never sees empty input)', () => {
|
|
91
|
+
// Fake: attempt 0 reads stdin then aborts 134; attempt 1 reads stdin and
|
|
92
|
+
// records it, then exits 0. If stdin were not preserved, the recorded
|
|
93
|
+
// payload on the retry would be empty.
|
|
94
|
+
const seen = join(dir, 'seen-stdin')
|
|
95
|
+
writeFileSync(
|
|
96
|
+
fake,
|
|
97
|
+
[
|
|
98
|
+
'#!/bin/sh',
|
|
99
|
+
`n=$(cat "${counter}")`,
|
|
100
|
+
`echo "$((n + 1))" > "${counter}"`,
|
|
101
|
+
'data=$(cat)', // drain stdin (the abort-after-read scenario)
|
|
102
|
+
`echo "$data" > "${seen}.$n"`,
|
|
103
|
+
'[ "$n" = "0" ] && exit 134',
|
|
104
|
+
'exit 0',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
)
|
|
107
|
+
const r = run('SECRET-PAYLOAD-123')
|
|
108
|
+
expect(r.status).toBe(0)
|
|
109
|
+
expect(attempts()).toBe(2)
|
|
110
|
+
// The RETRY (attempt 1) must have received the full payload, not empty.
|
|
111
|
+
expect(readFileSync(`${seen}.1`, 'utf-8').trim()).toBe('SECRET-PAYLOAD-123')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('FAILS CLOSED (propagates 134) for a security hook that aborts twice', () => {
|
|
115
|
+
// A genuinely broken secret scanner must NOT silently pass — exit 0 after
|
|
116
|
+
// two aborts would be a silent security bypass.
|
|
117
|
+
const secFake = join(dir, 'secret-guard-pretool.mjs')
|
|
118
|
+
writeFileSync(
|
|
119
|
+
secFake,
|
|
120
|
+
[
|
|
121
|
+
'#!/bin/sh',
|
|
122
|
+
`n=$(cat "${counter}")`,
|
|
123
|
+
`echo "$((n + 1))" > "${counter}"`,
|
|
124
|
+
'exit 134',
|
|
125
|
+
].join('\n'),
|
|
126
|
+
)
|
|
127
|
+
const r = spawnSync('sh', [wrapper, 'sh', secFake], { encoding: 'utf-8', input: '{}' })
|
|
128
|
+
expect(r.status).toBe(134) // fail closed — NOT skipped
|
|
129
|
+
expect(attempts()).toBe(2)
|
|
130
|
+
expect(r.stderr).toMatch(/FAILING CLOSED/)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable session-model file helpers (session-model-file.ts) — the gateway
|
|
3
|
+
* side of the stickiness contract (reference/rfcs/session-model-stickiness.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
7
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import {
|
|
11
|
+
serializeSessionModel,
|
|
12
|
+
parseSessionModel,
|
|
13
|
+
writeSessionModelFile,
|
|
14
|
+
readSessionModelFile,
|
|
15
|
+
readSessionModelFileRaw,
|
|
16
|
+
restoreSessionModelFileRaw,
|
|
17
|
+
clearSessionModelFile,
|
|
18
|
+
writeRelaunchModelIntent,
|
|
19
|
+
clearRelaunchModelIntent,
|
|
20
|
+
readConfiguredDefaultModel,
|
|
21
|
+
intentForRestartReason,
|
|
22
|
+
SESSION_MODEL_FILE,
|
|
23
|
+
RELAUNCH_MODEL_INTENT_FILE,
|
|
24
|
+
CONFIGURED_DEFAULT_MODEL_FILE,
|
|
25
|
+
} from '../gateway/session-model-file.js'
|
|
26
|
+
|
|
27
|
+
let dir: string
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), 'switchroom-sm-file-'))
|
|
30
|
+
})
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(dir, { recursive: true, force: true })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('serialize/parse round-trip', () => {
|
|
36
|
+
it('round-trips a record', () => {
|
|
37
|
+
const rec = { model: 'sr-glm-5', configuredDefaultAtWrite: 'claude-sonnet-5', ts: 1783948123456 }
|
|
38
|
+
expect(parseSessionModel(serializeSessionModel(rec))).toEqual(rec)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('rejects corrupt JSON, missing fields, and non-canonical model tokens', () => {
|
|
42
|
+
expect(parseSessionModel('{broken')).toBeNull()
|
|
43
|
+
expect(parseSessionModel('{"model":"opus"}')).toBeNull()
|
|
44
|
+
expect(parseSessionModel('{"model":"Opus 4.8","configuredDefaultAtWrite":"x","ts":1}')).toBeNull()
|
|
45
|
+
expect(parseSessionModel('{"model":42,"configuredDefaultAtWrite":"x","ts":1}')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('writeSessionModelFile — canonical-token guard (review finding 7)', () => {
|
|
50
|
+
it('writes a canonical token with the current default + a fresh ts', () => {
|
|
51
|
+
writeSessionModelFile(dir, 'claude-opus-4-8', 'claude-sonnet-5')
|
|
52
|
+
const rec = readSessionModelFile(dir)!
|
|
53
|
+
expect(rec.model).toBe('claude-opus-4-8')
|
|
54
|
+
expect(rec.configuredDefaultAtWrite).toBe('claude-sonnet-5')
|
|
55
|
+
expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('THROWS on a display label — "Opus 4.5" must never be persisted', () => {
|
|
59
|
+
expect(() => writeSessionModelFile(dir, 'Opus 4.5', 'claude-sonnet-5')).toThrow(/non-canonical/)
|
|
60
|
+
expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('rollback snapshot (scheduleModelRelaunch dispatch failure)', () => {
|
|
65
|
+
it('restores prior content when a file existed', () => {
|
|
66
|
+
writeSessionModelFile(dir, 'claude-opus-4-8', 'claude-sonnet-5')
|
|
67
|
+
const snapshot = readSessionModelFileRaw(dir)
|
|
68
|
+
writeSessionModelFile(dir, 'sr-glm-5', 'claude-sonnet-5')
|
|
69
|
+
restoreSessionModelFileRaw(dir, snapshot)
|
|
70
|
+
expect(readSessionModelFile(dir)!.model).toBe('claude-opus-4-8')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('deletes the file when there was none before', () => {
|
|
74
|
+
const snapshot = readSessionModelFileRaw(dir) // null
|
|
75
|
+
writeSessionModelFile(dir, 'sr-glm-5', 'claude-sonnet-5')
|
|
76
|
+
restoreSessionModelFileRaw(dir, snapshot)
|
|
77
|
+
expect(existsSync(join(dir, SESSION_MODEL_FILE))).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('relaunch intent', () => {
|
|
82
|
+
it('writes one-line JSON with intent, reason, and embedded ts (the freshness clock)', () => {
|
|
83
|
+
writeRelaunchModelIntent(dir, 'keep', 'user: /new from chat')
|
|
84
|
+
const raw = readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')
|
|
85
|
+
const parsed = JSON.parse(raw)
|
|
86
|
+
expect(parsed.intent).toBe('keep')
|
|
87
|
+
expect(parsed.reason).toBe('user: /new from chat')
|
|
88
|
+
expect(Math.abs(Date.now() - parsed.ts)).toBeLessThan(5000)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('last-writer-wins and clearable', () => {
|
|
92
|
+
writeRelaunchModelIntent(dir, 'keep', 'a')
|
|
93
|
+
writeRelaunchModelIntent(dir, 'revert', 'b')
|
|
94
|
+
expect(JSON.parse(readFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'utf8')).intent).toBe('revert')
|
|
95
|
+
clearRelaunchModelIntent(dir)
|
|
96
|
+
expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('intentForRestartReason — the triggerSelfRestart per-reason table (RFC §3)', () => {
|
|
101
|
+
it.each([
|
|
102
|
+
'schedule-restart-immediate',
|
|
103
|
+
'restart-drain-cap-forced',
|
|
104
|
+
'turn-complete-pending-restart',
|
|
105
|
+
'fleet-fallback-resume',
|
|
106
|
+
'sr-to-claude-model-switch',
|
|
107
|
+
])('switchroom-managed relaunch %s → keep', (reason) => {
|
|
108
|
+
expect(intentForRestartReason(reason)).toBe('keep')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('inline-button-restart (operator-deliberate) → revert', () => {
|
|
112
|
+
expect(intentForRestartReason('inline-button-restart')).toBe('revert')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
|
|
116
|
+
expect(intentForRestartReason('some-future-recovery-path')).toBe('keep')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('readConfiguredDefaultModel', () => {
|
|
121
|
+
it('reads the trimmed value; null when absent or empty', () => {
|
|
122
|
+
expect(readConfiguredDefaultModel(dir)).toBeNull()
|
|
123
|
+
writeFileSync(join(dir, CONFIGURED_DEFAULT_MODEL_FILE), 'claude-sonnet-5\n')
|
|
124
|
+
expect(readConfiguredDefaultModel(dir)).toBe('claude-sonnet-5')
|
|
125
|
+
writeFileSync(join(dir, CONFIGURED_DEFAULT_MODEL_FILE), '\n')
|
|
126
|
+
expect(readConfiguredDefaultModel(dir)).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('clearSessionModelFile is a safe no-op when absent', () => {
|
|
130
|
+
expect(() => clearSessionModelFile(dir)).not.toThrow()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -170,7 +170,7 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
|
|
|
170
170
|
const gw2 = makeGateway(fs, tg);
|
|
171
171
|
const res = await gw2.bootCleanup();
|
|
172
172
|
|
|
173
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
173
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
174
174
|
expect(tg.pinned.has(`${OWNER}:${msgId}`)).toBe(false); // orphan unpinned
|
|
175
175
|
expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
|
|
176
176
|
});
|
|
@@ -220,7 +220,7 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
|
|
|
220
220
|
// Fresh boot recovers it from the pending record.
|
|
221
221
|
const gw2 = makeGateway(fs, tg);
|
|
222
222
|
const res = await gw2.bootCleanup();
|
|
223
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
223
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
224
224
|
expect(tg.pinned.has(`${OWNER}:${rec[0].messageId}`)).toBe(false);
|
|
225
225
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
226
226
|
});
|
|
@@ -241,6 +241,6 @@ describe("slot-banner boot recovery (gateway wiring)", () => {
|
|
|
241
241
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
242
242
|
|
|
243
243
|
const gw2 = makeGateway(fs, tg);
|
|
244
|
-
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
|
|
244
|
+
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
|
|
245
245
|
});
|
|
246
246
|
});
|
|
@@ -141,7 +141,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
141
141
|
const gw2 = makeGateway(fs, tg);
|
|
142
142
|
const res = await gw2.bootCleanup();
|
|
143
143
|
|
|
144
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
144
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
145
145
|
expect(tg.pinned.has("-100123:715")).toBe(false); // orphan unpinned
|
|
146
146
|
expect(loadStatusPins(PATH, fs)).toEqual([]); // store emptied
|
|
147
147
|
});
|
|
@@ -177,7 +177,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
177
177
|
// Fresh boot recovers it from the pending record.
|
|
178
178
|
const gw2 = makeGateway(fs, tg);
|
|
179
179
|
const res = await gw2.bootCleanup();
|
|
180
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
180
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
181
181
|
expect(tg.pinned.has("-100123:715")).toBe(false);
|
|
182
182
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
183
183
|
});
|
|
@@ -196,7 +196,7 @@ describe("status-pin boot recovery (gateway wiring)", () => {
|
|
|
196
196
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
197
197
|
|
|
198
198
|
const gw2 = makeGateway(fs, tg);
|
|
199
|
-
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, total: 0 });
|
|
199
|
+
expect(await gw2.bootCleanup()).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
BOOT_UNPIN_MAX_ATTEMPTS,
|
|
3
4
|
loadStatusPins,
|
|
4
5
|
mutateStatusPinRow,
|
|
5
6
|
persistStatusPins,
|
|
@@ -195,12 +196,12 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
195
196
|
["-100123", 715],
|
|
196
197
|
["-100999", 42],
|
|
197
198
|
]);
|
|
198
|
-
expect(res).toEqual({ cleared: 2, total: 2 });
|
|
199
|
+
expect(res).toEqual({ cleared: 2, retained: 0, kept: 0, total: 2 });
|
|
199
200
|
// Store empty afterwards → no re-attempt next boot.
|
|
200
201
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
201
202
|
});
|
|
202
203
|
|
|
203
|
-
it("a failing unpin is non-fatal
|
|
204
|
+
it("retry-safe (#3001): a failing unpin is non-fatal, RETAINS the row with an attempt counter, and drops only the succeeded one", async () => {
|
|
204
205
|
const { fs } = memFs();
|
|
205
206
|
persistStatusPins(PATH, fs, [
|
|
206
207
|
pin({ pinKey: "fg:c:1", chatId: "-100123", messageId: 5 }),
|
|
@@ -216,11 +217,66 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
216
217
|
log: () => {},
|
|
217
218
|
});
|
|
218
219
|
|
|
219
|
-
// One failed, one succeeded —
|
|
220
|
-
|
|
220
|
+
// One failed, one succeeded — the failure is retained for a next-boot
|
|
221
|
+
// retry instead of forfeiting the orphan (the pre-#3001 behaviour).
|
|
222
|
+
expect(res).toEqual({ cleared: 1, retained: 1, kept: 0, total: 2 });
|
|
223
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
224
|
+
{ pinKey: "fg:c:1", chatId: "-100123", messageId: 5, attempts: 1 },
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("retry-safe (#3001): a row is forfeited once its attempts reach BOOT_UNPIN_MAX_ATTEMPTS", async () => {
|
|
229
|
+
const { fs } = memFs();
|
|
230
|
+
persistStatusPins(PATH, fs, [
|
|
231
|
+
pin({
|
|
232
|
+
pinKey: "fg:c:1",
|
|
233
|
+
chatId: "-100123",
|
|
234
|
+
messageId: 5,
|
|
235
|
+
attempts: BOOT_UNPIN_MAX_ATTEMPTS - 1,
|
|
236
|
+
}),
|
|
237
|
+
]);
|
|
238
|
+
const res = await runStatusPinBootCleanup({
|
|
239
|
+
path: PATH,
|
|
240
|
+
fs,
|
|
241
|
+
unpin: async () => {
|
|
242
|
+
throw new Error("chat gone forever");
|
|
243
|
+
},
|
|
244
|
+
log: () => {},
|
|
245
|
+
});
|
|
246
|
+
// Final attempt failed too — forfeited, not retained: a permanently-
|
|
247
|
+
// undeliverable unpin must not re-fail on every future boot.
|
|
248
|
+
expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 1 });
|
|
221
249
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
222
250
|
});
|
|
223
251
|
|
|
252
|
+
it("tool pins (#3001): an UNEXPIRED `tool:` row survives the boot untouched; an EXPIRED one is unpinned and dropped", async () => {
|
|
253
|
+
const { fs } = memFs();
|
|
254
|
+
const now = 1_750_000_000_000;
|
|
255
|
+
persistStatusPins(PATH, fs, [
|
|
256
|
+
pin({ pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 }),
|
|
257
|
+
pin({ pinKey: "tool:-100123:71", chatId: "-100123", messageId: 71, expiresAt: now }),
|
|
258
|
+
pin({ pinKey: "wk:agent-x", chatId: "-100123", messageId: 72 }),
|
|
259
|
+
]);
|
|
260
|
+
const unpinned: number[] = [];
|
|
261
|
+
const res = await runStatusPinBootCleanup({
|
|
262
|
+
path: PATH,
|
|
263
|
+
fs,
|
|
264
|
+
unpin: async (_c, messageId) => {
|
|
265
|
+
unpinned.push(messageId);
|
|
266
|
+
},
|
|
267
|
+
now,
|
|
268
|
+
log: () => {},
|
|
269
|
+
});
|
|
270
|
+
// The expired tool pin and the work-scoped wk: pin are unpinned; the
|
|
271
|
+
// unexpired tool pin is kept for a future boot (restart ≠ reset for a
|
|
272
|
+
// deliberate agent pin with no "work finished" event).
|
|
273
|
+
expect(unpinned).toEqual([71, 72]);
|
|
274
|
+
expect(res).toEqual({ cleared: 2, retained: 0, kept: 1, total: 3 });
|
|
275
|
+
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
276
|
+
{ pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 },
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
224
280
|
it("no-op on a fresh boot with no persisted pins (no unpin calls)", async () => {
|
|
225
281
|
const { fs } = memFs();
|
|
226
282
|
let calls = 0;
|
|
@@ -232,7 +288,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
232
288
|
},
|
|
233
289
|
log: () => {},
|
|
234
290
|
});
|
|
235
|
-
expect(res).toEqual({ cleared: 0, total: 0 });
|
|
291
|
+
expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 0 });
|
|
236
292
|
expect(calls).toBe(0);
|
|
237
293
|
});
|
|
238
294
|
|
|
@@ -254,7 +310,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
254
310
|
log: () => {},
|
|
255
311
|
});
|
|
256
312
|
expect(unpinned).toEqual([["-100777", 314]]);
|
|
257
|
-
expect(res).toEqual({ cleared: 1, total: 1 });
|
|
313
|
+
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
258
314
|
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
259
315
|
});
|
|
260
316
|
});
|