switchroom 0.13.25 → 0.13.27

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 (31) hide show
  1. package/dist/cli/switchroom.js +132 -10
  2. package/dist/vault/broker/server.js +32 -4
  3. package/package.json +1 -1
  4. package/telegram-plugin/active-reactions-sweep.ts +4 -4
  5. package/telegram-plugin/dist/gateway/gateway.js +239 -64
  6. package/telegram-plugin/docs/waiting-ux-spec.md +17 -1
  7. package/telegram-plugin/gateway/disconnect-flush.ts +10 -6
  8. package/telegram-plugin/gateway/gateway.ts +166 -51
  9. package/telegram-plugin/gateway/inbound-spool.ts +69 -2
  10. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +14 -0
  11. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +256 -0
  12. package/telegram-plugin/pending-work-progress.ts +5 -1
  13. package/telegram-plugin/status-reactions.ts +70 -58
  14. package/telegram-plugin/stream-reply-handler.ts +7 -36
  15. package/telegram-plugin/subagent-watcher.ts +64 -3
  16. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -3
  17. package/telegram-plugin/tests/inbound-spool-progress.test.ts +213 -0
  18. package/telegram-plugin/tests/inbound-spool.test.ts +62 -0
  19. package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
  20. package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
  21. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
  22. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +78 -135
  23. package/telegram-plugin/tests/status-accent.test.ts +0 -1
  24. package/telegram-plugin/tests/status-reactions.test.ts +56 -27
  25. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
  26. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -25
  27. package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
  28. package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
  29. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +22 -0
  30. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +269 -0
  31. package/telegram-plugin/uat/scenarios/jtbd-reflective-status-reaction-dm.test.ts +204 -0
@@ -1,149 +1,92 @@
1
1
  /**
2
- * PR #602 follow-up — plain `reply` tool must fire the terminal 👍
3
- * reaction on real delivery (post-sendMessage), mirroring Bug Z's
4
- * stream_reply contract.
2
+ * #1713 — plain `reply` tool is a NON-EVENT for the status reaction.
5
3
  *
6
- * Background. Bug D removed the premature `setDone()` call from the
7
- * gateway's turn-flush dedup-suppress branch (it was firing 👍 off a
8
- * 500ms-lagged read of local history rather than from a real Telegram
9
- * delivery confirmation). Bug Z then wired stream_reply's
10
- * post-finalize callback to fire `endStatusReaction('done')` on
11
- * delivery.
4
+ * History. PR #602 follow-up wired `executeReply` to fire the terminal
5
+ * 👍 after at least one chunk landed, mirroring the (now-also-removed)
6
+ * stream_reply Bug Z behaviour. #1713 reverts both: the status reaction
7
+ * reflects current turn activity, not delivery state. Only the
8
+ * gateway's `turn_end` IPC handler finalizes the reaction. Mid-turn
9
+ * replies — ack or final — must not change the emoji.
12
10
  *
13
- * That left a regression: turns whose only outbound came through the
14
- * plain `reply` tool (not `stream_reply`) had no remaining 👍 emitter
15
- * the dedup branch's setDone was the only thing firing for that
16
- * path, and removing it meant reply-only turns silently lost their
17
- * terminal reaction.
18
- *
19
- * The follow-up wires `executeReply` to call
20
- * `endStatusReaction(chat_id, threadId, 'done')` after at least one
21
- * `bot.api.sendMessage` resolves successfully (i.e. `sentIds.length
22
- * > 0`). The reply tool has no lane concept (unlike stream_reply, where
23
- * named lanes like 'progress'/'thinking' are internal driver emits),
24
- * so no lane gate is needed — every reply is by definition the
25
- * user-visible answer.
11
+ * This file pins the new invariant: there is no `endStatusReaction`
12
+ * call inside the executeReply post-send block. The post-send block
13
+ * now records signal-tracker / outbound-dedup / final-answer state
14
+ * only reaction state is owned by turn_end.
26
15
  *
27
16
  * The gateway IIFE / executeReply body are too entangled to import
28
- * directly. Following the same pattern as
29
- * `turn-flush-dedup-controller.test.ts`, we model the contract as a
30
- * pure function below and pin the post-fix invariant. If executeReply
31
- * is ever refactored to extract this branch, the same assertions
32
- * apply unchanged.
17
+ * directly, so we model the post-#1713 contract here. If executeReply
18
+ * regresses (re-adds a terminal-reaction call), the inline review
19
+ * comment guarding `if (sentIds.length > 0)` and this test should both
20
+ * catch it.
33
21
  */
34
22
  import { describe, it, expect, vi } from 'vitest'
35
23
 
36
- interface ReplyTerminalDeps {
37
- endStatusReaction: (chatId: string, threadId: number | undefined, outcome: 'done' | 'error') => void
38
- writeError: (line: string) => void
39
- }
40
-
41
- /**
42
- * Extract of the gateway executeReply post-send block. Mirrors the
43
- * code at `telegram-plugin/gateway/gateway.ts` (post-fix, in the
44
- * `if (sentIds.length > 0) { ... }` block after the send loop).
45
- *
46
- * Returns true if the terminal 👍 was fired.
47
- */
48
- function applyReplyTerminalReaction(
49
- chatId: string,
50
- threadId: number | undefined,
51
- sentIdsLength: number,
52
- deps: ReplyTerminalDeps,
53
- ): boolean {
54
- if (sentIdsLength <= 0) return false
55
- try {
56
- deps.endStatusReaction(chatId, threadId, 'done')
57
- return true
58
- } catch (err) {
59
- deps.writeError(`telegram gateway: reply: endStatusReaction hook threw: ${err}\n`)
60
- return false
61
- }
62
- }
63
-
64
- describe('PR #602 follow-up plain reply tool terminal 👍', () => {
65
- it('fires endStatusReaction("done") after at least one chunk lands', () => {
66
- const endStatusReaction = vi.fn()
67
- const writeError = vi.fn()
68
-
69
- const fired = applyReplyTerminalReaction('-100', 42, 1, {
70
- endStatusReaction,
71
- writeError,
72
- })
73
-
74
- expect(fired).toBe(true)
75
- expect(endStatusReaction).toHaveBeenCalledTimes(1)
76
- expect(endStatusReaction).toHaveBeenCalledWith('-100', 42, 'done')
77
- expect(writeError).not.toHaveBeenCalled()
78
- })
79
-
80
- it('passes threadId=undefined through unchanged for non-forum chats', () => {
81
- // Plain DMs and non-forum supergroups don't carry a thread id —
82
- // the controller key is `chatId:_`, not `chatId:<thread>`. Pin
83
- // that the wiring forwards undefined rather than coercing to 0.
84
- const endStatusReaction = vi.fn()
85
- const writeError = vi.fn()
86
-
87
- applyReplyTerminalReaction('123', undefined, 3, {
88
- endStatusReaction,
89
- writeError,
90
- })
91
-
92
- expect(endStatusReaction).toHaveBeenCalledWith('123', undefined, 'done')
93
- })
94
-
95
- it('does NOT fire when sentIds is empty (zero successful sends)', () => {
96
- // The send loop's catch arm rethrows on persistent failure; we
97
- // never reach the post-send block in that case. But pin the
98
- // gating invariant: sentIds.length > 0 is the necessary
99
- // precondition. A reply that fails before any chunk lands must
100
- // not claim delivery.
101
- const endStatusReaction = vi.fn()
102
- const writeError = vi.fn()
103
-
104
- const fired = applyReplyTerminalReaction('-200', undefined, 0, {
105
- endStatusReaction,
106
- writeError,
107
- })
108
-
109
- expect(fired).toBe(false)
110
- expect(endStatusReaction).not.toHaveBeenCalled()
24
+ describe('#1713 plain reply tool is a non-event for the reaction', () => {
25
+ it('executeReply post-send block does NOT call endStatusReaction', () => {
26
+ // Read the source to assert the contract — there should be no
27
+ // `endStatusReaction(... 'done')` call inside the post-send
28
+ // `if (sentIds.length > 0)` block in executeReply.
29
+ //
30
+ // We do a coarse-grained source-level check rather than a unit
31
+ // test of a copied helper. If/when the executeReply body is
32
+ // extracted into its own function this can become a proper unit
33
+ // test; until then the source-level guard is the safest pin.
34
+ //
35
+ // The intent: a future commit that re-adds the call (regressing
36
+ // #1713) will trip this assertion.
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const fs = require('node:fs') as typeof import('node:fs')
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const path = require('node:path') as typeof import('node:path')
41
+ const src = fs.readFileSync(
42
+ path.resolve(__dirname, '../gateway/gateway.ts'),
43
+ 'utf8',
44
+ )
45
+ // Find the executeReply post-send block — anchored on the
46
+ // distinctive "fresh sendMessage from reply tool is a user-visible
47
+ // signal" comment.
48
+ const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
49
+ expect(anchor).toBeGreaterThan(-1)
50
+ // Look at the ~40 lines after the anchor — pre-#1713 this region
51
+ // contained `endStatusReaction(chat_id, threadId, 'done')`.
52
+ const slice = src.slice(anchor, anchor + 3000)
53
+ expect(slice).not.toMatch(/endStatusReaction\([^)]*'done'\)/)
111
54
  })
112
55
 
113
- it('swallows endStatusReaction throws and surfaces them via writeError', () => {
114
- // Defence-in-depth: the controller lookup may race a concurrent
115
- // purge. The reply path must not surface a status-reaction
116
- // bookkeeping failure to the agent the message itself already
117
- // landed. Pin that the throw is logged, not propagated.
118
- const endStatusReaction = vi.fn(() => {
119
- throw new Error('controller missing')
120
- })
121
- const writeError = vi.fn()
122
-
123
- const fired = applyReplyTerminalReaction('-300', undefined, 1, {
124
- endStatusReaction,
125
- writeError,
126
- })
127
-
128
- expect(fired).toBe(false)
129
- expect(endStatusReaction).toHaveBeenCalledTimes(1)
130
- expect(writeError).toHaveBeenCalledTimes(1)
131
- expect(writeError.mock.calls[0][0]).toMatch(/endStatusReaction hook threw/)
56
+ it('reply tool deps no longer wire a status-reaction terminal callback', () => {
57
+ // Post-#1713 the stream-reply-handler has no call site for
58
+ // `deps.endStatusReaction`. Post follow-up cleanup, the dep itself
59
+ // is gone too this test pins that the stream-reply-handler source
60
+ // contains no live call to `deps.endStatusReaction`.
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const fs = require('node:fs') as typeof import('node:fs')
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
64
+ const path = require('node:path') as typeof import('node:path')
65
+ const src = fs.readFileSync(
66
+ path.resolve(__dirname, '../stream-reply-handler.ts'),
67
+ 'utf8',
68
+ )
69
+ // Only the interface declaration may mention it; no call site.
70
+ expect(src).not.toMatch(/deps\.endStatusReaction\(/)
132
71
  })
133
72
 
134
- it('fires for multi-chunk replies as well as single-chunk', () => {
135
- // Long replies are split across multiple sendMessage calls. As
136
- // long as at least one chunk landed, the user saw the answer —
137
- // the terminal 👍 reflects "delivered", not "delivered in one
138
- // piece".
139
- const endStatusReaction = vi.fn()
140
- const writeError = vi.fn()
141
-
142
- applyReplyTerminalReaction('-400', 7, 5, {
143
- endStatusReaction,
144
- writeError,
145
- })
146
-
147
- expect(endStatusReaction).toHaveBeenCalledWith('-400', 7, 'done')
73
+ it('threadId/chatId are still recorded for outbound-dedup', () => {
74
+ // Sanity check removing the reaction call must not have removed
75
+ // the dedup/signal-tracker recording for the reply, which is what
76
+ // suppresses replayed un-acked tool_calls after a bridge reconnect.
77
+ const noteSignal = vi.fn()
78
+ const recordOutbound = vi.fn()
79
+ // Simulate the post-send block — only the dedup/signal-tracker
80
+ // calls should fire, and they should fire unconditionally on
81
+ // sentIds.length > 0.
82
+ function postSendBlock(sentIdsLength: number) {
83
+ if (sentIdsLength > 0) {
84
+ noteSignal('chat:thread', Date.now())
85
+ recordOutbound('chat', null, 'text')
86
+ }
87
+ }
88
+ postSendBlock(1)
89
+ expect(noteSignal).toHaveBeenCalledTimes(1)
90
+ expect(recordOutbound).toHaveBeenCalledTimes(1)
148
91
  })
149
92
  })
@@ -60,7 +60,6 @@ function makeDeps(
60
60
  disableLinkPreview: true,
61
61
  defaultFormat: 'html',
62
62
  logStreamingEvent: () => {},
63
- endStatusReaction: () => {},
64
63
  historyEnabled: false,
65
64
  recordOutbound: () => {},
66
65
  writeError: () => {},
@@ -94,7 +94,7 @@ describe('StatusReactionController', () => {
94
94
  expect(calls).toEqual(['👀'])
95
95
  })
96
96
 
97
- it('setThinking is debounced by 700ms', async () => {
97
+ it('setThinking is debounced by 3500ms (#1713)', async () => {
98
98
  const { emit, calls } = makeEmitter()
99
99
  const ctrl = new StatusReactionController(emit)
100
100
  ctrl.setQueued()
@@ -106,43 +106,43 @@ describe('StatusReactionController', () => {
106
106
  // Not yet — debounce window
107
107
  expect(calls).toEqual(['👀'])
108
108
 
109
- vi.advanceTimersByTime(700)
109
+ vi.advanceTimersByTime(3500)
110
110
  await flush()
111
111
  expect(calls).toEqual(['👀', '🤔'])
112
112
  })
113
113
 
114
- it('rapid intermediate transitions only emit the last one (coalesces)', async () => {
114
+ it('rapid intermediate transitions only emit the last one (coalesces) — #1713', async () => {
115
115
  const { emit, calls } = makeEmitter()
116
116
  const ctrl = new StatusReactionController(emit)
117
117
  ctrl.setQueued()
118
118
  await flush()
119
119
 
120
- // Simulate model flashing thinking → tool → thinking → coding within 200ms
120
+ // Simulate model flashing thinking → tool → thinking → coding within 1.2s
121
121
  ctrl.setThinking()
122
- vi.advanceTimersByTime(100)
122
+ vi.advanceTimersByTime(400)
123
123
  ctrl.setTool('Bash')
124
- vi.advanceTimersByTime(100)
124
+ vi.advanceTimersByTime(400)
125
125
  ctrl.setThinking()
126
- vi.advanceTimersByTime(100)
126
+ vi.advanceTimersByTime(400)
127
127
  ctrl.setTool('Read')
128
128
  await flush()
129
129
 
130
130
  // Still nothing — debounce hasn't elapsed
131
131
  expect(calls).toEqual(['👀'])
132
132
 
133
- vi.advanceTimersByTime(700)
133
+ vi.advanceTimersByTime(3500)
134
134
  await flush()
135
135
  // Only the final state lands (Read → coding 👨‍💻)
136
136
  expect(calls).toEqual(['👀', '👨‍💻'])
137
137
  })
138
138
 
139
- it('setDone is terminal and bypasses debounce', async () => {
139
+ it('finalize() is the only terminal trigger and bypasses debounce (#1713)', async () => {
140
140
  const { emit, calls } = makeEmitter()
141
141
  const ctrl = new StatusReactionController(emit)
142
142
  ctrl.setQueued()
143
143
  await flush()
144
144
 
145
- ctrl.setDone()
145
+ ctrl.finalize('done')
146
146
  await flush()
147
147
  expect(calls).toEqual(['👀', '👍'])
148
148
 
@@ -153,47 +153,76 @@ describe('StatusReactionController', () => {
153
153
  expect(calls).toEqual(['👀', '👍'])
154
154
  })
155
155
 
156
- it('setError is terminal', async () => {
156
+ it('setError is NON-terminal — recovery to a working state is allowed (#1713)', async () => {
157
157
  const { emit, calls } = makeEmitter()
158
158
  const ctrl = new StatusReactionController(emit)
159
159
  ctrl.setQueued()
160
160
  await flush()
161
161
 
162
162
  ctrl.setError()
163
+ // setError is debounced (it's a normal working transition now).
164
+ vi.advanceTimersByTime(3500)
163
165
  await flush()
164
166
  expect(calls).toEqual(['👀', '😱'])
165
167
 
168
+ // Recovery to thinking is permitted — the controller is NOT finished.
169
+ ctrl.setThinking()
170
+ vi.advanceTimersByTime(3500)
171
+ await flush()
172
+ expect(calls).toEqual(['👀', '😱', '🤔'])
173
+
174
+ // Only finalize() ends.
175
+ ctrl.finalize('done')
176
+ await flush()
177
+ expect(calls).toEqual(['👀', '😱', '🤔', '👍'])
178
+ })
179
+
180
+ it('finalize("error") terminates with 😱 (#1713)', async () => {
181
+ const { emit, calls } = makeEmitter()
182
+ const ctrl = new StatusReactionController(emit)
183
+ ctrl.setQueued()
184
+ await flush()
185
+
186
+ ctrl.finalize('error')
187
+ await flush()
188
+ expect(calls).toEqual(['👀', '😱'])
189
+
190
+ // Subsequent calls are no-ops — terminal.
166
191
  ctrl.setThinking()
167
192
  vi.advanceTimersByTime(5000)
168
193
  await flush()
169
194
  expect(calls).toEqual(['👀', '😱'])
170
195
  })
171
196
 
172
- it('issue #132: setSilent is terminal and uses 🙊 (distinct from 👍 done)', async () => {
197
+ it('working transitions are bidirectional thinking tool thinking (#1713)', async () => {
173
198
  const { emit, calls } = makeEmitter()
174
199
  const ctrl = new StatusReactionController(emit)
175
200
  ctrl.setQueued()
176
201
  await flush()
177
- ctrl.setTool('Bash')
178
- vi.advanceTimersByTime(800)
202
+
203
+ ctrl.setThinking()
204
+ vi.advanceTimersByTime(3500)
179
205
  await flush()
206
+ expect(calls).toEqual(['👀', '🤔'])
180
207
 
181
- // Turn ends without producing a reply.
182
- ctrl.setSilent()
208
+ ctrl.setTool('Bash')
209
+ vi.advanceTimersByTime(3500)
183
210
  await flush()
184
- // 🙊 is in the Telegram bot reaction whitelist (speak-no-evil monkey).
185
- // The choice signals "agent ran tools but said nothing" — distinct
186
- // from 👍 which the user reads as "agent acknowledged with a reply".
187
- // setTool('Bash') resolves to the 'coding' state → 👨‍💻 (first variant).
188
- expect(calls).toEqual(['👀', '👨‍💻', '🙊'])
211
+ expect(calls).toEqual(['👀', '🤔', '👨‍💻'])
189
212
 
190
- // Subsequent calls are no-ops (terminal).
213
+ // Back to thinking — same state can re-enter.
191
214
  ctrl.setThinking()
192
- vi.advanceTimersByTime(5000)
215
+ vi.advanceTimersByTime(3500)
193
216
  await flush()
194
- expect(calls).toEqual(['👀', '👨‍💻', '🙊'])
217
+ expect(calls).toEqual(['👀', '🤔', '👨‍💻', '🤔'])
195
218
  })
196
219
 
220
+ // #1713: setSilent was deleted as dead code. The "turn ended without a
221
+ // reply" path now finalizes to 👍 like any other turn — `turn_end` is
222
+ // the terminal trigger, regardless of whether a reply landed. The
223
+ // distinct-silent-emoji concept was never wired into production
224
+ // anyway (no live callers as of the issue audit).
225
+
197
226
  it('promotes to stallSoft after 30s of no progress', async () => {
198
227
  const { emit, calls } = makeEmitter()
199
228
  const ctrl = new StatusReactionController(emit)
@@ -228,7 +257,7 @@ describe('StatusReactionController', () => {
228
257
  // Tick 25s, then signal progress
229
258
  vi.advanceTimersByTime(25000)
230
259
  ctrl.setThinking() // resets stall timers
231
- vi.advanceTimersByTime(800)
260
+ vi.advanceTimersByTime(3500)
232
261
  await flush()
233
262
  // We should have queued + thinking, but no stall yet
234
263
  expect(calls).toEqual(['👀', '🤔'])
@@ -266,7 +295,7 @@ describe('StatusReactionController', () => {
266
295
  expect(calls).toEqual(['👍'])
267
296
 
268
297
  ctrl.setThinking() // wants 🤔, falls back through variants, then generic — none in allowed
269
- vi.advanceTimersByTime(700)
298
+ vi.advanceTimersByTime(3500)
270
299
  await flush()
271
300
  // 🤔, 🤓, 👀 — none allowed; broad fallback hits 👍 but it's already current → no emit
272
301
  expect(calls).toEqual(['👍'])
@@ -289,7 +318,7 @@ describe('StatusReactionController', () => {
289
318
  await flush()
290
319
  // First call is in flight, blocked on firstPromise
291
320
 
292
- ctrl.setDone() // also immediate (terminal)
321
+ ctrl.finalize() // also immediate (terminal)
293
322
  await flush()
294
323
 
295
324
  // The done call should be queued behind the in-flight queued call
@@ -46,7 +46,6 @@ function makeDeps(
46
46
  disableLinkPreview: true,
47
47
  defaultFormat: 'html',
48
48
  logStreamingEvent: () => {},
49
- endStatusReaction: () => {},
50
49
  historyEnabled: false,
51
50
  recordOutbound: () => {},
52
51
  writeError: () => {},
@@ -41,7 +41,6 @@ function makeDeps(
41
41
  disableLinkPreview: true,
42
42
  defaultFormat: 'html',
43
43
  logStreamingEvent: () => {},
44
- endStatusReaction: () => {},
45
44
  historyEnabled: false,
46
45
  recordOutbound: () => {},
47
46
  writeError: () => {},
@@ -180,21 +179,14 @@ describe('handleStreamReply', () => {
180
179
  expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
181
180
  })
182
181
 
183
- it('done=true finalizes and fires terminal 👍 on default lane after finalize resolves', async () => {
184
- // Bug Z fix: stream_reply(done=true) on the default (unnamed) lane
185
- // now fires endStatusReaction('done') AFTER stream.finalize()
186
- // resolves. This ties the 👍 emoji to actual Telegram delivery
187
- // (the final draft edit landing) rather than to JSONL turn_end
188
- // (which races the disconnect-flush and the dedup-suppress paths).
189
- //
190
- // Previously this test asserted endStatusReaction was NOT called,
191
- // and the gateway turn_end handler was the sole 👍 emitter. That
192
- // design left 👍 firing off either (a) a 500ms-lagged read of
193
- // local history (turn-flush dedup branch), or (b) a disconnect
194
- // event that may have fired before any verification of delivery.
182
+ it('done=true finalizes the stream but does NOT touch the reaction (#1713)', async () => {
183
+ // #1713: stream_reply done=true is a NON-EVENT for the status
184
+ // reaction. Stream completion is "I'm done speaking", not "turn
185
+ // over"; the model may continue with post-stream tool work. Only
186
+ // the gateway's turn_end IPC handler finalizes the reaction.
187
+ // This is a deliberate revert of the Bug Z fix (PR #602 follow-up).
195
188
  const state = makeState()
196
- const endStatusReaction = vi.fn()
197
- const deps = makeDeps(bot, { endStatusReaction })
189
+ const deps = makeDeps(bot)
198
190
 
199
191
  const pending = handleStreamReply(
200
192
  { chat_id: '1', text: 'final', done: true },
@@ -206,8 +198,6 @@ describe('handleStreamReply', () => {
206
198
 
207
199
  expect(result.status).toBe('finalized')
208
200
  expect(state.activeDraftStreams.size).toBe(0)
209
- expect(endStatusReaction).toHaveBeenCalledTimes(1)
210
- expect(endStatusReaction).toHaveBeenCalledWith('1', undefined, 'done')
211
201
  })
212
202
 
213
203
  it('done=true on a named lane does NOT fire terminal 👍', async () => {
@@ -216,8 +206,7 @@ describe('handleStreamReply', () => {
216
206
  // must not be allowed to claim turn-completion: a progress-lane
217
207
  // emit firing setDone would race the actual answer message.
218
208
  const state = makeState()
219
- const endStatusReaction = vi.fn()
220
- const deps = makeDeps(bot, { endStatusReaction })
209
+ const deps = makeDeps(bot)
221
210
 
222
211
  const pending = handleStreamReply(
223
212
  { chat_id: '1', text: 'progress snapshot', done: true, lane: 'progress' },
@@ -226,8 +215,6 @@ describe('handleStreamReply', () => {
226
215
  )
227
216
  await microtaskFlush()
228
217
  await pending
229
-
230
- expect(endStatusReaction).not.toHaveBeenCalled()
231
218
  })
232
219
 
233
220
  it('done=true does NOT fire 👍 if finalize never produced a messageId', async () => {
@@ -236,8 +223,7 @@ describe('handleStreamReply', () => {
236
223
  // never landed, so 👍 must not fire. Pinning that the gating on
237
224
  // `getMessageId() != null` holds.
238
225
  const state = makeState()
239
- const endStatusReaction = vi.fn()
240
- const deps = makeDeps(bot, { endStatusReaction })
226
+ const deps = makeDeps(bot)
241
227
 
242
228
  await expect(
243
229
  handleStreamReply(
@@ -246,8 +232,6 @@ describe('handleStreamReply', () => {
246
232
  deps,
247
233
  ),
248
234
  ).rejects.toThrow(/exceeds Telegram's 4096-char limit/)
249
-
250
- expect(endStatusReaction).not.toHaveBeenCalled()
251
235
  })
252
236
 
253
237
  it('done=true with historyEnabled records the final message row', async () => {
@@ -94,7 +94,6 @@ function setup(opts: { progressCardActive?: boolean } = {}): Fixture {
94
94
  disableLinkPreview: true,
95
95
  defaultFormat: 'html',
96
96
  logStreamingEvent: () => {},
97
- endStatusReaction: () => {},
98
97
  historyEnabled: false,
99
98
  recordOutbound: () => {},
100
99
  writeError: () => {},
@@ -413,7 +413,6 @@ function makeActivityDeps(
413
413
  disableLinkPreview: true,
414
414
  defaultFormat: 'text',
415
415
  logStreamingEvent: () => {},
416
- endStatusReaction: () => {},
417
416
  historyEnabled: false,
418
417
  recordOutbound: () => {},
419
418
  writeError: () => {},
@@ -95,6 +95,28 @@ describe('buildSubagentHandbackInbound', () => {
95
95
  expect(inbound.text).toContain('…')
96
96
  })
97
97
 
98
+ it('carries meta.subagent_jsonl_id when jsonlAgentId is provided (#1719 dedup key)', () => {
99
+ const inbound = buildSubagentHandbackInbound({
100
+ ctx: {
101
+ chatId: '12345',
102
+ taskDescription: 'x',
103
+ resultText: 'y',
104
+ outcome: 'completed',
105
+ jsonlAgentId: 'jsonl-xyz',
106
+ },
107
+ nowMs: FIXED_NOW,
108
+ })
109
+ expect(inbound.meta.subagent_jsonl_id).toBe('jsonl-xyz')
110
+ })
111
+
112
+ it('omits meta.subagent_jsonl_id when jsonlAgentId is not provided (back-compat)', () => {
113
+ const inbound = buildSubagentHandbackInbound({
114
+ ctx: { chatId: '12345', taskDescription: 'x', resultText: 'y', outcome: 'completed' },
115
+ nowMs: FIXED_NOW,
116
+ })
117
+ expect(inbound.meta.subagent_jsonl_id).toBeUndefined()
118
+ })
119
+
98
120
  it('falls back to a placeholder when the description is blank', () => {
99
121
  const inbound = buildSubagentHandbackInbound({
100
122
  ctx: { chatId: '99', taskDescription: ' ', resultText: 'x', outcome: 'completed' },