switchroom 0.14.19 → 0.14.21

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 (32) hide show
  1. package/dist/agent-scheduler/index.js +6 -1
  2. package/dist/auth-broker/index.js +6 -1
  3. package/dist/cli/notion-write-pretool.mjs +6 -1
  4. package/dist/cli/switchroom.js +17 -3
  5. package/dist/host-control/main.js +6 -1
  6. package/dist/vault/approvals/kernel-server.js +6 -1
  7. package/dist/vault/broker/server.js +6 -1
  8. package/package.json +2 -2
  9. package/telegram-plugin/README.md +7 -3
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  12. package/telegram-plugin/dist/gateway/gateway.js +368 -153
  13. package/telegram-plugin/dist/server.js +1 -1
  14. package/telegram-plugin/gateway/coalesce-attachments.ts +79 -0
  15. package/telegram-plugin/gateway/gateway.ts +257 -39
  16. package/telegram-plugin/gateway/interrupt-defer.ts +106 -0
  17. package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -4
  18. package/telegram-plugin/tests/coalesce-attachments.test.ts +170 -0
  19. package/telegram-plugin/tests/interrupt-defer.test.ts +160 -0
  20. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +36 -0
  21. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
  22. package/telegram-plugin/tests/worker-activity-feed.test.ts +127 -0
  23. package/telegram-plugin/uat/assertions.ts +53 -0
  24. package/telegram-plugin/uat/driver.ts +28 -0
  25. package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
  26. package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
  27. package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
  28. package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
  29. package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
  30. package/telegram-plugin/uat/scenarios/jtbd-forwarded-burst-dm.test.ts +158 -0
  31. package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
  32. package/telegram-plugin/worker-activity-feed.ts +65 -9
@@ -184,10 +184,16 @@ export function planBufferedRedelivery(
184
184
  return out
185
185
  }
186
186
 
187
+ /** Meta keys that describe an attachment — the primary (image_path,
188
+ * attachment_*) plus the A2 numbered siblings (image_path_2,
189
+ * attachment_file_id_2, …) and attachment_count. */
190
+ const ATTACHMENT_META_RE = /^(image_path|attachment_)/
191
+
187
192
  /** Collapse a >1 run into a single turn. The newest message anchors the
188
193
  * turn (its messageId/ts/user/meta); texts join in arrival order; the
189
- * single attachment (if any) rides along from whichever message carried
190
- * it. Caller guarantees the run is mergeable + has at most one media. */
194
+ * attachment(s) (if any) ride along from whichever message carried them.
195
+ * Caller guarantees the run is mergeable + has at most one media-bearing
196
+ * entry. */
191
197
  function mergeRun(run: InboundMessage[]): InboundMessage {
192
198
  const last = run[run.length - 1]!
193
199
  const mediaEntry = run.find(inboundHasMedia)
@@ -195,10 +201,21 @@ function mergeRun(run: InboundMessage[]): InboundMessage {
195
201
  ...last,
196
202
  text: run.map((m) => m.text).join('\n'),
197
203
  }
198
- // Re-seat the single attachment/imagePath from the entry that owns it
199
- // (which may not be `last`), or strip them if the run is text-only.
204
+ // Re-seat the attachment/imagePath from the entry that owns it (which may
205
+ // not be `last`), or strip them if the run is text-only.
200
206
  delete merged.imagePath
201
207
  delete merged.attachment
208
+ if (mediaEntry != null && mediaEntry !== last) {
209
+ // The media-bearing entry isn't the anchor, so `last.meta` lacks the
210
+ // attachment fields the agent reads (image_path / attachment_* and the
211
+ // A2 numbered siblings). Splice the owning entry's attachment meta keys
212
+ // into the merged meta so the agent still sees every attachment.
213
+ const splicedMeta: Record<string, string> = { ...merged.meta }
214
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
215
+ if (ATTACHMENT_META_RE.test(k)) splicedMeta[k] = v
216
+ }
217
+ merged.meta = splicedMeta
218
+ }
202
219
  if (mediaEntry?.imagePath != null) merged.imagePath = mediaEntry.imagePath
203
220
  if (mediaEntry?.attachment != null) merged.attachment = mediaEntry.attachment
204
221
  return merged
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Unit tests for the A2 multi-attachment helpers
3
+ * (telegram-plugin/gateway/coalesce-attachments.ts).
4
+ *
5
+ * These pin the pure pieces of the multi-attachment fold-in that live
6
+ * outside gateway.ts so they can be exercised without loadAccess()/IPC:
7
+ * 1. resolveCoalesceMaxAttachments — the runtime cap default (10).
8
+ * 2. splitCoalescedAttachments — primary + capped extras, arrival order.
9
+ * 3. buildExtraAttachmentMeta — numbered meta fields starting at _2.
10
+ *
11
+ * A cap of 1 reproduces the historical single-attachment shape: primary
12
+ * only, no extras, no numbered meta.
13
+ */
14
+
15
+ import { describe, expect, it } from 'vitest'
16
+ import {
17
+ splitCoalescedAttachments,
18
+ buildExtraAttachmentMeta,
19
+ resolveCoalesceMaxAttachments,
20
+ DEFAULT_MAX_ATTACHMENTS,
21
+ type ResolvedExtraAttachment,
22
+ } from '../gateway/coalesce-attachments.js'
23
+
24
+ describe('resolveCoalesceMaxAttachments (default 10 = full album)', () => {
25
+ it('defaults to 10 when unset', () => {
26
+ expect(resolveCoalesceMaxAttachments(undefined)).toBe(10)
27
+ expect(DEFAULT_MAX_ATTACHMENTS).toBe(10)
28
+ })
29
+ it('honours an explicit operator cap', () => {
30
+ expect(resolveCoalesceMaxAttachments(1)).toBe(1)
31
+ expect(resolveCoalesceMaxAttachments(25)).toBe(25)
32
+ })
33
+ it('floors a 0 / negative cap at 1 (never strips the only attachment)', () => {
34
+ expect(resolveCoalesceMaxAttachments(0)).toBe(1)
35
+ expect(resolveCoalesceMaxAttachments(-5)).toBe(1)
36
+ })
37
+ })
38
+
39
+ interface Entry {
40
+ text: string
41
+ att?: string
42
+ }
43
+
44
+ const has = (e: Entry): boolean => e.att != null
45
+
46
+ describe('splitCoalescedAttachments', () => {
47
+ it('cap 1: keeps only the first attachment as primary, no extras', () => {
48
+ const entries: Entry[] = [
49
+ { text: 'a', att: 'photo-1' },
50
+ { text: 'b', att: 'photo-2' },
51
+ ]
52
+ const { primary, extras } = splitCoalescedAttachments(entries, has, 1)
53
+ expect(primary).toEqual({ text: 'a', att: 'photo-1' })
54
+ expect(extras).toEqual([])
55
+ })
56
+
57
+ it('picks the FIRST attachment-bearing entry as primary even when text-only entries precede it', () => {
58
+ const entries: Entry[] = [
59
+ { text: 'look' },
60
+ { text: 'at this', att: 'photo-1' },
61
+ { text: 'and this', att: 'photo-2' },
62
+ ]
63
+ const { primary, extras } = splitCoalescedAttachments(entries, has, 3)
64
+ expect(primary?.att).toBe('photo-1')
65
+ expect(extras.map((e) => e.att)).toEqual(['photo-2'])
66
+ })
67
+
68
+ it('preserves arrival order of extras', () => {
69
+ const entries: Entry[] = [
70
+ { text: '1', att: 'a' },
71
+ { text: '2', att: 'b' },
72
+ { text: '3', att: 'c' },
73
+ ]
74
+ const { primary, extras } = splitCoalescedAttachments(entries, has, 5)
75
+ expect(primary?.att).toBe('a')
76
+ expect(extras.map((e) => e.att)).toEqual(['b', 'c'])
77
+ })
78
+
79
+ it('caps extras at maxAttachments (overflow dropped here; bypassed upstream)', () => {
80
+ const entries: Entry[] = [
81
+ { text: '1', att: 'a' },
82
+ { text: '2', att: 'b' },
83
+ { text: '3', att: 'c' },
84
+ { text: '4', att: 'd' },
85
+ ]
86
+ const { primary, extras } = splitCoalescedAttachments(entries, has, 2)
87
+ expect(primary?.att).toBe('a')
88
+ expect(extras.map((e) => e.att)).toEqual(['b']) // total = cap of 2
89
+ })
90
+
91
+ it('returns undefined primary when no entry carries an attachment', () => {
92
+ const entries: Entry[] = [{ text: 'just' }, { text: 'text' }]
93
+ const { primary, extras } = splitCoalescedAttachments(entries, has, 3)
94
+ expect(primary).toBeUndefined()
95
+ expect(extras).toEqual([])
96
+ })
97
+
98
+ it('floors a cap of 0 / negative at 1 so the only attachment is never stripped', () => {
99
+ const entries: Entry[] = [{ text: '1', att: 'a' }, { text: '2', att: 'b' }]
100
+ expect(splitCoalescedAttachments(entries, has, 0).primary?.att).toBe('a')
101
+ expect(splitCoalescedAttachments(entries, has, -5).primary?.att).toBe('a')
102
+ expect(splitCoalescedAttachments(entries, has, 0).extras).toEqual([])
103
+ })
104
+ })
105
+
106
+ describe('buildExtraAttachmentMeta', () => {
107
+ it('returns an empty object for no extras (default single-attachment turn)', () => {
108
+ expect(buildExtraAttachmentMeta([])).toEqual({})
109
+ })
110
+
111
+ it('numbers a single photo extra as _2', () => {
112
+ const resolved: ResolvedExtraAttachment[] = [{ imagePath: '/inbox/p2.jpg' }]
113
+ expect(buildExtraAttachmentMeta(resolved)).toEqual({ image_path_2: '/inbox/p2.jpg' })
114
+ })
115
+
116
+ it('numbers multiple extras incrementally from _2', () => {
117
+ const resolved: ResolvedExtraAttachment[] = [
118
+ { imagePath: '/inbox/p2.jpg' },
119
+ { imagePath: '/inbox/p3.jpg' },
120
+ ]
121
+ expect(buildExtraAttachmentMeta(resolved)).toEqual({
122
+ image_path_2: '/inbox/p2.jpg',
123
+ image_path_3: '/inbox/p3.jpg',
124
+ })
125
+ })
126
+
127
+ it('emits full attachment metadata fields for a document extra', () => {
128
+ const resolved: ResolvedExtraAttachment[] = [
129
+ {
130
+ attachment: {
131
+ kind: 'document',
132
+ file_id: 'FID2',
133
+ size: 1234,
134
+ mime: 'application/pdf',
135
+ name: 'spec.pdf',
136
+ },
137
+ },
138
+ ]
139
+ expect(buildExtraAttachmentMeta(resolved)).toEqual({
140
+ attachment_kind_2: 'document',
141
+ attachment_file_id_2: 'FID2',
142
+ attachment_size_2: '1234',
143
+ attachment_mime_2: 'application/pdf',
144
+ attachment_name_2: 'spec.pdf',
145
+ })
146
+ })
147
+
148
+ it('omits optional metadata fields that are absent', () => {
149
+ const resolved: ResolvedExtraAttachment[] = [
150
+ { attachment: { kind: 'voice', file_id: 'FID2' } },
151
+ ]
152
+ expect(buildExtraAttachmentMeta(resolved)).toEqual({
153
+ attachment_kind_2: 'voice',
154
+ attachment_file_id_2: 'FID2',
155
+ })
156
+ })
157
+
158
+ it('handles a mix of photo and document extras with correct numbering', () => {
159
+ const resolved: ResolvedExtraAttachment[] = [
160
+ { imagePath: '/inbox/p2.jpg' },
161
+ { attachment: { kind: 'document', file_id: 'FID3', mime: 'text/plain' } },
162
+ ]
163
+ expect(buildExtraAttachmentMeta(resolved)).toEqual({
164
+ image_path_2: '/inbox/p2.jpg',
165
+ attachment_kind_3: 'document',
166
+ attachment_file_id_3: 'FID3',
167
+ attachment_mime_3: 'text/plain',
168
+ })
169
+ })
170
+ })
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Unit tests for the deferred safe-boundary interrupt core (Problem B).
3
+ *
4
+ * The gateway-side wiring (timer, SIGINT-via-tmux, sendToAgent resume,
5
+ * coalescing) is exercised by integration; these pin the pure decision:
6
+ * - ToolFlightTracker correctly tracks open tool calls by toolUseId and
7
+ * clears on turn_end / a fresh enqueue.
8
+ * - decideInterruptTiming returns fire-now unless the flag is on AND a tool
9
+ * is in flight.
10
+ * - resolveInterruptMaxWaitMs never yields a non-positive / forever wait.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import {
15
+ ToolFlightTracker,
16
+ decideInterruptTiming,
17
+ resolveInterruptMaxWaitMs,
18
+ resolveSafeBoundaryEnabled,
19
+ DEFAULT_INTERRUPT_MAX_WAIT_MS,
20
+ } from '../gateway/interrupt-defer.js'
21
+
22
+ describe('ToolFlightTracker', () => {
23
+ it('starts at a safe boundary (no tools in flight)', () => {
24
+ const t = new ToolFlightTracker()
25
+ expect(t.isMidToolCall()).toBe(false)
26
+ expect(t.inFlightCount()).toBe(0)
27
+ })
28
+
29
+ it('a tool_use opens an unsafe boundary; its tool_result closes it', () => {
30
+ const t = new ToolFlightTracker()
31
+ t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
32
+ expect(t.isMidToolCall()).toBe(true)
33
+ t.onEvent({ kind: 'tool_result', toolUseId: 'tu_1' })
34
+ expect(t.isMidToolCall()).toBe(false)
35
+ })
36
+
37
+ it('stays unsafe while ANY of several parallel tools is open', () => {
38
+ const t = new ToolFlightTracker()
39
+ t.onEvent({ kind: 'tool_use', toolUseId: 'a' })
40
+ t.onEvent({ kind: 'tool_use', toolUseId: 'b' })
41
+ t.onEvent({ kind: 'tool_use', toolUseId: 'c' })
42
+ expect(t.inFlightCount()).toBe(3)
43
+ t.onEvent({ kind: 'tool_result', toolUseId: 'b' })
44
+ t.onEvent({ kind: 'tool_result', toolUseId: 'a' })
45
+ expect(t.isMidToolCall()).toBe(true) // c still open
46
+ t.onEvent({ kind: 'tool_result', toolUseId: 'c' })
47
+ expect(t.isMidToolCall()).toBe(false)
48
+ })
49
+
50
+ it('turn_end clears any residual in-flight tools', () => {
51
+ const t = new ToolFlightTracker()
52
+ t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
53
+ t.onEvent({ kind: 'tool_use', toolUseId: 'tu_2' })
54
+ t.onEvent({ kind: 'turn_end' })
55
+ expect(t.isMidToolCall()).toBe(false)
56
+ })
57
+
58
+ it('a fresh enqueue clears the slate (new turn starts clean)', () => {
59
+ const t = new ToolFlightTracker()
60
+ t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
61
+ t.onEvent({ kind: 'enqueue' })
62
+ expect(t.isMidToolCall()).toBe(false)
63
+ })
64
+
65
+ it('ignores sub-agent and non-tool events', () => {
66
+ const t = new ToolFlightTracker()
67
+ t.onEvent({ kind: 'sub_agent_tool_use', toolUseId: 'sub_1' })
68
+ t.onEvent({ kind: 'thinking' })
69
+ t.onEvent({ kind: 'text' })
70
+ t.onEvent({ kind: 'tool_label', toolUseId: 'tu_x' })
71
+ expect(t.isMidToolCall()).toBe(false)
72
+ })
73
+
74
+ it('ignores tool_use with a missing / empty toolUseId', () => {
75
+ const t = new ToolFlightTracker()
76
+ t.onEvent({ kind: 'tool_use' })
77
+ t.onEvent({ kind: 'tool_use', toolUseId: null })
78
+ t.onEvent({ kind: 'tool_use', toolUseId: '' })
79
+ expect(t.isMidToolCall()).toBe(false)
80
+ })
81
+
82
+ it('tool_result for an unknown id is a harmless no-op', () => {
83
+ const t = new ToolFlightTracker()
84
+ t.onEvent({ kind: 'tool_use', toolUseId: 'real' })
85
+ t.onEvent({ kind: 'tool_result', toolUseId: 'never-opened' })
86
+ expect(t.isMidToolCall()).toBe(true) // 'real' still open
87
+ })
88
+
89
+ it('clear() resets the tracker', () => {
90
+ const t = new ToolFlightTracker()
91
+ t.onEvent({ kind: 'tool_use', toolUseId: 'tu_1' })
92
+ t.clear()
93
+ expect(t.isMidToolCall()).toBe(false)
94
+ })
95
+ })
96
+
97
+ describe('decideInterruptTiming', () => {
98
+ it('fires now when the flag is off, even mid-tool-call', () => {
99
+ expect(
100
+ decideInterruptTiming({ safeBoundaryEnabled: false, midToolCall: true }),
101
+ ).toBe('fire-now')
102
+ })
103
+
104
+ it('fires now when the flag is on but no tool is in flight', () => {
105
+ expect(
106
+ decideInterruptTiming({ safeBoundaryEnabled: true, midToolCall: false }),
107
+ ).toBe('fire-now')
108
+ })
109
+
110
+ it('defers only when the flag is on AND a tool is in flight', () => {
111
+ expect(
112
+ decideInterruptTiming({ safeBoundaryEnabled: true, midToolCall: true }),
113
+ ).toBe('defer')
114
+ })
115
+
116
+ it('fires now in the fully-off case', () => {
117
+ expect(
118
+ decideInterruptTiming({ safeBoundaryEnabled: false, midToolCall: false }),
119
+ ).toBe('fire-now')
120
+ })
121
+ })
122
+
123
+ describe('resolveSafeBoundaryEnabled (default ON)', () => {
124
+ it('defaults to true when unset', () => {
125
+ expect(resolveSafeBoundaryEnabled(undefined)).toBe(true)
126
+ })
127
+ it('stays true when explicitly true', () => {
128
+ expect(resolveSafeBoundaryEnabled(true)).toBe(true)
129
+ })
130
+ it('only an explicit false opts out', () => {
131
+ expect(resolveSafeBoundaryEnabled(false)).toBe(false)
132
+ })
133
+ })
134
+
135
+ describe('resolveInterruptMaxWaitMs', () => {
136
+ it('uses the configured value when positive', () => {
137
+ expect(resolveInterruptMaxWaitMs(3000)).toBe(3000)
138
+ })
139
+
140
+ it('falls back to the default when undefined', () => {
141
+ expect(resolveInterruptMaxWaitMs(undefined)).toBe(DEFAULT_INTERRUPT_MAX_WAIT_MS)
142
+ })
143
+
144
+ it('never returns a non-positive wait (no forever-wait)', () => {
145
+ expect(resolveInterruptMaxWaitMs(0)).toBe(DEFAULT_INTERRUPT_MAX_WAIT_MS)
146
+ expect(resolveInterruptMaxWaitMs(-1)).toBe(DEFAULT_INTERRUPT_MAX_WAIT_MS)
147
+ })
148
+
149
+ it('models the lifecycle: open tool → defer; tool_result → safe → fire', () => {
150
+ const t = new ToolFlightTracker()
151
+ t.onEvent({ kind: 'tool_use', toolUseId: 'w1' })
152
+ // `!` lands here, flag on:
153
+ expect(
154
+ decideInterruptTiming({ safeBoundaryEnabled: true, midToolCall: t.isMidToolCall() }),
155
+ ).toBe('defer')
156
+ // tool completes:
157
+ t.onEvent({ kind: 'tool_result', toolUseId: 'w1' })
158
+ expect(t.isMidToolCall()).toBe(false) // gateway fires the parked interrupt here
159
+ })
160
+ })
@@ -450,6 +450,42 @@ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a tu
450
450
  expect(plan[0]!.merged.imagePath).toBe('/tmp/p.jpg')
451
451
  })
452
452
 
453
+ it('splices attachment meta from the media entry when it is NOT the anchor (A2 numbered fields survive)', () => {
454
+ // A coalesced multi-attachment message buffered, then a text-only
455
+ // follow-up. mergeRun anchors on `last` (the text), whose meta has no
456
+ // attachment fields — so the owning entry's image_path + numbered
457
+ // siblings + attachment_count must be spliced into the merged meta or
458
+ // the agent would never see the photos.
459
+ const photo = userMsg({ text: 'look', ts: 1, imagePath: '/tmp/a.jpg' })
460
+ photo.meta = {
461
+ image_path: '/tmp/a.jpg',
462
+ image_path_2: '/tmp/b.jpg',
463
+ attachment_count: '2',
464
+ user: 'alice',
465
+ }
466
+ const txt = userMsg({ text: 'at these', ts: 2 })
467
+ txt.meta = { user: 'alice' }
468
+ const plan = planBufferedRedelivery([photo, txt])
469
+ expect(plan).toHaveLength(1)
470
+ const meta = plan[0]!.merged.meta
471
+ expect(meta.image_path).toBe('/tmp/a.jpg')
472
+ expect(meta.image_path_2).toBe('/tmp/b.jpg')
473
+ expect(meta.attachment_count).toBe('2')
474
+ // Top-level primary still re-seated for inboundHasMedia detection.
475
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/a.jpg')
476
+ })
477
+
478
+ it('does not need a meta splice when the media entry IS the anchor', () => {
479
+ const txt = userMsg({ text: 'intro', ts: 1 })
480
+ txt.meta = { user: 'alice' }
481
+ const photo = userMsg({ text: 'pic', ts: 2, imagePath: '/tmp/p.jpg' })
482
+ photo.meta = { image_path: '/tmp/p.jpg', user: 'alice' }
483
+ const plan = planBufferedRedelivery([txt, photo])
484
+ expect(plan).toHaveLength(1)
485
+ // Anchor is the photo, so its meta is inherited verbatim.
486
+ expect(plan[0]!.merged.meta.image_path).toBe('/tmp/p.jpg')
487
+ })
488
+
453
489
  it('preserves the run total — sum of originals equals input length (lossless)', () => {
454
490
  const msgs = [
455
491
  userMsg({ text: 'a', ts: 1 }),
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Structural pin for the permission-card resume beat.
3
+ *
4
+ * What broke (and the bug this guards against): when the operator
5
+ * answers a permission card, the suspended `claude` turn un-parks and
6
+ * resumes the SAME turn — the gateway must flip the awaiting glyph
7
+ * (🙏) back to a working glyph so the operator sees progress instead
8
+ * of a stuck card. That flip is `resumeReactionAfterVerdict()`.
9
+ *
10
+ * The verdict can arrive down several independent paths (button tap,
11
+ * always-allow, `/allow`·`/deny`, TTL auto-deny, free-text `y <id>`/
12
+ * `no <id>` reply, …). Every one of them calls
13
+ * `dispatchPermissionVerdict(...)` to un-park the turn — but the resume
14
+ * glyph flip is a *separate* call right next to it. The free-text-reply
15
+ * path shipped the dispatch WITHOUT the resume (fixed in v0.14.19), so
16
+ * answering via a text reply left the card frozen on 🙏 even though the
17
+ * turn was running. The controller-level behaviour is covered by
18
+ * `status-reactions.test.ts` ("setAwaiting" + watchdog re-arm); mtcute
19
+ * UAT cannot observe reactions at all, so this static pin is the only
20
+ * thing that catches a verdict path forgetting the resume.
21
+ *
22
+ * This guard fails loudly if any `dispatchPermissionVerdict(...)`
23
+ * callsite is not paired with a `resumeReactionAfterVerdict()` within a
24
+ * few lines — i.e. a new (or refactored) verdict path drops the resume.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import { readFileSync } from 'node:fs'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { dirname, resolve } from 'node:path'
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url))
33
+ const GATEWAY_SRC = readFileSync(
34
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
35
+ 'utf8',
36
+ )
37
+
38
+ const LINES = GATEWAY_SRC.split('\n')
39
+
40
+ // A `dispatchPermissionVerdict(` occurrence is a CALLSITE unless it's the
41
+ // function definition itself.
42
+ const isDefinition = (line: string) =>
43
+ /\bfunction\s+dispatchPermissionVerdict\b/.test(line)
44
+
45
+ const dispatchCallsites = LINES.flatMap((line, i) =>
46
+ /\bdispatchPermissionVerdict\s*\(/.test(line) && !isDefinition(line)
47
+ ? [i]
48
+ : [],
49
+ )
50
+
51
+ // How far below the dispatch the resume call is allowed to live. The
52
+ // widest real gap today is ~9 lines (the slash-command path); 15 gives
53
+ // refactor headroom without letting an unrelated resume "cover" a
54
+ // dispatch from a different block.
55
+ const RESUME_WINDOW = 15
56
+
57
+ describe('permission verdict → resume reaction wiring', () => {
58
+ it('there is at least one verdict-dispatch path to guard', () => {
59
+ expect(dispatchCallsites.length).toBeGreaterThan(0)
60
+ })
61
+
62
+ it('every dispatchPermissionVerdict() callsite flips the awaiting glyph back via resumeReactionAfterVerdict()', () => {
63
+ const unpaired: number[] = []
64
+ for (const idx of dispatchCallsites) {
65
+ const window = LINES.slice(idx, idx + RESUME_WINDOW + 1).join('\n')
66
+ if (!/\bresumeReactionAfterVerdict\s*\(\s*\)/.test(window)) {
67
+ // 1-based line number for a human-readable failure.
68
+ unpaired.push(idx + 1)
69
+ }
70
+ }
71
+ expect(
72
+ unpaired,
73
+ `dispatchPermissionVerdict() at gateway.ts line(s) ` +
74
+ `${unpaired.join(', ')} has no resumeReactionAfterVerdict() within ` +
75
+ `${RESUME_WINDOW} lines — that verdict path leaves the permission ` +
76
+ `card stuck on 🙏 after the operator answers. Add the resume call ` +
77
+ `(see the sibling paths and v0.14.19 / the free-text-reply fix).`,
78
+ ).toEqual([])
79
+ })
80
+
81
+ it('the resume helper still exists (the pairing is meaningless if it was deleted)', () => {
82
+ expect(/function\s+resumeReactionAfterVerdict\s*\(/.test(GATEWAY_SRC)).toBe(
83
+ true,
84
+ )
85
+ })
86
+ })
@@ -2,10 +2,24 @@ import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  renderWorkerActivity,
4
4
  createWorkerActivityFeed,
5
+ isWorkerActivityFeedEnabled,
5
6
  type WorkerActivityView,
6
7
  type BotApiForWorkerFeed,
7
8
  } from '../worker-activity-feed.js'
8
9
 
10
+ describe('isWorkerActivityFeedEnabled (default ON)', () => {
11
+ it('defaults to true when the env var is unset', () => {
12
+ expect(isWorkerActivityFeedEnabled(undefined)).toBe(true)
13
+ })
14
+ it('stays on for any value other than "0"', () => {
15
+ expect(isWorkerActivityFeedEnabled('1')).toBe(true)
16
+ expect(isWorkerActivityFeedEnabled('')).toBe(true)
17
+ })
18
+ it('only "0" disables it', () => {
19
+ expect(isWorkerActivityFeedEnabled('0')).toBe(false)
20
+ })
21
+ })
22
+
9
23
  function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
10
24
  return {
11
25
  description: 'research competitors',
@@ -92,6 +106,42 @@ describe('renderWorkerActivity', () => {
92
106
  expect(out).toContain('⚠️ <b>Worker failed</b>')
93
107
  })
94
108
 
109
+ it('grows a narrative block when narrativeLines is present', () => {
110
+ const out = renderWorkerActivity(
111
+ view({
112
+ latestSummary: 'newest only — should be ignored',
113
+ narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
114
+ }),
115
+ )
116
+ expect(out).toContain('↳ <i>read the brief</i>')
117
+ expect(out).toContain('↳ <i>scanned vendor A</i>')
118
+ expect(out).toContain('↳ <i>scanned vendor B</i>')
119
+ // The single-line latestSummary fallback is NOT used when a block is present.
120
+ expect(out).not.toContain('newest only')
121
+ // Three narrative lines → three ↳ lines.
122
+ expect(out.match(/↳/g) ?? []).toHaveLength(3)
123
+ })
124
+
125
+ it('falls back to latestSummary when narrativeLines is empty', () => {
126
+ const out = renderWorkerActivity(view({ narrativeLines: [], latestSummary: 'one line' }))
127
+ expect(out).toContain('↳ <i>one line</i>')
128
+ expect(out.match(/↳/g) ?? []).toHaveLength(1)
129
+ })
130
+
131
+ it('drops blank narrative lines from the block', () => {
132
+ const out = renderWorkerActivity(
133
+ view({ narrativeLines: ['kept', ' ', 'also kept'] }),
134
+ )
135
+ expect(out).toContain('↳ <i>kept</i>')
136
+ expect(out).toContain('↳ <i>also kept</i>')
137
+ expect(out.match(/↳/g) ?? []).toHaveLength(2)
138
+ })
139
+
140
+ it('escapes HTML inside narrative lines', () => {
141
+ const out = renderWorkerActivity(view({ narrativeLines: ['a <b>x</b> & y'] }))
142
+ expect(out).toContain('a &lt;b&gt;x&lt;/b&gt; &amp; y')
143
+ })
144
+
95
145
  it('escapes HTML in description, tool, arg, and summary', () => {
96
146
  const out = renderWorkerActivity(
97
147
  view({
@@ -246,6 +296,83 @@ describe('createWorkerActivityFeed', () => {
246
296
  expect(feed.size).toBe(0)
247
297
  })
248
298
 
299
+ it('accumulates distinct narrative lines into a growing block across ticks', async () => {
300
+ const bot = makeFakeBot()
301
+ let clock = 10_000
302
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
303
+
304
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'read the brief' }))
305
+ expect(bot.sent).toHaveLength(1)
306
+ expect(bot.sent[0].text).toContain('↳ <i>read the brief</i>')
307
+
308
+ clock = 11_000
309
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'scanned vendor A' }))
310
+ clock = 12_000
311
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
312
+
313
+ const last = bot.edits.at(-1)!
314
+ expect(last.text).toContain('↳ <i>read the brief</i>')
315
+ expect(last.text).toContain('↳ <i>scanned vendor A</i>')
316
+ expect(last.text).toContain('↳ <i>scanned vendor B</i>')
317
+ expect(last.text.match(/↳/g) ?? []).toHaveLength(3)
318
+ })
319
+
320
+ it('dedups a repeated narrative line so the block does not duplicate', async () => {
321
+ const bot = makeFakeBot()
322
+ let clock = 10_000
323
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
324
+
325
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'same line' }))
326
+ // Repeated narrative but a changed tool count → body differs, edit fires,
327
+ // but the narrative block must not gain a duplicate line.
328
+ clock = 11_000
329
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'same line' }))
330
+
331
+ const last = bot.edits.at(-1)!
332
+ expect(last.text.match(/↳/g) ?? []).toHaveLength(1)
333
+ })
334
+
335
+ it('caps the narrative block to the last 6 lines', async () => {
336
+ const bot = makeFakeBot()
337
+ let clock = 10_000
338
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
339
+
340
+ for (let i = 1; i <= 9; i++) {
341
+ clock += 1000
342
+ await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `line ${i}` }))
343
+ }
344
+
345
+ const last = bot.edits.at(-1)!
346
+ expect(last.text.match(/↳/g) ?? []).toHaveLength(6)
347
+ // Oldest lines evicted; newest retained.
348
+ expect(last.text).not.toContain('line 1')
349
+ expect(last.text).not.toContain('line 3')
350
+ expect(last.text).toContain('line 4')
351
+ expect(last.text).toContain('line 9')
352
+ })
353
+
354
+ it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
355
+ const bot = makeFakeBot()
356
+ let clock = 10_000
357
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 2500 })
358
+
359
+ await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'line A' }))
360
+ expect(bot.sent).toHaveLength(1)
361
+
362
+ // Throttled tick — no edit, but the line must still be accumulated.
363
+ clock = 11_000
364
+ await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'line B' }))
365
+ expect(bot.edits).toHaveLength(0)
366
+
367
+ // Past the throttle — the edit now carries BOTH lines.
368
+ clock = 13_000
369
+ await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
370
+ const last = bot.edits.at(-1)!
371
+ expect(last.text).toContain('↳ <i>line A</i>')
372
+ expect(last.text).toContain('↳ <i>line B</i>')
373
+ expect(last.text).toContain('↳ <i>line C</i>')
374
+ })
375
+
249
376
  it('forwards threadId as message_thread_id on send', async () => {
250
377
  const bot = makeFakeBot()
251
378
  let clock = 10_000