switchroom 0.18.6 → 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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- 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/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -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/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- 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 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -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/resume-inbound-builder.test.ts +286 -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/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- 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/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- 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
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the /status session-model freshness contract (#2982 + live-model PR):
|
|
3
|
+
* the FRESHEST observation wins between the transcript's `message.model` and
|
|
4
|
+
* the /model override, arbitrated by a shared monotonic sequence. This is the
|
|
5
|
+
* "model display must never be stale" invariant:
|
|
6
|
+
*
|
|
7
|
+
* - override set AFTER the last transcript observation (idle-time /model
|
|
8
|
+
* switch, no assistant line yet) → /status shows the override;
|
|
9
|
+
* - a new assistant line after that → the transcript wins again.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import { createSessionModelSource } from '../gateway/session-model-source.js'
|
|
13
|
+
|
|
14
|
+
describe('createSessionModelSource — freshest observation wins', () => {
|
|
15
|
+
it('returns null when neither source has reported', () => {
|
|
16
|
+
const s = createSessionModelSource()
|
|
17
|
+
expect(s.resolve()).toBeNull()
|
|
18
|
+
expect(s.getOverride()).toBeNull()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('transcript-only → transcript', () => {
|
|
22
|
+
const s = createSessionModelSource()
|
|
23
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
24
|
+
expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('override-only → override (fresh boot before the first assistant line)', () => {
|
|
28
|
+
const s = createSessionModelSource()
|
|
29
|
+
s.setOverride('sr-glm-5')
|
|
30
|
+
expect(s.resolve()).toEqual({ model: 'sr-glm-5', source: 'override' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('idle-after-switch window: an override set AFTER the last transcript line wins', () => {
|
|
34
|
+
// The #2982 regression this pins: /model switch while idle — the
|
|
35
|
+
// transcript still holds the OLD model, the override holds the NEW one.
|
|
36
|
+
const s = createSessionModelSource()
|
|
37
|
+
s.noteTranscriptModel('claude-opus-4-8') // old model's last assistant line
|
|
38
|
+
s.setOverride('Sonnet 5') // confirmed switch, no assistant line yet
|
|
39
|
+
expect(s.resolve()).toEqual({ model: 'Sonnet 5', source: 'override' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('a NEW assistant line after the switch reclaims the transcript as the source', () => {
|
|
43
|
+
const s = createSessionModelSource()
|
|
44
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
45
|
+
s.setOverride('Sonnet 5')
|
|
46
|
+
s.noteTranscriptModel('claude-sonnet-5') // first line under the new model
|
|
47
|
+
expect(s.resolve()).toEqual({ model: 'claude-sonnet-5', source: 'transcript' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clearing the override (null) falls back to the transcript', () => {
|
|
51
|
+
const s = createSessionModelSource()
|
|
52
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
53
|
+
s.setOverride('sr-glm-5')
|
|
54
|
+
s.setOverride(null)
|
|
55
|
+
expect(s.getOverride()).toBeNull()
|
|
56
|
+
expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('getOverride reports the override independent of freshness', () => {
|
|
60
|
+
const s = createSessionModelSource()
|
|
61
|
+
s.setOverride('sr-glm-5')
|
|
62
|
+
s.noteTranscriptModel('claude-opus-4-8') // transcript is now fresher
|
|
63
|
+
expect(s.resolve()?.source).toBe('transcript')
|
|
64
|
+
// ...but the override record itself is still readable (menu "session" marker).
|
|
65
|
+
expect(s.getOverride()).toBe('sr-glm-5')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -345,6 +345,42 @@ describe('projectTranscriptLine', () => {
|
|
|
345
345
|
messageId: '103',
|
|
346
346
|
})
|
|
347
347
|
})
|
|
348
|
+
|
|
349
|
+
// ─── Live model capture (message.model) ──────────────────────────────
|
|
350
|
+
it('emits a model event (first) from message.model on an assistant line', () => {
|
|
351
|
+
const line = JSON.stringify({
|
|
352
|
+
type: 'assistant',
|
|
353
|
+
message: {
|
|
354
|
+
model: 'claude-opus-4-8',
|
|
355
|
+
content: [{ type: 'tool_use', name: 'Bash', id: 'toolu_01', input: {} }],
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
// Model event is emitted BEFORE the content events so a same-batch render
|
|
359
|
+
// already reflects the current model.
|
|
360
|
+
expect(projectTranscriptLine(line)).toEqual([
|
|
361
|
+
{ kind: 'model', model: 'claude-opus-4-8' },
|
|
362
|
+
{ kind: 'tool_use', toolName: 'Bash', toolUseId: 'toolu_01', input: {} },
|
|
363
|
+
])
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('skips a synthetic model sentinel (keeps no model event)', () => {
|
|
367
|
+
const line = JSON.stringify({
|
|
368
|
+
type: 'assistant',
|
|
369
|
+
message: {
|
|
370
|
+
model: '<synthetic>',
|
|
371
|
+
content: [{ type: 'thinking', thinking: '...' }],
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('omits the model event when message.model is absent', () => {
|
|
378
|
+
const line = JSON.stringify({
|
|
379
|
+
type: 'assistant',
|
|
380
|
+
message: { content: [{ type: 'thinking', thinking: '...' }] },
|
|
381
|
+
})
|
|
382
|
+
expect(projectTranscriptLine(line)).toEqual([{ kind: 'thinking' }])
|
|
383
|
+
})
|
|
348
384
|
})
|
|
349
385
|
|
|
350
386
|
// ─── Bug 1 regression: per-file cursor state survives re-attachment ────
|
|
@@ -490,6 +526,34 @@ describe('projectSubagentLine', () => {
|
|
|
490
526
|
])
|
|
491
527
|
})
|
|
492
528
|
|
|
529
|
+
it('emits sub_agent_model (first) from message.model on a sub-agent assistant line', () => {
|
|
530
|
+
const st = { hasEmittedStart: true }
|
|
531
|
+
const line = JSON.stringify({
|
|
532
|
+
type: 'assistant',
|
|
533
|
+
message: {
|
|
534
|
+
model: 'sr-glm-5',
|
|
535
|
+
content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
const events = projectSubagentLine(line, 'X', st)
|
|
539
|
+
expect(events[0]).toEqual({ kind: 'sub_agent_model', agentId: 'X', model: 'sr-glm-5' })
|
|
540
|
+
expect(events[1].kind).toBe('sub_agent_tool_use')
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('skips a synthetic sub-agent model sentinel', () => {
|
|
544
|
+
const st = { hasEmittedStart: true }
|
|
545
|
+
const line = JSON.stringify({
|
|
546
|
+
type: 'assistant',
|
|
547
|
+
message: {
|
|
548
|
+
model: '<synthetic>',
|
|
549
|
+
content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
const events = projectSubagentLine(line, 'X', st)
|
|
553
|
+
expect(events.some((e) => e.kind === 'sub_agent_model')).toBe(false)
|
|
554
|
+
expect(events[0].kind).toBe('sub_agent_tool_use')
|
|
555
|
+
})
|
|
556
|
+
|
|
493
557
|
it('emits sub_agent_tool_use for regular tools; nested Agent fires ONLY nested_spawn', () => {
|
|
494
558
|
const st = { hasEmittedStart: true }
|
|
495
559
|
const line = JSON.stringify({
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
recordSilentTurnEnd,
|
|
11
11
|
recordUndeliveredTurnEnd,
|
|
12
12
|
SILENT_END_MAX_RETRIES,
|
|
13
|
+
SILENT_END_STALE_RECORD_MAX_AGE_MS,
|
|
13
14
|
} from '../silent-end.js'
|
|
14
15
|
import { isFinalAnswerReply } from '../final-answer-detect.js'
|
|
15
16
|
|
|
@@ -206,12 +207,56 @@ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
|
|
|
206
207
|
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
207
208
|
retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
|
|
208
209
|
}))
|
|
209
|
-
|
|
210
|
+
// Pin `now` alongside the record's timestamp=0 so this exercises the
|
|
211
|
+
// genuine same-turn ladder, not fix #8's age-based staleness bound
|
|
212
|
+
// (which is covered by its own dedicated tests below).
|
|
213
|
+
const r = recordSilentTurnEnd(
|
|
214
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_' },
|
|
215
|
+
{ now: () => 0 },
|
|
216
|
+
)
|
|
210
217
|
expect(r.exhausted).toBe(true)
|
|
211
218
|
// State cleared so the Stop hook on this final turn allows the stop.
|
|
212
219
|
expect(readSilentEndState()).toBeNull()
|
|
213
220
|
})
|
|
214
221
|
|
|
222
|
+
it('fix #8: an exhausted record older than the plausible turn lifetime starts a fresh retry budget (no delivery evidence needed)', () => {
|
|
223
|
+
// A crash/interrupt bypassed the gateway's own exhaust-read-and-clear,
|
|
224
|
+
// so a spent (retryCount >= MAX) record from an OLD turn survives on
|
|
225
|
+
// disk. turnKey is the STABLE statusKey(chatId, threadId) — it matches
|
|
226
|
+
// this brand-new dark turn on the same chat/thread even though it
|
|
227
|
+
// belongs to a completely different turn instance.
|
|
228
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
229
|
+
writeFileSync(path, JSON.stringify({
|
|
230
|
+
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
231
|
+
retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
|
|
232
|
+
}))
|
|
233
|
+
const now = SILENT_END_STALE_RECORD_MAX_AGE_MS + 1000 // just past the age bound
|
|
234
|
+
const r = recordSilentTurnEnd(
|
|
235
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_' },
|
|
236
|
+
{ now: () => now },
|
|
237
|
+
)
|
|
238
|
+
// Must run its OWN re-prompt ladder, not immediately fall back.
|
|
239
|
+
expect(r.exhausted).toBe(false)
|
|
240
|
+
expect(readSilentEndState()).toMatchObject({ turnKey: 'c:_', retryCount: 0 })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('fix #8: a genuinely same-turn exhausted record (within the age bound) still reports exhausted — no regression', () => {
|
|
244
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
245
|
+
const recentTimestamp = 1_000_000
|
|
246
|
+
writeFileSync(path, JSON.stringify({
|
|
247
|
+
chatId: 'c', threadId: null, turnKey: 'c:_',
|
|
248
|
+
retryCount: SILENT_END_MAX_RETRIES, timestamp: recentTimestamp,
|
|
249
|
+
}))
|
|
250
|
+
// Well within the plausible single-turn retry-ladder window.
|
|
251
|
+
const now = recentTimestamp + 5000
|
|
252
|
+
const r = recordSilentTurnEnd(
|
|
253
|
+
{ chatId: 'c', threadId: null, turnKey: 'c:_' },
|
|
254
|
+
{ now: () => now },
|
|
255
|
+
)
|
|
256
|
+
expect(r.exhausted).toBe(true)
|
|
257
|
+
expect(readSilentEndState()).toBeNull()
|
|
258
|
+
})
|
|
259
|
+
|
|
215
260
|
it('treats a capped prior state for a DIFFERENT turn as a fresh silent-end', () => {
|
|
216
261
|
const path = join(stateDir, 'silent-end-pending.json')
|
|
217
262
|
writeFileSync(path, JSON.stringify({
|
|
@@ -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
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level regression test for the chunk-boundary cap bug
|
|
3
|
+
* (fix/rich-render-chunk-boundary-cap).
|
|
4
|
+
*
|
|
5
|
+
* With `SWITCHROOM_RICH_RENDER` on, a near-cap body full of escapable chars
|
|
6
|
+
* (`_ * |`) makes `renderSafe` degrade the whole document to plain (its escaped
|
|
7
|
+
* rich form exceeds RICH_MESSAGE_MAX_CHARS). BEFORE the fix, the stream
|
|
8
|
+
* controller shipped that ~32k plain body through the plain `sendMessage`
|
|
9
|
+
* endpoint in ONE call — Telegram's plain endpoint caps at 4096, so the send
|
|
10
|
+
* is rejected (`message is too long`) and the streamed answer is dropped.
|
|
11
|
+
*
|
|
12
|
+
* AFTER the fix, `renderOutboundChunks` re-splits at safe boundaries so every
|
|
13
|
+
* emitted send fits its own wire cap: rich pieces <= 32768, plain pieces
|
|
14
|
+
* <= 4096, and no fenced block is bisected.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
17
|
+
import { createStreamController } from "../stream-controller.js";
|
|
18
|
+
import { createFakeBotApi } from "./fake-bot-api.js";
|
|
19
|
+
import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
|
|
20
|
+
|
|
21
|
+
const PLAIN_CAP = 4096; // Telegram's legacy plain-text sendMessage cap (format.ts:29).
|
|
22
|
+
|
|
23
|
+
function fenceCount(s: string): number {
|
|
24
|
+
return (s.match(/^```/gm) ?? []).length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("stream-controller enforces the wire cap on the post-escape body", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
delete process.env.SWITCHROOM_RICH_RENDER;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("REGRESSION: near-cap escapable first send never exceeds the plain wire cap", async () => {
|
|
33
|
+
process.env.SWITCHROOM_RICH_RENDER = "1";
|
|
34
|
+
const bot = createFakeBotApi({ startMessageId: 1000 });
|
|
35
|
+
const unit = "a_b*c|d ";
|
|
36
|
+
const body = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
37
|
+
|
|
38
|
+
const stream = createStreamController({
|
|
39
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
40
|
+
chatId: "c1",
|
|
41
|
+
throttleMs: 0,
|
|
42
|
+
});
|
|
43
|
+
await stream.update(body);
|
|
44
|
+
await stream.finalize();
|
|
45
|
+
|
|
46
|
+
expect(bot.state.sent.length).toBeGreaterThan(0);
|
|
47
|
+
for (const s of bot.state.sent) {
|
|
48
|
+
// Every send fits the rich cap...
|
|
49
|
+
expect(s.text.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
50
|
+
// ...and a PLAIN send (rich !== true) additionally fits the 4096 plain
|
|
51
|
+
// endpoint cap — the invariant HEAD violated (one ~32k plain send).
|
|
52
|
+
if (!s.rich) expect(s.text.length).toBeLessThanOrEqual(PLAIN_CAP);
|
|
53
|
+
// No send bisects a fenced block.
|
|
54
|
+
expect(fenceCount(s.text) % 2).toBe(0);
|
|
55
|
+
}
|
|
56
|
+
}, 30000);
|
|
57
|
+
|
|
58
|
+
it("BLOCKER: multi-update oversize stream emits tails ONCE, not per edit tick", async () => {
|
|
59
|
+
// Reproduces the duplicate-flood blocker: an oversize body splits into N
|
|
60
|
+
// pieces. The FIRST flush (send path) emits the anchor + (N-1) tail
|
|
61
|
+
// messages. Every SUBSEQUENT throttled flush routes through the EDIT
|
|
62
|
+
// callback. The pre-fix code re-sent all (N-1) tails as brand-new messages
|
|
63
|
+
// on each edit tick, so `sent.length` grew by (N-1) every update. After the
|
|
64
|
+
// fix, tails are parked once and edited in place — `sent.length` is flat.
|
|
65
|
+
process.env.SWITCHROOM_RICH_RENDER = "1";
|
|
66
|
+
const bot = createFakeBotApi({ startMessageId: 3000 });
|
|
67
|
+
const unit = "a_b*c|d ";
|
|
68
|
+
// Near-cap body that degrades to plain and splits into several pieces.
|
|
69
|
+
const base = unit.repeat(Math.floor((RICH_MESSAGE_MAX_CHARS - 20) / unit.length));
|
|
70
|
+
// Distinct short prefix per flush so the HEAD piece actually changes —
|
|
71
|
+
// an unchanged head yields a not-modified edit, which short-circuits before
|
|
72
|
+
// the tail loop and would hide the duplicate-resend bug.
|
|
73
|
+
const bodyFor = (i: number) => `v${i} ${base}`;
|
|
74
|
+
|
|
75
|
+
const stream = createStreamController({
|
|
76
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
77
|
+
chatId: "c1",
|
|
78
|
+
throttleMs: 0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// First flush → send path: anchor + tails, emitted exactly once.
|
|
82
|
+
await stream.update(bodyFor(1));
|
|
83
|
+
const afterFirst = bot.state.sent.length;
|
|
84
|
+
expect(afterFirst).toBeGreaterThan(1); // genuinely multi-piece
|
|
85
|
+
|
|
86
|
+
// Drive several more oversize updates — each routes through the EDIT
|
|
87
|
+
// callback. The count must NOT grow: tails are edited in place, not resent.
|
|
88
|
+
await stream.update(bodyFor(2));
|
|
89
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
90
|
+
await stream.update(bodyFor(3));
|
|
91
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
92
|
+
await stream.update(bodyFor(4));
|
|
93
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
94
|
+
await stream.finalize();
|
|
95
|
+
|
|
96
|
+
// Final: exactly the anchor + tail set, no duplicates across the lifetime.
|
|
97
|
+
expect(bot.state.sent.length).toBe(afterFirst);
|
|
98
|
+
const ids = bot.state.sent.map((s) => s.message_id);
|
|
99
|
+
expect(new Set(ids).size).toBe(ids.length); // no duplicate message ids
|
|
100
|
+
|
|
101
|
+
// Every currently-visible message fits its wire cap and (for plain) 4096.
|
|
102
|
+
for (const s of bot.state.sent) {
|
|
103
|
+
const cur = bot.textOf(s.message_id) ?? "";
|
|
104
|
+
expect(cur.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS);
|
|
105
|
+
if (!s.rich) expect(cur.length).toBeLessThanOrEqual(PLAIN_CAP);
|
|
106
|
+
}
|
|
107
|
+
}, 30000);
|
|
108
|
+
|
|
109
|
+
it("flag OFF leaves the single-send path untouched", async () => {
|
|
110
|
+
const bot = createFakeBotApi({ startMessageId: 2000 });
|
|
111
|
+
const stream = createStreamController({
|
|
112
|
+
bot: bot as unknown as Parameters<typeof createStreamController>[0]["bot"],
|
|
113
|
+
chatId: "c1",
|
|
114
|
+
throttleMs: 0,
|
|
115
|
+
});
|
|
116
|
+
await stream.update("**hi** _there_");
|
|
117
|
+
await stream.finalize();
|
|
118
|
+
expect(bot.state.sent).toHaveLength(1);
|
|
119
|
+
expect(bot.state.sent[0].rich).toBe(true);
|
|
120
|
+
expect(bot.state.sent[0].text).toBe("**hi** _there_");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -108,6 +108,45 @@ describe('subagent-tracker-pretool', () => {
|
|
|
108
108
|
expect(row!.last_activity_at).toBe(row!.started_at)
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
it('persists tool_input.model as the first-paint model on the row', () => {
|
|
112
|
+
const event = {
|
|
113
|
+
session_id: 'sess-model',
|
|
114
|
+
tool_name: 'Agent',
|
|
115
|
+
tool_use_id: 'toolu_model001',
|
|
116
|
+
tool_input: {
|
|
117
|
+
subagent_type: 'worker',
|
|
118
|
+
description: 'Build with a pinned model',
|
|
119
|
+
run_in_background: true,
|
|
120
|
+
model: 'claude-opus-4-8',
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
const result = runHook(PRETOOL_SCRIPT, event)
|
|
124
|
+
expect(result.status).toBe(0)
|
|
125
|
+
|
|
126
|
+
const db = openDb()
|
|
127
|
+
const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_model001') as
|
|
128
|
+
| { model: string | null }
|
|
129
|
+
| undefined
|
|
130
|
+
expect(row?.model).toBe('claude-opus-4-8')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('leaves model null when the Agent dispatch carries no model', () => {
|
|
134
|
+
const event = {
|
|
135
|
+
session_id: 'sess-nomodel',
|
|
136
|
+
tool_name: 'Agent',
|
|
137
|
+
tool_use_id: 'toolu_nomodel001',
|
|
138
|
+
tool_input: { subagent_type: 'worker', description: 'no model', run_in_background: false },
|
|
139
|
+
}
|
|
140
|
+
const result = runHook(PRETOOL_SCRIPT, event)
|
|
141
|
+
expect(result.status).toBe(0)
|
|
142
|
+
|
|
143
|
+
const db = openDb()
|
|
144
|
+
const row = db.prepare('SELECT model FROM subagents WHERE id = ?').get('toolu_nomodel001') as
|
|
145
|
+
| { model: string | null }
|
|
146
|
+
| undefined
|
|
147
|
+
expect(row?.model ?? null).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
111
150
|
it('does not write a row when tool_name is not Agent', () => {
|
|
112
151
|
const event = {
|
|
113
152
|
session_id: 'sess-abc123',
|