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.
- package/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +95 -2
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +749 -357
- package/dist/host-control/main.js +96 -3
- package/dist/vault/approvals/kernel-server.js +98 -5
- package/dist/vault/broker/server.js +98 -5
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +12 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +97 -107
- package/skills/switchroom-release/SKILL.md +2 -1
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +267 -52
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +43 -42
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- 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 {
|
|
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: () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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 = () => (
|
|
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):
|
|
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
|
|
1098
|
-
// record
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
|
1111
|
-
//
|
|
1112
|
-
|
|
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 = () => (
|
|
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
|
})
|