switchroom 0.18.8 → 0.18.10
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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +2051 -3180
- 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/model-command.ts +94 -8
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- 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-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- 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-session-command.test.ts +322 -0
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -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/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -19,9 +19,16 @@ import {
|
|
|
19
19
|
clearRelaunchModelIntent,
|
|
20
20
|
readConfiguredDefaultModel,
|
|
21
21
|
intentForRestartReason,
|
|
22
|
+
readRelaunchModelIntent,
|
|
23
|
+
clearStaleGatewayShutdownIntent,
|
|
24
|
+
GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX,
|
|
22
25
|
SESSION_MODEL_FILE,
|
|
23
26
|
RELAUNCH_MODEL_INTENT_FILE,
|
|
24
27
|
CONFIGURED_DEFAULT_MODEL_FILE,
|
|
28
|
+
parseSessionEffort,
|
|
29
|
+
writeSessionEffortFile,
|
|
30
|
+
readSessionEffortFile,
|
|
31
|
+
clearSessionEffortFile,
|
|
25
32
|
} from '../gateway/session-model-file.js'
|
|
26
33
|
|
|
27
34
|
let dir: string
|
|
@@ -108,8 +115,8 @@ describe('intentForRestartReason — the triggerSelfRestart per-reason table (RF
|
|
|
108
115
|
expect(intentForRestartReason(reason)).toBe('keep')
|
|
109
116
|
})
|
|
110
117
|
|
|
111
|
-
it('inline-button-restart (
|
|
112
|
-
expect(intentForRestartReason('inline-button-restart')).toBe('
|
|
118
|
+
it('inline-button-restart → keep (#3039: a restart is not "clear my model")', () => {
|
|
119
|
+
expect(intentForRestartReason('inline-button-restart')).toBe('keep')
|
|
113
120
|
})
|
|
114
121
|
|
|
115
122
|
it('unknown gateway reasons default to keep (only gateway code calls triggerSelfRestart; crashes never do)', () => {
|
|
@@ -117,6 +124,47 @@ describe('intentForRestartReason — the triggerSelfRestart per-reason table (RF
|
|
|
117
124
|
})
|
|
118
125
|
})
|
|
119
126
|
|
|
127
|
+
describe('readRelaunchModelIntent', () => {
|
|
128
|
+
it('round-trips a stamped intent; null when absent / corrupt / malformed', () => {
|
|
129
|
+
expect(readRelaunchModelIntent(dir)).toBeNull()
|
|
130
|
+
writeRelaunchModelIntent(dir, 'keep', 'watchdog recovery')
|
|
131
|
+
const rec = readRelaunchModelIntent(dir)!
|
|
132
|
+
expect(rec.intent).toBe('keep')
|
|
133
|
+
expect(rec.reason).toBe('watchdog recovery')
|
|
134
|
+
expect(Math.abs(Date.now() - rec.ts)).toBeLessThan(5000)
|
|
135
|
+
writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{broken')
|
|
136
|
+
expect(readRelaunchModelIntent(dir)).toBeNull()
|
|
137
|
+
writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), '{"intent":"maybe","reason":"x","ts":1}\n')
|
|
138
|
+
expect(readRelaunchModelIntent(dir)).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('clearStaleGatewayShutdownIntent (#3018 finding 4 — a gateway-only bounce must not leave a keep stamp)', () => {
|
|
143
|
+
it('clears ONLY a gateway-shutdown-stamped intent and reports it', () => {
|
|
144
|
+
writeRelaunchModelIntent(
|
|
145
|
+
dir,
|
|
146
|
+
'keep',
|
|
147
|
+
`${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful SIGTERM shutdown (deploy/rolling restart) — preserving user-chosen session model`,
|
|
148
|
+
)
|
|
149
|
+
expect(clearStaleGatewayShutdownIntent(dir)).toBe(true)
|
|
150
|
+
expect(existsSync(join(dir, RELAUNCH_MODEL_INTENT_FILE))).toBe(false)
|
|
151
|
+
// Idempotent: a second call finds nothing.
|
|
152
|
+
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('never touches a triggerSelfRestart / user-slash stamp (un-prefixed reason)', () => {
|
|
156
|
+
writeRelaunchModelIntent(dir, 'keep', 'sr-to-claude-model-switch')
|
|
157
|
+
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
158
|
+
expect(readRelaunchModelIntent(dir)!.reason).toBe('sr-to-claude-model-switch')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('is a safe no-op on an absent or corrupt intent file', () => {
|
|
162
|
+
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
163
|
+
writeFileSync(join(dir, RELAUNCH_MODEL_INTENT_FILE), 'not json')
|
|
164
|
+
expect(clearStaleGatewayShutdownIntent(dir)).toBe(false)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
120
168
|
describe('readConfiguredDefaultModel', () => {
|
|
121
169
|
it('reads the trimmed value; null when absent or empty', () => {
|
|
122
170
|
expect(readConfiguredDefaultModel(dir)).toBeNull()
|
|
@@ -130,3 +178,54 @@ describe('readConfiguredDefaultModel', () => {
|
|
|
130
178
|
expect(() => clearSessionModelFile(dir)).not.toThrow()
|
|
131
179
|
})
|
|
132
180
|
})
|
|
181
|
+
|
|
182
|
+
// ─── #3039: durable session-effort carrier ───────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('session-effort file helpers (#3039)', () => {
|
|
185
|
+
let dir: string
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
dir = mkdtempSync(join(tmpdir(), 'sr-session-effort-'))
|
|
188
|
+
})
|
|
189
|
+
afterEach(() => {
|
|
190
|
+
rmSync(dir, { recursive: true, force: true })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('round-trips a valid level with the configured default at write', () => {
|
|
194
|
+
writeSessionEffortFile(dir, 'xhigh', 'low')
|
|
195
|
+
const rec = readSessionEffortFile(dir)
|
|
196
|
+
expect(rec?.level).toBe('xhigh')
|
|
197
|
+
expect(rec?.configuredDefaultAtWrite).toBe('low')
|
|
198
|
+
expect(typeof rec?.ts).toBe('number')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('refuses to persist a non-allowlisted level (verbatim --effort surface)', () => {
|
|
202
|
+
expect(() => writeSessionEffortFile(dir, 'mega; rm -rf /', 'low')).toThrow(/non-allowlisted/)
|
|
203
|
+
expect(readSessionEffortFile(dir)).toBeNull()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('parseSessionEffort rejects corrupt JSON, bad shape, and bad levels', () => {
|
|
207
|
+
expect(parseSessionEffort('{broken')).toBeNull()
|
|
208
|
+
expect(parseSessionEffort('{"level":"turbo","configuredDefaultAtWrite":"low","ts":1}')).toBeNull()
|
|
209
|
+
expect(parseSessionEffort('{"level":"high","ts":1}')).toBeNull()
|
|
210
|
+
expect(parseSessionEffort('{"level":"high","configuredDefaultAtWrite":"low","ts":1}')?.level).toBe('high')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('clearSessionEffortFile removes it; read after clear is null', () => {
|
|
214
|
+
writeSessionEffortFile(dir, 'high', '')
|
|
215
|
+
clearSessionEffortFile(dir)
|
|
216
|
+
expect(readSessionEffortFile(dir)).toBeNull()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('intentForRestartReason is keep-for-everything (#3039)', () => {
|
|
221
|
+
it('every reason keeps — restarts never clear a user model choice', () => {
|
|
222
|
+
for (const reason of [
|
|
223
|
+
'inline-button-restart',
|
|
224
|
+
'user: /restart from chat',
|
|
225
|
+
'schedule-restart-immediate',
|
|
226
|
+
'anything-else',
|
|
227
|
+
]) {
|
|
228
|
+
expect(intentForRestartReason(reason)).toBe('keep')
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3020 — Telegram `/stop` command + bare operator "stop" keyword.
|
|
3
|
+
*
|
|
4
|
+
* Pure-logic coverage for telegram-plugin/gateway/stop-command.ts (parser +
|
|
5
|
+
* reply builder), plus structural pins on gateway.ts wiring in the style of
|
|
6
|
+
* gateway-pending-command-wiring.test.ts — the halt sequence lives in
|
|
7
|
+
* un-exported inline closures, so we assert on the source structure.
|
|
8
|
+
* Also pins the /stop → /agentstop rename in the command catalogue.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
14
|
+
import { dirname, resolve } from 'node:path'
|
|
15
|
+
import { parseStopKeyword, buildStopReply } from '../gateway/stop-command.js'
|
|
16
|
+
import { switchroomHelpCommandNames, switchroomHelpText } from '../welcome-text.js'
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
20
|
+
|
|
21
|
+
describe('parseStopKeyword', () => {
|
|
22
|
+
it('matches the exact word stop, case-insensitively', () => {
|
|
23
|
+
for (const t of ['stop', 'Stop', 'STOP', 'sToP']) {
|
|
24
|
+
expect(parseStopKeyword(t), t).toBe(true)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
it('tolerates surrounding whitespace and a single trailing . or !', () => {
|
|
28
|
+
for (const t of [' stop ', 'stop.', 'stop!', ' STOP! ', '\nstop\n']) {
|
|
29
|
+
expect(parseStopKeyword(t), JSON.stringify(t)).toBe(true)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
it('does NOT match stop inside normal speech', () => {
|
|
33
|
+
for (const t of [
|
|
34
|
+
'stop the build',
|
|
35
|
+
'please stop',
|
|
36
|
+
'stop it',
|
|
37
|
+
'stopping',
|
|
38
|
+
'nonstop',
|
|
39
|
+
'stop?',
|
|
40
|
+
'stop!!',
|
|
41
|
+
'stop..',
|
|
42
|
+
'/stop', // the slash command routes via bot.command, not the keyword
|
|
43
|
+
'! stop',
|
|
44
|
+
'stop now',
|
|
45
|
+
'',
|
|
46
|
+
' ',
|
|
47
|
+
]) {
|
|
48
|
+
expect(parseStopKeyword(t), JSON.stringify(t)).toBe(false)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('buildStopReply', () => {
|
|
54
|
+
it('is honest when nothing is running', () => {
|
|
55
|
+
const r = buildStopReply(false, [])
|
|
56
|
+
expect(r.text).toContain('Nothing running')
|
|
57
|
+
expect(r.text).not.toContain('cancelled')
|
|
58
|
+
})
|
|
59
|
+
it('confirms the cancel when a turn was in flight', () => {
|
|
60
|
+
const r = buildStopReply(true, [])
|
|
61
|
+
expect(r.text).toContain('Turn cancelled')
|
|
62
|
+
expect(r.text).not.toContain('Queued')
|
|
63
|
+
})
|
|
64
|
+
it('mentions surviving queued session commands (singular)', () => {
|
|
65
|
+
const r = buildStopReply(true, ['/model fable'])
|
|
66
|
+
expect(r.text).toContain('Turn cancelled')
|
|
67
|
+
expect(r.text).toContain('/model fable')
|
|
68
|
+
expect(r.text).toContain('survives')
|
|
69
|
+
expect(r.text).toContain('idle')
|
|
70
|
+
})
|
|
71
|
+
it('mentions surviving queued session commands (plural)', () => {
|
|
72
|
+
const r = buildStopReply(true, ['/model fable', '/effort high'])
|
|
73
|
+
expect(r.text).toContain('/model fable')
|
|
74
|
+
expect(r.text).toContain('/effort high')
|
|
75
|
+
expect(r.text).toContain('Queued commands')
|
|
76
|
+
expect(r.text).toContain('survive')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('gateway wiring — /stop cancels the in-flight turn (#3020)', () => {
|
|
81
|
+
it('registers /stop gated on isAuthorizedSender, honest-idle via turnInFlightForGate, halting via executeHaltNow', () => {
|
|
82
|
+
const idx = GATEWAY_SRC.indexOf("bot.command('stop', async ctx => {")
|
|
83
|
+
expect(idx).toBeGreaterThan(0)
|
|
84
|
+
const win = GATEWAY_SRC.slice(idx, idx + 900)
|
|
85
|
+
expect(win).toContain('if (!isAuthorizedSender(ctx)) return')
|
|
86
|
+
expect(win).toContain('turnInFlightForGate()')
|
|
87
|
+
expect(win).toContain('buildStopReply(false')
|
|
88
|
+
expect(win).toContain("executeHaltNow('stop-command')")
|
|
89
|
+
expect(win).toContain('buildStopReply(true')
|
|
90
|
+
// Queued /model + /effort survive the halt and are named in the reply.
|
|
91
|
+
expect(win).toContain('pendingSessionCommand.list()')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('container stop moved to /agentstop and still dispatches hostd agent_stop', () => {
|
|
95
|
+
const idx = GATEWAY_SRC.indexOf("bot.command('agentstop', async ctx => {")
|
|
96
|
+
expect(idx).toBeGreaterThan(0)
|
|
97
|
+
const win = GATEWAY_SRC.slice(idx, idx + 700)
|
|
98
|
+
expect(win).toContain("op: 'agent_stop'")
|
|
99
|
+
// The new /stop must NOT dispatch a container stop.
|
|
100
|
+
const stopIdx = GATEWAY_SRC.indexOf("bot.command('stop', async ctx => {")
|
|
101
|
+
expect(GATEWAY_SRC.slice(stopIdx, stopIdx + 900)).not.toContain("op: 'agent_stop'")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('executeHaltNow honors the safe-boundary deferral then fires tmux C-c, cancels the obligation, and releases busy state', () => {
|
|
105
|
+
const fnIdx = GATEWAY_SRC.indexOf('async function executeHaltNow(')
|
|
106
|
+
expect(fnIdx).toBeGreaterThan(0)
|
|
107
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 5500)
|
|
108
|
+
const deferIdx = win.indexOf('decideInterruptTiming({')
|
|
109
|
+
expect(deferIdx).toBeGreaterThan(0)
|
|
110
|
+
expect(win).toContain('resolveInterruptMaxWaitMs')
|
|
111
|
+
const waitIdx = win.indexOf('await waitForSafeBoundary(')
|
|
112
|
+
const sigintIdx = win.indexOf('sendAgentInterrupt({ agentName })')
|
|
113
|
+
const cancelIdx = win.indexOf('cancelInterruptedObligation()')
|
|
114
|
+
const stampIdx = win.indexOf("endedVia: 'stop' as const")
|
|
115
|
+
const endIdx = win.indexOf('endCurrentTurnAtomic(turn)')
|
|
116
|
+
const releaseIdx = win.indexOf('releaseTurnBufferGate(key, turn)')
|
|
117
|
+
// Ordered: defer-wait → C-c → obligation cancel → clean registry stamp
|
|
118
|
+
// (so the cancelled turn can never boot-resume) → deterministic release.
|
|
119
|
+
expect(waitIdx).toBeGreaterThan(deferIdx)
|
|
120
|
+
expect(sigintIdx).toBeGreaterThan(waitIdx)
|
|
121
|
+
expect(cancelIdx).toBeGreaterThan(sigintIdx)
|
|
122
|
+
expect(stampIdx).toBeGreaterThan(cancelIdx)
|
|
123
|
+
expect(endIdx).toBeGreaterThan(stampIdx)
|
|
124
|
+
expect(releaseIdx).toBeGreaterThan(endIdx)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('executeHaltNow guards against the wrong-turn kill race (snapshot at entry, no-op if the turn changed)', () => {
|
|
128
|
+
const fnIdx = GATEWAY_SRC.indexOf('async function executeHaltNow(')
|
|
129
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 5500)
|
|
130
|
+
// Identity snapshotted BEFORE the boundary wait…
|
|
131
|
+
const snapIdx = win.indexOf('const haltTarget = currentTurn')
|
|
132
|
+
const waitIdx = win.indexOf('await waitForSafeBoundary(')
|
|
133
|
+
expect(snapIdx).toBeGreaterThan(0)
|
|
134
|
+
expect(snapIdx).toBeLessThan(waitIdx)
|
|
135
|
+
// …and re-checked AFTER it, before the C-c: a changed turn no-ops both
|
|
136
|
+
// the SIGINT and the teardown (the requester's turn already ended).
|
|
137
|
+
const guardIdx = win.indexOf('currentTurn !== haltTarget')
|
|
138
|
+
const sigintIdx = win.indexOf('sendAgentInterrupt({ agentName })')
|
|
139
|
+
expect(guardIdx).toBeGreaterThan(waitIdx)
|
|
140
|
+
expect(guardIdx).toBeLessThan(sigintIdx)
|
|
141
|
+
const afterGuard = win.slice(guardIdx)
|
|
142
|
+
expect(afterGuard).toMatch(/\n\s*return\b/)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('executeHaltNow cancels the halted turn\'s pending permission cards and kicks the session-command drain', () => {
|
|
146
|
+
const fnIdx = GATEWAY_SRC.indexOf('async function executeHaltNow(')
|
|
147
|
+
const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 5500)
|
|
148
|
+
// Item 4: deny + strip pending Approve/Deny cards so the buffer gate
|
|
149
|
+
// opens and a later tap can't dispatch into an idle session.
|
|
150
|
+
const permCancelIdx = win.indexOf('cancelPendingPermissionsForHalt(origin)')
|
|
151
|
+
expect(permCancelIdx).toBeGreaterThan(0)
|
|
152
|
+
// Item 5: after busy release, drain a queued /model / /effort promptly
|
|
153
|
+
// instead of waiting for the 60s reaper.
|
|
154
|
+
const drainIdx = win.indexOf('if (!turnInFlightForGate()) void drainPendingSessionCommand()')
|
|
155
|
+
const releaseIdx = win.indexOf('releaseTurnBufferGate(key, turn)')
|
|
156
|
+
expect(drainIdx).toBeGreaterThan(releaseIdx)
|
|
157
|
+
// The cancel helper itself denies, strips, and forgets each card.
|
|
158
|
+
const helperIdx = GATEWAY_SRC.indexOf('function cancelPendingPermissionsForHalt(')
|
|
159
|
+
expect(helperIdx).toBeGreaterThan(0)
|
|
160
|
+
const helperWin = GATEWAY_SRC.slice(helperIdx, helperIdx + 1200)
|
|
161
|
+
expect(helperWin).toContain("behavior: 'deny'")
|
|
162
|
+
expect(helperWin).toContain('stripCancelledPermissionCards(')
|
|
163
|
+
expect(helperWin).toContain('pendingPermissions.delete(requestId)')
|
|
164
|
+
expect(helperWin).toContain('permCardStore.remove(requestId)')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('the session-event ingest kicks halt-boundary waiters alongside the deferred-interrupt boundary check', () => {
|
|
168
|
+
const idx = GATEWAY_SRC.indexOf('toolFlightTracker.onEvent(ev)')
|
|
169
|
+
expect(idx).toBeGreaterThan(0)
|
|
170
|
+
const win = GATEWAY_SRC.slice(idx, idx + 600)
|
|
171
|
+
expect(win).toContain('notifyHaltBoundaryWaiters()')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('bare "stop" bypasses the coalescer and routes into executeHaltNow in handleInbound', () => {
|
|
175
|
+
// Coalesce bypass sits next to the `!` bypass.
|
|
176
|
+
const bypassIdx = GATEWAY_SRC.indexOf('parseInterruptMarker(text).isInterrupt')
|
|
177
|
+
expect(bypassIdx).toBeGreaterThan(0)
|
|
178
|
+
const bypassWin = GATEWAY_SRC.slice(bypassIdx, bypassIdx + 800)
|
|
179
|
+
expect(bypassWin).toContain('parseStopKeyword(text)')
|
|
180
|
+
// handleInbound keyword block: honest idle reply or halt + confirmation.
|
|
181
|
+
const kwIdx = GATEWAY_SRC.indexOf("executeHaltNow('stop-keyword')")
|
|
182
|
+
expect(kwIdx).toBeGreaterThan(0)
|
|
183
|
+
const kwWin = GATEWAY_SRC.slice(kwIdx - 1600, kwIdx + 900)
|
|
184
|
+
expect(kwWin).toContain('turnInFlightForGate()')
|
|
185
|
+
expect(kwWin).toContain('buildStopReply(inFlight, queuedLabels)')
|
|
186
|
+
// Intercepted — never forwarded to the agent as a turn.
|
|
187
|
+
expect(kwWin).toContain('return')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('the empty-`!` interrupt routes through the same executeHaltNow helper', () => {
|
|
191
|
+
expect(GATEWAY_SRC).toContain("executeHaltNow('bang-empty')")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('/stop with an argument warns and does NOT halt (old container-stop muscle memory)', () => {
|
|
195
|
+
const idx = GATEWAY_SRC.indexOf("bot.command('stop', async ctx => {")
|
|
196
|
+
const win = GATEWAY_SRC.slice(idx, idx + 1600)
|
|
197
|
+
const argIdx = win.indexOf('const arg = ctx.match?.trim()')
|
|
198
|
+
const haltIdx = win.indexOf("executeHaltNow('stop-command')")
|
|
199
|
+
expect(argIdx).toBeGreaterThan(0)
|
|
200
|
+
// Arg check runs BEFORE the halt and returns without cancelling.
|
|
201
|
+
expect(argIdx).toBeLessThan(haltIdx)
|
|
202
|
+
expect(win).toContain('takes no argument')
|
|
203
|
+
expect(win).toContain('/agentstop')
|
|
204
|
+
expect(win).toContain('Nothing was stopped')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('a photo/attachment captioned "stop" never trips the halt-and-drop path', () => {
|
|
208
|
+
// Coalesce bypass: pure text only.
|
|
209
|
+
const bypassIdx = GATEWAY_SRC.indexOf(
|
|
210
|
+
'parseStopKeyword(text) && downloadImage == null && attachment == null',
|
|
211
|
+
)
|
|
212
|
+
expect(bypassIdx).toBeGreaterThan(0)
|
|
213
|
+
// handleInbound keyword block: also excludes coalesced extra attachments.
|
|
214
|
+
const kwIdx = GATEWAY_SRC.indexOf("executeHaltNow('stop-keyword')")
|
|
215
|
+
const kwWin = GATEWAY_SRC.slice(kwIdx - 2400, kwIdx)
|
|
216
|
+
expect(kwWin).toContain('downloadImage == null')
|
|
217
|
+
expect(kwWin).toContain('attachment == null')
|
|
218
|
+
expect(kwWin).toContain('extraAttachments == null || extraAttachments.length === 0')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('command catalogue — /agentstop rename regression (#3020)', () => {
|
|
223
|
+
it('help name array carries both stop (turn cancel) and agentstop (container)', () => {
|
|
224
|
+
expect(switchroomHelpCommandNames as readonly string[]).toContain('stop')
|
|
225
|
+
expect(switchroomHelpCommandNames as readonly string[]).toContain('agentstop')
|
|
226
|
+
})
|
|
227
|
+
it('help text documents /agentstop as the container verb and /stop as the turn cancel', () => {
|
|
228
|
+
const out = switchroomHelpText('assistant')
|
|
229
|
+
expect(out).toContain('/agentstop')
|
|
230
|
+
expect(out).toMatch(/\/stop.*(in-flight|turn)/i)
|
|
231
|
+
// No stale "/stop [name] — stop an agent" container wording.
|
|
232
|
+
expect(out).not.toContain('`/stop [name]`')
|
|
233
|
+
})
|
|
234
|
+
})
|
|
@@ -28,6 +28,7 @@ function makeHarness(opts: {
|
|
|
28
28
|
configStallThresholdMs?: number
|
|
29
29
|
configSilentSynthStallThresholdMs?: number
|
|
30
30
|
configSilentStallTerminalMs?: number
|
|
31
|
+
configInflightTerminalCapMs?: number
|
|
31
32
|
initialContent?: string
|
|
32
33
|
} = {}) {
|
|
33
34
|
const {
|
|
@@ -35,6 +36,7 @@ function makeHarness(opts: {
|
|
|
35
36
|
configStallThresholdMs,
|
|
36
37
|
configSilentSynthStallThresholdMs,
|
|
37
38
|
configSilentStallTerminalMs,
|
|
39
|
+
configInflightTerminalCapMs,
|
|
38
40
|
initialContent,
|
|
39
41
|
} = opts
|
|
40
42
|
|
|
@@ -91,6 +93,7 @@ function makeHarness(opts: {
|
|
|
91
93
|
stallThresholdMs: configStallThresholdMs,
|
|
92
94
|
silentSynthesisStallThresholdMs: configSilentSynthStallThresholdMs ?? configStallThresholdMs,
|
|
93
95
|
silentStallTerminalMs: configSilentStallTerminalMs,
|
|
96
|
+
inflightTerminalCapMs: configInflightTerminalCapMs,
|
|
94
97
|
rescanMs: 500,
|
|
95
98
|
onStall: (_id, idleMs) => stallCalls.push({ idleMs }),
|
|
96
99
|
onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
|
|
@@ -132,6 +135,7 @@ const ENV_KEYS = [
|
|
|
132
135
|
'SWITCHROOM_SUBAGENT_STALL_MS',
|
|
133
136
|
'SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS',
|
|
134
137
|
'SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS',
|
|
138
|
+
'SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS',
|
|
135
139
|
] as const
|
|
136
140
|
|
|
137
141
|
describe('subagent-watcher env-var threshold overrides', () => {
|
|
@@ -219,23 +223,28 @@ describe('subagent-watcher env-var threshold overrides', () => {
|
|
|
219
223
|
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
220
224
|
})
|
|
221
225
|
|
|
222
|
-
it('a Bash-in-flight worker skips the tight stall threshold
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
226
|
+
it('a Bash-in-flight worker skips the tight stall threshold, DEFERS terminal synthesis while the tool call is in flight, and still releases at the in-flight cap', () => {
|
|
227
|
+
// Design reconciliation (incident 2026-07-10 vs the #2777/#2782
|
|
228
|
+
// contract this test used to encode): a worker mid-`Bash` whose
|
|
229
|
+
// transcript ends in a tool_use with no matching tool_result is NOT
|
|
230
|
+
// silent — it's inside a legal long tool call — so the pass-2 terminal
|
|
231
|
+
// synthesis must NOT fire at silentStallTerminalMs (the old contract;
|
|
232
|
+
// it finalised a live worker's card). But the completion-gate release
|
|
233
|
+
// from #2777/#2782 is still load-bearing for a worker that DIED
|
|
234
|
+
// mid-tool, so the deferral is CAPPED at inflightTerminalCapMs of
|
|
235
|
+
// total idle: deferred while in flight, released at the cap.
|
|
228
236
|
const h = makeHarness({
|
|
229
237
|
agentId: 'env-thresh-bash',
|
|
230
238
|
configStallThresholdMs: 1000, // tight active-loop threshold
|
|
231
239
|
configSilentSynthStallThresholdMs: 10_000, // widened long-runner window
|
|
232
240
|
configSilentStallTerminalMs: 5000, // compressed terminal window
|
|
241
|
+
configInflightTerminalCapMs: 60_000, // compressed died-mid-tool cap
|
|
233
242
|
initialContent: buildJSONL(
|
|
234
243
|
subAgentUserMsg('bg task'),
|
|
235
244
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-B', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
236
245
|
),
|
|
237
246
|
})
|
|
238
|
-
h.advance(500) // register + tail → toolCount=1, lastTool=Bash
|
|
247
|
+
h.advance(500) // register + tail → toolCount=1, lastTool=Bash, tool-B in flight
|
|
239
248
|
h.unmarkHistorical()
|
|
240
249
|
|
|
241
250
|
// 5s idle — well past the 1s active-loop threshold, but a Bash worker uses
|
|
@@ -248,8 +257,17 @@ describe('subagent-watcher env-var threshold overrides', () => {
|
|
|
248
257
|
expect(h.stallCalls).toHaveLength(1)
|
|
249
258
|
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
250
259
|
|
|
251
|
-
// 5s post-stall
|
|
252
|
-
|
|
260
|
+
// 5s+ post-stall — the OLD contract synthesised here. The in-flight
|
|
261
|
+
// Bash (no tool_result yet) now defers it: a live long-runner must not
|
|
262
|
+
// have its card finalised under it.
|
|
263
|
+
h.advance(6_000) // ~17.5s total idle, < 60s cap
|
|
264
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
265
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
266
|
+
|
|
267
|
+
// Cross the in-flight cap (total idle ≥ 60s, tool_result still missing)
|
|
268
|
+
// → died-mid-tool: synthesis proceeds and the completion gate releases.
|
|
269
|
+
// The #2777/#2782 release guarantee survives, just later.
|
|
270
|
+
h.advance(50_000)
|
|
253
271
|
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
254
272
|
expect(h.finishCalls).toHaveLength(1)
|
|
255
273
|
})
|