switchroom 0.18.7 → 0.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +1403 -657
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/gateway.ts +564 -85
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- 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/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/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -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/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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2995 — mid-flight busy ack: gateway wiring guards.
|
|
3
|
+
*
|
|
4
|
+
* The gateway IIFE is too entangled to instantiate in-process, so these
|
|
5
|
+
* are source-level assertions (the established pattern —
|
|
6
|
+
* multitopic-routing-wiring.test.ts, buffer-gate-broadened.test.ts). They
|
|
7
|
+
* pin the load-bearing wiring: the buffered-inbound and steer call sites,
|
|
8
|
+
* the silent send, the shared queuedStatusMsgIds lifecycle (promote/reap
|
|
9
|
+
* cleanup for free), the per-turn dedupe reset, and the kill switch. The
|
|
10
|
+
* POLICY and the rendered text are behaviourally tested in
|
|
11
|
+
* busy-ack.test.ts; the live end-to-end shape in
|
|
12
|
+
* uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { resolve } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const gatewaySrc = readFileSync(
|
|
20
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
21
|
+
'utf-8',
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
describe('#2995 mid-flight busy ack — gateway wiring', () => {
|
|
25
|
+
it('the buffer-until-idle branch calls maybePostBusyAck for the inbound own chat/topic', () => {
|
|
26
|
+
expect(gatewaySrc).toMatch(
|
|
27
|
+
/maybePostBusyAck\('buffer-until-idle', chat_id, messageThreadId \?\? undefined\)/,
|
|
28
|
+
)
|
|
29
|
+
// …AFTER the pendingInboundBuffer.push (the ack narrates a real queue).
|
|
30
|
+
const branch = gatewaySrc.split("deliveryGate.decision === 'buffer-until-idle'")[1] ?? ''
|
|
31
|
+
const pushIdx = branch.indexOf('pendingInboundBuffer.push(selfAgent, inboundMsg)')
|
|
32
|
+
const ackIdx = branch.indexOf("maybePostBusyAck('buffer-until-idle'")
|
|
33
|
+
expect(pushIdx).toBeGreaterThanOrEqual(0)
|
|
34
|
+
expect(ackIdx).toBeGreaterThan(pushIdx)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('cross-topic queued status and the busy ack are mutually exclusive (no double-card race)', () => {
|
|
38
|
+
// Both helpers record into queuedStatusMsgIds only AFTER their
|
|
39
|
+
// sendMessage awaits resolve, so calling both in the same handler pass
|
|
40
|
+
// would race past each other's has(key) check and double-card the
|
|
41
|
+
// topic. The buffer branch must pick exactly one.
|
|
42
|
+
expect(gatewaySrc).toMatch(
|
|
43
|
+
/if \(crossTopicQueuedCard\) \{\s*postQueuedStatus\(chat_id, messageThreadId, inFlightThread\)\s*\} else \{/,
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('a mid-turn steer gets the steer-worded variant only AFTER successful bridge dispatch', () => {
|
|
48
|
+
// A "Steer noted" card for a send that missed (bridge offline) would
|
|
49
|
+
// be untrue — the ack lives inside the `if (delivered)` branch.
|
|
50
|
+
expect(gatewaySrc).toMatch(
|
|
51
|
+
/if \(delivered\) \{[\s\S]{0,700}if \(isSteering\) \{\s*maybePostBusyAck\('steer', chat_id, messageThreadId \?\? undefined\)/,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('an under-threshold miss arms ONE deferred re-check pinned to the running turn', () => {
|
|
56
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
57
|
+
// Armed only for the young-step miss, for the remaining age gap.
|
|
58
|
+
expect(fn).toMatch(/stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&\s*!busyAckRecheckTimers\.has\(key\)/)
|
|
59
|
+
expect(fn).toMatch(/BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs \+ 250/)
|
|
60
|
+
// The re-fire is guarded on the SAME turn still running.
|
|
61
|
+
expect(fn).toMatch(/currentTurn\?\.turnId !== turnIdAtSchedule\) return/)
|
|
62
|
+
// A posted card cancels the pending re-check.
|
|
63
|
+
expect(fn).toMatch(/clearTimeout\(pendingRecheck\)/)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('the decision is fed from live tool-flight + step-age readings (pure module owns policy)', () => {
|
|
67
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
68
|
+
expect(fn).toMatch(/shouldPostBusyAck\(\{ gateDecision, midToolCall, stepAgeMs, alreadyAcked \}\)/)
|
|
69
|
+
expect(fn).toMatch(/const midToolCall = toolFlightTracker\.isMidToolCall\(\)/)
|
|
70
|
+
expect(fn).toMatch(/silencePoke\.longestInFlightTool\(/)
|
|
71
|
+
expect(fn).toMatch(/const stepAgeMs = step\?\.durationMs \?\? null/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('dedupe: alreadyAcked couples the live card map AND the per-turn key set', () => {
|
|
75
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
76
|
+
expect(fn).toMatch(
|
|
77
|
+
/const alreadyAcked = queuedStatusMsgIds\.has\(key\) \|\| busyAckPostedKeys\.has\(key\)/,
|
|
78
|
+
)
|
|
79
|
+
expect(fn).toMatch(/busyAckPostedKeys\.add\(key\)/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('the card is sent SILENT (disable_notification: true) through the swallowing wrapper', () => {
|
|
83
|
+
const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
84
|
+
expect(fn).toMatch(/swallowingApiCall\(/)
|
|
85
|
+
expect(fn).toMatch(/disable_notification: true/)
|
|
86
|
+
// Thread-optional: a DM send must NOT pass message_thread_id.
|
|
87
|
+
expect(fn).toMatch(/\.\.\.\(threadId != null \? \{ message_thread_id: threadId \} : \{\}\)/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('the card shares queuedStatusMsgIds — promote/reap lifecycle cleans it up', () => {
|
|
91
|
+
const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
92
|
+
// Idempotent against the shared key (never stacks on the cross-topic card).
|
|
93
|
+
expect(fn).toMatch(/if \(queuedStatusMsgIds\.has\(key\)\) return/)
|
|
94
|
+
expect(fn).toMatch(/queuedStatusMsgIds\.set\(key, \{ chatId, threadId: threadId \?\? null, messageId \}\)/)
|
|
95
|
+
// Post-race orphan cleanup, same pattern as postQueuedStatus.
|
|
96
|
+
expect(fn).toMatch(/busy-ack\.post-race-cleanup/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('turn-end cleanup is PER-KEY: dedupe entry deleted and re-check timer cancelled for the ending turn only', () => {
|
|
100
|
+
const purge = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
|
|
101
|
+
// Per-key delete — a purge for topic A must not reset topic B's dedupe.
|
|
102
|
+
expect(purge).toMatch(/busyAckPostedKeys\.delete\(key\)/)
|
|
103
|
+
expect(purge).not.toMatch(/busyAckPostedKeys\.clear\(\)/)
|
|
104
|
+
expect(purge).toMatch(/busyAckRecheckTimers\.delete\(key\)/)
|
|
105
|
+
expect(purge).toMatch(/clearTimeout\(busyAckRecheck\)/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('DM cards are promoted too (promoteQueuedStatus no longer early-returns on a null thread)', () => {
|
|
109
|
+
const fn = gatewaySrc.split('function promoteQueuedStatus')[1]?.split('\nfunction ')[0] ?? ''
|
|
110
|
+
expect(fn).not.toMatch(/if \(thread == null\) return/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('kill switch defaults ON, independently disableable', () => {
|
|
114
|
+
expect(gatewaySrc).toMatch(/SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'/)
|
|
115
|
+
const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
|
|
116
|
+
expect(fn).toMatch(/if \(!MIDFLIGHT_BUSY_ACK_ENABLED\) return/)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2995 — mid-flight busy ack: pure decision module.
|
|
3
|
+
*
|
|
4
|
+
* Pins the deterministic policy (`shouldPostBusyAck`) and the rendered
|
|
5
|
+
* card text (`formatBusyAckText`) — the model-free ack a buffered/steered
|
|
6
|
+
* mid-turn inbound gets while the running turn sits inside one long tool
|
|
7
|
+
* step. Behavioral assertions on the RENDERED text (wording contract:
|
|
8
|
+
* "Queued" for the buffered path per the steer-or-queue classification-
|
|
9
|
+
* visibility invariant; never "Queued" for a steer).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
BUSY_ACK_STEP_AGE_THRESHOLD_MS,
|
|
15
|
+
shouldPostBusyAck,
|
|
16
|
+
formatBusyAckText,
|
|
17
|
+
} from '../gateway/busy-ack.js'
|
|
18
|
+
|
|
19
|
+
const base = {
|
|
20
|
+
gateDecision: 'buffer-until-idle' as const,
|
|
21
|
+
midToolCall: true,
|
|
22
|
+
stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS + 1,
|
|
23
|
+
alreadyAcked: false,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('shouldPostBusyAck', () => {
|
|
27
|
+
it('fires for a buffered inbound behind an old tool step', () => {
|
|
28
|
+
expect(shouldPostBusyAck(base)).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('fires for a steer behind an old tool step', () => {
|
|
32
|
+
expect(shouldPostBusyAck({ ...base, gateDecision: 'steer' })).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('never fires for a plain fresh-turn deliver', () => {
|
|
36
|
+
expect(shouldPostBusyAck({ ...base, gateDecision: 'deliver' })).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('never fires when not mid-tool-call (turn thinking between tools)', () => {
|
|
40
|
+
expect(shouldPostBusyAck({ ...base, midToolCall: false })).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('never fires below the step-age threshold (agent seconds from answering)', () => {
|
|
44
|
+
expect(
|
|
45
|
+
shouldPostBusyAck({ ...base, stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS - 1 }),
|
|
46
|
+
).toBe(false)
|
|
47
|
+
// exactly at the threshold → fires (>= semantics)
|
|
48
|
+
expect(
|
|
49
|
+
shouldPostBusyAck({ ...base, stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS }),
|
|
50
|
+
).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('never fires with no tracked step age', () => {
|
|
54
|
+
expect(shouldPostBusyAck({ ...base, stepAgeMs: null })).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('fires at most once per key — a second ping gets no second card', () => {
|
|
58
|
+
// The gateway feeds `alreadyAcked` from its per-turn key set + the live
|
|
59
|
+
// card map; the policy must refuse when either says "already acked".
|
|
60
|
+
expect(shouldPostBusyAck({ ...base, alreadyAcked: true })).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('threshold constant sits between the fast-tool envelope and the ignored horizon', () => {
|
|
64
|
+
expect(BUSY_ACK_STEP_AGE_THRESHOLD_MS).toBeGreaterThanOrEqual(5_000)
|
|
65
|
+
expect(BUSY_ACK_STEP_AGE_THRESHOLD_MS).toBeLessThanOrEqual(30_000)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('formatBusyAckText — rendered card text', () => {
|
|
70
|
+
it('buffered path says "Queued", names the blocking activity, and promises the answer', () => {
|
|
71
|
+
const text = formatBusyAckText({
|
|
72
|
+
gateDecision: 'buffer-until-idle',
|
|
73
|
+
toolName: 'Bash',
|
|
74
|
+
toolLabel: 'gh pr checks --watch',
|
|
75
|
+
})
|
|
76
|
+
expect(text).toContain('Queued')
|
|
77
|
+
expect(text).toContain('`Bash: gh pr checks --watch`')
|
|
78
|
+
expect(text).toMatch(/I'll answer when this step finishes/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('carries no point-in-time elapsed figure (a static card must not decay)', () => {
|
|
82
|
+
// The card is posted once and never re-rendered while the blocking
|
|
83
|
+
// step runs — any "(Nm elapsed)" would silently go stale on screen.
|
|
84
|
+
const text = formatBusyAckText({
|
|
85
|
+
gateDecision: 'buffer-until-idle',
|
|
86
|
+
toolName: 'Bash',
|
|
87
|
+
toolLabel: 'sleep 90',
|
|
88
|
+
})
|
|
89
|
+
expect(text).not.toMatch(/elapsed|\b\d+[sm]\b/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('steer path never says "Queued" (classification-visibility invariant)', () => {
|
|
93
|
+
const text = formatBusyAckText({
|
|
94
|
+
gateDecision: 'steer',
|
|
95
|
+
toolName: 'Bash',
|
|
96
|
+
toolLabel: 'sleep 90',
|
|
97
|
+
})
|
|
98
|
+
expect(text).not.toContain('Queued')
|
|
99
|
+
expect(text).toContain('Steer noted')
|
|
100
|
+
expect(text).toContain('`Bash: sleep 90`')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('degrades honestly when the tool label is unknown', () => {
|
|
104
|
+
const text = formatBusyAckText({
|
|
105
|
+
gateDecision: 'buffer-until-idle',
|
|
106
|
+
toolName: null,
|
|
107
|
+
toolLabel: null,
|
|
108
|
+
})
|
|
109
|
+
expect(text).toContain('Queued')
|
|
110
|
+
expect(text).toContain('a long-running step')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('uses the bare tool name when no label was derived', () => {
|
|
114
|
+
const text = formatBusyAckText({
|
|
115
|
+
gateDecision: 'buffer-until-idle',
|
|
116
|
+
toolName: 'Bash',
|
|
117
|
+
toolLabel: null,
|
|
118
|
+
})
|
|
119
|
+
expect(text).toContain('`Bash`')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
computeFloodWait,
|
|
7
|
+
floodWaitRemainingMs,
|
|
8
|
+
isFloodWaitActive,
|
|
9
|
+
readFloodState,
|
|
10
|
+
writeFloodState,
|
|
11
|
+
makeFloodWaitRecorder,
|
|
12
|
+
suppressNonEssentialSendMs,
|
|
13
|
+
floodStatePath,
|
|
14
|
+
type FloodWaitState,
|
|
15
|
+
} from '../flood-circuit-breaker.js'
|
|
16
|
+
|
|
17
|
+
describe('#2923 flood circuit-breaker', () => {
|
|
18
|
+
let dir: string
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
dir = mkdtempSync(join(tmpdir(), 'flood-cb-'))
|
|
21
|
+
})
|
|
22
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
23
|
+
|
|
24
|
+
it('records a flood-wait window from retry_after', () => {
|
|
25
|
+
const now = 1_000_000
|
|
26
|
+
const s = computeFloodWait(null, 4116, now)
|
|
27
|
+
expect(s.untilTs).toBe(now + 4116_000)
|
|
28
|
+
expect(s.retryAfterSec).toBe(4116)
|
|
29
|
+
expect(isFloodWaitActive(s, now)).toBe(true)
|
|
30
|
+
expect(isFloodWaitActive(s, s.untilTs + 1)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('EXTENDS (never shrinks) an existing window on a fresh, shorter 429', () => {
|
|
34
|
+
const now = 1_000_000
|
|
35
|
+
const long = computeFloodWait(null, 4000, now)
|
|
36
|
+
// A later 429 reporting a shorter ban must not pull the expiry earlier.
|
|
37
|
+
const shorter = computeFloodWait(long, 10, now + 1000)
|
|
38
|
+
expect(shorter.untilTs).toBe(long.untilTs)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('floodWaitRemainingMs is 0 for no state / expired state', () => {
|
|
42
|
+
expect(floodWaitRemainingMs(null, 5)).toBe(0)
|
|
43
|
+
const expired: FloodWaitState = { untilTs: 100, retryAfterSec: 1, recordedTs: 0 }
|
|
44
|
+
expect(floodWaitRemainingMs(expired, 200)).toBe(0)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('persists + reads back the window; corrupt marker → null', () => {
|
|
48
|
+
const p = floodStatePath(dir)
|
|
49
|
+
expect(readFloodState(p)).toBeNull()
|
|
50
|
+
const s = computeFloodWait(null, 60, 1000)
|
|
51
|
+
writeFloodState(p, s)
|
|
52
|
+
expect(readFloodState(p)).toEqual(s)
|
|
53
|
+
writeFileSync(p, '{bad')
|
|
54
|
+
expect(readFloodState(p)).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('makeFloodWaitRecorder persists the window (the onFloodWait hook)', () => {
|
|
58
|
+
const p = floodStatePath(dir)
|
|
59
|
+
const record = makeFloodWaitRecorder(p, () => 500_000)
|
|
60
|
+
record(4116)
|
|
61
|
+
const s = readFloodState(p)
|
|
62
|
+
expect(s).not.toBeNull()
|
|
63
|
+
expect(s!.untilTs).toBe(500_000 + 4116_000)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('suppressNonEssentialSendMs > 0 while a ban is open, 0 after it lifts', () => {
|
|
67
|
+
const p = floodStatePath(dir)
|
|
68
|
+
writeFloodState(p, computeFloodWait(null, 68 * 60, 1_000_000))
|
|
69
|
+
// A restart mid-ban: the boot card must be suppressed.
|
|
70
|
+
expect(suppressNonEssentialSendMs(p, 1_000_000)).toBeGreaterThan(0)
|
|
71
|
+
// After the window: send proceeds.
|
|
72
|
+
expect(suppressNonEssentialSendMs(p, 1_000_000 + 68 * 60_000 + 1)).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structural pins for the session-
|
|
2
|
+
* Structural pins for the session-model stickiness wiring in gateway.ts
|
|
3
|
+
* (reference/rfcs/session-model-stickiness.md).
|
|
3
4
|
*
|
|
4
5
|
* The behaviour lives in un-exported inline closures (buildModelDeps's
|
|
5
|
-
* scheduleModelRelaunch, the
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
* tests/scaffold.session-model
|
|
10
|
-
*
|
|
6
|
+
* scheduleModelRelaunch/scheduleRestart, triggerSelfRestart, the /restart and
|
|
7
|
+
* /new handlers, the model-menu callback branches, and the boot re-hydration
|
|
8
|
+
* block inside the startup IIFE), so — mirroring the other gateway-*.test.ts
|
|
9
|
+
* source-pins — we assert on the source structure. The end-to-end behaviour
|
|
10
|
+
* of the boot resolver is exercised in tests/scaffold.session-model.test.ts
|
|
11
|
+
* (rendered start.sh), the file helpers in session-model-file.test.ts, and
|
|
12
|
+
* the handler contract in model-command.test.ts.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { describe, it, expect } from 'vitest'
|
|
@@ -18,54 +20,199 @@ import { dirname, resolve } from 'node:path'
|
|
|
18
20
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
21
|
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
20
22
|
|
|
21
|
-
describe('gateway:
|
|
22
|
-
it('
|
|
23
|
+
describe('gateway: triggerSelfRestart stamps relaunch-model intent BEFORE the kill', () => {
|
|
24
|
+
it('docker branch: writeRelaunchModelIntent(intentForRestartReason(reason)) precedes the SIGTERM scheduling', () => {
|
|
25
|
+
const fnIdx = GATEWAY_SRC.indexOf('function triggerSelfRestart(')
|
|
26
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
27
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
|
|
28
|
+
const writeIdx = win.indexOf('writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)')
|
|
29
|
+
const killIdx = win.indexOf("process.kill(1, 'SIGTERM')")
|
|
30
|
+
// Write-before-kill invariant: boot default is REVERT, so the intent must
|
|
31
|
+
// be on disk synchronously before the SIGTERM is even scheduled.
|
|
32
|
+
expect(writeIdx).toBeGreaterThan(0)
|
|
33
|
+
expect(killIdx).toBeGreaterThan(writeIdx)
|
|
34
|
+
// And before the setTimeout that schedules it.
|
|
35
|
+
const timeoutIdx = win.indexOf('setTimeout(')
|
|
36
|
+
expect(timeoutIdx).toBeGreaterThan(writeIdx)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('legacy systemd branch stamps intent too (self-target only)', () => {
|
|
40
|
+
const fnIdx = GATEWAY_SRC.indexOf('function triggerSelfRestart(')
|
|
41
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 4200)
|
|
42
|
+
const legacyIdx = win.indexOf('// Legacy systemd path.')
|
|
43
|
+
expect(legacyIdx).toBeGreaterThan(0)
|
|
44
|
+
const legacyWin = win.slice(legacyIdx)
|
|
45
|
+
expect(legacyWin).toContain('writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)')
|
|
46
|
+
expect(legacyWin.indexOf('writeRelaunchModelIntent')).toBeLessThan(legacyWin.indexOf('spawn('))
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
|
|
51
|
+
it('writes the durable file via writeSessionModelFile before dispatching the restart', () => {
|
|
23
52
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
24
53
|
expect(idx).toBeGreaterThan(0)
|
|
25
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
26
|
-
|
|
54
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
55
|
+
const writeIdx = win.indexOf('writeSessionModelFile(')
|
|
56
|
+
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
57
|
+
expect(writeIdx).toBeGreaterThan(0)
|
|
58
|
+
expect(restartIdx).toBeGreaterThan(writeIdx)
|
|
27
59
|
})
|
|
28
60
|
|
|
29
61
|
it('sets the in-memory session-model override before dispatching the restart', () => {
|
|
30
62
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
31
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
32
|
-
// The override now lives on the freshness-aware sessionModelSource
|
|
33
|
-
// (session-model-source.ts) rather than a bare module-level variable.
|
|
63
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
34
64
|
const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
|
|
35
65
|
const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
|
|
36
66
|
expect(setIdx).toBeGreaterThan(0)
|
|
37
67
|
expect(restartIdx).toBeGreaterThan(setIdx)
|
|
38
68
|
})
|
|
39
69
|
|
|
70
|
+
it('rolls back the prior file content (not just deletion) on a non-in-flight dispatch failure', () => {
|
|
71
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
72
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
73
|
+
expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
|
|
74
|
+
expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
|
|
75
|
+
expect(win).toContain("!== 'restart_in_flight'")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('the non-in-flight rollback ALSO clears the keep-intent (a live intent with no restart coming would wrongly KEEP across a crash)', () => {
|
|
79
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
80
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
81
|
+
const inFlightIdx = win.indexOf("!== 'restart_in_flight'")
|
|
82
|
+
const clearIdx = win.indexOf('clearRelaunchModelIntent(agentDir)')
|
|
83
|
+
expect(inFlightIdx).toBeGreaterThan(0)
|
|
84
|
+
expect(clearIdx).toBeGreaterThan(inFlightIdx)
|
|
85
|
+
})
|
|
86
|
+
|
|
40
87
|
it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
|
|
41
88
|
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
|
|
42
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
89
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
43
90
|
expect(win).toContain('await deps.scheduleRestart(reason)')
|
|
44
91
|
})
|
|
45
92
|
})
|
|
46
93
|
|
|
47
|
-
describe('gateway:
|
|
94
|
+
describe('gateway: intent writers on the restart verbs', () => {
|
|
95
|
+
it('model-switch scheduleRestart stamps keep-intent before the hostd dispatch', () => {
|
|
96
|
+
const idx = GATEWAY_SRC.indexOf('scheduleRestart: async (reason: string)')
|
|
97
|
+
expect(idx).toBeGreaterThan(0)
|
|
98
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3200)
|
|
99
|
+
const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', reason)")
|
|
100
|
+
const dispatchIdx = win.indexOf("op: 'agent_restart'")
|
|
101
|
+
expect(keepIdx).toBeGreaterThan(0)
|
|
102
|
+
expect(dispatchIdx).toBeGreaterThan(keepIdx)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('scheduleRestart clears the keep-intent when hostd refuses (failed dispatch → no live intent on disk)', () => {
|
|
106
|
+
const idx = GATEWAY_SRC.indexOf('scheduleRestart: async (reason: string)')
|
|
107
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3200)
|
|
108
|
+
const failIdx = win.indexOf('hostd restart failed')
|
|
109
|
+
const clearIdx = win.indexOf('clearRelaunchModelIntent(smDir)')
|
|
110
|
+
expect(clearIdx).toBeGreaterThan(0)
|
|
111
|
+
expect(failIdx).toBeGreaterThan(clearIdx) // cleared before the throw's message
|
|
112
|
+
// And it sits in the same error branch as clearRestartMarker.
|
|
113
|
+
const markerIdx = win.indexOf('clearRestartMarker()')
|
|
114
|
+
expect(clearIdx).toBeGreaterThan(markerIdx)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('/restart stamps an explicit revert intent (reason honesty) before dispatch', () => {
|
|
118
|
+
const idx = GATEWAY_SRC.indexOf("stampUserRestartReason('user: /restart from chat')")
|
|
119
|
+
expect(idx).toBeGreaterThan(0)
|
|
120
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
121
|
+
const revertIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')")
|
|
122
|
+
const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
|
|
123
|
+
expect(revertIdx).toBeGreaterThan(0)
|
|
124
|
+
expect(dispatchIdx).toBeGreaterThan(revertIdx)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('/new and /reset stamp keep-intent (fresh conversation, same model — contract row 7)', () => {
|
|
128
|
+
const idx = GATEWAY_SRC.indexOf('stampUserRestartReason(`user: /${kind} from chat`)')
|
|
129
|
+
expect(idx).toBeGreaterThan(0)
|
|
130
|
+
const win = GATEWAY_SRC.slice(idx, idx + 700)
|
|
131
|
+
const keepIdx = win.indexOf("writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)")
|
|
132
|
+
const dispatchIdx = win.indexOf('tryHostdDispatch')
|
|
133
|
+
expect(keepIdx).toBeGreaterThan(0)
|
|
134
|
+
expect(dispatchIdx).toBeGreaterThan(keepIdx)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('gateway: model-menu callback persists the sticky override', () => {
|
|
139
|
+
it('a confirmed selection persists the canonical token (selectedModelToken), never the display label', () => {
|
|
140
|
+
const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
|
|
141
|
+
expect(idx).toBeGreaterThan(0)
|
|
142
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1600)
|
|
143
|
+
expect(win).toContain('outcome.selectedModelToken')
|
|
144
|
+
expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a confirmed "Default" selection CLEARS the sticky file', () => {
|
|
148
|
+
const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
|
|
149
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1800)
|
|
150
|
+
expect(win).toContain('outcome.clearedDefault')
|
|
151
|
+
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
152
|
+
})
|
|
153
|
+
|
|
48
154
|
it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
|
|
49
155
|
const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
|
|
50
156
|
expect(idx).toBeGreaterThan(0)
|
|
51
157
|
const win = GATEWAY_SRC.slice(idx, idx + 1400)
|
|
52
158
|
expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
|
|
53
|
-
// It must return before falling through to handleModelMenuCallback (which
|
|
54
|
-
// would inject an sr-* id claude's picker rejects).
|
|
55
159
|
expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
|
|
56
160
|
})
|
|
161
|
+
|
|
162
|
+
it('the sr-to-claude transition writes the durable file (and clears it on a Default tap)', () => {
|
|
163
|
+
const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
|
|
164
|
+
expect(idx).toBeGreaterThan(0)
|
|
165
|
+
const win = GATEWAY_SRC.slice(idx, idx + 3600)
|
|
166
|
+
expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
|
|
167
|
+
expect(win).toContain('clearSessionModelFile(agentDir)')
|
|
168
|
+
// Restart rides triggerSelfRestart with a keep-classified reason.
|
|
169
|
+
expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
|
|
170
|
+
})
|
|
57
171
|
})
|
|
58
172
|
|
|
59
|
-
describe('gateway
|
|
60
|
-
it('
|
|
173
|
+
describe('gateway: typed /model persists the REQUESTED canonical token', () => {
|
|
174
|
+
it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
|
|
175
|
+
const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
|
|
176
|
+
expect(idx).toBeGreaterThan(0)
|
|
177
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
178
|
+
expect(win).toContain("requested?.toLowerCase() === 'default'")
|
|
179
|
+
expect(win).toContain('sessionModelSource.setOverride(null)')
|
|
180
|
+
expect(win).toContain('clearSessionModelFile(smDir)')
|
|
181
|
+
// Non-default: the requested token (shape-gated, non-sr) is what persists.
|
|
182
|
+
expect(win).toContain('isValidModelArg(requested) && !isSrModel(requested)')
|
|
183
|
+
expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*requested/)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
|
|
187
|
+
const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
|
|
188
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2400)
|
|
189
|
+
// The default branch clears the file unconditionally, and only the
|
|
190
|
+
// in-memory override change is confirmation-gated inside it.
|
|
191
|
+
const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
|
|
192
|
+
const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
|
|
193
|
+
expect(clearIdx).toBeGreaterThan(0)
|
|
194
|
+
expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
|
|
195
|
+
// And the whole default branch is not nested in an `if (reply.selectedModel)` block:
|
|
196
|
+
const between = win.slice(0, clearIdx)
|
|
197
|
+
expect(between).not.toContain('if (reply.selectedModel) {')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('a persist failure is surfaced ON THE REPLY, not just stderr (typed + menu paths)', () => {
|
|
201
|
+
expect(GATEWAY_SRC.match(/won’t survive a relaunch/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
|
|
202
|
+
const typedIdx = GATEWAY_SRC.indexOf('persistWarning =')
|
|
203
|
+
expect(typedIdx).toBeGreaterThan(0)
|
|
204
|
+
expect(GATEWAY_SRC).toContain('reply.text + persistWarning')
|
|
205
|
+
// Menu path appends onto the outgoing card text.
|
|
206
|
+
expect(GATEWAY_SRC).toContain('outcome.reply.text +=')
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('gateway boot: session-model re-hydration + alert relay', () => {
|
|
211
|
+
it('re-hydrates the override from .active-session-model', () => {
|
|
61
212
|
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
|
|
62
213
|
expect(idx).toBeGreaterThan(0)
|
|
63
214
|
const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
|
|
64
|
-
// Only an override when the launched model differs from the configured one.
|
|
65
215
|
expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
|
|
66
|
-
// The configured value must be resolved through resolveMainModel (the SAME
|
|
67
|
-
// resolver start.sh's scaffold uses) so an unset/`default` model config does
|
|
68
|
-
// not get flagged as a phantom session override on an ordinary restart.
|
|
69
216
|
expect(win).toContain('resolveMainModel(raw ?? undefined)')
|
|
70
217
|
})
|
|
71
218
|
|
|
@@ -74,10 +221,15 @@ describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () =>
|
|
|
74
221
|
expect(idx).toBeGreaterThan(0)
|
|
75
222
|
const win = GATEWAY_SRC.slice(idx, idx + 1100)
|
|
76
223
|
expect(win).toContain('unlinkSync(alertPath)')
|
|
77
|
-
// Broadcasts to every operator in allowFrom, not just allowFrom[0].
|
|
78
224
|
expect(win).toContain('const operators = loadAccess().allowFrom')
|
|
79
225
|
expect(win).toContain('for (const operator of operators)')
|
|
80
226
|
expect(win).toContain('lockedBot.api')
|
|
81
227
|
expect(win).toContain('.sendMessage(operator')
|
|
82
228
|
})
|
|
83
229
|
})
|
|
230
|
+
|
|
231
|
+
describe('gateway: the legacy one-shot carrier is no longer written', () => {
|
|
232
|
+
it('no gateway code writes .session-model-override anymore (start.sh migration shim only reads it)', () => {
|
|
233
|
+
expect(GATEWAY_SRC).not.toMatch(/writeFileSync\([^)]*\.session-model-override/)
|
|
234
|
+
})
|
|
235
|
+
})
|