switchroom 0.19.22 → 0.19.24

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 (51) hide show
  1. package/dist/agent-scheduler/index.js +5 -2
  2. package/dist/auth-broker/index.js +95 -2
  3. package/dist/cli/notion-write-pretool.mjs +5 -2
  4. package/dist/cli/switchroom.js +749 -357
  5. package/dist/host-control/main.js +96 -3
  6. package/dist/vault/approvals/kernel-server.js +98 -5
  7. package/dist/vault/broker/server.js +98 -5
  8. package/package.json +5 -4
  9. package/profiles/_base/start.sh.hbs +101 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +64 -109
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
  12. package/profiles/_shared/dev-protocol.md.hbs +12 -42
  13. package/profiles/_shared/execution-discipline.md.hbs +7 -14
  14. package/profiles/coding/CLAUDE.md.hbs +0 -6
  15. package/profiles/default/CLAUDE.md.hbs +21 -50
  16. package/skills/dev-protocol/SKILL.md +97 -107
  17. package/skills/switchroom-release/SKILL.md +2 -1
  18. package/telegram-plugin/bunfig.toml +10 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +267 -52
  20. package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
  21. package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
  22. package/telegram-plugin/gateway/gateway.ts +43 -42
  23. package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
  24. package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
  25. package/telegram-plugin/gateway/stream-render.ts +6 -0
  26. package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
  27. package/telegram-plugin/gateway/turn-end.ts +1 -1
  28. package/telegram-plugin/gateway/turn-record-status.ts +19 -0
  29. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
  30. package/telegram-plugin/reply-owner-resolve.ts +110 -9
  31. package/telegram-plugin/send-gate-degraded.test.ts +45 -16
  32. package/telegram-plugin/send-gate.ts +185 -24
  33. package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
  34. package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
  35. package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
  36. package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
  37. package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
  38. package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
  39. package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
  40. package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
  41. package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
  42. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
  43. package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
  44. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
  45. package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
  46. package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
  47. package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
  48. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
  49. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
  50. package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
  51. package/vendor/hindsight-memory/tests/test_pending.py +12 -4
@@ -45,12 +45,51 @@ import {
45
45
  type VoiceOutPlan,
46
46
  } from '../gateway/outbound-send-path.js'
47
47
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
- import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
48
+ import {
49
+ FlushedTurnSupersedeRegistry,
50
+ DEFAULT_SUPERSEDE_TTL_MS,
51
+ flushedAnswerMatchesReply,
52
+ } from '../flushed-turn-supersede.js'
53
+ import type { ReplyOwnerTier, ReplyOwnerCandidates } from '../reply-owner-resolve.js'
49
54
  import { SubagentHandbackMarker, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
50
55
  import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
51
56
  import { redact } from '../secret-detect/redact.js'
52
57
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
53
58
 
59
+ /**
60
+ * Build the owner-resolution result the gateway's `resolveReplyOwnerTurn`
61
+ * returns, INCLUDING the candidate set the content-gate bypass corroborates
62
+ * against (`decideContentGateBypass`).
63
+ *
64
+ * Default candidates model the CORROBORATED shape: the resolved turn is also the
65
+ * framework-derived `latestEndedTurnId`, fresh within the supersede TTL — what
66
+ * the real resolver produces for a late reply to the chat's most recent turn,
67
+ * whether or not the model also echoed `origin_turn_id` / `reply_to`. Pass
68
+ * `over` to model a model-STEERED attribution (an `origin`/`quoted` id pointing
69
+ * at a turn the framework would NOT have resolved) or a stale latest-ended.
70
+ */
71
+ function ownerRes(
72
+ turn: CurrentTurn | null,
73
+ tier: ReplyOwnerTier,
74
+ over: Partial<ReplyOwnerCandidates> = {},
75
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates } {
76
+ const id = turn?.turnId ?? null
77
+ return {
78
+ turn,
79
+ tier,
80
+ candidates: {
81
+ liveTurnId: tier === 'live' ? id : null,
82
+ originTurnId: tier === 'origin' ? id : null,
83
+ quotedTurnId: tier === 'quoted' ? id : null,
84
+ latestEndedTurnId: id,
85
+ latestEndedAgeMs: 30_000,
86
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
87
+ ...over,
88
+ },
89
+ }
90
+ }
91
+
92
+
54
93
  // ── fake bot API ──────────────────────────────────────────────────────────
55
94
 
56
95
  interface RecordedCall {
@@ -211,7 +250,7 @@ function makeHarness(opts?: {
211
250
  assertSendable: () => {},
212
251
  statusKey: key,
213
252
  streamKey: key,
214
- resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
253
+ resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
215
254
  getLastSubagentHandbackAt: () => null,
216
255
  findTurnByOriginId: () => null,
217
256
  findTurnByQuotedMessageId: () => null,
@@ -640,7 +679,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
640
679
  // is the AMBIGUOUS fallback tier: the content gate applies here, so a
641
680
  // genuinely-new handback sends fresh while the turn's own contained answer
642
681
  // still supersedes.
643
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
682
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
644
683
  }
645
684
 
646
685
  it('CORE REGRESSION (red pre-fix): a handback with genuinely NEW content sends a ' +
@@ -712,7 +751,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
712
751
  const owner = makeFlushDeliveredEndedTurn()
713
752
  // NO registry record (the post-fire pre-record window); owner resolution
714
753
  // still recovers the ended flush-armed turn via the latest-ended tier.
715
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
754
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
716
755
 
717
756
  const res = await sendReply(h.deps, req(HANDBACK))
718
757
 
@@ -726,7 +765,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
726
765
  'window is still suppressed (the #2996 Part 2 backstop holds)', async () => {
727
766
  const h = makeHarness()
728
767
  const owner = makeFlushDeliveredEndedTurn()
729
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
768
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
730
769
 
731
770
  const res = await sendReply(h.deps, req(FLUSHED_TEXT))
732
771
 
@@ -774,7 +813,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
774
813
  // Late DM reply: latest-ended tier (the path the tier discriminator MISSES),
775
814
  // and crucially NO subagent_handback was enqueued for this chat after the
776
815
  // turn ended — so the reply is the flushed turn's OWN answer.
777
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
816
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
778
817
  h.deps.getLastSubagentHandbackAt = () => null
779
818
 
780
819
  const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
@@ -801,7 +840,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
801
840
  const h = makeHarness()
802
841
  const owner = makeFlushDeliveredEndedTurn()
803
842
  seedRecord(h, owner)
804
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
843
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
805
844
  // A background sub-agent handback was enqueued for this chat AFTER the turn
806
845
  // ended (now-30s) and within the 60s TTL (now-15s) — this reply might BE it.
807
846
  h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
@@ -838,7 +877,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
838
877
  // `quoted`. A handback IS in flight, so a positive tier must NOT override the
839
878
  // #3429 content gate — else the reworded handback text silently edits over
840
879
  // the flushed turn's DELIVERED answer (Telegram edits don't re-notify).
841
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
880
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'quoted')
842
881
  h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
843
882
 
844
883
  const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
@@ -865,7 +904,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
865
904
  const h = makeHarness()
866
905
  const owner = makeFlushDeliveredEndedTurn()
867
906
  seedRecord(h, owner)
868
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'live' })
907
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'live')
869
908
  h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
870
909
 
871
910
  const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
@@ -910,7 +949,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
910
949
  const h = makeHarness()
911
950
  const owner = makeFlushDeliveredEndedTurn()
912
951
  seedRecord(h, owner)
913
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
952
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
914
953
  // The gateway reads the marker the boot-replay stamped (chat-wide gate).
915
954
  h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
916
955
 
@@ -978,7 +1017,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
978
1017
  // The topic-A handback's reply is STEERED to topic B (message_thread_id=222)
979
1018
  // and resolves topic B's ended turn chat-wide (latest-ended), carrying FOREIGN
980
1019
  // 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' })
1020
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner as CurrentTurn, 'latest-ended')
982
1021
  h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
983
1022
 
984
1023
  const res = await sendReply(
@@ -1018,7 +1057,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1018
1057
  { turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
1019
1058
  Date.now(),
1020
1059
  )
1021
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
1060
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner as CurrentTurn, 'latest-ended')
1022
1061
  h.deps.getLastSubagentHandbackAt = () => null // no handback anywhere
1023
1062
 
1024
1063
  const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER, { message_thread_id: TOPIC }))
@@ -1050,7 +1089,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1050
1089
  // The reply resolves the flush-delivered ENDED turn via the ambiguous
1051
1090
  // latest-ended tier (no live turn, no positive attribution) and carries
1052
1091
  // FOREIGN content (a worker report, not this turn's answer nor a rewording).
1053
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
1092
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'latest-ended')
1054
1093
  // A decoupled completion IS in the window (the marker stamped it) — so this
1055
1094
  // late reply might BE it. The invariant keeps the content gate.
1056
1095
  h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
@@ -1091,25 +1130,31 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1091
1130
  expect(stampsHandbackMarker(undefined)).toBe(false)
1092
1131
  })
1093
1132
 
1094
- // ─── MUST-FIX 1 (dup-audit / Fable): model-steerable tiers NEVER bypass ───
1133
+ // ─── MUST-FIX 1 (dup-audit / Fable): an UNCORROBORATED steerable tier ───
1095
1134
  //
1096
1135
  // 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 () => {
1136
+ // origin_turn_id), so a reply can steer ITSELF onto a DIFFERENT ended turn's
1137
+ // record and — with the content gate bypassed — silently edit over that turn's
1138
+ // delivered answer. Fable executed this exact path (quoted tier, no marker,
1139
+ // foreign content → editMessageText ×1 over the flushed answer).
1140
+ //
1141
+ // What blocks it is CORROBORATION, not a blanket tier ban: the steered
1142
+ // attribution points at a turn the FRAMEWORK-derived `latestEndedTurnId` does
1143
+ // NOT resolve, so `decideContentGateBypass` declines and the content gate
1144
+ // holds. The `latestEndedTurnId` override below is what makes this test model
1145
+ // steering rather than a plain self-echo.
1146
+ it('MUST-FIX 1: a quoted-tier reply STEERED onto a different turn than the ' +
1147
+ 'framework resolves, with NO marker and FOREIGN content, does NOT bypass the ' +
1148
+ 'content gate — FRESH send, flushed answer never edited/deleted', async () => {
1107
1149
  const h = makeHarness()
1108
1150
  const owner = makeFlushDeliveredEndedTurn()
1109
1151
  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' })
1152
+ // Model-steered `quoted` attribution onto the flushed turn, while the
1153
+ // framework's own latest-ended candidate is a DIFFERENT, newer turn — the
1154
+ // divergence that proves the attribution is model-driven. No decoupled
1155
+ // completion anywhere in the chat (the residual F1 vector).
1156
+ h.deps.resolveReplyOwnerTurn = () =>
1157
+ ownerRes(owner, 'quoted', { latestEndedTurnId: `${CHAT}:_#ended-99` })
1113
1158
  h.deps.getLastSubagentHandbackAt = () => null
1114
1159
 
1115
1160
  const res = await sendReply(h.deps, req(HANDBACK))
@@ -1140,7 +1185,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1140
1185
  const h = makeHarness()
1141
1186
  const owner = makeFlushDeliveredEndedTurn()
1142
1187
  seedRecord(h, owner)
1143
- h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
1188
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'quoted')
1144
1189
  h.deps.getLastSubagentHandbackAt = () => null
1145
1190
 
1146
1191
  const res = await sendReply(h.deps, req(CANONICAL_REPLY))
@@ -1151,4 +1196,227 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
1151
1196
  expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1152
1197
  expect(res.content[0]!.text).toMatch(/^sent/)
1153
1198
  })
1199
+
1200
+ // ─── 2026-07-27 DM incident: a CORROBORATED `origin` echo must collapse ───
1201
+ //
1202
+ // The observed failure (a DM agent delivered two answers twice — a flushed
1203
+ // copy plus a reworded `reply` copy, each time):
1204
+ //
1205
+ // 1. the answer-ready quiescence flush posted the turn's composed prose as
1206
+ // message A ("turn-flush firing — N chars without reply tool");
1207
+ // 2. the silent-end Stop hook told the agent its answer had not reached the
1208
+ // user, so it fired `reply` with a RE-WORDED version of the same answer;
1209
+ // 3. the agent also echoed `origin_turn_id` back — pointing at its OWN turn —
1210
+ // so owner resolution won on the `origin` tier, not `latest-ended`
1211
+ // (`reply-route ... via=origin late=true originTurn=<same turn>`);
1212
+ // 4. `origin` was banned outright from the content-gate bypass, so the gate
1213
+ // ran, the rewording defeated both equality and containment, and the
1214
+ // gateway logged `flush supersede declined — new content (#3429)` and
1215
+ // shipped message B — a visible duplicate.
1216
+ //
1217
+ // Note what the incident was NOT: not a captured-prose delivery (this is the
1218
+ // quiescence turn-flush path), and not a text-matcher failure that a smarter
1219
+ // matcher would fix. Rewording is model-dependent and cannot be matched
1220
+ // reliably; the deterministic signal is that the echoed turn is the SAME turn
1221
+ // the framework's own `latestEndedTurnId` resolves. This test drives the REAL
1222
+ // send path over that exact shape.
1223
+ it('2026-07-27 REGRESSION: a reworded same-turn reply that ALSO echoes ' +
1224
+ 'origin_turn_id for its own turn collapses to ONE message (corroborated ' +
1225
+ '`origin` tier bypasses the content gate)', async () => {
1226
+ const h = makeHarness()
1227
+ const owner = makeFlushDeliveredEndedTurn()
1228
+ seedRecord(h, owner)
1229
+ // `origin` tier — the model echoed origin_turn_id — but pointing at the SAME
1230
+ // turn the framework-derived latestEndedTurnId resolves (ownerRes's default),
1231
+ // i.e. corroborated: the model gained nothing it couldn't have had by
1232
+ // omitting the echo entirely.
1233
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'origin')
1234
+ // No decoupled completion in the window — the reply is this turn's own answer.
1235
+ h.deps.getLastSubagentHandbackAt = () => null
1236
+
1237
+ // The reworded answer defeats BOTH text paths the old gate relied on, so the
1238
+ // test cannot pass by accident through `flushedAnswerMatchesReply`.
1239
+ expect(flushedAnswerMatchesReply(FLUSHED_TEXT, REWORDED_SAME_TURN_ANSWER)).toBe(false)
1240
+
1241
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
1242
+
1243
+ // OUTCOME: exactly ONE client-visible message. The flushed message A is
1244
+ // corrected in place into the reworded answer; NO second bubble ships.
1245
+ // (Pre-fix this was sendRichMessage ×1 alongside the surviving flush = the
1246
+ // two Telegram messages the user actually received.)
1247
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1248
+ expect(edits).toHaveLength(1)
1249
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1250
+ expect(edits[0]!.text).toContain('every one of the twelve agents')
1251
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1252
+ expect(res.content[0]!.text).toMatch(/^sent/)
1253
+ // Record consumed — the flush is gone, not merely shadowed.
1254
+ expect(
1255
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1256
+ liveTurnId: OWNER_TURN_ID,
1257
+ now: Date.now(),
1258
+ }).reason,
1259
+ ).toBe('no-record')
1260
+ })
1261
+
1262
+ // The behaviour the old "new content" check was really protecting: an agent
1263
+ // that deliberately sends a SECOND, genuinely different message in the same
1264
+ // turn must not have its first one deleted. This is structural, not textual —
1265
+ // the registry CONSUMES the record on supersede, so the second reply finds
1266
+ // 'no-record' and can never reach the first message. No reply counter needed.
1267
+ it('PRESERVED: a deliberate SECOND reply in the same turn does NOT delete or ' +
1268
+ 'edit the first — both messages survive', async () => {
1269
+ const h = makeHarness()
1270
+ const owner = makeFlushDeliveredEndedTurn()
1271
+ seedRecord(h, owner)
1272
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'origin')
1273
+ h.deps.getLastSubagentHandbackAt = () => null
1274
+
1275
+ // Reply #1 — the reworded answer; supersedes the flush (one message so far).
1276
+ await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
1277
+ const afterFirst = h.calls.length
1278
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(1)
1279
+
1280
+ // Reply #2 — a deliberate follow-up in the SAME turn, genuinely new content.
1281
+ const res = await sendReply(h.deps, req(HANDBACK))
1282
+
1283
+ const later = h.calls.slice(afterFirst)
1284
+ // It ships as its OWN fresh notifying message…
1285
+ const fresh = later.filter((c) => c.method === 'sendRichMessage')
1286
+ expect(fresh).toHaveLength(1)
1287
+ expect(fresh[0]!.text).toContain('migration audit')
1288
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
1289
+ // …and it neither deletes nor re-edits the message the first reply owns.
1290
+ expect(later.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1291
+ expect(later.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1292
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1293
+ })
1294
+
1295
+ // Same corroboration rule on the other steerable tier, so a model that quotes
1296
+ // instead of echoing gets the same collapse rather than a duplicate.
1297
+ it('a reworded same-turn reply on a CORROBORATED `quoted` tier also collapses ' +
1298
+ 'to one message', async () => {
1299
+ const h = makeHarness()
1300
+ const owner = makeFlushDeliveredEndedTurn()
1301
+ seedRecord(h, owner)
1302
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'quoted')
1303
+ h.deps.getLastSubagentHandbackAt = () => null
1304
+
1305
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
1306
+
1307
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1308
+ expect(edits).toHaveLength(1)
1309
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1310
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1311
+ expect(res.content[0]!.text).toMatch(/^sent/)
1312
+ })
1313
+
1314
+ // The corroboration must not resurrect the handback hazard: even a self-echoed
1315
+ // `origin` attribution keeps the content gate while a decoupled completion is
1316
+ // in the window, because the late reply might BE that completion.
1317
+ it('a corroborated `origin` reply STILL keeps the content gate while a handback ' +
1318
+ 'is in the window — foreign content sends FRESH, flush untouched', async () => {
1319
+ const h = makeHarness()
1320
+ const owner = makeFlushDeliveredEndedTurn()
1321
+ seedRecord(h, owner)
1322
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, 'origin')
1323
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
1324
+
1325
+ const res = await sendReply(h.deps, req(HANDBACK))
1326
+
1327
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
1328
+ expect(fresh).toHaveLength(1)
1329
+ expect(fresh[0]!.text).toContain('migration audit')
1330
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1331
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1332
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1333
+ // The flushed answer stands — its record is intact for the turn's own replay.
1334
+ expect(
1335
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1336
+ liveTurnId: OWNER_TURN_ID,
1337
+ replyText: CANONICAL_REPLY,
1338
+ now: Date.now(),
1339
+ }).supersede,
1340
+ ).toBe(true)
1341
+ })
1342
+
1343
+ // ─── #3727: the F1 RESIDUAL cell, pinned as an ACCEPTED trade-off ───
1344
+ //
1345
+ // Corroborated `origin`/`quoted` + FOREIGN content + NO handback marker in the
1346
+ // window. Both guards pass — the attribution is framework-corroborated, and
1347
+ // nothing says a decoupled completion is outstanding — so the content gate is
1348
+ // BYPASSED and the flushed answer is corrected in place. This is the accepted
1349
+ // outcome, not an oversight, and these tests exist so it can never flip
1350
+ // silently in either direction:
1351
+ //
1352
+ // - it is NOT new reach. The identical reply reaches the identical outcome
1353
+ // today by omitting `origin_turn_id` / `reply_to` and landing on
1354
+ // `latest-ended` with the same turn (the zero-new-capability property,
1355
+ // swept exhaustively in `reply-owner-resolve.test.ts`).
1356
+ // - it IS the documented F1 residual: a genuinely decoupled completion that
1357
+ // fails to stamp the `subagent_handback` marker is indistinguishable from
1358
+ // the turn's own reworded answer, because rewording cannot be matched
1359
+ // textually. The MITIGATION is the marker itself — stamped at the single
1360
+ // buffer chokepoint via `stampsHandbackMarker` (see the F1 tests above,
1361
+ // which pin the marked case sending FRESH with the flush untouched).
1362
+ //
1363
+ // If a future tightening of the corroboration rule flips this cell to a fresh
1364
+ // send, these go red — decide deliberately, don't drift into it.
1365
+ for (const tier of ['origin', 'quoted'] as const) {
1366
+ it(`#3727 (F1 residual, ACCEPTED): a corroborated \`${tier}\` reply with FOREIGN ` +
1367
+ 'content and NO handback marker bypasses the gate — the flushed answer is ' +
1368
+ 'edited in place, exactly one message survives', async () => {
1369
+ const h = makeHarness()
1370
+ const owner = makeFlushDeliveredEndedTurn()
1371
+ seedRecord(h, owner)
1372
+ // Corroborated: the model-supplied attribution names the SAME turn the
1373
+ // framework-derived `latestEndedTurnId` resolves (ownerRes's default).
1374
+ h.deps.resolveReplyOwnerTurn = () => ownerRes(owner, tier)
1375
+ // No decoupled completion anywhere in the chat — the residual F1 vector.
1376
+ h.deps.getLastSubagentHandbackAt = () => null
1377
+
1378
+ // The content is genuinely FOREIGN: the gate would have declined it, so
1379
+ // this test cannot pass by accident through `flushedAnswerMatchesReply`.
1380
+ expect(flushedAnswerMatchesReply(FLUSHED_TEXT, HANDBACK)).toBe(false)
1381
+
1382
+ const res = await sendReply(h.deps, req(HANDBACK))
1383
+
1384
+ // OUTCOME: exactly one client-visible message — the flushed message A is
1385
+ // edited into the foreign content; NO second bubble ships.
1386
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
1387
+ expect(edits).toHaveLength(1)
1388
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
1389
+ expect(edits[0]!.text).toContain('migration audit')
1390
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
1391
+ expect(res.content[0]!.text).toMatch(/^sent/)
1392
+ // The flush record is CONSUMED — the provisional answer is gone, not
1393
+ // merely shadowed.
1394
+ expect(
1395
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
1396
+ liveTurnId: OWNER_TURN_ID,
1397
+ now: Date.now(),
1398
+ }).reason,
1399
+ ).toBe('no-record')
1400
+ })
1401
+ }
1402
+
1403
+ // A STALE framework candidate must not corroborate anything: the latest-ended
1404
+ // freshness bound (F2) is what stops an old turn inheriting deletion authority.
1405
+ it('a `origin` echo does NOT bypass when the framework latest-ended candidate ' +
1406
+ 'is STALE (past the supersede TTL) — content gate holds', async () => {
1407
+ const h = makeHarness()
1408
+ const owner = makeFlushDeliveredEndedTurn()
1409
+ seedRecord(h, owner)
1410
+ h.deps.resolveReplyOwnerTurn = () =>
1411
+ ownerRes(owner, 'origin', { latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1 })
1412
+ h.deps.getLastSubagentHandbackAt = () => null
1413
+
1414
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
1415
+
1416
+ // Gate held → fresh send, flushed message untouched.
1417
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
1418
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
1419
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
1420
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
1421
+ })
1154
1422
  })