switchroom 0.18.26 → 0.18.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/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +494 -36
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +305 -41
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- package/telegram-plugin/worker-activity-feed.ts +91 -1
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { describe, it, expect } from 'vitest'
|
|
21
21
|
import {
|
|
22
22
|
decideSupersede,
|
|
23
|
+
decideSupersedeCorrection,
|
|
23
24
|
FlushedTurnSupersedeRegistry,
|
|
24
25
|
DEFAULT_SUPERSEDE_TTL_MS,
|
|
25
26
|
type FlushedTurnRecord,
|
|
@@ -33,6 +34,65 @@ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
|
|
|
33
34
|
...over,
|
|
34
35
|
})
|
|
35
36
|
|
|
37
|
+
describe('decideSupersedeCorrection — edit-in-place vs delete+resend', () => {
|
|
38
|
+
const base = {
|
|
39
|
+
flushMessageIds: [500],
|
|
40
|
+
chunkCount: 1,
|
|
41
|
+
hasFiles: false,
|
|
42
|
+
suppressText: false,
|
|
43
|
+
hasOpenPreview: false,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('edits in place when the flush posted ONE message and the reply fits one plain-text message', () => {
|
|
47
|
+
const c = decideSupersedeCorrection(base)
|
|
48
|
+
expect(c.mode).toBe('edit-in-place')
|
|
49
|
+
// edit target is the single flushed message; nothing is deleted (A becomes B).
|
|
50
|
+
expect(c).toMatchObject({ mode: 'edit-in-place', editMessageId: 500, deleteMessageIds: [] })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('literal (format:text) single-message replies still edit in place (text is text)', () => {
|
|
54
|
+
// literalText is not an input to the decision — a literal reply is still a
|
|
55
|
+
// text message the edit lane renders identically.
|
|
56
|
+
const c = decideSupersedeCorrection({ ...base })
|
|
57
|
+
expect(c.mode).toBe('edit-in-place')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('falls back to delete+resend for a MULTI-PART reply (edit can only carry one message)', () => {
|
|
61
|
+
const c = decideSupersedeCorrection({ ...base, chunkCount: 3 })
|
|
62
|
+
expect(c).toEqual({ mode: 'delete-resend', deleteMessageIds: [500] })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('falls back to delete+resend when the flush posted MORE THAN ONE message', () => {
|
|
66
|
+
const c = decideSupersedeCorrection({ ...base, flushMessageIds: [500, 501] })
|
|
67
|
+
expect(c).toEqual({ mode: 'delete-resend', deleteMessageIds: [500, 501] })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to delete+resend for a file/album reply (not a plain-text edit)', () => {
|
|
71
|
+
const c = decideSupersedeCorrection({ ...base, hasFiles: true })
|
|
72
|
+
expect(c.mode).toBe('delete-resend')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to delete+resend for a voice-only reply (no text body to edit into)', () => {
|
|
76
|
+
const c = decideSupersedeCorrection({ ...base, suppressText: true })
|
|
77
|
+
expect(c.mode).toBe('delete-resend')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls back to delete+resend when a draft-stream preview already owns the edit lane', () => {
|
|
81
|
+
const c = decideSupersedeCorrection({ ...base, hasOpenPreview: true })
|
|
82
|
+
expect(c.mode).toBe('delete-resend')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('anti-duplicate invariant: every correction resolves to exactly one surviving message', () => {
|
|
86
|
+
// edit-in-place → A becomes B (1 message, 0 deletes); delete-resend → all
|
|
87
|
+
// flushed ids deleted then B sent fresh (1 message). Neither leaves A+B both
|
|
88
|
+
// visible, and neither loses the reply.
|
|
89
|
+
const editing = decideSupersedeCorrection(base)
|
|
90
|
+
expect(editing.mode === 'edit-in-place' && editing.deleteMessageIds.length).toBe(0)
|
|
91
|
+
const resending = decideSupersedeCorrection({ ...base, chunkCount: 2 })
|
|
92
|
+
expect(resending.deleteMessageIds).toEqual([500])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
36
96
|
describe('decideSupersede — the duplicate-reply decision core', () => {
|
|
37
97
|
it('supersedes when the reply is attributed to the SAME turn as the flush', () => {
|
|
38
98
|
// The common late-replay dup: the gateway resolves the reply's turnId (from
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createHandbackPreturnSignal } from '../gateway/handback-preturn-signal.js'
|
|
4
|
+
import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
|
|
5
|
+
import { buildSubagentHandbackInbound } from '../gateway/subagent-handback-inbound-builder.js'
|
|
6
|
+
import { deriveTurnId } from '../gateway/derive-turn-id.js'
|
|
7
|
+
import { chatKey } from '../gateway/chat-key.js'
|
|
8
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
9
|
+
import type { PreTurnCardRecord } from '../gateway/handback-preturn-signal.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* #3268 IDENTITY ROUND-TRIP (the anti-masking integration test).
|
|
13
|
+
*
|
|
14
|
+
* The unit contract test hand-feeds a matching `messageId` to BOTH the seam's
|
|
15
|
+
* release side and the enqueue's adopt side, so it CANNOT catch a builder that
|
|
16
|
+
* fails to round-trip the identity across the bridge. This test closes that gap:
|
|
17
|
+
* it runs the REAL `buildSubagentHandbackInbound` and the REAL `deriveTurnId`,
|
|
18
|
+
* and models the enqueue exactly as the gateway does —
|
|
19
|
+
*
|
|
20
|
+
* ev.messageId ← the channel envelope's `message_id` attribute, which is
|
|
21
|
+
* rendered ONLY from `meta.message_id` (the top-level
|
|
22
|
+
* `messageId` field does NOT survive the bridge; see
|
|
23
|
+
* resume-inbound-builder.ts's identical round-trip).
|
|
24
|
+
* turnId ← deriveTurnId(ev.chatId, enqThreadId, ev.messageId)
|
|
25
|
+
* ?? `${chatKey}#synthetic-${startedAt}` (gateway.ts enqueue)
|
|
26
|
+
*
|
|
27
|
+
* and asserts the enqueue-derived `turnId` EQUALS the id the seam recorded at
|
|
28
|
+
* release — so `tryAdopt` actually fires end-to-end.
|
|
29
|
+
*
|
|
30
|
+
* This FAILS on a builder that omits `meta.message_id` (ev.messageId → null →
|
|
31
|
+
* deriveTurnId → null → synthetic fallback → adoption never matches → the
|
|
32
|
+
* pre-turn card is orphaned + the false "handback never started" reap message
|
|
33
|
+
* fires on every SUCCESSFUL handback), and PASSES once the builder rounds-trips
|
|
34
|
+
* the identity.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// The gateway's enqueue turn-id, reconstructed with the SAME primitives the
|
|
38
|
+
// gateway uses (real deriveTurnId; synthetic fallback for a null message id).
|
|
39
|
+
function enqueueTurnIdFor(inbound: InboundMessage, startedAt: number): string {
|
|
40
|
+
// ev.messageId at enqueue comes from meta.message_id (channel-rendered), NOT
|
|
41
|
+
// the top-level messageId field.
|
|
42
|
+
const evMessageId = inbound.meta.message_id ?? null
|
|
43
|
+
// ev.threadId at enqueue comes from meta.message_thread_id.
|
|
44
|
+
const enqThreadId =
|
|
45
|
+
inbound.meta.message_thread_id != null ? Number(inbound.meta.message_thread_id) : null
|
|
46
|
+
return (
|
|
47
|
+
deriveTurnId(inbound.chatId, enqThreadId, evMessageId) ??
|
|
48
|
+
`${chatKey(inbound.chatId, enqThreadId)}#synthetic-${startedAt}`
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function flushMicrotasks(): Promise<void> {
|
|
53
|
+
for (let i = 0; i < 12; i++) await Promise.resolve()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeScheduler() {
|
|
57
|
+
let seq = 0
|
|
58
|
+
let currentTime = 0
|
|
59
|
+
const timers = new Map<number, { fn: () => void; at: number }>()
|
|
60
|
+
return {
|
|
61
|
+
now: () => currentTime,
|
|
62
|
+
setTimer: (fn: () => void, ms: number): unknown => {
|
|
63
|
+
const id = ++seq
|
|
64
|
+
timers.set(id, { fn, at: currentTime + ms })
|
|
65
|
+
return id
|
|
66
|
+
},
|
|
67
|
+
clearTimer: (h: unknown): void => {
|
|
68
|
+
timers.delete(h as number)
|
|
69
|
+
},
|
|
70
|
+
async advance(ms: number): Promise<void> {
|
|
71
|
+
const target = currentTime + ms
|
|
72
|
+
for (;;) {
|
|
73
|
+
let nextId: number | null = null
|
|
74
|
+
let nextAt = Infinity
|
|
75
|
+
for (const [id, t] of timers) {
|
|
76
|
+
if (t.at <= target && t.at < nextAt) {
|
|
77
|
+
nextId = id
|
|
78
|
+
nextAt = t.at
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (nextId == null) break
|
|
82
|
+
const t = timers.get(nextId)!
|
|
83
|
+
timers.delete(nextId)
|
|
84
|
+
currentTime = t.at
|
|
85
|
+
t.fn()
|
|
86
|
+
await flushMicrotasks()
|
|
87
|
+
}
|
|
88
|
+
currentTime = target
|
|
89
|
+
await flushMicrotasks()
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeSeam(sched: ReturnType<typeof makeScheduler>) {
|
|
95
|
+
const sendChatAction = vi.fn<(c: string, t: number | null) => void>()
|
|
96
|
+
const typing = createTurnTypingLoop({
|
|
97
|
+
sendChatAction,
|
|
98
|
+
chatKey: (c, t) => chatKey(c, t) as string,
|
|
99
|
+
refreshMs: 4000,
|
|
100
|
+
})
|
|
101
|
+
let msgSeq = 5000
|
|
102
|
+
const openCard = vi.fn<(c: string, t: number | null) => Promise<number | null>>(async () => ++msgSeq)
|
|
103
|
+
const records = new Map<string, PreTurnCardRecord>()
|
|
104
|
+
const signal = createHandbackPreturnSignal({
|
|
105
|
+
// The gateway wires these to the SAME real chatKey / deriveTurnId.
|
|
106
|
+
chatKey: (c, t) => chatKey(c, t) as string,
|
|
107
|
+
deriveTurnId,
|
|
108
|
+
startTypingLoop: (c, t) => typing.start(c, t),
|
|
109
|
+
stopTypingLoop: (c, t) => typing.stop(c, t),
|
|
110
|
+
openCard,
|
|
111
|
+
finalizeCard: () => {},
|
|
112
|
+
writeCardRecord: (r) => records.set(r.turnKey, r),
|
|
113
|
+
clearCardRecord: (turnKey, id) => {
|
|
114
|
+
const cur = records.get(turnKey)
|
|
115
|
+
if (cur != null && cur.activityMessageId === id) records.delete(turnKey)
|
|
116
|
+
},
|
|
117
|
+
now: sched.now,
|
|
118
|
+
setTimer: sched.setTimer,
|
|
119
|
+
clearTimer: sched.clearTimer,
|
|
120
|
+
debounceMs: 700,
|
|
121
|
+
adoptTimeoutMs: 30_000,
|
|
122
|
+
})
|
|
123
|
+
return { signal, typing, openCard, records }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const teardown: Array<() => void> = []
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
for (const fn of teardown.splice(0)) fn()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('#3268 handback identity round-trip — adoption fires end-to-end', () => {
|
|
132
|
+
it('the REAL builder + REAL deriveTurnId yield the SAME turnId at release and enqueue (DM)', async () => {
|
|
133
|
+
const sched = makeScheduler()
|
|
134
|
+
const seam = makeSeam(sched)
|
|
135
|
+
teardown.push(() => seam.typing.stopAll())
|
|
136
|
+
|
|
137
|
+
const inbound = buildSubagentHandbackInbound({
|
|
138
|
+
ctx: {
|
|
139
|
+
chatId: '12345',
|
|
140
|
+
taskDescription: 'Refactor the auth module',
|
|
141
|
+
resultText: 'Done.',
|
|
142
|
+
outcome: 'completed',
|
|
143
|
+
},
|
|
144
|
+
nowMs: 1_700_000_000_000,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Release-side identity, computed exactly as the seam does internally.
|
|
148
|
+
const releaseTurnId = deriveTurnId('12345', null, inbound.messageId)
|
|
149
|
+
// Enqueue-side identity, computed exactly as the gateway does.
|
|
150
|
+
const enqueueTurnId = enqueueTurnIdFor(inbound, /* startedAt */ 1_700_000_000_500)
|
|
151
|
+
|
|
152
|
+
// THE round-trip: the two independently-derived ids must be equal AND
|
|
153
|
+
// non-null (a synthetic fallback would be non-equal).
|
|
154
|
+
expect(releaseTurnId).not.toBeNull()
|
|
155
|
+
expect(enqueueTurnId).toBe(releaseTurnId)
|
|
156
|
+
|
|
157
|
+
// And end-to-end: release into the real seam, then adopt with the
|
|
158
|
+
// enqueue-derived id — adoption must fire (card seeded).
|
|
159
|
+
seam.signal.noteHandbackRelease(inbound)
|
|
160
|
+
await sched.advance(700)
|
|
161
|
+
expect(seam.openCard).toHaveBeenCalledTimes(1)
|
|
162
|
+
const adoption = seam.signal.tryAdopt(enqueueTurnId)
|
|
163
|
+
expect(adoption).not.toBeNull()
|
|
164
|
+
expect(adoption!.activityMessageId).not.toBeNull()
|
|
165
|
+
expect(seam.signal.pendingCount()).toBe(0)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('round-trips identity through a supergroup topic (threadId survives via meta.message_thread_id)', async () => {
|
|
169
|
+
const sched = makeScheduler()
|
|
170
|
+
const seam = makeSeam(sched)
|
|
171
|
+
teardown.push(() => seam.typing.stopAll())
|
|
172
|
+
|
|
173
|
+
const inbound = buildSubagentHandbackInbound({
|
|
174
|
+
ctx: {
|
|
175
|
+
chatId: '-100999',
|
|
176
|
+
threadId: 42,
|
|
177
|
+
taskDescription: 'Migrate DB',
|
|
178
|
+
resultText: 'Done.',
|
|
179
|
+
outcome: 'completed',
|
|
180
|
+
},
|
|
181
|
+
nowMs: 1_700_000_111_111,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const releaseTurnId = deriveTurnId('-100999', 42, inbound.messageId)
|
|
185
|
+
const enqueueTurnId = enqueueTurnIdFor(inbound, 1_700_000_111_500)
|
|
186
|
+
expect(releaseTurnId).not.toBeNull()
|
|
187
|
+
expect(enqueueTurnId).toBe(releaseTurnId)
|
|
188
|
+
|
|
189
|
+
seam.signal.noteHandbackRelease(inbound)
|
|
190
|
+
await sched.advance(700)
|
|
191
|
+
const adoption = seam.signal.tryAdopt(enqueueTurnId)
|
|
192
|
+
expect(adoption).not.toBeNull()
|
|
193
|
+
expect(adoption!.statusKey).toBe(chatKey('-100999', 42) as string)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('the enqueue message id comes from meta.message_id, not the top-level messageId field', () => {
|
|
197
|
+
// Guards the exact bridge-survival fact the bug hinged on: the identity must
|
|
198
|
+
// live in meta (channel-rendered), not only on the top-level field.
|
|
199
|
+
const inbound = buildSubagentHandbackInbound({
|
|
200
|
+
ctx: { chatId: '7', taskDescription: 't', resultText: 'r', outcome: 'completed' },
|
|
201
|
+
nowMs: 1_700_000_222_222,
|
|
202
|
+
})
|
|
203
|
+
expect(inbound.meta.message_id).toBe(String(1_700_000_222_222))
|
|
204
|
+
// The synthetic fallback (what enqueue would use if meta.message_id were
|
|
205
|
+
// absent) must NOT equal the real release id — proving the round-trip is
|
|
206
|
+
// load-bearing.
|
|
207
|
+
const releaseTurnId = deriveTurnId('7', null, inbound.messageId)
|
|
208
|
+
const syntheticFallback = `${chatKey('7', null)}#synthetic-999`
|
|
209
|
+
expect(syntheticFallback).not.toBe(releaseTurnId)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createHandbackPreturnSignal,
|
|
5
|
+
isHandbackInbound,
|
|
6
|
+
PRETURN_TURNKEY_PREFIX,
|
|
7
|
+
type HandbackPreturnSignalDeps,
|
|
8
|
+
type PreTurnCardRecord,
|
|
9
|
+
} from '../gateway/handback-preturn-signal.js'
|
|
10
|
+
import { createTurnTypingLoop } from '../gateway/turn-typing-loop.js'
|
|
11
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Contract test for the sub-agent-handback dead-air fix (the extracted
|
|
15
|
+
* emit→adopt→reap seam). Drives the pure seam through an INJECTED clock +
|
|
16
|
+
* INJECTED scheduler (NOT vitest's `vi.useFakeTimers` / `advanceTimersByTimeAsync`,
|
|
17
|
+
* which bun's test runner does not implement) so it passes identically under
|
|
18
|
+
* BOTH runners (the telegram-plugin dual-run rule). Asserts the whole card
|
|
19
|
+
* lifecycle without the gateway or the Telegram bot API:
|
|
20
|
+
*
|
|
21
|
+
* - drain-site emit within the debounce window (typing action + card)
|
|
22
|
+
* - adoption across the enqueue boundary is an EDIT (no second card send)
|
|
23
|
+
* and the typing loop never samples zero
|
|
24
|
+
* - turn-end hands off cleanly (record migrated, typing stopped once)
|
|
25
|
+
* - a never-adopted handback self-reaps its frozen card past the TTL
|
|
26
|
+
* - an identity race (user inbound adopts first) never mis-adopts the
|
|
27
|
+
* handback and never double-sends
|
|
28
|
+
*
|
|
29
|
+
* The first two assertions describe behaviour that DOES NOT EXIST on
|
|
30
|
+
* pre-feature `main` (no pre-turn emit at all, and a handback turn never even
|
|
31
|
+
* got a turn-long typing loop) — the seam is what makes them true.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// Mirror the gateway's chatKey (null/0/undefined thread → '_').
|
|
35
|
+
const chatKey = (chatId: string, threadId: number | null) =>
|
|
36
|
+
`${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
|
|
37
|
+
|
|
38
|
+
// Mirror the gateway's deriveTurnId (`${chatKey}#${messageId}`, null for no id).
|
|
39
|
+
const deriveTurnId = (
|
|
40
|
+
chatId: string,
|
|
41
|
+
threadId: number | null,
|
|
42
|
+
messageId: string | number | null | undefined,
|
|
43
|
+
): string | null => {
|
|
44
|
+
if (messageId == null || messageId === '' || String(messageId) === '0') return null
|
|
45
|
+
return `${chatKey(chatId, threadId)}#${messageId}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function handbackInbound(opts: {
|
|
49
|
+
chatId: string
|
|
50
|
+
threadId?: number
|
|
51
|
+
messageId: number
|
|
52
|
+
}): InboundMessage {
|
|
53
|
+
return {
|
|
54
|
+
type: 'inbound',
|
|
55
|
+
chatId: opts.chatId,
|
|
56
|
+
...(opts.threadId != null ? { threadId: opts.threadId } : {}),
|
|
57
|
+
messageId: opts.messageId,
|
|
58
|
+
user: 'subagent-watcher',
|
|
59
|
+
userId: 0,
|
|
60
|
+
ts: opts.messageId,
|
|
61
|
+
text: '🤝 A background worker you dispatched has finished.',
|
|
62
|
+
meta: { source: 'subagent_handback', outcome: 'completed' },
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function userInbound(opts: {
|
|
67
|
+
chatId: string
|
|
68
|
+
threadId?: number
|
|
69
|
+
messageId: number
|
|
70
|
+
}): InboundMessage {
|
|
71
|
+
return {
|
|
72
|
+
type: 'inbound',
|
|
73
|
+
chatId: opts.chatId,
|
|
74
|
+
...(opts.threadId != null ? { threadId: opts.threadId } : {}),
|
|
75
|
+
messageId: opts.messageId,
|
|
76
|
+
user: 'ken',
|
|
77
|
+
userId: 42,
|
|
78
|
+
ts: opts.messageId,
|
|
79
|
+
text: 'hi',
|
|
80
|
+
meta: {},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Flush the microtask queue so the seam's async openCard promise chain settles
|
|
85
|
+
* between scheduler ticks. Runner-agnostic (no fake-timer API): a handful of
|
|
86
|
+
* `await` turns drains the fixed-depth chain the seam builds. */
|
|
87
|
+
async function flushMicrotasks(): Promise<void> {
|
|
88
|
+
for (let i = 0; i < 12; i++) await Promise.resolve()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Manual injected scheduler + clock — the runner-agnostic replacement for
|
|
92
|
+
* vitest fake timers. Fires due timers in chronological order, allowing a
|
|
93
|
+
* fired timer to schedule another, and flushes microtasks after each fire so
|
|
94
|
+
* the async card-open chain completes deterministically. */
|
|
95
|
+
function makeScheduler() {
|
|
96
|
+
let seq = 0
|
|
97
|
+
let currentTime = 0
|
|
98
|
+
const timers = new Map<number, { fn: () => void; at: number }>()
|
|
99
|
+
return {
|
|
100
|
+
now: () => currentTime,
|
|
101
|
+
setTimer: (fn: () => void, ms: number): unknown => {
|
|
102
|
+
const id = ++seq
|
|
103
|
+
timers.set(id, { fn, at: currentTime + ms })
|
|
104
|
+
return id
|
|
105
|
+
},
|
|
106
|
+
clearTimer: (handle: unknown): void => {
|
|
107
|
+
timers.delete(handle as number)
|
|
108
|
+
},
|
|
109
|
+
async advance(ms: number): Promise<void> {
|
|
110
|
+
const target = currentTime + ms
|
|
111
|
+
for (;;) {
|
|
112
|
+
let nextId: number | null = null
|
|
113
|
+
let nextAt = Infinity
|
|
114
|
+
for (const [id, t] of timers) {
|
|
115
|
+
if (t.at <= target && t.at < nextAt) {
|
|
116
|
+
nextId = id
|
|
117
|
+
nextAt = t.at
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (nextId == null) break
|
|
121
|
+
const t = timers.get(nextId)!
|
|
122
|
+
timers.delete(nextId)
|
|
123
|
+
currentTime = t.at
|
|
124
|
+
t.fn()
|
|
125
|
+
await flushMicrotasks()
|
|
126
|
+
}
|
|
127
|
+
currentTime = target
|
|
128
|
+
await flushMicrotasks()
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface Harness {
|
|
134
|
+
signal: ReturnType<typeof createHandbackPreturnSignal>
|
|
135
|
+
typing: ReturnType<typeof createTurnTypingLoop>
|
|
136
|
+
sched: ReturnType<typeof makeScheduler>
|
|
137
|
+
sendChatAction: ReturnType<typeof vi.fn>
|
|
138
|
+
openCard: ReturnType<typeof vi.fn>
|
|
139
|
+
finalizeCard: ReturnType<typeof vi.fn>
|
|
140
|
+
records: Map<string, PreTurnCardRecord>
|
|
141
|
+
finalizedIds: number[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const harnesses: Harness[] = []
|
|
145
|
+
|
|
146
|
+
function makeHarness(overrides: Partial<HandbackPreturnSignalDeps> = {}): Harness {
|
|
147
|
+
const sched = makeScheduler()
|
|
148
|
+
const sendChatAction = vi.fn<(c: string, t: number | null) => void>()
|
|
149
|
+
const typing = createTurnTypingLoop({
|
|
150
|
+
sendChatAction,
|
|
151
|
+
chatKey: (c, t) => chatKey(c, t),
|
|
152
|
+
refreshMs: 4000,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
let msgSeq = 1000
|
|
156
|
+
const openCard = vi.fn<(c: string, t: number | null) => Promise<number | null>>(
|
|
157
|
+
async () => ++msgSeq,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// In-memory durable record store keyed by turnKey (mirrors the activity card
|
|
161
|
+
// store's per-turnKey upsert semantics).
|
|
162
|
+
const records = new Map<string, PreTurnCardRecord>()
|
|
163
|
+
const writeCardRecord = (r: PreTurnCardRecord) => {
|
|
164
|
+
records.set(r.turnKey, r)
|
|
165
|
+
}
|
|
166
|
+
const clearCardRecord = (turnKey: string, activityMessageId: number) => {
|
|
167
|
+
const cur = records.get(turnKey)
|
|
168
|
+
if (cur != null && cur.activityMessageId === activityMessageId) records.delete(turnKey)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const finalizedIds: number[] = []
|
|
172
|
+
const finalizeCard = vi.fn<(r: PreTurnCardRecord) => void>((r) => {
|
|
173
|
+
finalizedIds.push(r.activityMessageId)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const signal = createHandbackPreturnSignal({
|
|
177
|
+
chatKey: (c, t) => chatKey(c, t),
|
|
178
|
+
deriveTurnId,
|
|
179
|
+
startTypingLoop: (c, t) => typing.start(c, t),
|
|
180
|
+
stopTypingLoop: (c, t) => typing.stop(c, t),
|
|
181
|
+
openCard,
|
|
182
|
+
finalizeCard,
|
|
183
|
+
writeCardRecord,
|
|
184
|
+
clearCardRecord,
|
|
185
|
+
now: sched.now,
|
|
186
|
+
setTimer: sched.setTimer,
|
|
187
|
+
clearTimer: sched.clearTimer,
|
|
188
|
+
debounceMs: 700,
|
|
189
|
+
adoptTimeoutMs: 30_000,
|
|
190
|
+
...overrides,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const h: Harness = { signal, typing, sched, sendChatAction, openCard, finalizeCard, records, finalizedIds }
|
|
194
|
+
harnesses.push(h)
|
|
195
|
+
return h
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', () => {
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
// Real setInterval-backed typing loops in the harnesses are unref'd, but
|
|
201
|
+
// clear them so no interval leaks across the runner's test files.
|
|
202
|
+
for (const h of harnesses.splice(0)) h.typing.stopAll()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('isHandbackInbound only matches the load-bearing source string', () => {
|
|
206
|
+
expect(isHandbackInbound(handbackInbound({ chatId: 'c', messageId: 1 }))).toBe(true)
|
|
207
|
+
expect(isHandbackInbound(userInbound({ chatId: 'c', messageId: 1 }))).toBe(false)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('emits a typing action + a pre-turn card within the debounce window on drain release', async () => {
|
|
211
|
+
const h = makeHarness()
|
|
212
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatA', threadId: 7, messageId: 555 }))
|
|
213
|
+
|
|
214
|
+
// Nothing paints before the debounce elapses (flicker guard).
|
|
215
|
+
await h.sched.advance(699)
|
|
216
|
+
expect(h.sendChatAction).not.toHaveBeenCalled()
|
|
217
|
+
expect(h.openCard).not.toHaveBeenCalled()
|
|
218
|
+
|
|
219
|
+
// At the debounce boundary: typing loop lights up AND the card opens.
|
|
220
|
+
await h.sched.advance(1)
|
|
221
|
+
expect(h.sendChatAction).toHaveBeenCalledWith('chatA', 7)
|
|
222
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
223
|
+
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
224
|
+
expect(h.openCard).toHaveBeenCalledWith('chatA', 7)
|
|
225
|
+
|
|
226
|
+
// A COMPLETE durable record was persisted under a synthetic pre-turn key.
|
|
227
|
+
expect(h.records.size).toBe(1)
|
|
228
|
+
const rec = [...h.records.values()][0]!
|
|
229
|
+
expect(rec.turnKey.startsWith(PRETURN_TURNKEY_PREFIX)).toBe(true)
|
|
230
|
+
expect(rec.chatId).toBe('chatA')
|
|
231
|
+
expect(rec.threadId).toBe(7)
|
|
232
|
+
expect(typeof rec.activityMessageId).toBe('number')
|
|
233
|
+
expect(typeof rec.startedAt).toBe('number')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('adoption across the enqueue boundary edits (no second card) and keeps typing ≥1 continuously', async () => {
|
|
237
|
+
const h = makeHarness()
|
|
238
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatB', messageId: 900 }))
|
|
239
|
+
await h.sched.advance(700)
|
|
240
|
+
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
241
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
242
|
+
|
|
243
|
+
// Typing is already lit before the turn mints — sample it: never zero.
|
|
244
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
245
|
+
|
|
246
|
+
// The enqueue seam mints the SAME turnId deriveTurnId would compute.
|
|
247
|
+
const turnId = deriveTurnId('chatB', null, 900)!
|
|
248
|
+
const adoption = h.signal.tryAdopt(turnId)
|
|
249
|
+
expect(adoption).not.toBeNull()
|
|
250
|
+
expect(adoption!.activityMessageId).toBe(resolvedId)
|
|
251
|
+
expect(adoption!.statusKey).toBe('chatB:_')
|
|
252
|
+
|
|
253
|
+
// Adoption is an EDIT: no second card send across the boundary.
|
|
254
|
+
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
255
|
+
// Typing loop never sampled zero across the seam.
|
|
256
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
257
|
+
|
|
258
|
+
// Durable record migrated from the synthetic key to the real topic key so
|
|
259
|
+
// the turn's own end-of-turn clearActivitySummary owns the teardown.
|
|
260
|
+
expect([...h.records.keys()]).toEqual(['chatB:_'])
|
|
261
|
+
expect(h.records.get('chatB:_')!.activityMessageId).toBe(resolvedId)
|
|
262
|
+
|
|
263
|
+
// Entry consumed — no lingering pre-turn state.
|
|
264
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('turn-end hands off cleanly: typing stops exactly once, durable record cleared, map empty', async () => {
|
|
268
|
+
const h = makeHarness()
|
|
269
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatC', threadId: 3, messageId: 12 }))
|
|
270
|
+
await h.sched.advance(700)
|
|
271
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
272
|
+
const turnId = deriveTurnId('chatC', 3, 12)!
|
|
273
|
+
const adoption = h.signal.tryAdopt(turnId)!
|
|
274
|
+
expect(adoption.activityMessageId).toBe(resolvedId)
|
|
275
|
+
|
|
276
|
+
// Simulate the gateway's canonical turn-end: clearActivitySummary clears the
|
|
277
|
+
// durable record (statusKey + exact id) and stopTurnTypingLoop stops once.
|
|
278
|
+
h.records.delete('chatC:3') // clearActivityCardRecord(statusKey, id)
|
|
279
|
+
h.typing.stop('chatC', 3)
|
|
280
|
+
|
|
281
|
+
expect(h.typing.activeCount()).toBe(0)
|
|
282
|
+
expect(h.records.size).toBe(0)
|
|
283
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('never-adopted handback self-reaps its frozen card past the TTL', async () => {
|
|
287
|
+
const h = makeHarness()
|
|
288
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatD', messageId: 77 }))
|
|
289
|
+
await h.sched.advance(700)
|
|
290
|
+
const resolvedId = await h.openCard.mock.results[0]!.value
|
|
291
|
+
expect(h.typing.activeCount()).toBe(1)
|
|
292
|
+
expect(h.records.size).toBe(1)
|
|
293
|
+
|
|
294
|
+
// No enqueue ever arrives. Past the adopt TTL the orphan self-reaps.
|
|
295
|
+
await h.sched.advance(30_000)
|
|
296
|
+
|
|
297
|
+
expect(h.finalizedIds).toEqual([resolvedId]) // finalized exactly THAT card
|
|
298
|
+
expect(h.typing.activeCount()).toBe(0) // typing stopped
|
|
299
|
+
expect(h.records.size).toBe(0) // durable record cleared (at-most-once)
|
|
300
|
+
expect(h.signal.pendingCount()).toBe(0) // map empty
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
|
|
304
|
+
const h = makeHarness()
|
|
305
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))
|
|
306
|
+
await h.sched.advance(700)
|
|
307
|
+
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
308
|
+
|
|
309
|
+
// A racing user inbound on the SAME topic key mints a DIFFERENT turnId.
|
|
310
|
+
const userTurnId = deriveTurnId('chatE', null, 201)! // user's message id
|
|
311
|
+
expect(h.signal.tryAdopt(userTurnId)).toBeNull() // must NOT mis-adopt
|
|
312
|
+
expect(h.signal.pendingCount()).toBe(1) // handback entry still live
|
|
313
|
+
|
|
314
|
+
// The handback's own enqueue adopts correctly — still exactly one send.
|
|
315
|
+
const handbackTurnId = deriveTurnId('chatE', null, 200)!
|
|
316
|
+
const adoption = h.signal.tryAdopt(handbackTurnId)
|
|
317
|
+
expect(adoption).not.toBeNull()
|
|
318
|
+
expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
|
|
319
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('dedupes a second handback release for the same topic; ignores non-handback inbounds', async () => {
|
|
323
|
+
const h = makeHarness()
|
|
324
|
+
// Non-handback → no-op.
|
|
325
|
+
h.signal.noteHandbackRelease(userInbound({ chatId: 'chatF', messageId: 1 }))
|
|
326
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
327
|
+
|
|
328
|
+
// Two handbacks for the same topic → the first owns the pre-turn signal.
|
|
329
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatF', messageId: 10 }))
|
|
330
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatF', messageId: 11 }))
|
|
331
|
+
expect(h.signal.pendingCount()).toBe(1)
|
|
332
|
+
await h.sched.advance(700)
|
|
333
|
+
expect(h.openCard).toHaveBeenCalledTimes(1)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('skips the emit when the adopting turn already settled before the debounce fires', async () => {
|
|
337
|
+
const settled = new Set<string>()
|
|
338
|
+
const h = makeHarness({ isTurnSettled: (k) => settled.has(k) })
|
|
339
|
+
h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatG', messageId: 5 }))
|
|
340
|
+
settled.add('chatG:_') // the turn delivered its answer during the debounce
|
|
341
|
+
await h.sched.advance(700)
|
|
342
|
+
expect(h.openCard).not.toHaveBeenCalled()
|
|
343
|
+
expect(h.typing.activeCount()).toBe(0)
|
|
344
|
+
expect(h.signal.pendingCount()).toBe(0)
|
|
345
|
+
})
|
|
346
|
+
})
|