switchroom 0.19.6 → 0.19.8

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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Wire-level outcome tests for the universal accidental-formatting guard
3
+ * (`installRichMarkdownGuard`, #3252/#3463 follow-up).
4
+ *
5
+ * These MUST go through a REAL grammy `Bot` with a stubbed transport. The
6
+ * existing unit/golden harnesses (`tests/bot-api.harness.ts` etc.) mock the
7
+ * `api` object ABOVE the grammy transformer layer, so `api.config.use`
8
+ * transformers never run there — a test written through them is a FALSE guard
9
+ * (it stays green with the transformer absent or mis-gated). By stubbing
10
+ * `client.fetch` we capture the exact serialized wire body AFTER the
11
+ * transformer has mutated the raw payload, which is the only place the guard's
12
+ * effect is observable.
13
+ *
14
+ * Red-team F-R1: the markdown lives at `payload.rich_message.markdown`, NOT
15
+ * `payload.markdown`. A guard gating on the latter matches nothing and every
16
+ * one of these assertions would still fail — so these tests pin the correct
17
+ * field shape too.
18
+ */
19
+ import { describe, it, expect } from 'vitest'
20
+ import { Bot } from 'grammy'
21
+ import { installTgPostLogger, installRichMarkdownGuard } from '../shared/bot-runtime.js'
22
+
23
+ interface CapturedCall {
24
+ method: string
25
+ body: Record<string, unknown>
26
+ }
27
+
28
+ /**
29
+ * Build a real grammy Bot whose transport is stubbed: every API call is
30
+ * captured (method + parsed JSON body) and answered with a minimal ok
31
+ * response. `botInfo` is supplied so `bot.init()` never hits the network.
32
+ */
33
+ function makeCapturingBot(): { bot: Bot; calls: CapturedCall[] } {
34
+ const calls: CapturedCall[] = []
35
+ const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
36
+ const method = String(url).split('/').pop() ?? ''
37
+ let body: Record<string, unknown> = {}
38
+ if (typeof init?.body === 'string') {
39
+ body = JSON.parse(init.body) as Record<string, unknown>
40
+ }
41
+ calls.push({ method, body })
42
+ // Minimal Telegram ok envelope. `result` shape doesn't matter for these
43
+ // send/edit calls — grammy only reads `ok`/`result`.
44
+ return {
45
+ ok: true,
46
+ status: 200,
47
+ json: async () => ({ ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: 'private' } } }),
48
+ } as unknown as Response
49
+ }) as unknown as typeof fetch
50
+
51
+ const bot = new Bot('123456:TEST_TOKEN', {
52
+ botInfo: {
53
+ id: 123456,
54
+ is_bot: true,
55
+ first_name: 'Test',
56
+ username: 'test_bot',
57
+ can_join_groups: false,
58
+ can_read_all_group_messages: false,
59
+ supports_inline_queries: false,
60
+ can_connect_to_business: false,
61
+ has_main_web_app: false,
62
+ },
63
+ client: { fetch: fakeFetch },
64
+ })
65
+ // Install exactly the production transformer stack ordering: logger first,
66
+ // then guard (guard composes outermost — grammy runs last-installed first).
67
+ installTgPostLogger(bot)
68
+ installRichMarkdownGuard(bot)
69
+ return { bot, calls }
70
+ }
71
+
72
+ function lastRichMarkdown(calls: CapturedCall[]): unknown {
73
+ const c = calls[calls.length - 1]
74
+ return (c.body.rich_message as { markdown?: unknown } | undefined)?.markdown
75
+ }
76
+
77
+ describe('installRichMarkdownGuard — universal wire seam', () => {
78
+ it('(a) escapes a raw { markdown } sendRichMessage on the wire', async () => {
79
+ const { bot, calls } = makeCapturingBot()
80
+ await bot.api.sendRichMessage(1, { markdown: '#3460 done' })
81
+ expect(calls[calls.length - 1].method).toBe('sendRichMessage')
82
+ expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
83
+ })
84
+
85
+ it('(b) escapes an editMessageText object-arg { markdown } on the wire', async () => {
86
+ const { bot, calls } = makeCapturingBot()
87
+ await bot.api.editMessageText(1, 42, { markdown: '#3460 done' })
88
+ expect(calls[calls.length - 1].method).toBe('editMessageText')
89
+ expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
90
+ })
91
+
92
+ it('(c) leaves a literalText / string-arg edit UNTOUCHED (carries text, not rich_message)', async () => {
93
+ const { bot, calls } = makeCapturingBot()
94
+ await bot.api.editMessageText(1, 42, '#3460 done')
95
+ const c = calls[calls.length - 1]
96
+ expect(c.method).toBe('editMessageText')
97
+ expect(c.body.text).toBe('#3460 done')
98
+ expect(c.body.rich_message).toBeUndefined()
99
+ })
100
+
101
+ it('(d) leaves a genuine heading byte-identical', async () => {
102
+ const { bot, calls } = makeCapturingBot()
103
+ await bot.api.sendRichMessage(1, { markdown: '# Title\n## Sub' })
104
+ expect(lastRichMarkdown(calls)).toBe('# Title\n## Sub')
105
+ })
106
+
107
+ it('(e) is idempotent on an already-guarded (richMessage-wrapped) body', async () => {
108
+ const { bot, calls } = makeCapturingBot()
109
+ // Simulate a body that already went through richMessage()'s internal guard.
110
+ await bot.api.sendRichMessage(1, { markdown: '\\#3460 done' })
111
+ expect(lastRichMarkdown(calls)).toBe('\\#3460 done')
112
+ })
113
+
114
+ it('does not mutate the caller\'s shared input object', async () => {
115
+ const { bot } = makeCapturingBot()
116
+ const input = { markdown: '#3460 done' }
117
+ await bot.api.sendRichMessage(1, input)
118
+ // Caller's object is unchanged; only the cloned wire payload is escaped.
119
+ expect(input.markdown).toBe('#3460 done')
120
+ })
121
+ })
@@ -46,7 +46,7 @@ import {
46
46
  } from '../gateway/outbound-send-path.js'
47
47
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
48
  import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
49
- import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
49
+ import { SubagentHandbackMarker, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
50
50
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
51
51
  import { redact } from '../secret-detect/redact.js'
52
52
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
@@ -887,7 +887,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
887
887
  const marker = new SubagentHandbackMarker()
888
888
  const buffer = createPendingInboundBuffer({
889
889
  log: () => {},
890
- onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
890
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
891
891
  })
892
892
 
893
893
  // Simulate the boot-replay re-push of an un-acked spooled handback envelope.
@@ -905,14 +905,14 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
905
905
  meta: { source: 'subagent_handback' },
906
906
  })
907
907
  // The chokepoint stamped the marker from the replay push (pre-fix: empty).
908
- expect(marker.lastAt(CHAT)).toBe(handbackTs)
908
+ expect(marker.lastAt(CHAT, undefined)).toBe(handbackTs)
909
909
 
910
910
  const h = makeHarness()
911
911
  const owner = makeFlushDeliveredEndedTurn()
912
912
  seedRecord(h, owner)
913
913
  h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
914
- // The gateway reads the marker the boot-replay stamped.
915
- h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAt(chatId)
914
+ // The gateway reads the marker the boot-replay stamped (chat-wide gate).
915
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
916
916
 
917
917
  const res = await sendReply(h.deps, req(HANDBACK))
918
918
 
@@ -929,12 +929,226 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
929
929
  const marker = new SubagentHandbackMarker()
930
930
  const buffer = createPendingInboundBuffer({
931
931
  log: () => {},
932
- onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
932
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
933
933
  })
934
934
  buffer.push('marko', {
935
935
  type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
936
936
  ts: Date.now(), text: 'hi', meta: {},
937
937
  })
938
- expect(marker.lastAt(CHAT)).toBe(null)
938
+ expect(marker.lastAt(CHAT, undefined)).toBe(null)
939
+ })
940
+
941
+ // ─── MUST-FIX 2 (dup-audit / Fable): the gate is un-steerable by a model
942
+ // thread arg — a cross-topic handback still keeps the content gate ───
943
+ //
944
+ // Fable's PROVEN regression: F2's thread-keyed gate read let a foreign-content
945
+ // reply carrying `message_thread_id=<other topic>` dodge a handback marker
946
+ // stamped on the real topic → silent edit-over (the #3429 double-loss). Because
947
+ // `findLatestEndedTurnForChat` resolves owners CHAT-WIDE, a topic-A handback can
948
+ // resolve — and supersede — topic B's ended turn. The gate read is therefore
949
+ // chat-wide (`lastAtInChat`): any in-window handback in the chat keeps the gate,
950
+ // so the reply's own thread arg cannot bypass it. Drives the REAL buffer
951
+ // chokepoint + REAL send path.
952
+ it('MUST-FIX 2: a handback stamped in topic A keeps the content gate for a ' +
953
+ 'foreign reply steered to topic B (message_thread_id) — FRESH send, no silent edit-over', async () => {
954
+ const TOPIC_A = 111
955
+ const TOPIC_B = 222
956
+ const marker = new SubagentHandbackMarker()
957
+ const buffer = createPendingInboundBuffer({
958
+ log: () => {},
959
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
960
+ })
961
+ // A background handback lands in TOPIC A (envelope threadId=A) via the real
962
+ // chokepoint — after topic B's turn ended (now-30s), within the 60s TTL.
963
+ const handbackTs = Date.now() - 15_000
964
+ buffer.push('marko', {
965
+ type: 'inbound', chatId: CHAT, threadId: TOPIC_A, messageId: handbackTs,
966
+ user: 'subagent-watcher', userId: 0, ts: handbackTs, text: 'handback',
967
+ meta: { source: 'subagent_handback' },
968
+ })
969
+
970
+ // Topic B has its OWN flush-delivered ended turn with a real answer.
971
+ const h = makeHarness()
972
+ const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC_B } as unknown as CurrentTurn
973
+ h.deps.flushedTurnSupersede.record(
974
+ CHAT, TOPIC_B,
975
+ { turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
976
+ Date.now(),
977
+ )
978
+ // The topic-A handback's reply is STEERED to topic B (message_thread_id=222)
979
+ // and resolves topic B's ended turn chat-wide (latest-ended), carrying FOREIGN
980
+ // content. The gate read is chat-wide, so the topic-A stamp is seen.
981
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
982
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
983
+
984
+ const res = await sendReply(
985
+ h.deps,
986
+ req(HANDBACK, { message_thread_id: TOPIC_B }),
987
+ )
988
+
989
+ // Gate kept → FRESH notifying send; topic B's flushed answer is NEITHER
990
+ // edited nor deleted. (Under the F2 thread-keyed gate this was editMessageText
991
+ // ×1 over msg 4242 — the silent-loss regression this closes.)
992
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
993
+ expect(fresh).toHaveLength(1)
994
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
995
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
996
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
997
+ // Topic B's flush record NOT consumed — the delivered answer stands.
998
+ expect(
999
+ h.deps.flushedTurnSupersede.peek(CHAT, TOPIC_B, {
1000
+ liveTurnId: (owner as CurrentTurn).turnId,
1001
+ replyText: CANONICAL_REPLY,
1002
+ now: Date.now(),
1003
+ }).supersede,
1004
+ ).toBe(true)
1005
+ })
1006
+
1007
+ // Thread-keyed COLLAPSE still routes per-topic: a topic reply supersedes its
1008
+ // OWN topic's flush record (via the framework-resolved owner thread, not the
1009
+ // raw arg), so a genuine own-reply in a forum topic collapses to one message.
1010
+ it('F2 positive: a topic own-reply (no handback) supersedes its own topic\'s ' +
1011
+ 'flush record — one message, on the framework-resolved thread lane', async () => {
1012
+ const TOPIC = 222
1013
+ const h = makeHarness()
1014
+ const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC } as unknown as CurrentTurn
1015
+ // Flush recorded on the owner turn's OWN topic lane (what the flush does).
1016
+ h.deps.flushedTurnSupersede.record(
1017
+ CHAT, TOPIC,
1018
+ { turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1019
+ Date.now(),
1020
+ )
1021
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
1022
+ h.deps.getLastSubagentHandbackAt = () => null // no handback anywhere
1023
+
1024
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER, { message_thread_id: TOPIC }))
1025
+
1026
+ // Collapses to ONE message: the topic's flushed message edited in place.
1027
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1028
+ expect(edits).toHaveLength(1)
1029
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1030
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1031
+ expect(res.content[0]!.text).toMatch(/^sent/)
1032
+ })
1033
+
1034
+ // ─── F1 (dup-audit): negative outcome guard — no silent edit-over ───
1035
+ //
1036
+ // The content-gate bypass must NEVER silently edit over a flush-delivered
1037
+ // answer with FOREIGN content on a non-live tier. A decoupled completion (the
1038
+ // handback class the marker covers) landing with genuinely different content,
1039
+ // resolving an ended flushed turn via the latest-ended tier, must SEND FRESH —
1040
+ // the flushed answer left untouched (Telegram edits do not re-notify, so an
1041
+ // edit-over is a silent double-loss: the handback never surfaces AND the answer
1042
+ // is destroyed — the #3429 incident). This pins the invariant's OUTCOME: the
1043
+ // stamp (driven by the single chokepoint predicate) keeps the gate, and the
1044
+ // foreign reply never touches the delivered answer.
1045
+ it('F1: a decoupled foreign-content reply on a non-live tier resolving a ' +
1046
+ 'DIFFERENT ended turn sends FRESH — the flushed answer is never edited/deleted', async () => {
1047
+ const h = makeHarness()
1048
+ const owner = makeFlushDeliveredEndedTurn()
1049
+ seedRecord(h, owner)
1050
+ // The reply resolves the flush-delivered ENDED turn via the ambiguous
1051
+ // latest-ended tier (no live turn, no positive attribution) and carries
1052
+ // FOREIGN content (a worker report, not this turn's answer nor a rewording).
1053
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
1054
+ // A decoupled completion IS in the window (the marker stamped it) — so this
1055
+ // late reply might BE it. The invariant keeps the content gate.
1056
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
1057
+
1058
+ const res = await sendReply(h.deps, req(HANDBACK))
1059
+
1060
+ // FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
1061
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1062
+ expect(fresh).toHaveLength(1)
1063
+ expect(fresh[0]!.text).toContain('migration audit')
1064
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1065
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1066
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1067
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1068
+ // The flush record is NOT consumed — the flushed answer stands, and the
1069
+ // turn's OWN canonical replay can still correct it.
1070
+ expect(
1071
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1072
+ liveTurnId: OWNER_TURN_ID,
1073
+ replyText: CANONICAL_REPLY,
1074
+ now: Date.now(),
1075
+ }).supersede,
1076
+ ).toBe(true)
1077
+ })
1078
+
1079
+ // Structural: the stamp membership lives in exactly ONE predicate, consulted
1080
+ // at the ONE buffer chokepoint. If a future refactor inlines a bare
1081
+ // `=== 'subagent_handback'` check at the chokepoint (bypassing the predicate),
1082
+ // the invariant's single-extension-point guarantee is lost — fail loudly.
1083
+ it('F1: the buffer chokepoint stamps via the stampsHandbackMarker predicate, ' +
1084
+ 'not an inlined source literal', () => {
1085
+ const bufferSrc = readFileSync(new URL('../gateway/pending-inbound-buffer.ts', import.meta.url), 'utf8')
1086
+ expect(bufferSrc).toContain('stampsHandbackMarker(msg.meta?.source)')
1087
+ // The predicate itself is the single membership definition.
1088
+ expect(stampsHandbackMarker('subagent_handback')).toBe(true)
1089
+ expect(stampsHandbackMarker('cron')).toBe(false)
1090
+ expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
1091
+ expect(stampsHandbackMarker(undefined)).toBe(false)
1092
+ })
1093
+
1094
+ // ─── MUST-FIX 1 (dup-audit / Fable): model-steerable tiers NEVER bypass ───
1095
+ //
1096
+ // The `quoted`/`origin` tiers resolve from MODEL-supplied args (reply_to /
1097
+ // origin_turn_id), so a reply can steer ITSELF onto a different ended turn's
1098
+ // record. Marker-absence proves "own answer" only for the framework-derived
1099
+ // latest-ended tier — a quoted/origin resolution with no marker is NOT
1100
+ // ownership evidence. Fable executed this exact path (quoted tier, no marker,
1101
+ // foreign content → editMessageText ×1 over the flushed answer). With the tier
1102
+ // restriction, quoted/origin ALWAYS traverse the content gate → foreign content
1103
+ // sends FRESH, the flushed answer untouched. RED on pre-fix code
1104
+ // (replyIsOwnAnswer = tier==='live' || !handbackCouldOwnReply → quoted bypassed).
1105
+ it('MUST-FIX 1: a quoted-tier reply with NO marker and FOREIGN content does NOT ' +
1106
+ 'bypass the content gate — FRESH send, flushed answer never edited/deleted', async () => {
1107
+ const h = makeHarness()
1108
+ const owner = makeFlushDeliveredEndedTurn()
1109
+ seedRecord(h, owner)
1110
+ // Model-steered `quoted` attribution to a DIFFERENT ended flushed turn, with
1111
+ // NO decoupled completion anywhere in the chat (the residual F1 vector).
1112
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
1113
+ h.deps.getLastSubagentHandbackAt = () => null
1114
+
1115
+ const res = await sendReply(h.deps, req(HANDBACK))
1116
+
1117
+ // FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
1118
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1119
+ expect(fresh).toHaveLength(1)
1120
+ expect(fresh[0]!.text).toContain('migration audit')
1121
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1122
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1123
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1124
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1125
+ // Flush record NOT consumed — the delivered answer stands.
1126
+ expect(
1127
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1128
+ liveTurnId: OWNER_TURN_ID,
1129
+ replyText: CANONICAL_REPLY,
1130
+ now: Date.now(),
1131
+ }).supersede,
1132
+ ).toBe(true)
1133
+ })
1134
+
1135
+ // Sibling positive: a quoted-tier reply carrying the turn's OWN answer (same
1136
+ // content, contained in the flushed blob) still collapses through the content
1137
+ // gate — the tier restriction only blocks FOREIGN content, not genuine replays.
1138
+ it('MUST-FIX 1 sibling: a quoted-tier reply with the SAME answer still collapses ' +
1139
+ '(content gate passes) — one message', async () => {
1140
+ const h = makeHarness()
1141
+ const owner = makeFlushDeliveredEndedTurn()
1142
+ seedRecord(h, owner)
1143
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
1144
+ h.deps.getLastSubagentHandbackAt = () => null
1145
+
1146
+ const res = await sendReply(h.deps, req(CANONICAL_REPLY))
1147
+
1148
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1149
+ expect(edits).toHaveLength(1)
1150
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1151
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1152
+ expect(res.content[0]!.text).toMatch(/^sent/)
939
1153
  })
940
1154
  })
@@ -56,7 +56,12 @@ function makeFakeBot() {
56
56
  const api = {
57
57
  sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
58
58
  sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
59
- editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => { rec('editMessageText', c, typeof b === 'string' ? b : (b as { markdown: string }).markdown, o); return {} },
59
+ editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
60
+ // Preserve the REAL edit target id (m) so tests can assert an edit-in-place
61
+ // hit the flushed message, not a synthetic fresh id.
62
+ calls.push({ method: 'editMessageText', chat_id: c, text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, opts: o, reply_markup: o.reply_markup ?? null, message_id: m })
63
+ return {}
64
+ },
60
65
  deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
61
66
  }
62
67
  return { api, calls }
@@ -100,6 +105,7 @@ function makeStreamDeps(opts?: {
100
105
  dedup?: OutboundDedupCache
101
106
  turn?: CurrentTurn | null
102
107
  deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
108
+ flushedTurnSupersede?: FlushedTurnSupersedeRegistry
103
109
  }): StreamHarness {
104
110
  const { api, calls } = makeFakeBot()
105
111
  const dedup = opts?.dedup ?? new OutboundDedupCache()
@@ -135,7 +141,7 @@ function makeStreamDeps(opts?: {
135
141
  activeTurnStartedAt: new Map(),
136
142
  backstopDeliveryLedger: ledger,
137
143
  bot: { api },
138
- flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
144
+ flushedTurnSupersede: opts?.flushedTurnSupersede ?? new FlushedTurnSupersedeRegistry(),
139
145
  idleTracker: { noteEvent: noop },
140
146
  lastPtyPreviewByChat: new Map(),
141
147
  obligationLedger: { close: noop, noteTurnEnded: noop },
@@ -206,12 +212,12 @@ function makeStreamDeps(opts?: {
206
212
  }
207
213
 
208
214
  // ── the P2 sendReply harness (same content, sharing the ONE cache) ─────────
209
- function makeSendReplyDeps(dedup: OutboundDedupCache) {
215
+ function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedTurnSupersedeRegistry) {
210
216
  const { api, calls } = makeFakeBot()
211
217
  const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
212
218
  const deps = {
213
219
  outboundDedup: dedup,
214
- flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
220
+ flushedTurnSupersede: sharedSupersede ?? new FlushedTurnSupersedeRegistry(),
215
221
  firstTextReplyLogged: new Set<string>(),
216
222
  suppressPtyPreview: new Set<string>(),
217
223
  activeDraftStreams: new Map(),
@@ -386,6 +392,136 @@ describe('cross-surface dedup — ONE OutboundDedupCache across P4 stream + P2 r
386
392
  })
387
393
  })
388
394
 
395
+ // ── F3 (dup-audit 2026-07-21): the flush RECORD wiring is load-bearing ──────
396
+ //
397
+ // The entire flush→reply dedup depends on stream-render.ts recording the
398
+ // flush's delivered ids into the shared FlushedTurnSupersedeRegistry
399
+ // (`flushedTurnSupersede.record(...)`). Every OTHER outcome test seeds that
400
+ // record by hand (send-reply-golden's seedRecord/seedFlushRecord), so DELETING
401
+ // the record call would reintroduce the dominant flush→reply duplicate with all
402
+ // those tests still green. This drives the REAL flush record() end-to-end (no
403
+ // pre-seed) across ONE shared registry and asserts the reworded same-turn reply
404
+ // collapses to EXACTLY ONE message — so it goes RED if the record() call is
405
+ // removed. This is the guard the audit flagged as missing.
406
+ describe('F3 — flush record() → same-turn reworded reply collapse (end-to-end, no pre-seed)', () => {
407
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
408
+ const FLUSHED_ANSWER =
409
+ 'All twelve agents are healthy right now. The gateway, the vault broker and the ' +
410
+ 'approval kernel all report green health checks, and no container has restarted in ' +
411
+ 'the last twenty four hours, so there is nothing that needs your attention at the moment.'
412
+ const REWORDED =
413
+ 'Good news on the fleet. Every one of the twelve agents is running fine at the moment. ' +
414
+ 'Gateway, vault broker and approval kernel are all green, and nothing has restarted in ' +
415
+ 'the past day, so you do not need to do anything right now.'
416
+
417
+ it('flush → record → late reworded reply collapses to ONE message ' +
418
+ '(RED if stream-render flushedTurnSupersede.record is removed)', async () => {
419
+ // ONE shared supersede registry across BOTH surfaces — exactly the gateway
420
+ // singleton wiring. The flush RECORDS (the real stream-render.ts:1828 call);
421
+ // the reply CONSUMES. Nothing is pre-seeded.
422
+ const supersede = new FlushedTurnSupersedeRegistry()
423
+ const turn = makeTurn({ capturedText: [FLUSHED_ANSWER], capturedBlockMeta: [true] })
424
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
425
+
426
+ // Drive the REAL turn-flush path: it delivers message A (deliverAnswer → id
427
+ // 4242) AND records {turnId, [4242]} into the shared registry.
428
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
429
+ await settleFlush()
430
+ expect(sh.delivered).toContain(FLUSHED_ANSWER)
431
+ // The record actually landed (this is what a removal breaks first).
432
+ expect(
433
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
434
+ ).toBe('supersede')
435
+
436
+ // The model's REAL reply lands late with a REWORDED version of the same
437
+ // answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
438
+ const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
439
+ s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
440
+ // (getLastSubagentHandbackAt returns null in the base deps → own answer.)
441
+
442
+ const res = await sendReply(s.deps, req(REWORDED))
443
+
444
+ // EXACTLY ONE client-visible message: the flushed message (4242) edited in
445
+ // place into the reworded reply — NO fresh second bubble. Without the
446
+ // record(), the reply finds no record, falls to the latch branch, sees
447
+ // reworded ≠ flushed content, does NOT suppress, and ships a DUPLICATE
448
+ // sendRichMessage (edits=0, sends=1) → these assertions fail.
449
+ const edits = s.calls.filter((c) => c.method === 'editMessageText')
450
+ expect(edits).toHaveLength(1)
451
+ expect(edits[0]!.message_id).toBe(4242)
452
+ expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
453
+ expect(res.content[0]!.text).toMatch(/^sent/)
454
+ })
455
+ })
456
+
457
+ // ── F5 (dup-audit): true take()-before-record() interleaving ────────────────
458
+ //
459
+ // The residual race the supersede registry cannot reach: a reply whose
460
+ // supersede take() runs in the window AFTER the flush FIRED but BEFORE it
461
+ // recorded its message ids. The flush arms `turn.answerDelivered='flush'` (+
462
+ // flushedAnswerText) SYNCHRONOUSLY at fire time — before its async deliver and
463
+ // before record — so a same-answer reply landing in that window is suppressed by
464
+ // the latch, not shipped as a duplicate. This drives a genuine two-emitter
465
+ // interleave (inverted ordering: reply take() strictly before flush record())
466
+ // and asserts exactly one delivered message.
467
+ describe('F5 — take()-before-record() interleaving delivers exactly one message', () => {
468
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
469
+ // ≥200 chars so the late reply is a substantive final answer (the floor the
470
+ // flush latch is scoped to) — else it would never trip the suppression.
471
+ const ANSWER =
472
+ 'Yes, that is all done and confirmed. The migration ran cleanly against the staging ' +
473
+ 'database, every integration check passed on the first attempt, the rollback plan is ' +
474
+ 'staged in case it is ever needed, and I have written the full run log to the shared ' +
475
+ 'drive so the team can review exactly what changed and when it happened.'
476
+
477
+ it('a reply whose take() runs BEFORE the flush record() is suppressed by the ' +
478
+ 'flush-armed latch — one delivered message, not two', async () => {
479
+ const supersede = new FlushedTurnSupersedeRegistry()
480
+ const turn = makeTurn({ capturedText: [ANSWER], capturedBlockMeta: [true] })
481
+ const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
482
+
483
+ // Fire the flush. Its latch is set SYNCHRONOUSLY here; deliverAnswer + record
484
+ // run in the async IIFE that has NOT completed — the pre-record window.
485
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
486
+ // INVERTED ORDERING: the reply's take() runs now, before the flush record().
487
+ expect(
488
+ supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
489
+ ).toBe('no-record') // record genuinely not written yet
490
+
491
+ const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
492
+ s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
493
+ // The same answer landing again in the race window → latch backstop suppresses.
494
+ const res = await sendReply(s.deps, req(ANSWER))
495
+
496
+ expect(s.calls).toHaveLength(0) // the reply shipped nothing
497
+ expect(res.content[0]!.text).toContain('deduped')
498
+
499
+ await settleFlush() // let the flush's async deliver + record complete
500
+ // Exactly one message reached the user: the flush's message A.
501
+ expect(sh.delivered).toHaveLength(1)
502
+ expect(sh.delivered[0]).toContain('done and confirmed')
503
+ })
504
+ })
505
+
506
+ // ── Double-flush idempotency at the delivery primitive (audit §3.5) ─────────
507
+ //
508
+ // The E2-vs-E3 double flush (answer-ready quiescence THEN turn-end backstop for
509
+ // the SAME turn) is guarded by backstopDeliveryLedger.claim. The ledger is
510
+ // unit-tested, but the audit wanted it pinned at the SEND-COUNT level in the
511
+ // flush integration. Two turn_end dispatches for one turn must deliver once.
512
+ describe('double-flush idempotency — deliverAnswer fires once (send-count level)', () => {
513
+ const settleFlush = () => new Promise((r) => setTimeout(r, 650))
514
+ it('two turn_end dispatches for the same turn deliver the answer EXACTLY once', async () => {
515
+ const turn = makeTurn()
516
+ const answer = turn.capturedText.join('')
517
+ const sh = makeStreamDeps({ turn })
518
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claims the latch
519
+ handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claim fails → no-op
520
+ await settleFlush()
521
+ expect(sh.delivered).toEqual([answer])
522
+ })
523
+ })
524
+
389
525
  describe('structural — the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
390
526
  const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
391
527
  const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')