switchroom 0.13.26 → 0.13.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/active-reactions-sweep.ts +4 -4
- package/telegram-plugin/dist/gateway/gateway.js +240 -64
- package/telegram-plugin/docs/waiting-ux-spec.md +17 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +10 -6
- package/telegram-plugin/gateway/gateway.ts +199 -61
- package/telegram-plugin/gateway/inbound-spool.ts +69 -2
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +14 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +256 -0
- package/telegram-plugin/pending-work-progress.ts +5 -1
- package/telegram-plugin/status-reactions.ts +70 -58
- package/telegram-plugin/stream-reply-handler.ts +7 -36
- package/telegram-plugin/subagent-watcher.ts +64 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -3
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +213 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +62 -0
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +0 -1
- package/telegram-plugin/tests/outbound-ordering.test.ts +0 -1
- package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -1
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +119 -133
- package/telegram-plugin/tests/status-accent.test.ts +0 -1
- package/telegram-plugin/tests/status-reactions.test.ts +56 -27
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +0 -1
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -25
- package/telegram-plugin/tests/streaming-e2e.test.ts +0 -1
- package/telegram-plugin/tests/streaming-orchestration.test.ts +0 -1
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +22 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +269 -0
- package/telegram-plugin/uat/scenarios/jtbd-reflective-status-reaction-dm.test.ts +204 -0
|
@@ -1,149 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* reaction
|
|
4
|
-
* stream_reply contract.
|
|
2
|
+
* #1713 + #1728 — interim-ack `reply` tool is a non-event for the
|
|
3
|
+
* status reaction; final-answer `reply` finalizes.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* History.
|
|
6
|
+
* - PR #602 follow-up wired `executeReply` to fire the terminal 👍
|
|
7
|
+
* after at least one chunk landed.
|
|
8
|
+
* - #1713 (#1718) reverted that: reaction reflects current turn
|
|
9
|
+
* activity, not delivery state. Made `turn_end` the sole terminal
|
|
10
|
+
* trigger so mid-turn ACK replies don't collapse the working-state
|
|
11
|
+
* ladder to 👍.
|
|
12
|
+
* - #1728 (this fix) carve-out: Claude Code's `turn_duration` system
|
|
13
|
+
* event is unreliable for the trivial-prompt happy path, leaving
|
|
14
|
+
* `activeTurnStartedAt` set forever and every subsequent inbound
|
|
15
|
+
* stuck "held mid-turn" (the v0.13.27 wedge). When the reply IS
|
|
16
|
+
* the final answer (`isFinalAnswerReply` returns true), executeReply
|
|
17
|
+
* calls `finalizeStatusReaction` to release the buffer gate and
|
|
18
|
+
* emit the (debounced 3500ms) terminal 👍. Interim acks bypass this
|
|
19
|
+
* branch and remain non-events for the reaction.
|
|
12
20
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
21
|
+
* Net contract pinned here:
|
|
22
|
+
* - executeReply post-send block must NOT call the legacy
|
|
23
|
+
* `endStatusReaction('done')` (the pre-#1713 bug class).
|
|
24
|
+
* - executeReply MUST call `finalizeStatusReaction` gated on
|
|
25
|
+
* `isFinalAnswerReply` so the buffer gate releases on final answer.
|
|
26
26
|
*
|
|
27
27
|
* The gateway IIFE / executeReply body are too entangled to import
|
|
28
|
-
* directly
|
|
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.
|
|
28
|
+
* directly, so we do source-level assertions.
|
|
33
29
|
*/
|
|
34
30
|
import { describe, it, expect, vi } from 'vitest'
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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')
|
|
32
|
+
describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
33
|
+
it('executeReply post-send block does NOT call legacy endStatusReaction', () => {
|
|
34
|
+
// The pre-#1713 bug class was `endStatusReaction(chat_id, threadId,
|
|
35
|
+
// 'done')` firing 👍 unconditionally on every reply (including
|
|
36
|
+
// mid-turn acks). That call must stay removed.
|
|
37
|
+
//
|
|
38
|
+
// We do a coarse-grained source-level check rather than a unit
|
|
39
|
+
// test of a copied helper. If/when the executeReply body is
|
|
40
|
+
// extracted into its own function this can become a proper unit
|
|
41
|
+
// test; until then the source-level guard is the safest pin.
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
const fs = require('node:fs') as typeof import('node:fs')
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
const path = require('node:path') as typeof import('node:path')
|
|
46
|
+
const src = fs.readFileSync(
|
|
47
|
+
path.resolve(__dirname, '../gateway/gateway.ts'),
|
|
48
|
+
'utf8',
|
|
49
|
+
)
|
|
50
|
+
// Find the executeReply post-send block — anchored on the
|
|
51
|
+
// distinctive "fresh sendMessage from reply tool is a user-visible
|
|
52
|
+
// signal" comment.
|
|
53
|
+
const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
|
|
54
|
+
expect(anchor).toBeGreaterThan(-1)
|
|
55
|
+
// Look at the ~40 lines after the anchor — pre-#1713 this region
|
|
56
|
+
// contained `endStatusReaction(chat_id, threadId, 'done')`.
|
|
57
|
+
const slice = src.slice(anchor, anchor + 3000)
|
|
58
|
+
expect(slice).not.toMatch(/endStatusReaction\([^)]*'done'\)/)
|
|
93
59
|
})
|
|
94
60
|
|
|
95
|
-
it('
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
61
|
+
it('executeReply post-send block DOES call finalizeStatusReaction gated on isFinalAnswerReply (#1728 wedge fix)', () => {
|
|
62
|
+
// #1728 — the v0.13.27 wedge: `turn_duration` system events from
|
|
63
|
+
// Claude Code don't reliably land for trivial-prompt turns, so
|
|
64
|
+
// activeTurnStartedAt never clears and every subsequent inbound
|
|
65
|
+
// gets held mid-turn. The fix: when executeReply detects a final-
|
|
66
|
+
// answer reply (the same `isFinalAnswerReply` classifier #1664
|
|
67
|
+
// uses for silent-end re-prompt gating), trigger
|
|
68
|
+
// `finalizeStatusReaction` to release the buffer gate. Interim
|
|
69
|
+
// acks (isFinalAnswerReply === false) MUST bypass this branch and
|
|
70
|
+
// remain non-events for the reaction (preserves #1713).
|
|
71
|
+
//
|
|
72
|
+
// If a future commit removes the finalizeStatusReaction call or
|
|
73
|
+
// un-gates it from isFinalAnswerReply, the wedge returns.
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
75
|
+
const fs = require('node:fs') as typeof import('node:fs')
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
|
+
const path = require('node:path') as typeof import('node:path')
|
|
78
|
+
const src = fs.readFileSync(
|
|
79
|
+
path.resolve(__dirname, '../gateway/gateway.ts'),
|
|
80
|
+
'utf8',
|
|
81
|
+
)
|
|
82
|
+
const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
|
|
83
|
+
expect(anchor).toBeGreaterThan(-1)
|
|
84
|
+
const slice = src.slice(anchor, anchor + 3000)
|
|
85
|
+
// The finalize MUST appear in the post-send block.
|
|
86
|
+
expect(slice).toMatch(/finalizeStatusReaction\(/)
|
|
87
|
+
// It MUST be gated by isFinalAnswerReply (the classifier prevents
|
|
88
|
+
// interim acks from firing 👍, which would regress #1713).
|
|
89
|
+
expect(slice).toMatch(/isFinalAnswerReply\(/)
|
|
90
|
+
// Sanity: the finalize MUST appear AFTER the isFinalAnswerReply
|
|
91
|
+
// check (i.e. inside the gated branch), not as a sibling that
|
|
92
|
+
// fires unconditionally.
|
|
93
|
+
const gateIdx = slice.indexOf('isFinalAnswerReply(')
|
|
94
|
+
const finalizeIdx = slice.indexOf('finalizeStatusReaction(')
|
|
95
|
+
expect(gateIdx).toBeGreaterThan(-1)
|
|
96
|
+
expect(finalizeIdx).toBeGreaterThan(gateIdx)
|
|
111
97
|
})
|
|
112
98
|
|
|
113
|
-
it('
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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/)
|
|
99
|
+
it('reply tool deps no longer wire a status-reaction terminal callback', () => {
|
|
100
|
+
// Post-#1713 the stream-reply-handler has no call site for
|
|
101
|
+
// `deps.endStatusReaction`. Post follow-up cleanup, the dep itself
|
|
102
|
+
// is gone too — this test pins that the stream-reply-handler source
|
|
103
|
+
// contains no live call to `deps.endStatusReaction`.
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
105
|
+
const fs = require('node:fs') as typeof import('node:fs')
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
107
|
+
const path = require('node:path') as typeof import('node:path')
|
|
108
|
+
const src = fs.readFileSync(
|
|
109
|
+
path.resolve(__dirname, '../stream-reply-handler.ts'),
|
|
110
|
+
'utf8',
|
|
111
|
+
)
|
|
112
|
+
// Only the interface declaration may mention it; no call site.
|
|
113
|
+
expect(src).not.toMatch(/deps\.endStatusReaction\(/)
|
|
132
114
|
})
|
|
133
115
|
|
|
134
|
-
it('
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
116
|
+
it('threadId/chatId are still recorded for outbound-dedup', () => {
|
|
117
|
+
// Sanity check — removing the reaction call must not have removed
|
|
118
|
+
// the dedup/signal-tracker recording for the reply, which is what
|
|
119
|
+
// suppresses replayed un-acked tool_calls after a bridge reconnect.
|
|
120
|
+
const noteSignal = vi.fn()
|
|
121
|
+
const recordOutbound = vi.fn()
|
|
122
|
+
// Simulate the post-send block — only the dedup/signal-tracker
|
|
123
|
+
// calls should fire, and they should fire unconditionally on
|
|
124
|
+
// sentIds.length > 0.
|
|
125
|
+
function postSendBlock(sentIdsLength: number) {
|
|
126
|
+
if (sentIdsLength > 0) {
|
|
127
|
+
noteSignal('chat:thread', Date.now())
|
|
128
|
+
recordOutbound('chat', null, 'text')
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
postSendBlock(1)
|
|
132
|
+
expect(noteSignal).toHaveBeenCalledTimes(1)
|
|
133
|
+
expect(recordOutbound).toHaveBeenCalledTimes(1)
|
|
148
134
|
})
|
|
149
135
|
})
|
|
@@ -94,7 +94,7 @@ describe('StatusReactionController', () => {
|
|
|
94
94
|
expect(calls).toEqual(['👀'])
|
|
95
95
|
})
|
|
96
96
|
|
|
97
|
-
it('setThinking is debounced by
|
|
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(
|
|
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
|
|
120
|
+
// Simulate model flashing thinking → tool → thinking → coding within 1.2s
|
|
121
121
|
ctrl.setThinking()
|
|
122
|
-
vi.advanceTimersByTime(
|
|
122
|
+
vi.advanceTimersByTime(400)
|
|
123
123
|
ctrl.setTool('Bash')
|
|
124
|
-
vi.advanceTimersByTime(
|
|
124
|
+
vi.advanceTimersByTime(400)
|
|
125
125
|
ctrl.setThinking()
|
|
126
|
-
vi.advanceTimersByTime(
|
|
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(
|
|
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('
|
|
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.
|
|
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('
|
|
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
|
-
|
|
178
|
-
|
|
202
|
+
|
|
203
|
+
ctrl.setThinking()
|
|
204
|
+
vi.advanceTimersByTime(3500)
|
|
179
205
|
await flush()
|
|
206
|
+
expect(calls).toEqual(['👀', '🤔'])
|
|
180
207
|
|
|
181
|
-
|
|
182
|
-
|
|
208
|
+
ctrl.setTool('Bash')
|
|
209
|
+
vi.advanceTimersByTime(3500)
|
|
183
210
|
await flush()
|
|
184
|
-
|
|
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
|
-
//
|
|
213
|
+
// Back to thinking — same state can re-enter.
|
|
191
214
|
ctrl.setThinking()
|
|
192
|
-
vi.advanceTimersByTime(
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
|
@@ -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
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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: () => {},
|
|
@@ -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' },
|