switchroom 0.13.26 → 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.
- 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 +239 -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 +166 -51
- 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 +78 -135
- 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
|
@@ -114,6 +114,14 @@ export interface WorkerEntry {
|
|
|
114
114
|
* narrative line.
|
|
115
115
|
*/
|
|
116
116
|
lastResultText: string
|
|
117
|
+
/**
|
|
118
|
+
* Last bucket index for which an `onProgress` callback was fired for
|
|
119
|
+
* this sub-agent (#1720). Null until the first envelope. The gateway
|
|
120
|
+
* owns the actual decision via `decideSubagentProgress`; this field
|
|
121
|
+
* persists the cursor across `sub_agent_text` events on the same
|
|
122
|
+
* entry so the watcher doesn't re-fire within the same bucket window.
|
|
123
|
+
*/
|
|
124
|
+
lastProgressBucketIdx: number | null
|
|
117
125
|
/**
|
|
118
126
|
* Most recent tool call observed on this sub-agent's JSONL tail —
|
|
119
127
|
* tool name + sanitised arg for fleet-row display (P0 of #662). Null
|
|
@@ -281,6 +289,23 @@ export interface SubagentWatcherConfig {
|
|
|
281
289
|
* `subagent_handback` inbound. */
|
|
282
290
|
resultText: string
|
|
283
291
|
}) => void
|
|
292
|
+
/**
|
|
293
|
+
* #1720: fires on every `sub_agent_text` event for a running
|
|
294
|
+
* sub-agent. The gateway decides whether to materialise a
|
|
295
|
+
* `subagent_progress` envelope via `decideSubagentProgress` (pure,
|
|
296
|
+
* bucket-deterministic); the watcher just surfaces the cue.
|
|
297
|
+
* `setBucketIdx` writes back the per-entry cursor so a same-bucket
|
|
298
|
+
* re-fire is suppressed. Foreground vs background classification is
|
|
299
|
+
* the gateway's call.
|
|
300
|
+
*/
|
|
301
|
+
onProgress?: (args: {
|
|
302
|
+
agentId: string
|
|
303
|
+
description: string
|
|
304
|
+
latestSummary: string
|
|
305
|
+
elapsedMs: number
|
|
306
|
+
prevBucketIdx: number | null
|
|
307
|
+
setBucketIdx: (b: number) => void
|
|
308
|
+
}) => void
|
|
284
309
|
/** `Date.now` override for tests. */
|
|
285
310
|
now?: () => number
|
|
286
311
|
/** `setInterval` override for tests. */
|
|
@@ -483,6 +508,21 @@ export function readSubTail(
|
|
|
483
508
|
* the clerk agent logged 540k ENOENT lines in 3 days (30/sec
|
|
484
509
|
* sustained) AND leaked one fs.watch FD per stranded entry. */
|
|
485
510
|
onFileVanished?: (agentId: string, code: 'ENOENT' | 'EACCES') => void,
|
|
511
|
+
/** Fires on every `sub_agent_text` event for a running sub-agent
|
|
512
|
+
* (#1720). The gateway decides whether to materialise a progress
|
|
513
|
+
* envelope via `decideSubagentProgress` — pure decision, watcher
|
|
514
|
+
* just surfaces the cue. `latestSummary` is the narrative text;
|
|
515
|
+
* `elapsedMs` is `now - entry.dispatchedAt`; `prevBucketIdx` is
|
|
516
|
+
* `entry.lastProgressBucketIdx` (gateway calls `setBucketIdx` on a
|
|
517
|
+
* successful deliver so a same-bucket re-fire is suppressed). */
|
|
518
|
+
onProgress?: (args: {
|
|
519
|
+
agentId: string
|
|
520
|
+
description: string
|
|
521
|
+
latestSummary: string
|
|
522
|
+
elapsedMs: number
|
|
523
|
+
prevBucketIdx: number | null
|
|
524
|
+
setBucketIdx: (b: number) => void
|
|
525
|
+
}) => void,
|
|
486
526
|
): void {
|
|
487
527
|
try {
|
|
488
528
|
const stat = fs.statSync(entry.filePath)
|
|
@@ -620,6 +660,26 @@ export function readSubTail(
|
|
|
620
660
|
// args or file content — consistent with the watcher's
|
|
621
661
|
// "descriptions only" privacy posture.
|
|
622
662
|
entry.lastResultText = ev.text.trim().slice(0, SUBAGENT_RESULT_TEXT_MAX)
|
|
663
|
+
// #1720: surface a progress cue for the gateway. Only fire
|
|
664
|
+
// while the entry is still running and not historical — a
|
|
665
|
+
// terminal entry's last narrative line is the handback
|
|
666
|
+
// payload, not a mid-flight progress nudge.
|
|
667
|
+
if (onProgress != null && entry.state === 'running' && !entry.historical) {
|
|
668
|
+
try {
|
|
669
|
+
onProgress({
|
|
670
|
+
agentId: entry.agentId,
|
|
671
|
+
description: entry.description,
|
|
672
|
+
latestSummary: entry.lastResultText,
|
|
673
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
674
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
675
|
+
setBucketIdx: (b: number) => {
|
|
676
|
+
entry.lastProgressBucketIdx = b
|
|
677
|
+
},
|
|
678
|
+
})
|
|
679
|
+
} catch (cbErr) {
|
|
680
|
+
log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
623
683
|
} else if (ev.kind === 'sub_agent_turn_end') {
|
|
624
684
|
if (entry.state === 'running') {
|
|
625
685
|
entry.state = 'done'
|
|
@@ -802,6 +862,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
802
862
|
stallTerminalSynthesised: false,
|
|
803
863
|
lastSummaryLine: '',
|
|
804
864
|
lastResultText: '',
|
|
865
|
+
lastProgressBucketIdx: null,
|
|
805
866
|
lastTool: null,
|
|
806
867
|
historical: isHistorical,
|
|
807
868
|
}
|
|
@@ -832,7 +893,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
832
893
|
// Initial read
|
|
833
894
|
readSubTail(entry, tail, n, (desc) => {
|
|
834
895
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
835
|
-
}, fs, log, db, parentStateDir, config.onUnstall)
|
|
896
|
+
}, fs, log, db, parentStateDir, config.onUnstall, undefined, config.onProgress)
|
|
836
897
|
|
|
837
898
|
// If the JSONL already contained a turn_end at registration time
|
|
838
899
|
// (file written-then-watched), fire the state-transition + completion
|
|
@@ -863,7 +924,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
863
924
|
if (!entry || !t) return
|
|
864
925
|
readSubTail(entry, t, nowFn(), (desc) => {
|
|
865
926
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
866
|
-
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
|
|
927
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
|
|
867
928
|
maybySendStateTransition(agentId)
|
|
868
929
|
})
|
|
869
930
|
} catch (err) {
|
|
@@ -1201,7 +1262,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1201
1262
|
if (!tail) continue
|
|
1202
1263
|
readSubTail(entry, tail, n, (desc) => {
|
|
1203
1264
|
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1204
|
-
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent)
|
|
1265
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
|
|
1205
1266
|
maybySendStateTransition(agentId)
|
|
1206
1267
|
}
|
|
1207
1268
|
|
|
@@ -20,7 +20,9 @@ import { describe, it, expect, vi } from 'vitest'
|
|
|
20
20
|
import { flushOnAgentDisconnect } from '../gateway/disconnect-flush.js'
|
|
21
21
|
|
|
22
22
|
interface FakeCtrl {
|
|
23
|
-
|
|
23
|
+
// #1713: disconnect-flush now routes through finalize() — single
|
|
24
|
+
// terminal path for the status-reaction controller.
|
|
25
|
+
finalize: (reason?: 'done' | 'error') => void
|
|
24
26
|
}
|
|
25
27
|
interface FakeStream {
|
|
26
28
|
isFinal: () => boolean
|
|
@@ -37,8 +39,8 @@ function makeDeps(agentName: string | null) {
|
|
|
37
39
|
const log = vi.fn()
|
|
38
40
|
|
|
39
41
|
const activeStatusReactions = new Map<string, FakeCtrl>([
|
|
40
|
-
['chat1:thr1:msg1', {
|
|
41
|
-
['chat2:thr2:msg2', {
|
|
42
|
+
['chat1:thr1:msg1', { finalize: setDoneA }],
|
|
43
|
+
['chat2:thr2:msg2', { finalize: setDoneB }],
|
|
42
44
|
])
|
|
43
45
|
const activeReactionMsgIds = new Map<string, { chatId: string; messageId: number }>([
|
|
44
46
|
['chat1:thr1:msg1', { chatId: 'chat1', messageId: 1 }],
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1720 — extensions to inbound-spool for the progress envelope.
|
|
3
|
+
*
|
|
4
|
+
* Three behaviours pinned here that the existing spool tests don't
|
|
5
|
+
* cover:
|
|
6
|
+
*
|
|
7
|
+
* 1. spoolId for `subagent_progress` is bucket-deterministic — two
|
|
8
|
+
* envelopes for the same (jsonl id, bucket idx) collapse to ONE
|
|
9
|
+
* live entry regardless of ts.
|
|
10
|
+
* 2. `liveEntries()` skips entries whose `meta.expiresAt` has
|
|
11
|
+
* passed (stale progress is worse than no progress).
|
|
12
|
+
* 3. `dropMatching(predicate)` tombstones live entries by spool id
|
|
13
|
+
* prefix — the handback path uses this to sweep stale progress
|
|
14
|
+
* envelopes for a sub-agent at the moment its handback is queued.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import {
|
|
19
|
+
createInboundSpool,
|
|
20
|
+
spoolId,
|
|
21
|
+
type InboundSpoolFsSeam,
|
|
22
|
+
} from '../gateway/inbound-spool.js'
|
|
23
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
24
|
+
|
|
25
|
+
function progressMsg(over: {
|
|
26
|
+
jsonlId: string
|
|
27
|
+
bucketIdx: number
|
|
28
|
+
ts?: number
|
|
29
|
+
chatId?: string
|
|
30
|
+
expiresAt?: number
|
|
31
|
+
}): InboundMessage {
|
|
32
|
+
const ts = over.ts ?? 1_000_000
|
|
33
|
+
const meta: Record<string, string> = {
|
|
34
|
+
source: 'subagent_progress',
|
|
35
|
+
subagent_jsonl_id: over.jsonlId,
|
|
36
|
+
bucket_idx: String(over.bucketIdx),
|
|
37
|
+
}
|
|
38
|
+
if (over.expiresAt != null) meta.expiresAt = String(over.expiresAt)
|
|
39
|
+
return {
|
|
40
|
+
type: 'inbound',
|
|
41
|
+
chatId: over.chatId ?? 'c1',
|
|
42
|
+
messageId: ts,
|
|
43
|
+
user: 'subagent-watcher',
|
|
44
|
+
userId: 0,
|
|
45
|
+
ts,
|
|
46
|
+
text: 'progress envelope',
|
|
47
|
+
meta,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeFs(): InboundSpoolFsSeam {
|
|
52
|
+
const files = new Map<string, string>()
|
|
53
|
+
return {
|
|
54
|
+
appendFileSync: (p, d) => files.set(p, (files.get(p) ?? '') + d),
|
|
55
|
+
readFileSync: (p) => files.get(p) ?? '',
|
|
56
|
+
writeFileSync: (p, d) => files.set(p, d),
|
|
57
|
+
renameSync: (from, to) => {
|
|
58
|
+
files.set(to, files.get(from) ?? '')
|
|
59
|
+
files.delete(from)
|
|
60
|
+
},
|
|
61
|
+
existsSync: (p) => files.has(p),
|
|
62
|
+
statSizeSync: (p) => Buffer.byteLength(files.get(p) ?? ''),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PATH = '/state/agent/telegram/inbound-spool.jsonl'
|
|
67
|
+
|
|
68
|
+
describe('spoolId — subagent_progress branch (#1720)', () => {
|
|
69
|
+
it('two progress envelopes for the same (jsonl id, bucket idx) have IDENTICAL ids', () => {
|
|
70
|
+
const a = spoolId(progressMsg({ jsonlId: 'j1', bucketIdx: 2, ts: 1000 }))
|
|
71
|
+
const b = spoolId(progressMsg({ jsonlId: 'j1', bucketIdx: 2, ts: 9999 }))
|
|
72
|
+
expect(a).toBe('s:progress:j1:2')
|
|
73
|
+
expect(a).toBe(b)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('different bucket idx → different id', () => {
|
|
77
|
+
expect(
|
|
78
|
+
spoolId(progressMsg({ jsonlId: 'j1', bucketIdx: 1 })),
|
|
79
|
+
).not.toBe(spoolId(progressMsg({ jsonlId: 'j1', bucketIdx: 2 })))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('different jsonl id → different id', () => {
|
|
83
|
+
expect(
|
|
84
|
+
spoolId(progressMsg({ jsonlId: 'j1', bucketIdx: 1 })),
|
|
85
|
+
).not.toBe(spoolId(progressMsg({ jsonlId: 'j2', bucketIdx: 1 })))
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('inbound-spool — progress envelopes coalesce by bucket', () => {
|
|
90
|
+
it('three puts for the same (jsonl id, bucket idx) yield ONE live entry', () => {
|
|
91
|
+
const fs = fakeFs()
|
|
92
|
+
const s = createInboundSpool({ path: PATH, fs })
|
|
93
|
+
expect(s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1, ts: 1000 }))).toBe(true)
|
|
94
|
+
expect(s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1, ts: 2000 }))).toBe(false)
|
|
95
|
+
expect(s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1, ts: 3000 }))).toBe(false)
|
|
96
|
+
expect(s.liveCount()).toBe(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('a fresh spool over the same file still sees ONE live entry (restart-safe coalesce)', () => {
|
|
100
|
+
const fs = fakeFs()
|
|
101
|
+
const s1 = createInboundSpool({ path: PATH, fs })
|
|
102
|
+
s1.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1, ts: 1000 }))
|
|
103
|
+
s1.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1, ts: 2000 }))
|
|
104
|
+
const s2 = createInboundSpool({ path: PATH, fs })
|
|
105
|
+
expect(s2.liveCount()).toBe(1)
|
|
106
|
+
expect(s2.liveEntries()[0].msg.meta.subagent_jsonl_id).toBe('j1')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('successive buckets DO NOT collapse', () => {
|
|
110
|
+
const fs = fakeFs()
|
|
111
|
+
const s = createInboundSpool({ path: PATH, fs })
|
|
112
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1 }))
|
|
113
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 2 }))
|
|
114
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 3 }))
|
|
115
|
+
expect(s.liveCount()).toBe(3)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('inbound-spool — TTL filter on liveEntries (#1720)', () => {
|
|
120
|
+
it('an entry whose expiresAt is in the past is OMITTED from liveEntries', () => {
|
|
121
|
+
const fs = fakeFs()
|
|
122
|
+
let t = 1_000_000
|
|
123
|
+
const s = createInboundSpool({ path: PATH, fs, now: () => t })
|
|
124
|
+
// expiresAt = 1_002_000 — within window at put-time
|
|
125
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1, ts: t, expiresAt: 1_002_000 }))
|
|
126
|
+
expect(s.liveEntries()).toHaveLength(1)
|
|
127
|
+
expect(s.liveCount()).toBe(1) // raw count includes it
|
|
128
|
+
// Advance clock past expiry
|
|
129
|
+
t = 1_010_000
|
|
130
|
+
expect(s.liveEntries()).toHaveLength(0)
|
|
131
|
+
// liveCount is the raw map size — TTL is a read-time filter.
|
|
132
|
+
expect(s.liveCount()).toBe(1)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('entries without expiresAt are not affected (handback envelopes stay live)', () => {
|
|
136
|
+
const fs = fakeFs()
|
|
137
|
+
let t = 1_000_000
|
|
138
|
+
const s = createInboundSpool({ path: PATH, fs, now: () => t })
|
|
139
|
+
s.put('worker', {
|
|
140
|
+
type: 'inbound',
|
|
141
|
+
chatId: 'c1',
|
|
142
|
+
messageId: 999,
|
|
143
|
+
user: 'x',
|
|
144
|
+
userId: 0,
|
|
145
|
+
ts: t,
|
|
146
|
+
text: 'handback',
|
|
147
|
+
meta: { source: 'subagent_handback' },
|
|
148
|
+
})
|
|
149
|
+
t = 9_000_000_000
|
|
150
|
+
expect(s.liveEntries()).toHaveLength(1)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('a non-numeric or empty expiresAt is treated as "no expiry"', () => {
|
|
154
|
+
const fs = fakeFs()
|
|
155
|
+
let t = 1_000_000
|
|
156
|
+
const s = createInboundSpool({ path: PATH, fs, now: () => t })
|
|
157
|
+
s.put('worker', {
|
|
158
|
+
type: 'inbound',
|
|
159
|
+
chatId: 'c1',
|
|
160
|
+
messageId: 1,
|
|
161
|
+
user: 'x',
|
|
162
|
+
userId: 0,
|
|
163
|
+
ts: t,
|
|
164
|
+
text: 'x',
|
|
165
|
+
meta: { source: 'subagent_progress', subagent_jsonl_id: 'j1', bucket_idx: '1', expiresAt: '' },
|
|
166
|
+
})
|
|
167
|
+
s.put('worker', {
|
|
168
|
+
type: 'inbound',
|
|
169
|
+
chatId: 'c1',
|
|
170
|
+
messageId: 2,
|
|
171
|
+
user: 'x',
|
|
172
|
+
userId: 0,
|
|
173
|
+
ts: t,
|
|
174
|
+
text: 'x',
|
|
175
|
+
meta: { source: 'subagent_progress', subagent_jsonl_id: 'j2', bucket_idx: '1', expiresAt: 'not-a-number' },
|
|
176
|
+
})
|
|
177
|
+
t = 9_000_000_000
|
|
178
|
+
expect(s.liveEntries()).toHaveLength(2)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('inbound-spool — dropMatching (#1720 handback sweep)', () => {
|
|
183
|
+
it('drops every live entry whose id matches the predicate', () => {
|
|
184
|
+
const fs = fakeFs()
|
|
185
|
+
const s = createInboundSpool({ path: PATH, fs })
|
|
186
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1 }))
|
|
187
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 2 }))
|
|
188
|
+
s.put('worker', progressMsg({ jsonlId: 'j2', bucketIdx: 1 }))
|
|
189
|
+
expect(s.liveCount()).toBe(3)
|
|
190
|
+
const dropped = s.dropMatching((id) => id.startsWith('s:progress:j1:'))
|
|
191
|
+
expect(dropped).toBe(2)
|
|
192
|
+
expect(s.liveCount()).toBe(1)
|
|
193
|
+
expect(s.liveEntries()[0].msg.meta.subagent_jsonl_id).toBe('j2')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('a drop survives across a restart (tombstoned in the log)', () => {
|
|
197
|
+
const fs = fakeFs()
|
|
198
|
+
const s1 = createInboundSpool({ path: PATH, fs })
|
|
199
|
+
s1.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1 }))
|
|
200
|
+
s1.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 2 }))
|
|
201
|
+
expect(s1.dropMatching((id) => id.startsWith('s:progress:j1:'))).toBe(2)
|
|
202
|
+
const s2 = createInboundSpool({ path: PATH, fs })
|
|
203
|
+
expect(s2.liveCount()).toBe(0)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('an empty match set is a no-op (returns 0)', () => {
|
|
207
|
+
const fs = fakeFs()
|
|
208
|
+
const s = createInboundSpool({ path: PATH, fs })
|
|
209
|
+
s.put('worker', progressMsg({ jsonlId: 'j1', bucketIdx: 1 }))
|
|
210
|
+
expect(s.dropMatching((id) => id.startsWith('s:progress:nope:'))).toBe(0)
|
|
211
|
+
expect(s.liveCount()).toBe(1)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -67,6 +67,68 @@ describe('spoolId — stable dedup key', () => {
|
|
|
67
67
|
const b = spoolId(msg({ messageId: 0, meta: { source: 'cron' }, ts: 100 }))
|
|
68
68
|
expect(a).toBe(b)
|
|
69
69
|
})
|
|
70
|
+
// #1719: a subagent_handback envelope carries the JSONL agent id, and
|
|
71
|
+
// spoolId() keys on it — so a re-built envelope for the same finished
|
|
72
|
+
// sub-agent (different Date.now()-derived ts/messageId across a
|
|
73
|
+
// restart or the onFinish race) collapses to one spool entry instead
|
|
74
|
+
// of re-firing the handback turn.
|
|
75
|
+
it('subagent_handback → s:handback:<jsonl_agent_id>, stable across ts', () => {
|
|
76
|
+
const a = spoolId(
|
|
77
|
+
msg({
|
|
78
|
+
messageId: 1700_000_000_000,
|
|
79
|
+
ts: 1700_000_000_000,
|
|
80
|
+
meta: { source: 'subagent_handback', subagent_jsonl_id: 'abc-123' },
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
const b = spoolId(
|
|
84
|
+
msg({
|
|
85
|
+
messageId: 1700_000_999_999,
|
|
86
|
+
ts: 1700_000_999_999,
|
|
87
|
+
meta: { source: 'subagent_handback', subagent_jsonl_id: 'abc-123' },
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
expect(a).toBe('s:handback:abc-123')
|
|
91
|
+
expect(b).toBe(a) // stable across Date.now() drift / restart re-build
|
|
92
|
+
})
|
|
93
|
+
it('subagent_handback for distinct sub-agents stays distinct', () => {
|
|
94
|
+
const a = spoolId(
|
|
95
|
+
msg({ messageId: 0, meta: { source: 'subagent_handback', subagent_jsonl_id: 'a' } }),
|
|
96
|
+
)
|
|
97
|
+
const b = spoolId(
|
|
98
|
+
msg({ messageId: 0, meta: { source: 'subagent_handback', subagent_jsonl_id: 'b' } }),
|
|
99
|
+
)
|
|
100
|
+
expect(a).not.toBe(b)
|
|
101
|
+
})
|
|
102
|
+
it('subagent_handback without jsonl id falls back to legacy id (back-compat)', () => {
|
|
103
|
+
const a = spoolId(
|
|
104
|
+
msg({ messageId: 555, meta: { source: 'subagent_handback' }, ts: 100 }),
|
|
105
|
+
)
|
|
106
|
+
// messageId > 0 → legacy m:<chat>:<msgId> still wins.
|
|
107
|
+
expect(a).toBe('m:c1:555')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('inbound-spool — subagent_handback dedup across restart re-build (#1719)', () => {
|
|
112
|
+
it('two handback envelopes for the same jsonl id collapse to one live entry', () => {
|
|
113
|
+
const fs = fakeFs()
|
|
114
|
+
const s = createInboundSpool({ path: PATH, fs })
|
|
115
|
+
const first = msg({
|
|
116
|
+
messageId: 1700_000_000_000,
|
|
117
|
+
ts: 1700_000_000_000,
|
|
118
|
+
meta: { source: 'subagent_handback', subagent_jsonl_id: 'jsonl-xyz' },
|
|
119
|
+
})
|
|
120
|
+
// Simulates a second onFinish (or boot-replay re-build) for the
|
|
121
|
+
// same sub-agent. Different ts / messageId — same jsonl id.
|
|
122
|
+
const second = msg({
|
|
123
|
+
messageId: 1700_000_999_999,
|
|
124
|
+
ts: 1700_000_999_999,
|
|
125
|
+
meta: { source: 'subagent_handback', subagent_jsonl_id: 'jsonl-xyz' },
|
|
126
|
+
})
|
|
127
|
+
expect(s.put('worker', first)).toBe(true)
|
|
128
|
+
expect(s.put('worker', second)).toBe(false) // deduped, no re-fire
|
|
129
|
+
expect(s.liveCount()).toBe(1)
|
|
130
|
+
expect(s.liveEntries()).toHaveLength(1)
|
|
131
|
+
})
|
|
70
132
|
})
|
|
71
133
|
|
|
72
134
|
describe('inbound-spool — put / ack / dedup', () => {
|
|
@@ -37,7 +37,6 @@ function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamRep
|
|
|
37
37
|
disableLinkPreview: true,
|
|
38
38
|
defaultFormat: 'html',
|
|
39
39
|
logStreamingEvent: () => {},
|
|
40
|
-
endStatusReaction: () => {},
|
|
41
40
|
historyEnabled: false,
|
|
42
41
|
recordOutbound: () => {},
|
|
43
42
|
writeError: () => {},
|
|
@@ -215,7 +215,6 @@ describe('wrapBot + handleStreamReply + reply ordering', () => {
|
|
|
215
215
|
disableLinkPreview: true,
|
|
216
216
|
defaultFormat: 'text',
|
|
217
217
|
logStreamingEvent: () => {},
|
|
218
|
-
endStatusReaction: () => {},
|
|
219
218
|
historyEnabled: false,
|
|
220
219
|
recordOutbound: () => {},
|
|
221
220
|
writeError: () => {},
|
|
@@ -37,7 +37,6 @@ function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamRep
|
|
|
37
37
|
disableLinkPreview: true,
|
|
38
38
|
defaultFormat: 'html',
|
|
39
39
|
logStreamingEvent: () => {},
|
|
40
|
-
endStatusReaction: () => {},
|
|
41
40
|
historyEnabled: false,
|
|
42
41
|
recordOutbound: () => {},
|
|
43
42
|
writeError: () => {},
|