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.
Files changed (32) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +494 -36
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +305 -41
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/send-gate.test.ts +138 -0
  18. package/telegram-plugin/send-gate.ts +104 -1
  19. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  20. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  22. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  23. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  24. package/telegram-plugin/tests/model-command.test.ts +112 -0
  25. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  26. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  27. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  28. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  29. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  30. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  31. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  32. 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
+ })