switchroom 0.19.5 → 0.19.7

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.
@@ -46,6 +46,8 @@ 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, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
50
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
49
51
  import { redact } from '../secret-detect/redact.js'
50
52
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
51
53
 
@@ -209,7 +211,8 @@ function makeHarness(opts?: {
209
211
  assertSendable: () => {},
210
212
  statusKey: key,
211
213
  streamKey: key,
212
- resolveReplyOwnerTurn: () => null,
214
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
215
+ getLastSubagentHandbackAt: () => null,
213
216
  findTurnByOriginId: () => null,
214
217
  findTurnByQuotedMessageId: () => null,
215
218
  resolveAnswerThreadWithLog: (_c, explicit) => explicit,
@@ -633,8 +636,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
633
636
  Date.now(),
634
637
  )
635
638
  // The late reply resolves the ENDED flush-delivered turn as its owner
636
- // (latest-ended tier — no live turn, no origin echo, no quote in a DM).
637
- h.deps.resolveReplyOwnerTurn = () => owner
639
+ // (latest-ended tier — no live turn, no origin echo, no quote in a DM). This
640
+ // is the AMBIGUOUS fallback tier: the content gate applies here, so a
641
+ // genuinely-new handback sends fresh while the turn's own contained answer
642
+ // still supersedes.
643
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
638
644
  }
639
645
 
640
646
  it('CORE REGRESSION (red pre-fix): a handback with genuinely NEW content sends a ' +
@@ -642,6 +648,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
642
648
  const h = makeHarness()
643
649
  const owner = makeFlushDeliveredEndedTurn()
644
650
  seedFlushRecord(h, owner)
651
+ // A background sub-agent handback WAS enqueued for this chat after the
652
+ // flushed turn ended and within the supersede TTL — so this late reply might
653
+ // BE that handback. The marker keeps the #3429 content gate. (owner.endedAt
654
+ // = now-30s; handback at now-15s is after it and within the 60s TTL.)
655
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
645
656
 
646
657
  // The handback lands LATE: req.turn = null (no live gateway turn — a
647
658
  // sub-agent completion is not a new inbound).
@@ -700,8 +711,8 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
700
711
  const h = makeHarness()
701
712
  const owner = makeFlushDeliveredEndedTurn()
702
713
  // NO registry record (the post-fire pre-record window); owner resolution
703
- // still recovers the ended flush-armed turn.
704
- h.deps.resolveReplyOwnerTurn = () => owner
714
+ // still recovers the ended flush-armed turn via the latest-ended tier.
715
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
705
716
 
706
717
  const res = await sendReply(h.deps, req(HANDBACK))
707
718
 
@@ -715,11 +726,429 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
715
726
  'window is still suppressed (the #2996 Part 2 backstop holds)', async () => {
716
727
  const h = makeHarness()
717
728
  const owner = makeFlushDeliveredEndedTurn()
718
- h.deps.resolveReplyOwnerTurn = () => owner
729
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
719
730
 
720
731
  const res = await sendReply(h.deps, req(FLUSHED_TEXT))
721
732
 
722
733
  expect(h.calls).toHaveLength(0) // nothing sent, nothing edited
723
734
  expect(res.content[0]!.text).toContain('deduped')
724
735
  })
736
+
737
+ // ─── The REAL marko DM incident (fix/backstop-duplicate-reply) ───
738
+ //
739
+ // The dominant real duplicate: the model narrates its answer as prose (flushed
740
+ // as message A), then fires the `reply` tool with a RE-WORDED version of the
741
+ // same answer. On a DM this late reply has NO live turn, NO `origin_turn_id`
742
+ // echo, and NO explicit `reply_to`, so it resolves its owner via the ambiguous
743
+ // `latest-ended` tier — the #3429 content gate then declines (reworded text ⊄
744
+ // flushed blob) and a fresh SECOND bubble ships = the visible duplicate
745
+ // (agent:marko 2026-07-20: turns #1177/#1182/#1201 double-sent; 11/11 declines
746
+ // were own-replies with NO handback in flight).
747
+ //
748
+ // The two cases are indistinguishable by tier (both latest-ended) and by
749
+ // content (rewording). The deterministic discriminator is the gateway-
750
+ // synthesized `subagent_handback` marker: case A has NONE enqueued after the
751
+ // flushed turn ended; a genuine background handback (case B) does. These two
752
+ // tests drive the REAL send path with the REAL supersede registry over the
753
+ // SAME reworded text and the SAME latest-ended tier, differing ONLY in the
754
+ // handback marker — proving it is the marker that flips the outcome.
755
+ const REWORDED_SAME_TURN_ANSWER =
756
+ 'Good news on the fleet: every one of the twelve agents is running fine right now. ' +
757
+ 'The gateway, the vault broker and the approval kernel are all green, and nothing has ' +
758
+ 'restarted in the past day — so there is nothing you need to do.'
759
+
760
+ function seedRecord(h: ReturnType<typeof makeHarness>, owner: CurrentTurn): void {
761
+ h.deps.flushedTurnSupersede.record(
762
+ CHAT,
763
+ undefined,
764
+ { turnId: owner.turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
765
+ Date.now(),
766
+ )
767
+ }
768
+
769
+ it('CASE A — REAL marko DM incident: reworded own reply, latest-ended tier, ' +
770
+ 'NO handback in flight → supersedes the flushed draft, collapses to ONE message', async () => {
771
+ const h = makeHarness()
772
+ const owner = makeFlushDeliveredEndedTurn()
773
+ seedRecord(h, owner)
774
+ // Late DM reply: latest-ended tier (the path the tier discriminator MISSES),
775
+ // and crucially NO subagent_handback was enqueued for this chat after the
776
+ // turn ended — so the reply is the flushed turn's OWN answer.
777
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
778
+ h.deps.getLastSubagentHandbackAt = () => null
779
+
780
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
781
+
782
+ // Exactly ONE client-visible message: the flushed message edited in place
783
+ // into the reworded reply — NO fresh second bubble (duplicate closed).
784
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
785
+ expect(edits).toHaveLength(1)
786
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
787
+ expect(edits[0]!.text).toContain('every one of the twelve agents')
788
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
789
+ expect(res.content[0]!.text).toMatch(/^sent/)
790
+ // Record consumed.
791
+ expect(
792
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
793
+ liveTurnId: OWNER_TURN_ID,
794
+ now: Date.now(),
795
+ }).reason,
796
+ ).toBe('no-record')
797
+ })
798
+
799
+ it('CASE B — genuine background handback: SAME reworded text, SAME latest-ended tier, ' +
800
+ 'but a handback WAS enqueued in-window → keeps the gate, sends FRESH (TWO messages)', async () => {
801
+ const h = makeHarness()
802
+ const owner = makeFlushDeliveredEndedTurn()
803
+ seedRecord(h, owner)
804
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
805
+ // A background sub-agent handback was enqueued for this chat AFTER the turn
806
+ // ended (now-30s) and within the 60s TTL (now-15s) — this reply might BE it.
807
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
808
+
809
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
810
+
811
+ // A fresh notifying bubble ships; the flushed message is NEITHER edited nor
812
+ // deleted — flush (A) + fresh (B) = two surfaced messages (#3429 preserved).
813
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
814
+ expect(fresh).toHaveLength(1)
815
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
816
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
817
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
818
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
819
+ // Record NOT consumed.
820
+ expect(
821
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
822
+ liveTurnId: OWNER_TURN_ID,
823
+ replyText: CANONICAL_REPLY,
824
+ now: Date.now(),
825
+ }).supersede,
826
+ ).toBe(true)
827
+ })
828
+
829
+ it('MUST-FIX 1 (silent-data-loss): a handback reply model-STEERED to the quoted ' +
830
+ 'tier (reply_to = the flushed turn\'s source msg) must NOT edit/delete the flushed ' +
831
+ 'answer while a handback is in flight — sends FRESH (two messages)', async () => {
832
+ const h = makeHarness()
833
+ const owner = makeFlushDeliveredEndedTurn()
834
+ seedRecord(h, owner)
835
+ // The `quoted` tier is derived from the MODEL-SUPPLIED `args.reply_to`, so a
836
+ // background handback turn can point reply_to at the user's original message
837
+ // (which the prior turn's flush is recorded against) to resolve THAT turn via
838
+ // `quoted`. A handback IS in flight, so a positive tier must NOT override the
839
+ // #3429 content gate — else the reworded handback text silently edits over
840
+ // the flushed turn's DELIVERED answer (Telegram edits don't re-notify).
841
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
842
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
843
+
844
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
845
+
846
+ // Fresh notifying send; the flushed answer is NEITHER edited nor deleted.
847
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
848
+ expect(fresh).toHaveLength(1)
849
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
850
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
851
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
852
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
853
+ // Flush record NOT consumed — the flushed answer stands.
854
+ expect(
855
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
856
+ liveTurnId: OWNER_TURN_ID,
857
+ replyText: CANONICAL_REPLY,
858
+ now: Date.now(),
859
+ }).supersede,
860
+ ).toBe(true)
861
+ })
862
+
863
+ it('the framework-owned `live` tier MAY still bypass a handback window (a live ' +
864
+ 'currentTurn is not model-derived and cannot collide with an ended turn\'s record)', async () => {
865
+ const h = makeHarness()
866
+ const owner = makeFlushDeliveredEndedTurn()
867
+ seedRecord(h, owner)
868
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'live' })
869
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
870
+
871
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
872
+
873
+ // Live tier bypasses → supersede via edit-in-place (one message).
874
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
875
+ expect(edits).toHaveLength(1)
876
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
877
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
878
+ expect(res.content[0]!.text).toMatch(/^sent/)
879
+ })
880
+
881
+ it('MUST-FIX 2 (boot-replay): a handback re-pushed through the buffer chokepoint ' +
882
+ 'stamps the marker (with the envelope ts) → gate preserved → FRESH send, no silent edit', async () => {
883
+ // Real marker + real buffer wired exactly as the gateway wires them. The
884
+ // boot-replay loop re-pushes un-acked spooled inbounds — including handback
885
+ // envelopes — through this SAME push(); the chokepoint stamp is what makes a
886
+ // replayed handback populate the (post-restart empty) marker.
887
+ const marker = new SubagentHandbackMarker()
888
+ const buffer = createPendingInboundBuffer({
889
+ log: () => {},
890
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
891
+ })
892
+
893
+ // Simulate the boot-replay re-push of an un-acked spooled handback envelope.
894
+ // The envelope carries its own ms `ts` (after the flushed turn ended, within
895
+ // TTL) — owner.endedAt below is now-30s, this handback is now-15s.
896
+ const handbackTs = Date.now() - 15_000
897
+ buffer.push('marko', {
898
+ type: 'inbound',
899
+ chatId: CHAT,
900
+ messageId: handbackTs,
901
+ user: 'subagent-watcher',
902
+ userId: 0,
903
+ ts: handbackTs,
904
+ text: 'handback result',
905
+ meta: { source: 'subagent_handback' },
906
+ })
907
+ // The chokepoint stamped the marker from the replay push (pre-fix: empty).
908
+ expect(marker.lastAt(CHAT, undefined)).toBe(handbackTs)
909
+
910
+ const h = makeHarness()
911
+ const owner = makeFlushDeliveredEndedTurn()
912
+ seedRecord(h, owner)
913
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
914
+ // The gateway reads the marker the boot-replay stamped (chat-wide gate).
915
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
916
+
917
+ const res = await sendReply(h.deps, req(HANDBACK))
918
+
919
+ // Gate preserved → the replayed handback delivers FRESH; the post-boot
920
+ // flushed answer is NEITHER edited nor deleted (no silent #3429 on restart).
921
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
922
+ expect(fresh).toHaveLength(1)
923
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
924
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
925
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
926
+ })
927
+
928
+ it('the buffer chokepoint stamps ONLY handback envelopes (a normal inbound does not)', () => {
929
+ const marker = new SubagentHandbackMarker()
930
+ const buffer = createPendingInboundBuffer({
931
+ log: () => {},
932
+ onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
933
+ })
934
+ buffer.push('marko', {
935
+ type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
936
+ ts: Date.now(), text: 'hi', meta: {},
937
+ })
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/)
1153
+ })
725
1154
  })