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
@@ -32,6 +32,21 @@ const gatewaySrc = readFileSync(
32
32
  resolve(__dirname, '..', 'gateway', 'gateway.ts'),
33
33
  'utf-8',
34
34
  )
35
+ // #2996 P4-A: one sticky-latch set site + the per-turn object-literal init live
36
+ // in handleSessionEvent (the tool-label/answer send-success + the `enqueue`
37
+ // turn ctor), which moved VERBATIM to stream-render.ts. The population spans
38
+ // gateway.ts + that module.
39
+ const streamSrc = readFileSync(
40
+ resolve(__dirname, '..', 'gateway', 'stream-render.ts'),
41
+ 'utf-8',
42
+ )
43
+ // #2996 P4-B: the drain-side set-TRUE site (drainActivitySummary) moved on
44
+ // into narrative-lane.ts.
45
+ const laneSrc = readFileSync(
46
+ resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'),
47
+ 'utf-8',
48
+ )
49
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc + '\n' + laneSrc
35
50
 
36
51
  describe('M-2: activityEverOpened sticky-true invariant', () => {
37
52
  it('activityEverOpened = true appears only at the two feed-OPEN signal sites', () => {
@@ -39,19 +54,19 @@ describe('M-2: activityEverOpened sticky-true invariant', () => {
39
54
  // handback pre-turn ADOPTION seed (an adopted turn only edits, so it stamps
40
55
  // the flag itself). Both are set-TRUE; the sticky invariant (no reset to
41
56
  // false) is enforced by the next test.
42
- const setTrueMatches = [...gatewaySrc.matchAll(/activityEverOpened\s*=\s*true/g)]
57
+ const setTrueMatches = [...gatewayAndStreamSrc.matchAll(/activityEverOpened\s*=\s*true/g)]
43
58
  expect(setTrueMatches).toHaveLength(2)
44
59
  })
45
60
 
46
61
  it('turn.activityEverOpened = false never appears (no standalone reset)', () => {
47
62
  // The only `false` value must be in the object literal initialiser
48
63
  // (e.g. `activityEverOpened: false`), never a standalone reassignment.
49
- const resetMatches = [...gatewaySrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
64
+ const resetMatches = [...gatewayAndStreamSrc.matchAll(/turn\.activityEverOpened\s*=\s*false/g)]
50
65
  expect(resetMatches).toHaveLength(0)
51
66
  })
52
67
 
53
68
  it('activityEverOpened is initialised false in the turn object literal (per-turn reset)', () => {
54
69
  // The object literal form `activityEverOpened: false` must exist (per-turn init).
55
- expect(gatewaySrc).toMatch(/activityEverOpened:\s*false/)
70
+ expect(gatewayAndStreamSrc).toMatch(/activityEverOpened:\s*false/)
56
71
  })
57
72
  })
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Outcome pins for the agent-emitted inline-keyboard callback family
3
+ * (`agent:` prefix, #271) extracted from the gateway's callback_query
4
+ * dispatcher (switchroom#2996 P6). Drives handleAgentButtonCallback with a
5
+ * mock ctx + injected deps and asserts: fall-through for non-agent data, the
6
+ * allowFrom gate, the ack-toast-first contract (#710), the exact InboundMessage
7
+ * envelope routed through the injected turn-safe delivery, the restarting
8
+ * notice only on bridge-offline, and the single-use keyboard strip + meta
9
+ * cleanup.
10
+ */
11
+
12
+ import { describe, it, expect, vi } from 'vitest'
13
+ import {
14
+ handleAgentButtonCallback,
15
+ type AgentButtonCallbackDeps,
16
+ } from '../gateway/agent-button-callback-handler.js'
17
+
18
+ const DATA = 'agent:approve_pr'
19
+
20
+ function makeCtx(over: Record<string, unknown> = {}) {
21
+ return {
22
+ from: { id: 42, username: 'ken' },
23
+ chat: { id: 42 },
24
+ callbackQuery: {
25
+ message: {
26
+ message_id: 77,
27
+ text: 'Deploy?',
28
+ reply_markup: {
29
+ inline_keyboard: [[{ text: 'Approve', callback_data: DATA }]],
30
+ },
31
+ },
32
+ },
33
+ answerCallbackQuery: vi.fn(async () => {}),
34
+ editMessageText: vi.fn(async () => {}),
35
+ editMessageReplyMarkup: vi.fn(async () => {}),
36
+ ...over,
37
+ } as any
38
+ }
39
+
40
+ function makeDeps(over: Partial<AgentButtonCallbackDeps> = {}) {
41
+ const deliverButtonTapInbound = vi.fn(async () => 'delivered')
42
+ const sendRestartingNotice = vi.fn()
43
+ const metaDelete = vi.fn()
44
+ const deps: AgentButtonCallbackDeps = {
45
+ loadAccess: () => ({ allowFrom: ['42'] }),
46
+ agentButtonMeta: { get: () => undefined, delete: metaDelete },
47
+ deliverButtonTapInbound,
48
+ sendRestartingNotice,
49
+ buttonConfirmSingleUseWarned: new Set(),
50
+ buttonConfirmParseModeWarned: new Set(),
51
+ log: vi.fn(),
52
+ ...over,
53
+ }
54
+ return { deps, deliverButtonTapInbound, sendRestartingNotice, metaDelete }
55
+ }
56
+
57
+ describe('handleAgentButtonCallback — routing + gates', () => {
58
+ it('returns false for non-agent callback data', async () => {
59
+ const { deps } = makeDeps()
60
+ const ctx = makeCtx()
61
+ expect(await handleAgentButtonCallback(ctx, 'perm:allow:abcde', deps)).toBe(false)
62
+ expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
63
+ })
64
+
65
+ it('gates non-allowlisted senders before any delivery', async () => {
66
+ const { deps, deliverButtonTapInbound } = makeDeps()
67
+ const ctx = makeCtx({ from: { id: 666 } })
68
+ expect(await handleAgentButtonCallback(ctx, DATA, deps)).toBe(true)
69
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
70
+ expect(deliverButtonTapInbound).not.toHaveBeenCalled()
71
+ })
72
+ })
73
+
74
+ describe('handleAgentButtonCallback — delivery envelope', () => {
75
+ it('acks FIRST with the default toast, then delivers the exact inbound (#710/#271)', async () => {
76
+ const { deps, deliverButtonTapInbound } = makeDeps()
77
+ const ctx = makeCtx()
78
+ process.env.SWITCHROOM_AGENT_NAME = 'klanker'
79
+ expect(await handleAgentButtonCallback(ctx, DATA, deps)).toBe(true)
80
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✓ received' })
81
+ // Ack precedes delivery.
82
+ expect(ctx.answerCallbackQuery.mock.invocationCallOrder[0]).toBeLessThan(
83
+ deliverButtonTapInbound.mock.invocationCallOrder[0],
84
+ )
85
+ const [agent, msg] = deliverButtonTapInbound.mock.calls[0] as unknown as [string, any]
86
+ expect(agent).toBe('klanker')
87
+ expect(msg).toMatchObject({
88
+ type: 'inbound',
89
+ chatId: '42',
90
+ messageId: 77,
91
+ user: 'ken',
92
+ userId: 42,
93
+ text: '[user tapped button: "Approve" → approve_pr]',
94
+ meta: expect.objectContaining({
95
+ button_callback: 'true',
96
+ button_callback_data: 'approve_pr',
97
+ button_text: 'Approve',
98
+ }),
99
+ })
100
+ })
101
+
102
+ it('uses the stashed per-button ack_text when present', async () => {
103
+ // meta is keyed by the RAW payload (prefix stripped).
104
+ const metaMap = new Map([['approve_pr', { ack_text: 'On it!' }]])
105
+ const { deps } = makeDeps({
106
+ agentButtonMeta: { get: () => metaMap as any, delete: vi.fn() },
107
+ })
108
+ const ctx = makeCtx()
109
+ await handleAgentButtonCallback(ctx, DATA, deps)
110
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'On it!' })
111
+ })
112
+
113
+ it('shows the restarting notice ONLY on bridge-offline', async () => {
114
+ const { deps, sendRestartingNotice } = makeDeps({
115
+ deliverButtonTapInbound: vi.fn(async () => 'buffered-bridge-offline'),
116
+ })
117
+ await handleAgentButtonCallback(makeCtx(), DATA, deps)
118
+ expect(sendRestartingNotice).toHaveBeenCalledWith('42', undefined)
119
+
120
+ const second = makeDeps({
121
+ deliverButtonTapInbound: vi.fn(async () => 'buffered-mid-turn'),
122
+ })
123
+ await handleAgentButtonCallback(makeCtx(), DATA, second.deps)
124
+ expect(second.sendRestartingNotice).not.toHaveBeenCalled()
125
+ })
126
+ })
127
+
128
+ describe('handleAgentButtonCallback — single-use strip', () => {
129
+ it('strips the keyboard by default (no stashed meta) after the tap', async () => {
130
+ const { deps } = makeDeps()
131
+ const ctx = makeCtx()
132
+ await handleAgentButtonCallback(ctx, DATA, deps)
133
+ expect(ctx.editMessageReplyMarkup).toHaveBeenCalledWith({
134
+ reply_markup: { inline_keyboard: [] },
135
+ })
136
+ })
137
+
138
+ it('preserves the keyboard when a button opts out via single_use:false', async () => {
139
+ const metaMap = new Map([['approve_pr', { single_use: false }]])
140
+ const metaDelete = vi.fn()
141
+ const { deps } = makeDeps({
142
+ agentButtonMeta: { get: () => metaMap as any, delete: metaDelete },
143
+ })
144
+ const ctx = makeCtx()
145
+ await handleAgentButtonCallback(ctx, DATA, deps)
146
+ expect(ctx.editMessageReplyMarkup).not.toHaveBeenCalled()
147
+ expect(metaDelete).not.toHaveBeenCalled()
148
+ })
149
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Outcome pins for the ask_user option-callback family (`aq:<idx>:<askId>`,
3
+ * #574) extracted from the gateway's callback_query dispatcher
4
+ * (switchroom#2996 P6). Uses the REAL encode/decode from ask-user.ts and
5
+ * asserts: non-ask data falls through (returns false), the allowFrom gate,
6
+ * stale-tap and out-of-bounds acks, and the full resolve path (timer cleared,
7
+ * store entry deleted, promise resolved with the choice, message edited with
8
+ * the checkmark, toast acked).
9
+ */
10
+
11
+ import { describe, it, expect, vi } from 'vitest'
12
+ import {
13
+ handleAskCallback,
14
+ type AskCallbackDeps,
15
+ type PendingAskUserEntry,
16
+ } from '../gateway/ask-callback-handler.js'
17
+ import { encodeAskCallback } from '../ask-user.js'
18
+
19
+ function makeCtx(over: Record<string, unknown> = {}) {
20
+ return {
21
+ from: { id: 42 },
22
+ callbackQuery: { message: { text: 'Pick one <a&b>' } },
23
+ answerCallbackQuery: vi.fn(async () => {}),
24
+ editMessageText: vi.fn(async () => {}),
25
+ ...over,
26
+ } as any
27
+ }
28
+
29
+ function makeDeps(entries: Record<string, PendingAskUserEntry> = {}) {
30
+ const store = new Map(Object.entries(entries))
31
+ const deps: AskCallbackDeps = {
32
+ loadAccess: () => ({ allowFrom: ['42'] }),
33
+ pendingAskUser: store,
34
+ }
35
+ return { deps, store }
36
+ }
37
+
38
+ function entryWith(options: string[]) {
39
+ let resolved: unknown = null
40
+ const entry: PendingAskUserEntry = {
41
+ options,
42
+ resolve: (o: unknown) => {
43
+ resolved = o
44
+ },
45
+ timer: setTimeout(() => {}, 60_000),
46
+ }
47
+ return { entry, getResolved: () => resolved }
48
+ }
49
+
50
+ describe('handleAskCallback — routing', () => {
51
+ it('returns false for non-ask callback data (dispatcher falls through)', async () => {
52
+ const { deps } = makeDeps()
53
+ const ctx = makeCtx()
54
+ expect(await handleAskCallback(ctx, 'apv:whatever', deps)).toBe(false)
55
+ expect(ctx.answerCallbackQuery).not.toHaveBeenCalled()
56
+ })
57
+
58
+ it('gates non-allowlisted senders with a Not authorized toast', async () => {
59
+ const { entry } = entryWith(['a'])
60
+ const { deps } = makeDeps({ aaaa1111: entry })
61
+ const ctx = makeCtx({ from: { id: 666 } })
62
+ expect(await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 0), deps)).toBe(true)
63
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
64
+ clearTimeout(entry.timer)
65
+ })
66
+
67
+ it('acks a stale tap (entry gone) with the expired toast', async () => {
68
+ const { deps } = makeDeps()
69
+ const ctx = makeCtx()
70
+ expect(await handleAskCallback(ctx, encodeAskCallback('deadbeef', 0), deps)).toBe(true)
71
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'This question expired.' })
72
+ })
73
+
74
+ it('rejects an out-of-bounds index (hostile callback) without resolving', async () => {
75
+ const { entry, getResolved } = entryWith(['only'])
76
+ const { deps, store } = makeDeps({ aaaa1111: entry })
77
+ const ctx = makeCtx()
78
+ await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 3), deps)
79
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Invalid option.' })
80
+ expect(getResolved()).toBeNull()
81
+ expect(store.has('aaaa1111')).toBe(true)
82
+ clearTimeout(entry.timer)
83
+ })
84
+ })
85
+
86
+ describe('handleAskCallback — resolve path', () => {
87
+ it('resolves the deferred, deletes the entry, edits the message, acks the toast', async () => {
88
+ const { entry, getResolved } = entryWith(['yes', 'no'])
89
+ const { deps, store } = makeDeps({ aaaa1111: entry })
90
+ const ctx = makeCtx()
91
+ expect(await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 1), deps)).toBe(true)
92
+ expect(getResolved()).toEqual({ kind: 'answered', choice: 'no', idx: 1 })
93
+ expect(store.has('aaaa1111')).toBe(false)
94
+ // Edited body: escaped source text + checkmarked choice.
95
+ const edited = JSON.stringify(ctx.editMessageText.mock.calls[0][0])
96
+ expect(edited).toContain('✅')
97
+ expect(edited).toContain('no')
98
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✅ no' })
99
+ })
100
+
101
+ it('still resolves + acks when the source message has no text (no edit attempted)', async () => {
102
+ const { entry, getResolved } = entryWith(['ok'])
103
+ const { deps } = makeDeps({ aaaa1111: entry })
104
+ const ctx = makeCtx({ callbackQuery: { message: {} } })
105
+ await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 0), deps)
106
+ expect(getResolved()).toEqual({ kind: 'answered', choice: 'ok', idx: 0 })
107
+ expect(ctx.editMessageText).not.toHaveBeenCalled()
108
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✅ ok' })
109
+ })
110
+
111
+ it('a failing editMessageText is swallowed — the ack toast still fires', async () => {
112
+ const { entry } = entryWith(['ok'])
113
+ const { deps } = makeDeps({ aaaa1111: entry })
114
+ const ctx = makeCtx({ editMessageText: vi.fn(async () => { throw new Error('too old') }) })
115
+ await handleAskCallback(ctx, encodeAskCallback('aaaa1111', 0), deps)
116
+ expect(ctx.answerCallbackQuery).toHaveBeenCalledWith({ text: '✅ ok' })
117
+ })
118
+ })
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Outcome pins for the metadata-only attachment inbound handlers extracted
3
+ * from gateway.ts (switchroom#2996 P6 cluster C). Each handler is driven with
4
+ * a fake grammy ctx + a mock coalescing dispatch dep; the assertions pin the
5
+ * EXACT text envelope and AttachmentMeta slot forwarded to
6
+ * handleInboundCoalesced — the behaviour the gateway previously produced inline.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import {
11
+ handleDocumentMessage,
12
+ handleAudioMessage,
13
+ handleVideoMessage,
14
+ handleVideoNoteMessage,
15
+ handleStickerMessage,
16
+ handleAnimationMessage,
17
+ type AttachmentHandlerDeps,
18
+ } from '../gateway/attachment-message-handlers.js'
19
+
20
+ function makeDeps() {
21
+ const handleInboundCoalesced = vi.fn(async () => {})
22
+ const deps: AttachmentHandlerDeps = { handleInboundCoalesced }
23
+ return { deps, handleInboundCoalesced }
24
+ }
25
+
26
+ // Build a fake ctx whose `message` carries the given content key (+ caption).
27
+ function ctxWith(content: Record<string, unknown>): any {
28
+ return { message: content, chat: { id: 42 } }
29
+ }
30
+
31
+ describe('handleDocumentMessage', () => {
32
+ it('renders the document envelope with a sanitized name', async () => {
33
+ const { deps, handleInboundCoalesced } = makeDeps()
34
+ const ctx = ctxWith({ document: { file_id: 'd1', file_name: 'a<b>.pdf', file_size: 10, mime_type: 'application/pdf' } })
35
+ await handleDocumentMessage(ctx, deps)
36
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
37
+ ctx,
38
+ '(document: a_b_.pdf)',
39
+ undefined,
40
+ { kind: 'document', file_id: 'd1', size: 10, mime: 'application/pdf', name: 'a_b_.pdf' },
41
+ )
42
+ })
43
+ it('prefers the caption and falls back to "file" when unnamed', async () => {
44
+ const { deps, handleInboundCoalesced } = makeDeps()
45
+ const ctx = ctxWith({ document: { file_id: 'd2' }, caption: 'see this' })
46
+ await handleDocumentMessage(ctx, deps)
47
+ const [, text, , meta] = (handleInboundCoalesced as any).mock.calls[0]
48
+ expect(text).toBe('see this')
49
+ expect(meta).toEqual({ kind: 'document', file_id: 'd2', size: undefined, mime: undefined, name: undefined })
50
+ })
51
+ })
52
+
53
+ describe('handleAudioMessage', () => {
54
+ it('renders title, then name, then "audio" fallback', async () => {
55
+ const { deps, handleInboundCoalesced } = makeDeps()
56
+ const ctx = ctxWith({ audio: { file_id: 'a1', title: 'Song', file_name: 'x.mp3', file_size: 5, mime_type: 'audio/mpeg' } })
57
+ await handleAudioMessage(ctx, deps)
58
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
59
+ ctx,
60
+ '(audio: Song)',
61
+ undefined,
62
+ { kind: 'audio', file_id: 'a1', size: 5, mime: 'audio/mpeg', name: 'x.mp3' },
63
+ )
64
+ })
65
+ })
66
+
67
+ describe('handleVideoMessage', () => {
68
+ it('renders the video envelope, caption-or-fallback', async () => {
69
+ const { deps, handleInboundCoalesced } = makeDeps()
70
+ const ctx = ctxWith({ video: { file_id: 'v1', file_size: 9, mime_type: 'video/mp4', file_name: 'clip.mp4' } })
71
+ await handleVideoMessage(ctx, deps)
72
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
73
+ ctx,
74
+ '(video)',
75
+ undefined,
76
+ { kind: 'video', file_id: 'v1', size: 9, mime: 'video/mp4', name: 'clip.mp4' },
77
+ )
78
+ })
79
+ })
80
+
81
+ describe('handleVideoNoteMessage', () => {
82
+ it('renders the fixed video-note envelope', async () => {
83
+ const { deps, handleInboundCoalesced } = makeDeps()
84
+ const ctx = ctxWith({ video_note: { file_id: 'vn1', file_size: 3 } })
85
+ await handleVideoNoteMessage(ctx, deps)
86
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
87
+ ctx,
88
+ '(video note)',
89
+ undefined,
90
+ { kind: 'video_note', file_id: 'vn1', size: 3 },
91
+ )
92
+ })
93
+ })
94
+
95
+ describe('handleStickerMessage', () => {
96
+ it('includes emoji and set name when present', async () => {
97
+ const { deps, handleInboundCoalesced } = makeDeps()
98
+ const ctx = ctxWith({ sticker: { file_id: 's1', emoji: '🎉', set_name: 'Party', file_size: 2 } })
99
+ await handleStickerMessage(ctx, deps)
100
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
101
+ ctx,
102
+ '(sticker — 🎉 from "Party")',
103
+ undefined,
104
+ { kind: 'sticker', file_id: 's1', size: 2 },
105
+ )
106
+ })
107
+ it('falls back to bare "(sticker)" with no emoji/set', async () => {
108
+ const { deps, handleInboundCoalesced } = makeDeps()
109
+ const ctx = ctxWith({ sticker: { file_id: 's2' } })
110
+ await handleStickerMessage(ctx, deps)
111
+ const [, text] = (handleInboundCoalesced as any).mock.calls[0]
112
+ expect(text).toBe('(sticker)')
113
+ })
114
+ })
115
+
116
+ describe('handleAnimationMessage', () => {
117
+ it('prefixes the caption to the gif envelope', async () => {
118
+ const { deps, handleInboundCoalesced } = makeDeps()
119
+ const ctx = ctxWith({ animation: { file_id: 'g1', file_size: 7, mime_type: 'video/mp4', file_name: 'a.gif' }, caption: 'perfect' })
120
+ await handleAnimationMessage(ctx, deps)
121
+ expect(handleInboundCoalesced).toHaveBeenCalledWith(
122
+ ctx,
123
+ '(gif) perfect',
124
+ undefined,
125
+ { kind: 'animation', file_id: 'g1', size: 7, mime: 'video/mp4', name: 'a.gif' },
126
+ )
127
+ })
128
+ it('renders bare "(gif)" when uncaptioned', async () => {
129
+ const { deps, handleInboundCoalesced } = makeDeps()
130
+ const ctx = ctxWith({ animation: { file_id: 'g2' } })
131
+ await handleAnimationMessage(ctx, deps)
132
+ const [, text] = (handleInboundCoalesced as any).mock.calls[0]
133
+ expect(text).toBe('(gif)')
134
+ })
135
+ })
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Boot-card "Routing" row (.routing-mode observability — 2026-07-17 boot-race
3
+ * incident: silent fable→opus demotion on one agent, silent fable→router-root
4
+ * divergence on another).
5
+ *
6
+ * Contract:
7
+ * - `readRoutingMode` parses `<agentDir>/.routing-mode`, returns null on
8
+ * absence/garbage (tolerant), and flags `prevBoot` when the file's mtime
9
+ * predates this gateway boot (render-timing hazard: the gateway's outer
10
+ * pass can render the card before the inner pass writes this boot's line).
11
+ * - `renderBootCard` renders the row when `routing` is passed; stale values
12
+ * are suffixed "(prev boot)" — never presented as current; divergence
13
+ * (landed ≠ declared) gets the warning dot; absent file → no row at all.
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
17
+ import { mkdtempSync, rmSync, writeFileSync, utimesSync, readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { tmpdir } from 'node:os'
20
+ import { readRoutingMode, renderRoutingRow, renderBootCard, containerBootStartMs } from '../gateway/boot-card.js'
21
+
22
+ const LINE =
23
+ 'mode=router-root base=http://litellm:4010 model=fable litellm_ok=1 declared=router-root ts=2026-07-17T02:00:00Z\n'
24
+
25
+ describe('readRoutingMode', () => {
26
+ let dir: string
27
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'routing-mode-')) })
28
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
29
+
30
+ it('parses a current-boot line', () => {
31
+ writeFileSync(join(dir, '.routing-mode'), LINE)
32
+ const r = readRoutingMode(dir, Date.now() - 60_000)
33
+ expect(r).not.toBeNull()
34
+ expect(r!.mode).toBe('router-root')
35
+ expect(r!.declared).toBe('router-root')
36
+ expect(r!.model).toBe('fable')
37
+ expect(r!.litellmOk).toBe('1')
38
+ expect(r!.prevBoot).toBe(false)
39
+ })
40
+
41
+ it('returns null when the file is absent (fleets pre-dating the state file)', () => {
42
+ expect(readRoutingMode(dir, Date.now())).toBeNull()
43
+ })
44
+
45
+ it('returns null on a garbage file with no mode= field', () => {
46
+ writeFileSync(join(dir, '.routing-mode'), 'not a routing line\n')
47
+ expect(readRoutingMode(dir, Date.now())).toBeNull()
48
+ })
49
+
50
+ it('flags prevBoot when the file mtime predates this gateway boot', () => {
51
+ writeFileSync(join(dir, '.routing-mode'), LINE)
52
+ const old = (Date.now() - 3_600_000) / 1000
53
+ utimesSync(join(dir, '.routing-mode'), old, old)
54
+ const r = readRoutingMode(dir, Date.now() - 1_000)
55
+ expect(r).not.toBeNull()
56
+ expect(r!.prevBoot).toBe(true)
57
+ })
58
+ })
59
+
60
+ describe('containerBootStartMs', () => {
61
+ it('derives PID-1 start epoch from /proc/stat btime + /proc/1/stat starttime', () => {
62
+ const btime = 1_700_000_000 // seconds since epoch
63
+ const startTicks = 500 // 5s after boot at 100 HZ
64
+ const fsImpl = {
65
+ readFileSync: ((p: unknown) => {
66
+ if (p === '/proc/stat') return `cpu 1 2 3\nbtime ${btime}\nprocesses 100\n`
67
+ if (p === '/proc/1/stat')
68
+ // pid (comm) state ... field22=starttime. comm has a space+paren to
69
+ // exercise the last-')' split.
70
+ return `1 (init a) S 0 1 1 0 -1 0 0 0 0 0 0 0 0 0 0 0 0 0 ${startTicks} 0`
71
+ throw new Error('unexpected path')
72
+ }) as typeof readFileSync,
73
+ }
74
+ expect(containerBootStartMs(fsImpl)).toBe((btime + startTicks / 100) * 1000)
75
+ })
76
+
77
+ it('returns null when /proc is unavailable (fallback path for callers)', () => {
78
+ const fsImpl = {
79
+ readFileSync: (() => {
80
+ throw new Error('ENOENT')
81
+ }) as typeof readFileSync,
82
+ }
83
+ expect(containerBootStartMs(fsImpl)).toBeNull()
84
+ })
85
+
86
+ it('returns null when btime is missing', () => {
87
+ const fsImpl = {
88
+ readFileSync: ((p: unknown) =>
89
+ p === '/proc/stat' ? 'cpu 1 2 3\nprocesses 100\n' : '1 (init) S 0') as typeof readFileSync,
90
+ }
91
+ expect(containerBootStartMs(fsImpl)).toBeNull()
92
+ })
93
+ })
94
+
95
+ describe('renderRoutingRow / renderBootCard routing row', () => {
96
+ const base = { agentName: 'klanker', version: 'v1' }
97
+
98
+ it('renders a converged row with the routing dot and model', () => {
99
+ const row = renderRoutingRow({
100
+ mode: 'router-root', declared: 'router-root', model: 'fable', litellmOk: '1', prevBoot: false,
101
+ })
102
+ expect(row).toContain('**Routing**')
103
+ expect(row).toContain('router-root')
104
+ expect(row).toContain('`fable`')
105
+ expect(row).toContain('litellm ok')
106
+ expect(row).not.toContain('≠')
107
+ expect(row).not.toContain('prev boot')
108
+ })
109
+
110
+ it('marks divergence (landed ≠ declared) with the warning dot', () => {
111
+ const row = renderRoutingRow({
112
+ mode: 'passthrough', declared: 'router-root', model: 'opus', litellmOk: '0', prevBoot: false,
113
+ })
114
+ expect(row).toContain('🟡')
115
+ expect(row).toContain('≠ declared router-root')
116
+ expect(row).toContain('litellm unconfirmed')
117
+ })
118
+
119
+ it('labels a stale (previous-boot) value instead of presenting it as current', () => {
120
+ const row = renderRoutingRow({
121
+ mode: 'router-root', declared: 'router-root', prevBoot: true,
122
+ })
123
+ expect(row).toContain('(prev boot)')
124
+ })
125
+
126
+ it('renderBootCard includes the routing row when passed, omits it when routing is null/absent', () => {
127
+ const withRow = renderBootCard({
128
+ ...base,
129
+ routing: { mode: 'router-root', declared: 'passthrough', model: 'fable', litellmOk: '1', prevBoot: false },
130
+ })
131
+ expect(withRow).toContain('**Routing**')
132
+ expect(withRow).toContain('router-root')
133
+
134
+ const without = renderBootCard({ ...base })
135
+ expect(without).not.toContain('Routing')
136
+ const withNull = renderBootCard({ ...base, routing: null })
137
+ expect(withNull).toBe(without)
138
+ })
139
+ })