switchroom 0.13.51 → 0.13.53
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/agent-scheduler/index.js +317 -132
- package/dist/auth-broker/index.js +494 -156
- package/dist/cli/drive-write-pretool.mjs +18 -3
- package/dist/cli/switchroom.js +2452 -1114
- package/dist/host-control/main.js +246 -127
- package/dist/vault/approvals/kernel-server.js +8269 -8146
- package/dist/vault/broker/server.js +2811 -2688
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -4
- package/profiles/_shared/agent-self-service.md.hbs +12 -22
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -1
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-status/SKILL.md +8 -6
- package/telegram-plugin/chat-lock.ts +87 -19
- package/telegram-plugin/dist/gateway/gateway.js +752 -120
- package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +258 -55
- package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
- package/telegram-plugin/stream-reply-handler.ts +10 -8
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
- package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
- package/telegram-plugin/typing-wrap.ts +43 -21
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR3b regression pin: supergroup-mode parallel-turns deadlock fix.
|
|
5
|
+
*
|
|
6
|
+
* The bug. Pre-fix, the gateway used ONE map `activeTurnStartedAt`
|
|
7
|
+
* for two distinct concerns:
|
|
8
|
+
* (a) "user-visible turn started" — set eagerly in the fresh-turn
|
|
9
|
+
* branch on inbound RECEIPT, used by per-key reads (status-query
|
|
10
|
+
* metric, wedge detection, progress timeout, etc.) that want a
|
|
11
|
+
* receipt-side timestamp.
|
|
12
|
+
* (b) "claude is currently busy on this turn" — the fleet-wide gate
|
|
13
|
+
* at purgeReactionTracking / releaseTurnBufferGate /
|
|
14
|
+
* idle-drain / inbound buffer-or-deliver, where `.size === 0`
|
|
15
|
+
* means "claude is idle, safe to flush buffered inbound."
|
|
16
|
+
*
|
|
17
|
+
* Under fleet-shared / DM topology the two concerns coincide — every
|
|
18
|
+
* received inbound is delivered to claude — so the singleton worked.
|
|
19
|
+
*
|
|
20
|
+
* Under SUPERGROUP-OWNED topology (one agent owns the whole
|
|
21
|
+
* supergroup, multiple topics share the gateway process), they
|
|
22
|
+
* diverge:
|
|
23
|
+
*
|
|
24
|
+
* 1. Topic A delivers + processes — keyA in activeTurnStartedAt
|
|
25
|
+
* (set on receipt) AND claudeBusyKeys (set on delivery).
|
|
26
|
+
* 2. Topic B inbound arrives → fresh-turn branch eagerly sets
|
|
27
|
+
* keyB in activeTurnStartedAt, displays 👀 / starts typing.
|
|
28
|
+
* 3. `decideInboundDelivery` reads `turnInFlight = .size > 0` →
|
|
29
|
+
* TRUE (keyA present) → B is buffered, NOT delivered to claude.
|
|
30
|
+
* 4. A's turn_end → purgeReactionTracking deletes keyA →
|
|
31
|
+
* `.size === 0` check fires the held-inbound flush. BUT under
|
|
32
|
+
* old (singleton) semantics, keyB is STILL in
|
|
33
|
+
* activeTurnStartedAt (set in step 2, never cleared because B
|
|
34
|
+
* never started in claude so no turn_end ever fires for B).
|
|
35
|
+
* 5. `.size > 0` (keyB lingers) → flush never runs → B's buffered
|
|
36
|
+
* msg never delivered → B's user sees 👀 forever.
|
|
37
|
+
*
|
|
38
|
+
* DEADLOCK.
|
|
39
|
+
*
|
|
40
|
+
* The fix splits concerns. `activeTurnStartedAt` keeps semantic (a)
|
|
41
|
+
* — set on receipt, all per-key reads use it. `claudeBusyKeys` (new)
|
|
42
|
+
* carries semantic (b) — set ONLY on successful sendToAgent
|
|
43
|
+
* (delivery), cleared on turn_end / disconnect / buffer-gate-release.
|
|
44
|
+
* Fleet gates switch to claudeBusyKeys. The deadlock breaks because
|
|
45
|
+
* step 5's gate now reads `claudeBusyKeys.size` which only ever held
|
|
46
|
+
* keyA (delivered) → after step 4's delete, size === 0 → flush →
|
|
47
|
+
* B's buffered msg delivered → claudeBusyKeys.add(keyB) → ... →
|
|
48
|
+
* B's eventual turn_end clears keyB.
|
|
49
|
+
*
|
|
50
|
+
* This test pins the load-bearing invariants. The actual wiring
|
|
51
|
+
* lives in `gateway.ts` and `disconnect-flush.ts`; this file is the
|
|
52
|
+
* structural-contract regression guard so a future "let's simplify
|
|
53
|
+
* the gates back to one map" refactor fails loudly here.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
describe('PR3b parallel-turns deadlock fix: claudeBusyKeys decoupled from activeTurnStartedAt', () => {
|
|
57
|
+
function makeState() {
|
|
58
|
+
const activeTurnStartedAt = new Map<string, number>()
|
|
59
|
+
const claudeBusyKeys = new Set<string>()
|
|
60
|
+
return { activeTurnStartedAt, claudeBusyKeys }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Mirror the gateway's fresh-turn-on-receipt path (the part that
|
|
64
|
+
// updates the two maps). Stripped to ONLY the state mutations we
|
|
65
|
+
// care about for this invariant.
|
|
66
|
+
function receiveInbound(state: ReturnType<typeof makeState>, key: string, at: number): void {
|
|
67
|
+
state.activeTurnStartedAt.set(key, at)
|
|
68
|
+
// CRITICAL: claudeBusyKeys is NOT touched here. The whole point
|
|
69
|
+
// of the split is that receipt ≠ delivery.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function deliverToClaude(state: ReturnType<typeof makeState>, key: string): void {
|
|
73
|
+
// Mirror of markClaudeBusyForInbound at every successful
|
|
74
|
+
// sendToAgent callsite in gateway.ts.
|
|
75
|
+
state.claudeBusyKeys.add(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function turnEnd(state: ReturnType<typeof makeState>, key: string): void {
|
|
79
|
+
// Mirror of purgeReactionTracking + releaseTurnBufferGate —
|
|
80
|
+
// both maps cleared together at turn_end.
|
|
81
|
+
state.activeTurnStartedAt.delete(key)
|
|
82
|
+
state.claudeBusyKeys.delete(key)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function fleetGateOpen(state: ReturnType<typeof makeState>): boolean {
|
|
86
|
+
// Mirror of the four fleet-wide gates:
|
|
87
|
+
// purgeReactionTracking line 1393: if (claudeBusyKeys.size === 0)
|
|
88
|
+
// releaseTurnBufferGate line 1484: if (claudeBusyKeys.size === 0)
|
|
89
|
+
// onScheduleRestart line 4020: turnInFlight = .size > 0
|
|
90
|
+
// idle-drain tick line 4343: if (.size > 0) return false
|
|
91
|
+
// handleInbound line 8087: turnInFlightAtReceipt = .size > 0
|
|
92
|
+
return state.claudeBusyKeys.size === 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('pre-fix scenario: singleton map deadlocks supergroup-mode parallel turns', () => {
|
|
96
|
+
// Simulate the BROKEN behavior — one map serving both concerns
|
|
97
|
+
// (i.e. what would happen if claudeBusyKeys did NOT exist and
|
|
98
|
+
// the gates read activeTurnStartedAt.size). This is the
|
|
99
|
+
// *opposite* of the test below; documents what we're fixing.
|
|
100
|
+
const legacyState = new Map<string, number>()
|
|
101
|
+
const legacyFleetGate = () => legacyState.size === 0
|
|
102
|
+
|
|
103
|
+
// Step 1: A delivered (eager set on receipt)
|
|
104
|
+
legacyState.set('keyA', 100)
|
|
105
|
+
// Step 2: B received but buffered (eager set fires regardless)
|
|
106
|
+
legacyState.set('keyB', 200)
|
|
107
|
+
expect(legacyFleetGate()).toBe(false)
|
|
108
|
+
|
|
109
|
+
// Step 4: A's turn_end clears keyA
|
|
110
|
+
legacyState.delete('keyA')
|
|
111
|
+
// DEADLOCK — keyB lingers forever because B never started
|
|
112
|
+
// in claude so no turn_end ever fires for B.
|
|
113
|
+
expect(legacyFleetGate()).toBe(false)
|
|
114
|
+
expect(legacyState.has('keyB')).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('post-fix scenario: split maps let A.turn_end unblock B', () => {
|
|
118
|
+
const state = makeState()
|
|
119
|
+
|
|
120
|
+
// Step 1: A's inbound received AND delivered to claude.
|
|
121
|
+
receiveInbound(state, 'keyA', 100)
|
|
122
|
+
deliverToClaude(state, 'keyA')
|
|
123
|
+
expect(state.activeTurnStartedAt.has('keyA')).toBe(true)
|
|
124
|
+
expect(state.claudeBusyKeys.has('keyA')).toBe(true)
|
|
125
|
+
|
|
126
|
+
// Step 2: B's inbound received, but `decideInboundDelivery`
|
|
127
|
+
// returned buffer-until-idle (turnInFlightAtReceipt was true
|
|
128
|
+
// because keyA is in claudeBusyKeys). B's fresh-turn branch
|
|
129
|
+
// STILL fired — eager activeTurnStartedAt[keyB] set —
|
|
130
|
+
// for the user-visible 👀 / typing indicator.
|
|
131
|
+
receiveInbound(state, 'keyB', 200)
|
|
132
|
+
// CRITICAL: claudeBusyKeys does NOT contain keyB because B was
|
|
133
|
+
// buffered, not delivered. The split semantics is the entire
|
|
134
|
+
// PR3b contract.
|
|
135
|
+
expect(state.activeTurnStartedAt.has('keyB')).toBe(true)
|
|
136
|
+
expect(state.claudeBusyKeys.has('keyB')).toBe(false)
|
|
137
|
+
expect(state.claudeBusyKeys.size).toBe(1)
|
|
138
|
+
expect(fleetGateOpen(state)).toBe(false)
|
|
139
|
+
|
|
140
|
+
// Step 4: A's turn_end clears keyA from BOTH maps.
|
|
141
|
+
turnEnd(state, 'keyA')
|
|
142
|
+
|
|
143
|
+
// Step 5 (the deadlock fix): fleet gate now OPENS because
|
|
144
|
+
// claudeBusyKeys is empty, even though activeTurnStartedAt
|
|
145
|
+
// still has keyB (which is fine — that's the user-visible
|
|
146
|
+
// receipt timestamp, not the claude-busy flag).
|
|
147
|
+
expect(state.claudeBusyKeys.size).toBe(0)
|
|
148
|
+
expect(state.activeTurnStartedAt.has('keyB')).toBe(true)
|
|
149
|
+
expect(fleetGateOpen(state)).toBe(true)
|
|
150
|
+
|
|
151
|
+
// The flush triggers → B's buffered msg gets sent →
|
|
152
|
+
// deliverToClaude fires for keyB → busy gate closes again.
|
|
153
|
+
deliverToClaude(state, 'keyB')
|
|
154
|
+
expect(fleetGateOpen(state)).toBe(false)
|
|
155
|
+
|
|
156
|
+
// B's turn_end completes the cycle.
|
|
157
|
+
turnEnd(state, 'keyB')
|
|
158
|
+
expect(state.activeTurnStartedAt.size).toBe(0)
|
|
159
|
+
expect(state.claudeBusyKeys.size).toBe(0)
|
|
160
|
+
expect(fleetGateOpen(state)).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('per-key reads (priorTurnStartedAt timing, status-query metric) keep working on activeTurnStartedAt', () => {
|
|
164
|
+
// The split must not regress per-key timestamp reads. These all
|
|
165
|
+
// want the RECEIPT timestamp (the user's send-time, not when
|
|
166
|
+
// claude finally got to it), so they correctly read
|
|
167
|
+
// activeTurnStartedAt — preserved across the buffer window.
|
|
168
|
+
const state = makeState()
|
|
169
|
+
receiveInbound(state, 'keyA', 100)
|
|
170
|
+
deliverToClaude(state, 'keyA')
|
|
171
|
+
receiveInbound(state, 'keyB', 200) // buffered, no deliverToClaude
|
|
172
|
+
|
|
173
|
+
// A second message on keyB arriving during the buffer window —
|
|
174
|
+
// mid-turn classification reads activeTurnStartedAt.get(keyB)
|
|
175
|
+
// for `priorTurnStartedAt`, which must still be 200 (the
|
|
176
|
+
// original receipt time, even though B never reached claude).
|
|
177
|
+
expect(state.activeTurnStartedAt.get('keyB')).toBe(200)
|
|
178
|
+
// And keyA's receipt timestamp is preserved too — A's
|
|
179
|
+
// follow-ups during its own processing window get accurate
|
|
180
|
+
// secondsSinceTurnStart metric.
|
|
181
|
+
expect(state.activeTurnStartedAt.get('keyA')).toBe(100)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('disconnect-flush sweeps both maps together (the bridge died, all in-flight turns are dead)', () => {
|
|
185
|
+
// Mirror of the disconnect-flush sweep loop in disconnect-flush.ts —
|
|
186
|
+
// a registered-agent disconnect clears both maps because every
|
|
187
|
+
// turn it was processing is dead by definition.
|
|
188
|
+
const state = makeState()
|
|
189
|
+
receiveInbound(state, 'keyA', 100)
|
|
190
|
+
deliverToClaude(state, 'keyA')
|
|
191
|
+
receiveInbound(state, 'keyB', 200) // buffered
|
|
192
|
+
|
|
193
|
+
// Bridge dies (claude crashed mid-A). Sweep both maps.
|
|
194
|
+
for (const k of [...state.activeTurnStartedAt.keys()]) {
|
|
195
|
+
state.activeTurnStartedAt.delete(k)
|
|
196
|
+
state.claudeBusyKeys.delete(k)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
expect(state.activeTurnStartedAt.size).toBe(0)
|
|
200
|
+
expect(state.claudeBusyKeys.size).toBe(0)
|
|
201
|
+
expect(fleetGateOpen(state)).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('idempotent: multiple inbounds for the same key (e.g. A user spamming) are a single entry', () => {
|
|
205
|
+
// Set semantics — A's 5 follow-up messages while A is processing
|
|
206
|
+
// collapse to a single claudeBusyKeys entry. Turn_end clears
|
|
207
|
+
// once; size accounting stays correct.
|
|
208
|
+
const state = makeState()
|
|
209
|
+
receiveInbound(state, 'keyA', 100)
|
|
210
|
+
deliverToClaude(state, 'keyA')
|
|
211
|
+
deliverToClaude(state, 'keyA')
|
|
212
|
+
deliverToClaude(state, 'keyA')
|
|
213
|
+
expect(state.claudeBusyKeys.size).toBe(1)
|
|
214
|
+
turnEnd(state, 'keyA')
|
|
215
|
+
expect(state.claudeBusyKeys.size).toBe(0)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
2
2
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
3
3
|
|
|
4
4
|
function makeDeps(overrides: { isSurfaceTool?: (name: string) => boolean } = {}) {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// PR3 supergroup-mode: start/stop now take (chatId, threadId?). The
|
|
6
|
+
// existing tests cover the chatId-only case (threadId omitted → null);
|
|
7
|
+
// new tests below pin the per-thread isolation.
|
|
8
|
+
const startTypingLoop = vi.fn<(chatId: string, threadId?: number | null) => void>()
|
|
9
|
+
const stopTypingLoop = vi.fn<(chatId: string, threadId?: number | null) => void>()
|
|
7
10
|
const isSurfaceTool =
|
|
8
11
|
overrides.isSurfaceTool ??
|
|
9
12
|
((name: string) =>
|
|
@@ -28,7 +31,7 @@ describe('createTypingWrapper', () => {
|
|
|
28
31
|
w.onToolUse('t1', 'chat-A', 'Bash')
|
|
29
32
|
// First tool on a fresh chat fires immediately — no timer wait required.
|
|
30
33
|
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
31
|
-
expect(deps.startTypingLoop).toHaveBeenCalledWith('chat-A')
|
|
34
|
+
expect(deps.startTypingLoop).toHaveBeenCalledWith('chat-A', null)
|
|
32
35
|
})
|
|
33
36
|
|
|
34
37
|
it('a parallel second tool on the same chat uses the debounce', () => {
|
|
@@ -69,7 +72,7 @@ describe('createTypingWrapper', () => {
|
|
|
69
72
|
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
70
73
|
w.onToolResult('t1')
|
|
71
74
|
expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
|
|
72
|
-
expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A')
|
|
75
|
+
expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A', null)
|
|
73
76
|
})
|
|
74
77
|
|
|
75
78
|
it('skips surface tools (reply/stream_reply/edit_message/react)', () => {
|
|
@@ -93,16 +96,16 @@ describe('createTypingWrapper', () => {
|
|
|
93
96
|
w.onToolUse('t1', 'chat-A', 'Bash')
|
|
94
97
|
w.onToolUse('t2', 'chat-B', 'Grep')
|
|
95
98
|
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
96
|
-
expect(deps.startTypingLoop).toHaveBeenNthCalledWith(1, 'chat-A')
|
|
97
|
-
expect(deps.startTypingLoop).toHaveBeenNthCalledWith(2, 'chat-B')
|
|
99
|
+
expect(deps.startTypingLoop).toHaveBeenNthCalledWith(1, 'chat-A', null)
|
|
100
|
+
expect(deps.startTypingLoop).toHaveBeenNthCalledWith(2, 'chat-B', null)
|
|
98
101
|
|
|
99
102
|
w.onToolResult('t1')
|
|
100
103
|
expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
|
|
101
|
-
expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-A')
|
|
104
|
+
expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-A', null)
|
|
102
105
|
|
|
103
106
|
w.onToolResult('t2')
|
|
104
107
|
expect(deps.stopTypingLoop).toHaveBeenCalledTimes(2)
|
|
105
|
-
expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-B')
|
|
108
|
+
expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-B', null)
|
|
106
109
|
})
|
|
107
110
|
|
|
108
111
|
it('drainAll clears pending entries and stops any started loops', () => {
|
|
@@ -138,4 +141,58 @@ describe('createTypingWrapper', () => {
|
|
|
138
141
|
vi.advanceTimersByTime(2)
|
|
139
142
|
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
140
143
|
})
|
|
144
|
+
|
|
145
|
+
// ─── PR3 supergroup-mode: per-(chat,thread) lane isolation ────────────
|
|
146
|
+
it('SAME chat + DIFFERENT threads each get their own immediate-fire lane', () => {
|
|
147
|
+
const deps = makeDeps()
|
|
148
|
+
const w = createTypingWrapper(deps)
|
|
149
|
+
// Both are "first tool on lane" — both fire immediately, not debounced.
|
|
150
|
+
w.onToolUse('t1', 'chat-A', 'Bash', 17)
|
|
151
|
+
w.onToolUse('t2', 'chat-A', 'Read', 23)
|
|
152
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
153
|
+
expect(deps.startTypingLoop).toHaveBeenNthCalledWith(1, 'chat-A', 17)
|
|
154
|
+
expect(deps.startTypingLoop).toHaveBeenNthCalledWith(2, 'chat-A', 23)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('SAME chat + SAME thread STILL uses debounce on the second tool', () => {
|
|
158
|
+
const deps = makeDeps()
|
|
159
|
+
const w = createTypingWrapper(deps)
|
|
160
|
+
w.onToolUse('t1', 'chat-A', 'Bash', 17)
|
|
161
|
+
w.onToolUse('t2', 'chat-A', 'Read', 17)
|
|
162
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
163
|
+
vi.advanceTimersByTime(500)
|
|
164
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('stopping topic A does NOT clear topic B\'s lane (the headline bug fix)', () => {
|
|
168
|
+
// The bug: chatId-only keying meant `activeChats.delete(chatId)`
|
|
169
|
+
// when topic A's tool ended ALSO marked topic B's lane as inactive,
|
|
170
|
+
// so topic B's next tool would re-fire immediately (wrong — it's
|
|
171
|
+
// already typing) and a subsequent stop could mismatch.
|
|
172
|
+
// Per-(chat,thread) lane keying preserves independence.
|
|
173
|
+
const deps = makeDeps()
|
|
174
|
+
const w = createTypingWrapper(deps)
|
|
175
|
+
w.onToolUse('t1', 'chat-A', 'Bash', 17) // topic A lane: active
|
|
176
|
+
w.onToolUse('t2', 'chat-A', 'Read', 23) // topic B lane: active (independent)
|
|
177
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
178
|
+
|
|
179
|
+
w.onToolResult('t1') // topic A done
|
|
180
|
+
expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-A', 17)
|
|
181
|
+
// Topic B is still active — a third tool on topic B should DEBOUNCE
|
|
182
|
+
// (lane is still active), not fire immediately.
|
|
183
|
+
w.onToolUse('t3', 'chat-A', 'Edit', 23)
|
|
184
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2) // no immediate fire
|
|
185
|
+
vi.advanceTimersByTime(500)
|
|
186
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(3)
|
|
187
|
+
expect(deps.startTypingLoop).toHaveBeenLastCalledWith('chat-A', 23)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('treats undefined / null threadId as the same lane (chatKey null/0 collapse)', () => {
|
|
191
|
+
const deps = makeDeps()
|
|
192
|
+
const w = createTypingWrapper(deps)
|
|
193
|
+
w.onToolUse('t1', 'chat-A', 'Bash') // undefined thread
|
|
194
|
+
w.onToolUse('t2', 'chat-A', 'Read', null) // null thread — same lane
|
|
195
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1) // only first fires immediately
|
|
196
|
+
expect(deps.startTypingLoop).toHaveBeenLastCalledWith('chat-A', null)
|
|
197
|
+
})
|
|
141
198
|
})
|
|
@@ -1,26 +1,43 @@
|
|
|
1
1
|
// Auto-wrap tool dispatch with a Telegram typing-indicator loop so the user
|
|
2
2
|
// sees a live "agent is working" signal during the 3–30s gap where the
|
|
3
3
|
// progress card is deliberately suppressed (its initialDelayMs is 3s).
|
|
4
|
-
// The first tool call on a given chat fires the typing loop
|
|
5
|
-
// there's no silent dead window before the progress card
|
|
6
|
-
// calls on the same
|
|
7
|
-
// Surface tools own their own loop — see isSurfaceTool.
|
|
4
|
+
// The first tool call on a given (chat, thread) fires the typing loop
|
|
5
|
+
// immediately so there's no silent dead window before the progress card
|
|
6
|
+
// appears. Subsequent calls on the same lane honour the debounce to avoid
|
|
7
|
+
// churn. Surface tools own their own loop — see isSurfaceTool.
|
|
8
|
+
//
|
|
9
|
+
// Keying changed from `chatId` to `(chatId, threadId)` in PR3 of the
|
|
10
|
+
// supergroup-mode rollout. In supergroup mode one agent owns many topics
|
|
11
|
+
// in one chat; chatId-only keying made topic A's typing indicator die when
|
|
12
|
+
// topic B's tool-call ended (last-stop-wins on a shared key). Per-thread
|
|
13
|
+
// keying preserves independent typing loops across topics — matches the
|
|
14
|
+
// per-(chat,thread) state model the rest of the gateway already uses.
|
|
15
|
+
// Callers that don't yet carry a thread context pass `undefined` and
|
|
16
|
+
// behave exactly as before (null thread collapses to `_` per chatKey()).
|
|
17
|
+
|
|
18
|
+
import { chatKey } from './gateway/chat-key.js'
|
|
8
19
|
|
|
9
20
|
export interface TypingWrapperDeps {
|
|
10
|
-
startTypingLoop: (chatId: string) => void
|
|
11
|
-
stopTypingLoop: (chatId: string) => void
|
|
21
|
+
startTypingLoop: (chatId: string, threadId?: number | null) => void
|
|
22
|
+
stopTypingLoop: (chatId: string, threadId?: number | null) => void
|
|
12
23
|
isSurfaceTool: (toolName: string) => boolean
|
|
13
24
|
debounceMs?: number
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
export interface TypingWrapper {
|
|
17
|
-
onToolUse: (
|
|
28
|
+
onToolUse: (
|
|
29
|
+
toolUseId: string,
|
|
30
|
+
chatId: string,
|
|
31
|
+
toolName: string,
|
|
32
|
+
threadId?: number | null,
|
|
33
|
+
) => void
|
|
18
34
|
onToolResult: (toolUseId: string) => void
|
|
19
35
|
drainAll: () => void
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
interface Entry {
|
|
23
39
|
chatId: string
|
|
40
|
+
threadId: number | null
|
|
24
41
|
timer: ReturnType<typeof setTimeout>
|
|
25
42
|
started: boolean
|
|
26
43
|
}
|
|
@@ -28,29 +45,33 @@ interface Entry {
|
|
|
28
45
|
export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
29
46
|
const debounceMs = deps.debounceMs ?? 500
|
|
30
47
|
const pending = new Map<string, Entry>()
|
|
31
|
-
// Track
|
|
32
|
-
// tool call fires immediately while subsequent
|
|
33
|
-
|
|
48
|
+
// Track per-(chat,thread) lanes that already have an active typing loop
|
|
49
|
+
// so the first tool call on a lane fires immediately while subsequent
|
|
50
|
+
// calls on the same lane use the debounce.
|
|
51
|
+
const activeLanes = new Set<string>()
|
|
34
52
|
|
|
35
53
|
return {
|
|
36
|
-
onToolUse(toolUseId, chatId, toolName) {
|
|
54
|
+
onToolUse(toolUseId, chatId, toolName, threadId) {
|
|
37
55
|
if (!toolUseId) return
|
|
38
56
|
if (deps.isSurfaceTool(toolName)) return
|
|
57
|
+
const tid = threadId ?? null
|
|
58
|
+
const lane = chatKey(chatId, tid) as string
|
|
39
59
|
// Replace any pre-existing entry for the same id defensively.
|
|
40
60
|
const prior = pending.get(toolUseId)
|
|
41
61
|
if (prior) {
|
|
42
62
|
clearTimeout(prior.timer)
|
|
43
|
-
if (prior.started) deps.stopTypingLoop(prior.chatId)
|
|
63
|
+
if (prior.started) deps.stopTypingLoop(prior.chatId, prior.threadId)
|
|
44
64
|
pending.delete(toolUseId)
|
|
45
65
|
}
|
|
46
|
-
// First tool on this
|
|
66
|
+
// First tool on this lane: fire immediately rather than waiting for
|
|
47
67
|
// the debounce — this closes the silent dead window before the first
|
|
48
68
|
// progress card appears.
|
|
49
|
-
if (!
|
|
50
|
-
deps.startTypingLoop(chatId)
|
|
51
|
-
|
|
69
|
+
if (!activeLanes.has(lane)) {
|
|
70
|
+
deps.startTypingLoop(chatId, tid)
|
|
71
|
+
activeLanes.add(lane)
|
|
52
72
|
const entry: Entry = {
|
|
53
73
|
chatId,
|
|
74
|
+
threadId: tid,
|
|
54
75
|
started: true,
|
|
55
76
|
timer: setTimeout(() => {}, 0), // no-op sentinel
|
|
56
77
|
}
|
|
@@ -59,9 +80,10 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
|
59
80
|
}
|
|
60
81
|
const entry: Entry = {
|
|
61
82
|
chatId,
|
|
83
|
+
threadId: tid,
|
|
62
84
|
started: false,
|
|
63
85
|
timer: setTimeout(() => {
|
|
64
|
-
deps.startTypingLoop(chatId)
|
|
86
|
+
deps.startTypingLoop(chatId, tid)
|
|
65
87
|
entry.started = true
|
|
66
88
|
}, debounceMs),
|
|
67
89
|
}
|
|
@@ -74,8 +96,8 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
|
74
96
|
if (!entry) return
|
|
75
97
|
clearTimeout(entry.timer)
|
|
76
98
|
if (entry.started) {
|
|
77
|
-
deps.stopTypingLoop(entry.chatId)
|
|
78
|
-
|
|
99
|
+
deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
100
|
+
activeLanes.delete(chatKey(entry.chatId, entry.threadId) as string)
|
|
79
101
|
}
|
|
80
102
|
pending.delete(toolUseId)
|
|
81
103
|
},
|
|
@@ -83,10 +105,10 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
|
83
105
|
drainAll() {
|
|
84
106
|
for (const entry of pending.values()) {
|
|
85
107
|
clearTimeout(entry.timer)
|
|
86
|
-
if (entry.started) deps.stopTypingLoop(entry.chatId)
|
|
108
|
+
if (entry.started) deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
87
109
|
}
|
|
88
110
|
pending.clear()
|
|
89
|
-
|
|
111
|
+
activeLanes.clear()
|
|
90
112
|
},
|
|
91
113
|
}
|
|
92
114
|
}
|