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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +156 -13
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/gateway.ts +36 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +91 -12
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +40 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +221 -0
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +435 -6
- package/telegram-plugin/tests/stream-render-golden.test.ts +142 -5
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +165 -0
|
@@ -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
|
-
|
|
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
|
})
|