switchroom 0.18.32 → 0.19.0

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.
Files changed (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -28,13 +28,21 @@ import { readFileSync } from "node:fs";
28
28
  import { resolve } from "node:path";
29
29
 
30
30
  // #2996 Phase 5: the callback-query handler families moved verbatim to
31
- // gateway/callback-query-handlers.ts; these pins read the gateway source
32
- // COMBINED with that module so the wiring assertions keep covering the
33
- // same runtime source text.
31
+ // gateway/callback-query-handlers.ts, and the agent-facing card-STAGING
32
+ // execute* tool handlers (vault_request_save & co) moved verbatim to
33
+ // gateway/card-tool-handlers.ts (#2996 P5-tail); these pins read the gateway
34
+ // source COMBINED with those modules so the wiring assertions keep covering
35
+ // the same runtime source text.
34
36
  const gatewaySrc =
35
37
  readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
36
38
  "\n" +
37
- readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
39
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8") +
40
+ "\n" +
41
+ readFileSync(resolve(__dirname, "..", "gateway", "card-tool-handlers.ts"), "utf-8") +
42
+ "\n" +
43
+ // P7 PR-8 (#2996): the vault pending-op intercept (incl. the rename-staged-
44
+ // key validation) moved verbatim into gateway/inbound-interceptors.ts.
45
+ readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
38
46
 
39
47
  /** The exported regex literal — what the gateway actually validates against. */
40
48
  function extractVaultKeyRegex(): RegExp {
@@ -49,7 +57,10 @@ function extractVaultKeyRegex(): RegExp {
49
57
  }
50
58
 
51
59
  /** Either inline literal with `/`, OR a reference to VAULT_KEY_REGEX. */
52
- const ACCEPTS_SLASH = /\[A-Za-z0-9_(\/|\\\/|\.)+-\]|VAULT_KEY_REGEX/;
60
+ // `vaultKeyRegex` is the InboundInterceptorDeps field gateway.ts binds to the
61
+ // shared VAULT_KEY_REGEX constant (P7 PR-8) — same runtime regex, new name at
62
+ // the moved call site.
63
+ const ACCEPTS_SLASH = /\[A-Za-z0-9_(\/|\\\/|\.)+-\]|VAULT_KEY_REGEX|vaultKeyRegex/;
53
64
 
54
65
  describe("vault-key regex accepts canonical slash-namespaced keys (#1047)", () => {
55
66
  it("vault_request_save validation includes '/' in the charclass", () => {
@@ -24,13 +24,16 @@ const bridgeSrc = readFileSync(
24
24
  'utf-8',
25
25
  )
26
26
  // #2996 Phase 5: the callback-query handler families moved verbatim to
27
- // gateway/callback-query-handlers.ts; these pins read the gateway source
28
- // COMBINED with that module so the wiring assertions keep covering the
29
- // same runtime source text.
27
+ // gateway/callback-query-handlers.ts, and the agent-facing card-STAGING
28
+ // execute* tool handlers moved verbatim to gateway/card-tool-handlers.ts
29
+ // (#2996 P5-tail); these pins read the gateway source COMBINED with those
30
+ // modules so the wiring assertions keep covering the same runtime source text.
30
31
  const gatewaySrc =
31
32
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
32
33
  '\n' +
33
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
34
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
35
+ '\n' +
36
+ readFileSync(resolve(__dirname, '..', 'gateway', 'card-tool-handlers.ts'), 'utf-8')
34
37
 
35
38
  describe('vault_request_access (#1012)', () => {
36
39
  it('bridge advertises the tool to MCP clients', () => {
@@ -64,7 +67,9 @@ describe('vault_request_access (#1012)', () => {
64
67
  it('gateway routes vault_request_access in executeToolCall', () => {
65
68
  // fails when: the switch arm is dropped. Tool would be accepted
66
69
  // by ALLOWED_TOOLS but fall through to the `unknown tool` branch.
67
- expect(gatewaySrc).toMatch(/case\s+'vault_request_access':\s*\n\s*return\s+executeVaultRequestAccess/)
70
+ // #2996 P5-tail: the dispatcher arm now routes through the extracted
71
+ // card-tool-handlers factory instance.
72
+ expect(gatewaySrc).toMatch(/case\s+'vault_request_access':\s*\n\s*return\s+cardToolHandlers\.executeVaultRequestAccess/)
68
73
  })
69
74
 
70
75
  it('gateway dispatches vra: callback prefix', () => {
@@ -29,7 +29,10 @@ import { resolve } from 'node:path'
29
29
  const gatewaySrc =
30
30
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
31
31
  '\n' +
32
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
32
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
33
+ '\n' +
34
+ // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
35
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
33
36
 
34
37
  describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
35
38
  it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
@@ -30,7 +30,12 @@ import { join, dirname } from 'node:path'
30
30
  const __dir = dirname(fileURLToPath(import.meta.url))
31
31
  const pluginDir = join(__dir, '..')
32
32
 
33
- const gatewaySrc = readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8')
33
+ const gatewaySrc =
34
+ readFileSync(join(pluginDir, 'gateway', 'gateway.ts'), 'utf8') +
35
+ '\n' +
36
+ // P7 PR-8 (#2996): the vault pending-op intercept (incl. the unlock branch)
37
+ // moved verbatim into gateway/inbound-interceptors.ts (interceptVault).
38
+ readFileSync(join(pluginDir, 'gateway', 'inbound-interceptors.ts'), 'utf8')
34
39
 
35
40
  // ─── helpers ─────────────────────────────────────────────────────────────────
36
41
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Outcome pins for the voice inbound handler extracted from gateway.ts
3
+ * (switchroom#2996 P6 cluster E). Drives the engine-selection + transcription
4
+ * fallback branch against injected access / host-capability / transcribe
5
+ * surfaces + a mock coalescing dispatch. Asserts the EXACT envelope forwarded
6
+ * to handleInboundCoalesced for each path (local/cloud transcript, caption
7
+ * prefix, disabled, and every fallback to the legacy "(voice message)").
8
+ */
9
+
10
+ import { describe, it, expect, vi, afterEach } from 'vitest'
11
+ import { handleVoiceMessage, type VoiceHandlerDeps } from '../gateway/voice-message-handler.js'
12
+
13
+ const VOICE_META = { kind: 'voice', file_id: 'v1', size: 100, mime: 'audio/ogg' }
14
+
15
+ function makeDeps(over: Partial<VoiceHandlerDeps> = {}) {
16
+ const handleInboundCoalesced = vi.fn(async () => {})
17
+ const deps: VoiceHandlerDeps = {
18
+ loadAccess: () => ({ voice_in: { enabled: true, provider: 'openai', api_key: 'k', language: 'en' } }),
19
+ loadHostCapabilities: () => null,
20
+ maybeTranscribeVoiceLocal: vi.fn(async () => 'LOCAL TEXT'),
21
+ maybeTranscribeVoice: vi.fn(async () => 'CLOUD TEXT'),
22
+ handleInboundCoalesced,
23
+ ...over,
24
+ }
25
+ return { deps, handleInboundCoalesced }
26
+ }
27
+
28
+ function ctxWith(caption?: string): any {
29
+ return { message: { voice: { file_id: 'v1', file_size: 100, mime_type: 'audio/ogg' }, caption }, chat: { id: 42 } }
30
+ }
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks()
34
+ delete process.env.SWITCHROOM_VOICE_ENGINE
35
+ })
36
+
37
+ describe('handleVoiceMessage — cloud path', () => {
38
+ it('transcribes via cloud and forwards a [voice transcript] envelope', async () => {
39
+ delete process.env.SWITCHROOM_VOICE_ENGINE
40
+ const { deps, handleInboundCoalesced } = makeDeps()
41
+ await handleVoiceMessage(ctxWith(), deps)
42
+ expect(deps.maybeTranscribeVoice).toHaveBeenCalledWith('v1', 'audio/ogg', 'en', 'k')
43
+ expect(deps.maybeTranscribeVoiceLocal).not.toHaveBeenCalled()
44
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
45
+ expect.anything(), '[voice transcript] CLOUD TEXT', undefined, VOICE_META,
46
+ )
47
+ })
48
+
49
+ it('prefixes the caption to the transcript when present', async () => {
50
+ const { deps, handleInboundCoalesced } = makeDeps()
51
+ await handleVoiceMessage(ctxWith('note:'), deps)
52
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('note:\n\n[voice transcript] CLOUD TEXT')
53
+ })
54
+ })
55
+
56
+ describe('handleVoiceMessage — local path', () => {
57
+ it('routes to the local sidecar when the engine env is local', async () => {
58
+ process.env.SWITCHROOM_VOICE_ENGINE = 'local'
59
+ // voice_in.provider need not be openai for the local path.
60
+ const { deps, handleInboundCoalesced } = makeDeps({
61
+ loadAccess: () => ({ voice_in: { enabled: true, language: 'de' } }),
62
+ })
63
+ await handleVoiceMessage(ctxWith(), deps)
64
+ expect(deps.maybeTranscribeVoiceLocal).toHaveBeenCalledWith('v1', 'audio/ogg', 'de')
65
+ expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
66
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('[voice transcript] LOCAL TEXT')
67
+ })
68
+
69
+ it('reads the engine from host-capabilities when env is unset', async () => {
70
+ delete process.env.SWITCHROOM_VOICE_ENGINE
71
+ const { deps } = makeDeps({
72
+ loadAccess: () => ({ voice_in: { enabled: true } }),
73
+ loadHostCapabilities: () => ({ voice: { engine: 'local' } }),
74
+ })
75
+ await handleVoiceMessage(ctxWith(), deps)
76
+ expect(deps.maybeTranscribeVoiceLocal).toHaveBeenCalled()
77
+ })
78
+ })
79
+
80
+ describe('handleVoiceMessage — fallbacks to the legacy envelope', () => {
81
+ it('falls back when voice_in is disabled', async () => {
82
+ const { deps, handleInboundCoalesced } = makeDeps({ loadAccess: () => ({}) })
83
+ await handleVoiceMessage(ctxWith(), deps)
84
+ expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
85
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
86
+ expect.anything(), '(voice message)', undefined, VOICE_META,
87
+ )
88
+ })
89
+
90
+ it('falls back to the legacy envelope on transcription failure', async () => {
91
+ const { deps, handleInboundCoalesced } = makeDeps({ maybeTranscribeVoice: vi.fn(async () => null) })
92
+ await handleVoiceMessage(ctxWith(), deps)
93
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
94
+ expect.anything(), '(voice message)', undefined, VOICE_META,
95
+ )
96
+ })
97
+
98
+ it('uses the caption as the legacy envelope when present', async () => {
99
+ const { deps, handleInboundCoalesced } = makeDeps({ loadAccess: () => ({}) })
100
+ await handleVoiceMessage(ctxWith('hi there'), deps)
101
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('hi there')
102
+ })
103
+
104
+ it('does not use the cloud path when provider is not openai', async () => {
105
+ delete process.env.SWITCHROOM_VOICE_ENGINE
106
+ const { deps } = makeDeps({ loadAccess: () => ({ voice_in: { enabled: true } }) })
107
+ await handleVoiceMessage(ctxWith(), deps)
108
+ expect(deps.maybeTranscribeVoice).not.toHaveBeenCalled()
109
+ expect(deps.maybeTranscribeVoiceLocal).not.toHaveBeenCalled()
110
+ })
111
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Outcome pins for the on-demand voice ('🔊 Listen') callback family
3
+ * (`voice:<token>`) extracted from the gateway's callback_query dispatcher
4
+ * (switchroom#2996 P6). Uses the REAL voice-ondemand token helpers and the
5
+ * REAL sendVoiceReusingFileId contract via injected send fns: asserts the
6
+ * fall-through for non-voice data, the auth gate, expiry toast, the
7
+ * file_id fast path (no upload), the upload path capturing the returned
8
+ * file_id, and the single-use keyboard strip on success only.
9
+ */
10
+
11
+ import { describe, it, expect, vi } from 'vitest'
12
+ import {
13
+ handleVoiceOnDemandCallback,
14
+ type VoiceOnDemandCallbackDeps,
15
+ } from '../gateway/voice-ondemand-callback-handler.js'
16
+
17
+ const TOKEN = 'tok123'
18
+ const DATA = `voice:${TOKEN}`
19
+
20
+ function makeCtx(over: Record<string, unknown> = {}) {
21
+ return {
22
+ from: { id: 42 },
23
+ chat: { id: 42 },
24
+ callbackQuery: { message: { message_id: 77 } },
25
+ answerCallbackQuery: vi.fn(async () => {}),
26
+ ...over,
27
+ } as any
28
+ }
29
+
30
+ function makeDeps(over: Partial<VoiceOnDemandCallbackDeps> = {}) {
31
+ const setTelegramFileId = vi.fn()
32
+ const sendVoiceByFileId = vi.fn(async () => ({ voice: { file_id: 'same' } }))
33
+ const sendVoiceByUpload = vi.fn(async () => ({ voice: { file_id: 'fresh-id' } }))
34
+ const stripListenKeyboard = vi.fn(async () => {})
35
+ const log = vi.fn()
36
+ const deps: VoiceOnDemandCallbackDeps = {
37
+ loadAccess: () => ({ allowFrom: ['42'] }),
38
+ voiceOnDemandCache: {
39
+ get: () => null,
40
+ setTelegramFileId,
41
+ },
42
+ sendVoiceByFileId,
43
+ sendVoiceByUpload,
44
+ stripListenKeyboard,
45
+ log,
46
+ ...over,
47
+ }
48
+ return { deps, setTelegramFileId, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard, log }
49
+ }
50
+
51
+ describe('handleVoiceOnDemandCallback — routing + gates', () => {
52
+ it('returns false for non-voice callback data', async () => {
53
+ const { deps } = makeDeps()
54
+ const ctx = makeCtx()
55
+ expect(await handleVoiceOnDemandCallback(ctx, 'agent:whatever', deps)).toBe(false)
56
+ expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
57
+ })
58
+
59
+ it('gates non-allowlisted senders', async () => {
60
+ const { deps, sendVoiceByFileId } = makeDeps()
61
+ const ctx = makeCtx({ from: { id: 666 } })
62
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
63
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
64
+ expect(sendVoiceByFileId).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('acks an expired token politely', async () => {
68
+ const { deps } = makeDeps() // cache.get → null
69
+ const ctx = makeCtx()
70
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
71
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({
72
+ text: 'Voice expired — send again to hear it.',
73
+ })
74
+ })
75
+ })
76
+
77
+ describe('handleVoiceOnDemandCallback — send paths', () => {
78
+ it('file_id fast path: sends BY id, strips the keyboard, no upload', async () => {
79
+ const { deps, sendVoiceByFileId, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
80
+ voiceOnDemandCache: {
81
+ get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
82
+ setTelegramFileId: vi.fn(),
83
+ },
84
+ })
85
+ const ctx = makeCtx()
86
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
87
+ expect(sendVoiceByFileId).toHaveBeenCalledWith(
88
+ '42',
89
+ 'cached-id',
90
+ expect.objectContaining({ reply_parameters: { message_id: 77 } }),
91
+ undefined,
92
+ )
93
+ expect(sendVoiceByUpload).not.toHaveBeenCalled()
94
+ expect(stripListenKeyboard).toHaveBeenCalledWith('42', 77, undefined)
95
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '🔊' })
96
+ })
97
+
98
+ it('pre-synth file path: uploads the on-disk audio and captures the returned file_id', async () => {
99
+ const { mkdtempSync, writeFileSync } = await import('node:fs')
100
+ const { tmpdir } = await import('node:os')
101
+ const { join } = await import('node:path')
102
+ const dir = mkdtempSync(join(tmpdir(), 'voice-od-'))
103
+ const fp = join(dir, 'a.ogg')
104
+ writeFileSync(fp, Buffer.from([1, 2, 3]))
105
+ const setTelegramFileId = vi.fn()
106
+ const { deps, sendVoiceByUpload, stripListenKeyboard } = makeDeps({
107
+ voiceOnDemandCache: {
108
+ get: () => ({ text: 'hello', speed: 1, filePath: fp }),
109
+ setTelegramFileId,
110
+ },
111
+ })
112
+ const ctx = makeCtx()
113
+ await handleVoiceOnDemandCallback(ctx, DATA, deps)
114
+ expect(sendVoiceByUpload).toHaveBeenCalled()
115
+ // Returned message's file_id captured back into the cache for reuse.
116
+ expect(setTelegramFileId).toHaveBeenCalledWith(TOKEN, 'fresh-id')
117
+ expect(stripListenKeyboard).toHaveBeenCalled()
118
+ })
119
+
120
+ it('send failure: logs non-fatally and leaves the keyboard intact (retryable)', async () => {
121
+ const { deps, stripListenKeyboard, log } = makeDeps({
122
+ voiceOnDemandCache: {
123
+ get: () => ({ text: 'hello', speed: 1, telegramFileId: 'cached-id' }),
124
+ setTelegramFileId: vi.fn(),
125
+ },
126
+ sendVoiceByFileId: vi.fn(async () => {
127
+ throw new Error('boom')
128
+ }),
129
+ // Stale-id fallback then tries upload; make it fail too so the send
130
+ // resolves as send-failed overall. filePath absent → lazy synth path
131
+ // would need the sidecar; keep it simple: upload throws.
132
+ sendVoiceByUpload: vi.fn(async () => {
133
+ throw new Error('boom2')
134
+ }),
135
+ })
136
+ const ctx = makeCtx()
137
+ expect(await handleVoiceOnDemandCallback(ctx, DATA, deps)).toBe(true)
138
+ expect(stripListenKeyboard).not.toHaveBeenCalled()
139
+ })
140
+ })
@@ -6,7 +6,7 @@ import {
6
6
  type WorkerActivityView,
7
7
  type BotApiForWorkerFeed,
8
8
  } from '../worker-activity-feed.js'
9
- import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from '../status-no-truncate.js'
9
+ import { STATUS_ROLLING_LINES, WORKER_HISTORY_MAX, STATUS_LINE_MAX } from '../status-no-truncate.js'
10
10
  import { SEND_GATE_SHED } from '../send-gate.js'
11
11
 
12
12
  describe('isWorkerActivityFeedEnabled (default ON)', () => {
@@ -188,17 +188,31 @@ describe('renderWorkerActivity', () => {
188
188
  expect(stepCount(out)).toBe(2)
189
189
  })
190
190
 
191
- it('shows a "+N earlier…" header when the feed exceeds STATUS_ROLLING_LINES (worker surface)', () => {
192
- const total = STATUS_ROLLING_LINES + 3
191
+ it('shows a "+N earlier…" header when the feed exceeds WORKER_HISTORY_MAX (worker surface, #3349)', () => {
192
+ const total = WORKER_HISTORY_MAX + 3
193
193
  const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
194
194
  const out = renderWorkerActivity(view({ narrativeLines: lines }))
195
- expect(out).toContain(`_✓ +${total - STATUS_ROLLING_LINES} earlier…_`)
195
+ expect(out).toContain(`_✓ +${total - WORKER_HISTORY_MAX} earlier…_`)
196
196
  expect(out).not.toContain('step 1<')
197
- const firstVisible = total - STATUS_ROLLING_LINES + 1
197
+ const firstVisible = total - WORKER_HISTORY_MAX + 1
198
198
  expect(out).toContain(`~~_✓ step ${firstVisible}_~~`)
199
199
  expect(out).toContain(`**→ step ${total}**`)
200
- // STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
201
- expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
200
+ // WORKER_HISTORY_MAX visible step lines (the overflow header isn't a step).
201
+ expect(out.match(/step \d/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
202
+ })
203
+
204
+ it('the lone-worker card shows the full 6-step trail — the raised ceiling (#3349)', () => {
205
+ // Ken's curve at w=1 is 6; the single-worker surface must reach it (the old
206
+ // STATUS_ROLLING_LINES=5 cap could never show the 6th step).
207
+ expect(WORKER_HISTORY_MAX).toBe(6)
208
+ const lines = Array.from({ length: 8 }, (_, n) => `ln-${n + 1}`)
209
+ const out = renderWorkerActivity(view({ narrativeLines: lines }))
210
+ // Last 6 render (ln-3 … ln-8); the two oldest collapse into a "+2 earlier…".
211
+ for (let n = 3; n <= 8; n++) expect(out).toContain(`ln-${n}`)
212
+ expect(out).toContain('_✓ +2 earlier…_')
213
+ expect(out).toContain('**→ ln-8**') // newest last, bold in-progress
214
+ // Exactly 6 rendered step bullets (the +N header is not a bullet).
215
+ expect((out.match(/ln-\d/g) ?? []).length).toBe(6)
202
216
  })
203
217
 
204
218
  it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
@@ -457,7 +471,7 @@ describe('createWorkerActivityFeed', () => {
457
471
  expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
458
472
  })
459
473
 
460
- it('rolls the narrative block to the last STATUS_ROLLING_LINES lines', async () => {
474
+ it('rolls the narrative block to the last WORKER_HISTORY_MAX lines (#3349)', async () => {
461
475
  const bot = makeFakeBot()
462
476
  let clock = 10_000
463
477
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
@@ -469,12 +483,34 @@ describe('createWorkerActivityFeed', () => {
469
483
  }
470
484
 
471
485
  const last = bot.edits.at(-1)!
472
- expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
473
- const firstVisible = total - STATUS_ROLLING_LINES + 1
486
+ expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(WORKER_HISTORY_MAX)
487
+ const firstVisible = total - WORKER_HISTORY_MAX + 1
474
488
  for (let i = 1; i < firstVisible; i++) expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
475
489
  for (let i = firstVisible; i <= total; i++) expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
476
490
  })
477
491
 
492
+ it('the deeper 6-step trail does NOT add edits — one landed render per substantive step (#3349, no #3270 flood)', async () => {
493
+ // Render-only change: edit frequency is a function of SUBSTANTIVE changes
494
+ // (new narrative step), never of how many trail lines a render carries. Push
495
+ // 8 distinct steps to a lone worker; with the edit floor at 0 exactly one
496
+ // render lands per step: 1 send + 7 edits = 8 landed renders, regardless of
497
+ // the 6-line trail depth. A widened window that re-fired edits would break
498
+ // this (that is the #3270 flood we must not reintroduce).
499
+ const bot = makeFakeBot()
500
+ let clock = 10_000
501
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
502
+ for (let i = 1; i <= 8; i++) {
503
+ clock += 1000
504
+ await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `edit-step-${i}` }))
505
+ }
506
+ expect(bot.sent).toHaveLength(1)
507
+ expect(bot.edits).toHaveLength(7)
508
+ // Re-pushing the SAME newest step (no substance change) fires NO extra edit.
509
+ clock += 1000
510
+ await feed.update('w1', 'chat', view({ toolCount: 8, latestSummary: 'edit-step-8' }))
511
+ expect(bot.edits).toHaveLength(7)
512
+ })
513
+
478
514
  it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
479
515
  const bot = makeFakeBot()
480
516
  let clock = 10_000
@@ -576,10 +612,10 @@ describe('createWorkerActivityFeed — log sink', () => {
576
612
  // char-budget backstop is the only wire-limit ceiling.
577
613
 
578
614
  describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
579
- it('with 12 narrative lines, exactly the last STATUS_ROLLING_LINES render + a +N earlier header', () => {
615
+ it('with 12 narrative lines, exactly the last WORKER_HISTORY_MAX render + a +N earlier header (#3349)', () => {
580
616
  const narrativeLines = Array.from({ length: 12 }, (_, i) => `stp-${String(i + 1).padStart(3, '0')}`)
581
617
  const out = renderWorkerActivity(view({ narrativeLines }))
582
- const firstVisible = 12 - STATUS_ROLLING_LINES + 1
618
+ const firstVisible = 12 - WORKER_HISTORY_MAX + 1
583
619
  for (let i = firstVisible; i <= 12; i++) {
584
620
  expect(out).toContain(`stp-${String(i).padStart(3, '0')}`)
585
621
  }
@@ -587,7 +623,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
587
623
  expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
588
624
  }
589
625
  // Overflow header now appears on the worker surface too.
590
- expect(out).toContain(`_✓ +${12 - STATUS_ROLLING_LINES} earlier…_`)
626
+ expect(out).toContain(`_✓ +${12 - WORKER_HISTORY_MAX} earlier…_`)
591
627
  expect(out).toContain('**→ stp-012**')
592
628
  })
593
629
 
@@ -623,7 +659,7 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
623
659
  })
624
660
 
625
661
  describe('rolling window — createWorkerActivityFeed narrative accumulation', () => {
626
- it('with 12 pushes, only the last STATUS_ROLLING_LINES appear in the render', async () => {
662
+ it('with 12 pushes, only the last WORKER_HISTORY_MAX appear in the render (#3349)', async () => {
627
663
  const bot = makeFakeBot()
628
664
  let clock = 10_000
629
665
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
@@ -634,16 +670,16 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
634
670
  }
635
671
 
636
672
  const last = bot.edits.at(-1)!
637
- const firstVisible = 12 - STATUS_ROLLING_LINES + 1
673
+ const firstVisible = 12 - WORKER_HISTORY_MAX + 1
638
674
  for (let i = firstVisible; i <= 12; i++) {
639
675
  expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
640
676
  }
641
677
  for (let i = 1; i < firstVisible; i++) {
642
678
  expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
643
679
  }
644
- // The manager caps the in-memory narrative at STATUS_ROLLING_LINES, so the
680
+ // The manager caps the in-memory narrative at WORKER_HISTORY_MAX, so the
645
681
  // render never sees overflow — no "+N earlier…" marker on the manager path
646
- // (it surfaces only on direct renderWorkerActivity calls with >5 lines).
682
+ // (it surfaces only on direct renderWorkerActivity calls with >6 lines).
647
683
  expect(last.text).not.toContain('earlier…')
648
684
  expect(last.text).toContain('**→ ln-012**')
649
685
  })
@@ -1027,6 +1063,37 @@ describe('createWorkerActivityFeed — resurrection guard + deferred finalize',
1027
1063
  expect(bot.sent[1].text).toContain('back to work')
1028
1064
  })
1029
1065
 
1066
+ it('#3373: a GENUINE terminal then a SendMessage resume re-surfaces a live card (resurrect+update), while an un-resumed terminal stays dead', async () => {
1067
+ const bot = makeFakeBot()
1068
+ let clock = 10_000
1069
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1070
+
1071
+ // Worker runs, then GENUINELY completes (a real turn_end handback) — the
1072
+ // row is dropped and the agentId latched into the terminal `finalized` gate.
1073
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'step one' }))
1074
+ await feed.finish('w1', view({ state: 'done', toolCount: 1, latestSummary: 'real result' }))
1075
+ expect(feed.has('w1')).toBe(false)
1076
+
1077
+ // Anti-zombie: with NO resume signal, later cues stay swallowed forever — a
1078
+ // genuinely-finished worker must never zombie-resurface on a stray tick.
1079
+ clock = 12_000
1080
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'stray tick' }))
1081
+ expect(bot.sent).toHaveLength(1)
1082
+ expect(feed.has('w1')).toBe(false)
1083
+
1084
+ // Now the worker is RESUMED via SendMessage: the watcher observes jsonl
1085
+ // growth past the terminal boundary (#3315) and fires onResume, which the
1086
+ // gateway wires to feed.resurrect — clearing the finalized latch.
1087
+ feed.resurrect('w1')
1088
+
1089
+ // The resumed worker's next progress cue re-surfaces a fresh live card.
1090
+ clock = 13_000
1091
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'resumed work' }))
1092
+ expect(bot.sent).toHaveLength(2)
1093
+ expect(feed.has('w1')).toBe(true)
1094
+ expect(bot.sent[1].text).toContain('resumed work')
1095
+ })
1096
+
1030
1097
  it('#3023: resurrect() on a never-finalized worker is a harmless no-op', () => {
1031
1098
  const bot = makeFakeBot()
1032
1099
  const feed = createWorkerActivityFeed({ bot, now: () => 10_000 })
@@ -1337,8 +1404,8 @@ describe('narrative dedup — non-adjacent repeats collapse (A,B,A)', () => {
1337
1404
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
1338
1405
 
1339
1406
  await feed.update('w2', 'chat', view({ latestSummary: 'step-repeat' }))
1340
- // Push STATUS_ROLLING_LINES distinct lines so 'step-repeat' scrolls out.
1341
- for (let i = 0; i < STATUS_ROLLING_LINES; i++) {
1407
+ // Push WORKER_HISTORY_MAX distinct lines so 'step-repeat' scrolls out (#3349).
1408
+ for (let i = 0; i < WORKER_HISTORY_MAX; i++) {
1342
1409
  clock += 1000
1343
1410
  await feed.update('w2', 'chat', view({ latestSummary: `filler-${i}` }))
1344
1411
  }