switchroom 0.19.6 → 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 +106 -15
- package/telegram-plugin/gateway/gateway.ts +8 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +55 -16
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -8
- package/telegram-plugin/gateway/subagent-handback-marker.ts +198 -19
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -7
- package/telegram-plugin/tests/stream-render-golden.test.ts +140 -4
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +143 -14
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.19.
|
|
2123
|
+
var VERSION = "0.19.7", COMMIT_SHA = "2ddec4ae";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26663
26663
|
import { dirname as dirname4, join as join7 } from "node:path";
|
|
26664
26664
|
|
|
26665
26665
|
// src/build-info.ts
|
|
26666
|
-
var VERSION = "0.19.
|
|
26666
|
+
var VERSION = "0.19.7";
|
|
26667
26667
|
|
|
26668
26668
|
// src/cli/resolve-version.ts
|
|
26669
26669
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.19.
|
|
4
|
+
"version": "0.19.7",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -74662,11 +74662,12 @@ async function sendReply(deps, req) {
|
|
|
74662
74662
|
const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
|
|
74663
74663
|
const resolvedTurnId = ownerTurn?.turnId ?? null;
|
|
74664
74664
|
const ownerEndedAt = ownerTurn?.endedAt ?? null;
|
|
74665
|
+
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
|
|
74665
74666
|
const handbackAt = getLastSubagentHandbackAt(chat_id);
|
|
74666
74667
|
const now = Date.now();
|
|
74667
74668
|
const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
|
|
74668
|
-
const replyIsOwnAnswer = ownerTier === "live" || !handbackCouldOwnReply;
|
|
74669
|
-
const decision = flushedTurnSupersede.take(chat_id,
|
|
74669
|
+
const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
|
|
74670
|
+
const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
|
|
74670
74671
|
if (decision.supersede) {
|
|
74671
74672
|
process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
|
|
74672
74673
|
`);
|
|
@@ -78177,13 +78178,33 @@ function resolveReplyOwnerTurnId(candidates) {
|
|
|
78177
78178
|
}
|
|
78178
78179
|
|
|
78179
78180
|
// gateway/subagent-handback-marker.ts
|
|
78181
|
+
var MAIN_THREAD_KEY = "<main>";
|
|
78182
|
+
|
|
78180
78183
|
class SubagentHandbackMarker {
|
|
78181
|
-
|
|
78182
|
-
|
|
78183
|
-
|
|
78184
|
+
byChat = new Map;
|
|
78185
|
+
threadKey(threadId) {
|
|
78186
|
+
return threadId == null ? MAIN_THREAD_KEY : String(threadId);
|
|
78187
|
+
}
|
|
78188
|
+
record(chatId, threadId, now) {
|
|
78189
|
+
let inner = this.byChat.get(chatId);
|
|
78190
|
+
if (inner == null) {
|
|
78191
|
+
inner = new Map;
|
|
78192
|
+
this.byChat.set(chatId, inner);
|
|
78193
|
+
}
|
|
78194
|
+
inner.set(this.threadKey(threadId), now);
|
|
78184
78195
|
}
|
|
78185
|
-
lastAt(chatId) {
|
|
78186
|
-
return this.
|
|
78196
|
+
lastAt(chatId, threadId) {
|
|
78197
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
|
|
78198
|
+
}
|
|
78199
|
+
lastAtInChat(chatId) {
|
|
78200
|
+
const inner = this.byChat.get(chatId);
|
|
78201
|
+
if (inner == null || inner.size === 0)
|
|
78202
|
+
return null;
|
|
78203
|
+
let max = -Infinity;
|
|
78204
|
+
for (const ts of inner.values())
|
|
78205
|
+
if (ts > max)
|
|
78206
|
+
max = ts;
|
|
78207
|
+
return max === -Infinity ? null : max;
|
|
78187
78208
|
}
|
|
78188
78209
|
}
|
|
78189
78210
|
|
|
@@ -84663,6 +84684,76 @@ function buildDiffPreviewCard(input) {
|
|
|
84663
84684
|
return { text: text4, reply_markup: kb };
|
|
84664
84685
|
}
|
|
84665
84686
|
|
|
84687
|
+
// gateway/subagent-handback-marker.ts
|
|
84688
|
+
var INBOUND_SOURCE_CLASSIFICATION = {
|
|
84689
|
+
subagent_handback: { decoupledCompletion: true },
|
|
84690
|
+
cron: { decoupledCompletion: false },
|
|
84691
|
+
reaction: { decoupledCompletion: false },
|
|
84692
|
+
subagent_progress: { decoupledCompletion: false },
|
|
84693
|
+
resume_interrupted: { decoupledCompletion: false },
|
|
84694
|
+
resume_deferred: { decoupledCompletion: false },
|
|
84695
|
+
resume_watchdog_timeout: { decoupledCompletion: false },
|
|
84696
|
+
vault_grant_approved: { decoupledCompletion: false },
|
|
84697
|
+
vault_grant_denied: { decoupledCompletion: false },
|
|
84698
|
+
vault_grant_timeout: { decoupledCompletion: false },
|
|
84699
|
+
vault_save_completed: { decoupledCompletion: false },
|
|
84700
|
+
vault_save_discarded: { decoupledCompletion: false },
|
|
84701
|
+
vault_save_failed: { decoupledCompletion: false },
|
|
84702
|
+
vault_save_timeout: { decoupledCompletion: false },
|
|
84703
|
+
secret_provided: { decoupledCompletion: false },
|
|
84704
|
+
secret_declined: { decoupledCompletion: false },
|
|
84705
|
+
secret_provide_failed: { decoupledCompletion: false },
|
|
84706
|
+
secret_request_timeout: { decoupledCompletion: false },
|
|
84707
|
+
mental_model_propose_timeout: { decoupledCompletion: false },
|
|
84708
|
+
bridge_dead_restart: { decoupledCompletion: false },
|
|
84709
|
+
obligation_represent: { decoupledCompletion: false },
|
|
84710
|
+
missed_approval_retry: { decoupledCompletion: false },
|
|
84711
|
+
skill_proposal_apply: { decoupledCompletion: false },
|
|
84712
|
+
warmup: { decoupledCompletion: false },
|
|
84713
|
+
mental_model_proposal_applied: { decoupledCompletion: false },
|
|
84714
|
+
mental_model_proposal_denied: { decoupledCompletion: false },
|
|
84715
|
+
mental_model_proposal_failed: { decoupledCompletion: false },
|
|
84716
|
+
webhook: { decoupledCompletion: false },
|
|
84717
|
+
linear: { decoupledCompletion: false }
|
|
84718
|
+
};
|
|
84719
|
+
function stampsHandbackMarker(source) {
|
|
84720
|
+
if (source == null)
|
|
84721
|
+
return false;
|
|
84722
|
+
const known = INBOUND_SOURCE_CLASSIFICATION[source];
|
|
84723
|
+
if (known == null)
|
|
84724
|
+
return true;
|
|
84725
|
+
return known.decoupledCompletion;
|
|
84726
|
+
}
|
|
84727
|
+
var MAIN_THREAD_KEY2 = "<main>";
|
|
84728
|
+
|
|
84729
|
+
class SubagentHandbackMarker2 {
|
|
84730
|
+
byChat = new Map;
|
|
84731
|
+
threadKey(threadId) {
|
|
84732
|
+
return threadId == null ? MAIN_THREAD_KEY2 : String(threadId);
|
|
84733
|
+
}
|
|
84734
|
+
record(chatId, threadId, now) {
|
|
84735
|
+
let inner = this.byChat.get(chatId);
|
|
84736
|
+
if (inner == null) {
|
|
84737
|
+
inner = new Map;
|
|
84738
|
+
this.byChat.set(chatId, inner);
|
|
84739
|
+
}
|
|
84740
|
+
inner.set(this.threadKey(threadId), now);
|
|
84741
|
+
}
|
|
84742
|
+
lastAt(chatId, threadId) {
|
|
84743
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
|
|
84744
|
+
}
|
|
84745
|
+
lastAtInChat(chatId) {
|
|
84746
|
+
const inner = this.byChat.get(chatId);
|
|
84747
|
+
if (inner == null || inner.size === 0)
|
|
84748
|
+
return null;
|
|
84749
|
+
let max = -Infinity;
|
|
84750
|
+
for (const ts of inner.values())
|
|
84751
|
+
if (ts > max)
|
|
84752
|
+
max = ts;
|
|
84753
|
+
return max === -Infinity ? null : max;
|
|
84754
|
+
}
|
|
84755
|
+
}
|
|
84756
|
+
|
|
84666
84757
|
// gateway/pending-inbound-buffer.ts
|
|
84667
84758
|
var DEFAULT_PENDING_INBOUND_CAP = 32;
|
|
84668
84759
|
function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
@@ -84777,9 +84868,9 @@ function createPendingInboundBuffer(opts = {}) {
|
|
|
84777
84868
|
}
|
|
84778
84869
|
}
|
|
84779
84870
|
q.push(msg);
|
|
84780
|
-
if (msg.meta?.source
|
|
84871
|
+
if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
|
|
84781
84872
|
try {
|
|
84782
|
-
opts.onHandbackEnqueue(msg.chatId, msg.ts);
|
|
84873
|
+
opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts);
|
|
84783
84874
|
} catch {}
|
|
84784
84875
|
}
|
|
84785
84876
|
spool?.put(agent, msg);
|
|
@@ -93460,10 +93551,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
|
|
|
93460
93551
|
}
|
|
93461
93552
|
|
|
93462
93553
|
// ../src/build-info.ts
|
|
93463
|
-
var VERSION = "0.19.
|
|
93464
|
-
var COMMIT_SHA = "
|
|
93465
|
-
var COMMIT_DATE = "2026-07-
|
|
93466
|
-
var LATEST_PR =
|
|
93554
|
+
var VERSION = "0.19.7";
|
|
93555
|
+
var COMMIT_SHA = "2ddec4ae";
|
|
93556
|
+
var COMMIT_DATE = "2026-07-20T19:59:43Z";
|
|
93557
|
+
var LATEST_PR = 3478;
|
|
93467
93558
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
93468
93559
|
|
|
93469
93560
|
// gateway/boot-version.ts
|
|
@@ -96382,7 +96473,7 @@ var RECENT_TURNS_MAX = 32;
|
|
|
96382
96473
|
var recentTurnsById = new Map;
|
|
96383
96474
|
var recentTurnIdBySourceMessageId = new Map;
|
|
96384
96475
|
var subagentHandbackMarker = new SubagentHandbackMarker;
|
|
96385
|
-
var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.
|
|
96476
|
+
var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAtInChat(chatId);
|
|
96386
96477
|
function rememberRecentTurn(turn) {
|
|
96387
96478
|
recentTurnsById.set(turn.turnId, turn);
|
|
96388
96479
|
if (turn.sourceMessageId != null) {
|
|
@@ -99172,7 +99263,7 @@ var pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
99172
99263
|
const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
|
|
99173
99264
|
swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
|
|
99174
99265
|
},
|
|
99175
|
-
onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts)
|
|
99266
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
|
|
99176
99267
|
});
|
|
99177
99268
|
function agentHasInFlightBackgroundWork(now) {
|
|
99178
99269
|
if (countRunningWorkers() > 0)
|
|
@@ -3889,12 +3889,17 @@ const recentTurnsById = new Map<string, CurrentTurn>()
|
|
|
3889
3889
|
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
3890
3890
|
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
3891
3891
|
|
|
3892
|
-
// fix/backstop-duplicate-reply — per-chat marker of the most recent
|
|
3892
|
+
// fix/backstop-duplicate-reply — per-chat/thread marker of the most recent
|
|
3893
3893
|
// gateway-synthesized `subagent_handback` enqueue (logic in
|
|
3894
3894
|
// subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
|
|
3895
|
+
// The record is thread-resolved (retains the originating topic), but the
|
|
3896
|
+
// content-gate READ is CHAT-WIDE (dup-audit MUST-FIX 2): the latest-ended owner
|
|
3897
|
+
// tier is chat-wide, so a thread-specific gate read was steerable by the reply's
|
|
3898
|
+
// own `message_thread_id` → silent edit-over. `lastAtInChat` makes the gate
|
|
3899
|
+
// un-steerable (any in-window handback in the chat keeps the content gate).
|
|
3895
3900
|
const subagentHandbackMarker = new SubagentHandbackMarker()
|
|
3896
3901
|
const getLastSubagentHandbackAt = (chatId: string): number | null =>
|
|
3897
|
-
subagentHandbackMarker.
|
|
3902
|
+
subagentHandbackMarker.lastAtInChat(chatId)
|
|
3898
3903
|
|
|
3899
3904
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
3900
3905
|
recentTurnsById.set(turn.turnId, turn)
|
|
@@ -9984,7 +9989,7 @@ const pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
9984
9989
|
// fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
|
|
9985
9990
|
// enqueue chokepoint so a boot-replayed handback (not just the live synthesis
|
|
9986
9991
|
// push) populates it. Every `subagent_handback` push funnels through here.
|
|
9987
|
-
onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
|
|
9992
|
+
onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
|
|
9988
9993
|
})
|
|
9989
9994
|
|
|
9990
9995
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
@@ -898,22 +898,45 @@ export async function sendReply(
|
|
|
898
898
|
//
|
|
899
899
|
// MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
|
|
900
900
|
// tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
|
|
901
|
-
// `args.origin_turn_id`), so a
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
//
|
|
901
|
+
// `args.origin_turn_id`), so a reply can STEER itself — pass
|
|
902
|
+
// `reply_to = <another ended turn's source msg>` and it resolves THAT turn via
|
|
903
|
+
// `quoted`. Marker-absence proves "own answer" ONLY for the framework-derived
|
|
904
|
+
// `latest-ended` tier; a model-steered `quoted`/`origin` attribution with no
|
|
905
|
+
// marker is NOT evidence of ownership, so bypassing the content gate there
|
|
906
|
+
// silently edits over a different ended turn's delivered answer (the #3429
|
|
907
|
+
// double-loss, executed by Fable 2026-07-21). The `replyIsOwnAnswer`
|
|
908
|
+
// computation below therefore restricts the bypass to `live` + `latest-ended`;
|
|
909
|
+
// `quoted`/`origin` ALWAYS traverse the content gate. See that comment.
|
|
909
910
|
//
|
|
910
|
-
// The content gate is therefore kept whenever a
|
|
911
|
-
// window (
|
|
912
|
-
//
|
|
913
|
-
// Concurrency note: a case-A own
|
|
914
|
-
// background handback in the same ≤TTL
|
|
915
|
-
//
|
|
911
|
+
// The content gate is therefore kept whenever a decoupled completion WAS
|
|
912
|
+
// enqueued in the window (on the latest-ended tier) OR the reply resolved via
|
|
913
|
+
// a model-steerable tier: the late reply might carry foreign content, so it
|
|
914
|
+
// sends fresh (two messages, #3429 preserved). Concurrency note: a case-A own
|
|
915
|
+
// reply coinciding with an unrelated background handback in the same ≤TTL
|
|
916
|
+
// window degrades to two messages — safe (never a silent drop/edit).
|
|
916
917
|
const ownerEndedAt = ownerTurn?.endedAt ?? null
|
|
918
|
+
// MUST-FIX 2 (dup-audit / Fable 2026-07-21) — key BOTH the supersede lane and
|
|
919
|
+
// the marker-gate read on the FRAMEWORK-resolved thread (the owner turn's
|
|
920
|
+
// `sessionThreadId` — the lane the flush recorded on), NOT the raw model arg
|
|
921
|
+
// `args.message_thread_id` (`replyThreadId`). The flush records on
|
|
922
|
+
// `turn.sessionThreadId`; the owner turn resolved here IS that turn, so its
|
|
923
|
+
// thread is where its record and its handback marker live. Reading the gate on
|
|
924
|
+
// the raw arg let a reply carry `message_thread_id=<other topic>` to dodge a
|
|
925
|
+
// handback marker stamped on the real topic → silent edit-over (the regression
|
|
926
|
+
// F2's raw-arg keying introduced). The owner turn's thread is not model-
|
|
927
|
+
// derived, so it cannot be steered. Falls back to the raw arg only when no
|
|
928
|
+
// owner turn resolved (no record to clobber on the collapse path).
|
|
929
|
+
const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
|
|
930
|
+
// MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
|
|
931
|
+
// lane-specific: `findLatestEndedTurnForChat` resolves owners chat-wide, so a
|
|
932
|
+
// handback in topic A can supersede topic B's ended turn; a thread-keyed gate
|
|
933
|
+
// read (the F2 regression) let a reply dodge that handback by carrying a
|
|
934
|
+
// different `message_thread_id`. Chat-wide makes the gate un-steerable — any
|
|
935
|
+
// in-window handback in the chat keeps the content gate (accepting the F2
|
|
936
|
+
// visible-dup in the overlap window; a self-healing dup beats a silent loss).
|
|
937
|
+
// The supersede `take()` LANE below stays thread-resolved (`gateThreadId` =
|
|
938
|
+
// the owner turn's framework thread, not the raw arg), so a handback's
|
|
939
|
+
// correction only ever touches ITS OWN topic's record.
|
|
917
940
|
const handbackAt = getLastSubagentHandbackAt(chat_id)
|
|
918
941
|
const now = Date.now()
|
|
919
942
|
const handbackCouldOwnReply =
|
|
@@ -921,10 +944,26 @@ export async function sendReply(
|
|
|
921
944
|
ownerEndedAt != null &&
|
|
922
945
|
handbackAt > ownerEndedAt &&
|
|
923
946
|
now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
|
|
924
|
-
|
|
947
|
+
// MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) — restrict the
|
|
948
|
+
// content-gate BYPASS to the tiers whose attribution is NOT model-steerable:
|
|
949
|
+
// - `live` — the framework-owned live `currentTurn` (not model-derived,
|
|
950
|
+
// and `decideSupersede`'s same-turnId check bars it from an
|
|
951
|
+
// ended turn's record). Bypasses even a handback window.
|
|
952
|
+
// - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
|
|
953
|
+
// actually needs; bypass ONLY when no decoupled completion is
|
|
954
|
+
// in the window (marker-absence ⇒ own answer).
|
|
955
|
+
// The `quoted` / `origin` tiers resolve from MODEL-SUPPLIED args
|
|
956
|
+
// (`args.reply_to` / `args.origin_turn_id`), so a reply can steer ITSELF onto
|
|
957
|
+
// a DIFFERENT ended turn's record — with marker-absence they used to bypass
|
|
958
|
+
// the content gate and silently edit-over that turn's delivered answer (the
|
|
959
|
+
// #3429 double-loss, executed by Fable). Those tiers therefore NEVER bypass:
|
|
960
|
+
// they always go through the content gate, so foreign content sends fresh and
|
|
961
|
+
// only a genuine same-answer reply collapses.
|
|
962
|
+
const replyIsOwnAnswer =
|
|
963
|
+
ownerTier === 'live' || (ownerTier === 'latest-ended' && !handbackCouldOwnReply)
|
|
925
964
|
const decision = flushedTurnSupersede.take(
|
|
926
965
|
chat_id,
|
|
927
|
-
|
|
966
|
+
gateThreadId,
|
|
928
967
|
{ liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
|
|
929
968
|
)
|
|
930
969
|
if (decision.supersede) {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import type { InboundMessage } from './ipc-protocol.js'
|
|
33
33
|
import type { InboundSpool } from './inbound-spool.js'
|
|
34
|
+
import { stampsHandbackMarker } from './subagent-handback-marker.js'
|
|
34
35
|
|
|
35
36
|
/** Default cap per agent. Tuned for `should fit a reasonable backlog of
|
|
36
37
|
* approval cards stacked while bridge is offline` but no more. */
|
|
@@ -82,13 +83,17 @@ export interface PendingInboundBufferOptions {
|
|
|
82
83
|
/**
|
|
83
84
|
* fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
|
|
84
85
|
* `subagent_handback` envelope (live synthesis AND boot-replay re-push),
|
|
85
|
-
* carrying the envelope's `chatId
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* carrying the envelope's `chatId`, its `threadId` (the originating forum
|
|
87
|
+
* topic, or undefined for a DM), and its own `ts` (ms). The gateway wires this
|
|
88
|
+
* to the per-chat/thread subagent-handback marker so the supersede path can
|
|
89
|
+
* tell a flushed turn's own late reply from a background handback attributed to
|
|
90
|
+
* it — INCLUDING after a restart, where the only handback push is the replay.
|
|
91
|
+
* The `threadId` is passed so the marker keys on the SAME `chatId|threadId`
|
|
92
|
+
* lane the supersede registry uses (dup-audit F2): a handback in one topic must
|
|
93
|
+
* not hold the content gate open in another. Best-effort: a throw here never
|
|
94
|
+
* breaks the push hot path.
|
|
90
95
|
*/
|
|
91
|
-
onHandbackEnqueue?: (chatId: string, ts: number) => void
|
|
96
|
+
onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
/**
|
|
@@ -362,9 +367,17 @@ export function createPendingInboundBuffer(
|
|
|
362
367
|
// content gate → silent edit-over-answer. Uses the envelope's own `ts`
|
|
363
368
|
// (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
|
|
364
369
|
// handback actually happened, not the replay moment. Best-effort.
|
|
365
|
-
|
|
370
|
+
// F1 (dup-audit) — the ONE chokepoint that decides which sources stamp the
|
|
371
|
+
// decoupled-completion marker, delegated to the single `stampsHandbackMarker`
|
|
372
|
+
// predicate (its membership is the invariant's only extension point). Every
|
|
373
|
+
// inbound — live synthesis AND boot-replay — funnels through this push(), so
|
|
374
|
+
// routing the decision here makes "a decoupled late-reply source stamps the
|
|
375
|
+
// marker" true BY CONSTRUCTION rather than by per-feature discipline.
|
|
376
|
+
if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
|
|
366
377
|
try {
|
|
367
|
-
|
|
378
|
+
// F2 (dup-audit): pass the envelope's originating topic so the marker
|
|
379
|
+
// keys on the same `chatId|threadId` lane as the supersede registry.
|
|
380
|
+
opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts)
|
|
368
381
|
} catch {
|
|
369
382
|
/* marker stamp is best-effort; never break the push hot path */
|
|
370
383
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-chat marker of the most recent gateway-synthesized
|
|
3
|
-
* enqueue (fix/backstop-duplicate-reply).
|
|
2
|
+
* Per-chat-and-thread marker of the most recent gateway-synthesized
|
|
3
|
+
* `subagent_handback` enqueue (fix/backstop-duplicate-reply).
|
|
4
4
|
*
|
|
5
5
|
* A BACKGROUND sub-agent completion is a GATEWAY-SYNTHESIZED event, not model
|
|
6
6
|
* output: when a background worker terminates the gateway wakes the agent with a
|
|
7
|
-
* `subagent_handback` inbound. Recording WHEN one was enqueued, per chat,
|
|
8
|
-
* ONE deterministic signal that distinguishes the two late-reply cases
|
|
9
|
-
* resolve a flush-delivered ENDED turn via the latest-ended tier — the
|
|
10
|
-
* owner-resolution tier alone cannot separate (a DM late reply has no
|
|
7
|
+
* `subagent_handback` inbound. Recording WHEN one was enqueued, per chat/thread,
|
|
8
|
+
* is the ONE deterministic signal that distinguishes the two late-reply cases
|
|
9
|
+
* that both resolve a flush-delivered ENDED turn via the latest-ended tier — the
|
|
10
|
+
* case the owner-resolution tier alone cannot separate (a DM late reply has no
|
|
11
11
|
* live/origin/quoted attribution, so both land on latest-ended):
|
|
12
12
|
*
|
|
13
13
|
* - CASE A — the flushed turn's OWN reworded reply landing late. NO
|
|
14
|
-
* `subagent_handback` was enqueued for this chat after the turn ended,
|
|
15
|
-
* reply is that turn's own answer → the supersede path collapses the
|
|
14
|
+
* `subagent_handback` was enqueued for this chat/thread after the turn ended,
|
|
15
|
+
* so the reply is that turn's own answer → the supersede path collapses the
|
|
16
16
|
* provisional flush REGARDLESS of the model's rewording (closes the #3429
|
|
17
17
|
* reworded-duplicate regression: agent:marko 2026-07-20, turns
|
|
18
18
|
* #1177/#1182/#1201 double-sent).
|
|
@@ -22,21 +22,200 @@
|
|
|
22
22
|
* and send fresh (two messages), never silently edit/delete the flushed
|
|
23
23
|
* answer.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* ## Why thread-keyed (F2, dup-audit 2026-07-21)
|
|
26
|
+
*
|
|
27
|
+
* The supersede registry this marker gates is keyed on `chatId|threadId`
|
|
28
|
+
* (`flushed-turn-supersede.ts` `makeKey`). Keying the marker on `chatId` ALONE
|
|
29
|
+
* was inconsistent: in a forum supergroup a background handback in topic A
|
|
30
|
+
* stamped the chat-wide marker, and a genuine CASE-A reworded own-reply in ANY
|
|
31
|
+
* other topic within the 60 s TTL then computed `handbackCouldOwnReply = true` →
|
|
32
|
+
* kept the content gate → shipped a second visible bubble instead of collapsing.
|
|
33
|
+
* The blast radius was every topic in the chat for 60 s per handback. Keying on
|
|
34
|
+
* `chatId + threadId` — the SAME lane the supersede registry uses — confines a
|
|
35
|
+
* handback's gate-hold to the topic it actually landed in, so an unrelated
|
|
36
|
+
* topic's CASE-A collapse is untouched. A DM (no thread) collapses to the
|
|
37
|
+
* chat-only key, so single-lane behaviour is unchanged.
|
|
38
|
+
*
|
|
39
|
+
* One entry per chat/thread (overwritten on each enqueue), so bounded by
|
|
40
|
+
* chat×topic count. No clock reads beyond the caller-supplied `now`; the gateway
|
|
41
|
+
* wires the actual enqueue site and the supersede-path read. Deterministic —
|
|
42
|
+
* keyed on a gateway-emitted event, never on model discipline (Ken's
|
|
43
|
+
* controls-in-code rule).
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* ## The enforced invariant (F1, dup-audit 2026-07-21)
|
|
47
|
+
*
|
|
48
|
+
* The whole content-gate bypass rests on this premise: *a decoupled late reply
|
|
49
|
+
* (turn == null) carrying content that is NOT the flushed turn's own answer,
|
|
50
|
+
* resolving an ENDED flushed turn as its owner, can ONLY be a gateway-synthesized
|
|
51
|
+
* completion — and every such completion stamps this marker.* If that premise
|
|
52
|
+
* holds, then marker-ABSENCE in the window proves the late reply IS the flushed
|
|
53
|
+
* turn's own (possibly reworded) answer, so bypassing the content gate is safe
|
|
54
|
+
* (closes the marko duplicate). If a NEW synthesized-inbound source were ever
|
|
55
|
+
* able to land as a decoupled late reply with foreign content WITHOUT stamping,
|
|
56
|
+
* it would silently edit over a delivered answer (the #3429 failure).
|
|
57
|
+
*
|
|
58
|
+
* We make the premise an ENFORCED invariant rather than an architectural
|
|
59
|
+
* coincidence by routing the stamp decision through this ONE predicate at the ONE
|
|
60
|
+
* chokepoint every inbound funnels through (`pendingInboundBuffer.push` — live
|
|
61
|
+
* synthesis, boot-replay, and every future source alike). The membership of the
|
|
62
|
+
* decoupled-completion class is defined HERE and nowhere else:
|
|
63
|
+
*
|
|
64
|
+
* - `subagent_handback` — the only source today that wakes the agent to emit a
|
|
65
|
+
* reply with NO live gateway turn of its own (a background worker completion),
|
|
66
|
+
* so its reply resolves a DIFFERENT, already-ended turn via the latest-ended
|
|
67
|
+
* tier. It is the F1 vector.
|
|
68
|
+
* - Every OTHER synthesized source (cron, resume_*, reaction, vault_*, …) lands
|
|
69
|
+
* as its OWN live inbound turn, so its reply resolves the `live` tier for its
|
|
70
|
+
* own turnId and structurally cannot supersede a different ended turn's flush
|
|
71
|
+
* record (`decideSupersede` requires `record.turnId === liveTurnId`). Those
|
|
72
|
+
* must NOT stamp — stamping them would needlessly hold the content gate open
|
|
73
|
+
* for an unrelated own-reply in the window (a safe but avoidable visible dup).
|
|
74
|
+
*
|
|
75
|
+
* This predicate is therefore the SINGLE point of extension: any future feature
|
|
76
|
+
* that synthesizes an inbound which can land as a DECOUPLED late reply (no live
|
|
77
|
+
* turn) MUST add its `meta.source` here — and because the chokepoint consults
|
|
78
|
+
* this predicate, doing so is the whole wiring. A source that forgets to opt in
|
|
79
|
+
* cannot reach the bypass unnoticed: the negative outcome guard
|
|
80
|
+
* (`send-reply-golden.test.ts`) pins that a decoupled foreign-content reply on a
|
|
81
|
+
* non-live tier never silently edits over the flushed answer.
|
|
82
|
+
*
|
|
83
|
+
* ## Inbound meta.source classification registry (F1 durability — dup-audit
|
|
84
|
+
* MUST-FIX 3, Fable 2026-07-21)
|
|
85
|
+
*
|
|
86
|
+
* The old predicate was a bare `=== 'subagent_handback'` with NO tripwire and an
|
|
87
|
+
* UNSAFE default: adding a new decoupled-completion source tripped nothing — no
|
|
88
|
+
* stamp → content-gate bypass eligible → silent edit-over of a delivered answer.
|
|
89
|
+
* This registry makes the classification EXPLICIT, EXHAUSTIVE and FAIL-SAFE:
|
|
90
|
+
*
|
|
91
|
+
* - Every known `meta.source` is listed with `decoupledCompletion`. Today ONLY
|
|
92
|
+
* `subagent_handback` is true; every other synthesized source lands as its
|
|
93
|
+
* OWN live inbound turn (live tier → cannot supersede a different ended
|
|
94
|
+
* turn's record), so it must NOT stamp.
|
|
95
|
+
* - An UNKNOWN / unclassified source FAILS SAFE: `stampsHandbackMarker` returns
|
|
96
|
+
* `true`, so it STAMPS → the content gate is KEPT → the worst case is a
|
|
97
|
+
* visible duplicate, NEVER a silent edit-over (inverted from the old
|
|
98
|
+
* deny-default, which failed toward silent loss).
|
|
99
|
+
* - The exhaustiveness test (`subagent-handback-marker.test.ts`) scans the
|
|
100
|
+
* gateway for `source:` / `meta.source ===` literals and FAILS when a new one
|
|
101
|
+
* is added without a registry entry — forcing a conscious classification.
|
|
102
|
+
*
|
|
103
|
+
* Defense-in-depth with the tier restriction (`outbound-send-path.ts`): the
|
|
104
|
+
* content-gate bypass is now limited to the `live` + `latest-ended` tiers, so
|
|
105
|
+
* model-steerable `quoted`/`origin` attributions can never bypass regardless of
|
|
106
|
+
* the marker. This registry closes the residual `latest-ended` vector for a
|
|
107
|
+
* FUTURE decoupled source, fail-safe.
|
|
29
108
|
*/
|
|
109
|
+
export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion: boolean }> = {
|
|
110
|
+
// The ONE decoupled-completion source today: a background worker termination
|
|
111
|
+
// wakes the agent with no live turn of its own → its reply resolves a
|
|
112
|
+
// different, already-ended turn via the latest-ended tier. THE F1 vector.
|
|
113
|
+
subagent_handback: { decoupledCompletion: true },
|
|
114
|
+
// Everything below lands as its OWN live inbound turn (live tier), so its reply
|
|
115
|
+
// resolves the live tier for its own turnId and structurally cannot supersede a
|
|
116
|
+
// different ended turn's record → not a decoupled-completion vector, must not stamp.
|
|
117
|
+
cron: { decoupledCompletion: false },
|
|
118
|
+
reaction: { decoupledCompletion: false },
|
|
119
|
+
subagent_progress: { decoupledCompletion: false },
|
|
120
|
+
resume_interrupted: { decoupledCompletion: false },
|
|
121
|
+
resume_deferred: { decoupledCompletion: false },
|
|
122
|
+
resume_watchdog_timeout: { decoupledCompletion: false },
|
|
123
|
+
vault_grant_approved: { decoupledCompletion: false },
|
|
124
|
+
vault_grant_denied: { decoupledCompletion: false },
|
|
125
|
+
vault_grant_timeout: { decoupledCompletion: false },
|
|
126
|
+
vault_save_completed: { decoupledCompletion: false },
|
|
127
|
+
vault_save_discarded: { decoupledCompletion: false },
|
|
128
|
+
vault_save_failed: { decoupledCompletion: false },
|
|
129
|
+
vault_save_timeout: { decoupledCompletion: false },
|
|
130
|
+
secret_provided: { decoupledCompletion: false },
|
|
131
|
+
secret_declined: { decoupledCompletion: false },
|
|
132
|
+
secret_provide_failed: { decoupledCompletion: false },
|
|
133
|
+
secret_request_timeout: { decoupledCompletion: false },
|
|
134
|
+
mental_model_propose_timeout: { decoupledCompletion: false },
|
|
135
|
+
bridge_dead_restart: { decoupledCompletion: false },
|
|
136
|
+
obligation_represent: { decoupledCompletion: false },
|
|
137
|
+
missed_approval_retry: { decoupledCompletion: false },
|
|
138
|
+
skill_proposal_apply: { decoupledCompletion: false },
|
|
139
|
+
warmup: { decoupledCompletion: false },
|
|
140
|
+
// dup-audit pass-2 (Fable) — sources the widened exhaustiveness scanner now
|
|
141
|
+
// sees. Each lands as its OWN live inbound turn (not a decoupled completion
|
|
142
|
+
// resolving a DIFFERENT ended turn), so it must NOT stamp — else its fail-safe
|
|
143
|
+
// stamp would hold the content gate chat-wide for 60 s and re-open the
|
|
144
|
+
// reworded-own-answer visible dup in that window.
|
|
145
|
+
// - mental_model_proposal_{applied,denied,failed}: resume-synthetic inbounds
|
|
146
|
+
// injected via `deliverResumeSyntheticOrBuffer` as their own live turn.
|
|
147
|
+
// - webhook / linear: built in `src/web/webhook-dispatch.ts`, delivered via
|
|
148
|
+
// the gateway's `webhookInject` (`sendToAgent`, buffer on miss) as their
|
|
149
|
+
// own live turn.
|
|
150
|
+
mental_model_proposal_applied: { decoupledCompletion: false },
|
|
151
|
+
mental_model_proposal_denied: { decoupledCompletion: false },
|
|
152
|
+
mental_model_proposal_failed: { decoupledCompletion: false },
|
|
153
|
+
webhook: { decoupledCompletion: false },
|
|
154
|
+
linear: { decoupledCompletion: false },
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether an inbound of this `meta.source` must stamp the decoupled-completion
|
|
159
|
+
* marker. Null/undefined (a normal user inbound — not synthesized) → false.
|
|
160
|
+
* A known source → its registry classification. An UNKNOWN source → true
|
|
161
|
+
* (fail-safe: stamp, so an unclassified future decoupled source can only cause a
|
|
162
|
+
* visible dup, never a silent edit-over).
|
|
163
|
+
*/
|
|
164
|
+
export function stampsHandbackMarker(source: string | null | undefined): boolean {
|
|
165
|
+
if (source == null) return false
|
|
166
|
+
const known = INBOUND_SOURCE_CLASSIFICATION[source]
|
|
167
|
+
if (known == null) return true // fail-safe: unknown synthesized source stamps
|
|
168
|
+
return known.decoupledCompletion
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
|
|
172
|
+
const MAIN_THREAD_KEY = '<main>'
|
|
173
|
+
|
|
30
174
|
export class SubagentHandbackMarker {
|
|
31
|
-
|
|
175
|
+
// chatId → (threadKey → last enqueue ms). Nested so the CONTENT-GATE read can
|
|
176
|
+
// query chat-wide (`lastAtInChat`) while the record stays thread-resolved.
|
|
177
|
+
private readonly byChat = new Map<string, Map<string, number>>()
|
|
178
|
+
|
|
179
|
+
private threadKey(threadId: number | undefined): string {
|
|
180
|
+
return threadId == null ? MAIN_THREAD_KEY : String(threadId)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Record that a `subagent_handback` was enqueued for `chatId`/`threadId` at
|
|
184
|
+
* `now` (ms). Thread-resolved so the record retains the originating topic. */
|
|
185
|
+
record(chatId: string, threadId: number | undefined, now: number): void {
|
|
186
|
+
let inner = this.byChat.get(chatId)
|
|
187
|
+
if (inner == null) {
|
|
188
|
+
inner = new Map<string, number>()
|
|
189
|
+
this.byChat.set(chatId, inner)
|
|
190
|
+
}
|
|
191
|
+
inner.set(this.threadKey(threadId), now)
|
|
192
|
+
}
|
|
32
193
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
194
|
+
/** Wall-clock ms of the most recent handback enqueue for a SPECIFIC
|
|
195
|
+
* `chatId`/`threadId` lane, or null. (Diagnostics / unit tests.) */
|
|
196
|
+
lastAt(chatId: string, threadId: number | undefined): number | null {
|
|
197
|
+
return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null
|
|
36
198
|
}
|
|
37
199
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Wall-clock ms of the most recent handback enqueue ANYWHERE in `chatId`
|
|
202
|
+
* (across every topic lane), or null. This is what the content-gate read uses
|
|
203
|
+
* (dup-audit MUST-FIX 2, Fable 2026-07-21): the owner-resolution latest-ended
|
|
204
|
+
* tier is CHAT-WIDE (`findLatestEndedTurnForChat` ignores thread), so a
|
|
205
|
+
* background handback in topic A can resolve — and supersede — topic B's
|
|
206
|
+
* ended turn. A thread-SPECIFIC gate read (the F2 regression) let a reply
|
|
207
|
+
* dodge that handback by carrying a different `message_thread_id`, silently
|
|
208
|
+
* editing over the answer. Querying chat-wide makes the gate un-steerable by
|
|
209
|
+
* the reply's own thread arg: any in-window handback in the chat keeps the
|
|
210
|
+
* content gate. The cost is the F2 visible-dup (a handback in one topic keeps
|
|
211
|
+
* the gate for a coinciding own-reply in another for ≤TTL) — a self-healing
|
|
212
|
+
* visible duplicate, which is strictly better than a silent edit-over.
|
|
213
|
+
*/
|
|
214
|
+
lastAtInChat(chatId: string): number | null {
|
|
215
|
+
const inner = this.byChat.get(chatId)
|
|
216
|
+
if (inner == null || inner.size === 0) return null
|
|
217
|
+
let max = -Infinity
|
|
218
|
+
for (const ts of inner.values()) if (ts > max) max = ts
|
|
219
|
+
return max === -Infinity ? null : max
|
|
41
220
|
}
|
|
42
221
|
}
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
} from '../gateway/outbound-send-path.js'
|
|
47
47
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
48
48
|
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
49
|
-
import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
|
|
49
|
+
import { SubagentHandbackMarker, stampsHandbackMarker } from '../gateway/subagent-handback-marker.js'
|
|
50
50
|
import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
|
|
51
51
|
import { redact } from '../secret-detect/redact.js'
|
|
52
52
|
import type { CurrentTurn, Access } from '../gateway/gateway.js'
|
|
@@ -887,7 +887,7 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
887
887
|
const marker = new SubagentHandbackMarker()
|
|
888
888
|
const buffer = createPendingInboundBuffer({
|
|
889
889
|
log: () => {},
|
|
890
|
-
onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
|
|
890
|
+
onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
|
|
891
891
|
})
|
|
892
892
|
|
|
893
893
|
// Simulate the boot-replay re-push of an un-acked spooled handback envelope.
|
|
@@ -905,14 +905,14 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
905
905
|
meta: { source: 'subagent_handback' },
|
|
906
906
|
})
|
|
907
907
|
// The chokepoint stamped the marker from the replay push (pre-fix: empty).
|
|
908
|
-
expect(marker.lastAt(CHAT)).toBe(handbackTs)
|
|
908
|
+
expect(marker.lastAt(CHAT, undefined)).toBe(handbackTs)
|
|
909
909
|
|
|
910
910
|
const h = makeHarness()
|
|
911
911
|
const owner = makeFlushDeliveredEndedTurn()
|
|
912
912
|
seedRecord(h, owner)
|
|
913
913
|
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
914
|
-
// The gateway reads the marker the boot-replay stamped.
|
|
915
|
-
h.deps.getLastSubagentHandbackAt = (chatId) => marker.
|
|
914
|
+
// The gateway reads the marker the boot-replay stamped (chat-wide gate).
|
|
915
|
+
h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
|
|
916
916
|
|
|
917
917
|
const res = await sendReply(h.deps, req(HANDBACK))
|
|
918
918
|
|
|
@@ -929,12 +929,226 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
|
|
|
929
929
|
const marker = new SubagentHandbackMarker()
|
|
930
930
|
const buffer = createPendingInboundBuffer({
|
|
931
931
|
log: () => {},
|
|
932
|
-
onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
|
|
932
|
+
onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
|
|
933
933
|
})
|
|
934
934
|
buffer.push('marko', {
|
|
935
935
|
type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
|
|
936
936
|
ts: Date.now(), text: 'hi', meta: {},
|
|
937
937
|
})
|
|
938
|
-
expect(marker.lastAt(CHAT)).toBe(null)
|
|
938
|
+
expect(marker.lastAt(CHAT, undefined)).toBe(null)
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
// ─── MUST-FIX 2 (dup-audit / Fable): the gate is un-steerable by a model
|
|
942
|
+
// thread arg — a cross-topic handback still keeps the content gate ───
|
|
943
|
+
//
|
|
944
|
+
// Fable's PROVEN regression: F2's thread-keyed gate read let a foreign-content
|
|
945
|
+
// reply carrying `message_thread_id=<other topic>` dodge a handback marker
|
|
946
|
+
// stamped on the real topic → silent edit-over (the #3429 double-loss). Because
|
|
947
|
+
// `findLatestEndedTurnForChat` resolves owners CHAT-WIDE, a topic-A handback can
|
|
948
|
+
// resolve — and supersede — topic B's ended turn. The gate read is therefore
|
|
949
|
+
// chat-wide (`lastAtInChat`): any in-window handback in the chat keeps the gate,
|
|
950
|
+
// so the reply's own thread arg cannot bypass it. Drives the REAL buffer
|
|
951
|
+
// chokepoint + REAL send path.
|
|
952
|
+
it('MUST-FIX 2: a handback stamped in topic A keeps the content gate for a ' +
|
|
953
|
+
'foreign reply steered to topic B (message_thread_id) — FRESH send, no silent edit-over', async () => {
|
|
954
|
+
const TOPIC_A = 111
|
|
955
|
+
const TOPIC_B = 222
|
|
956
|
+
const marker = new SubagentHandbackMarker()
|
|
957
|
+
const buffer = createPendingInboundBuffer({
|
|
958
|
+
log: () => {},
|
|
959
|
+
onHandbackEnqueue: (chatId, threadId, ts) => marker.record(chatId, threadId, ts),
|
|
960
|
+
})
|
|
961
|
+
// A background handback lands in TOPIC A (envelope threadId=A) via the real
|
|
962
|
+
// chokepoint — after topic B's turn ended (now-30s), within the 60s TTL.
|
|
963
|
+
const handbackTs = Date.now() - 15_000
|
|
964
|
+
buffer.push('marko', {
|
|
965
|
+
type: 'inbound', chatId: CHAT, threadId: TOPIC_A, messageId: handbackTs,
|
|
966
|
+
user: 'subagent-watcher', userId: 0, ts: handbackTs, text: 'handback',
|
|
967
|
+
meta: { source: 'subagent_handback' },
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
// Topic B has its OWN flush-delivered ended turn with a real answer.
|
|
971
|
+
const h = makeHarness()
|
|
972
|
+
const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC_B } as unknown as CurrentTurn
|
|
973
|
+
h.deps.flushedTurnSupersede.record(
|
|
974
|
+
CHAT, TOPIC_B,
|
|
975
|
+
{ turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
|
|
976
|
+
Date.now(),
|
|
977
|
+
)
|
|
978
|
+
// The topic-A handback's reply is STEERED to topic B (message_thread_id=222)
|
|
979
|
+
// and resolves topic B's ended turn chat-wide (latest-ended), carrying FOREIGN
|
|
980
|
+
// content. The gate read is chat-wide, so the topic-A stamp is seen.
|
|
981
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
|
|
982
|
+
h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAtInChat(chatId)
|
|
983
|
+
|
|
984
|
+
const res = await sendReply(
|
|
985
|
+
h.deps,
|
|
986
|
+
req(HANDBACK, { message_thread_id: TOPIC_B }),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
// Gate kept → FRESH notifying send; topic B's flushed answer is NEITHER
|
|
990
|
+
// edited nor deleted. (Under the F2 thread-keyed gate this was editMessageText
|
|
991
|
+
// ×1 over msg 4242 — the silent-loss regression this closes.)
|
|
992
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
993
|
+
expect(fresh).toHaveLength(1)
|
|
994
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
995
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
996
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
997
|
+
// Topic B's flush record NOT consumed — the delivered answer stands.
|
|
998
|
+
expect(
|
|
999
|
+
h.deps.flushedTurnSupersede.peek(CHAT, TOPIC_B, {
|
|
1000
|
+
liveTurnId: (owner as CurrentTurn).turnId,
|
|
1001
|
+
replyText: CANONICAL_REPLY,
|
|
1002
|
+
now: Date.now(),
|
|
1003
|
+
}).supersede,
|
|
1004
|
+
).toBe(true)
|
|
1005
|
+
})
|
|
1006
|
+
|
|
1007
|
+
// Thread-keyed COLLAPSE still routes per-topic: a topic reply supersedes its
|
|
1008
|
+
// OWN topic's flush record (via the framework-resolved owner thread, not the
|
|
1009
|
+
// raw arg), so a genuine own-reply in a forum topic collapses to one message.
|
|
1010
|
+
it('F2 positive: a topic own-reply (no handback) supersedes its own topic\'s ' +
|
|
1011
|
+
'flush record — one message, on the framework-resolved thread lane', async () => {
|
|
1012
|
+
const TOPIC = 222
|
|
1013
|
+
const h = makeHarness()
|
|
1014
|
+
const owner = { ...makeFlushDeliveredEndedTurn(), sessionThreadId: TOPIC } as unknown as CurrentTurn
|
|
1015
|
+
// Flush recorded on the owner turn's OWN topic lane (what the flush does).
|
|
1016
|
+
h.deps.flushedTurnSupersede.record(
|
|
1017
|
+
CHAT, TOPIC,
|
|
1018
|
+
{ turnId: (owner as CurrentTurn).turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
|
|
1019
|
+
Date.now(),
|
|
1020
|
+
)
|
|
1021
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner as CurrentTurn, tier: 'latest-ended' })
|
|
1022
|
+
h.deps.getLastSubagentHandbackAt = () => null // no handback anywhere
|
|
1023
|
+
|
|
1024
|
+
const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER, { message_thread_id: TOPIC }))
|
|
1025
|
+
|
|
1026
|
+
// Collapses to ONE message: the topic's flushed message edited in place.
|
|
1027
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
1028
|
+
expect(edits).toHaveLength(1)
|
|
1029
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
1030
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
1031
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
// ─── F1 (dup-audit): negative outcome guard — no silent edit-over ───
|
|
1035
|
+
//
|
|
1036
|
+
// The content-gate bypass must NEVER silently edit over a flush-delivered
|
|
1037
|
+
// answer with FOREIGN content on a non-live tier. A decoupled completion (the
|
|
1038
|
+
// handback class the marker covers) landing with genuinely different content,
|
|
1039
|
+
// resolving an ended flushed turn via the latest-ended tier, must SEND FRESH —
|
|
1040
|
+
// the flushed answer left untouched (Telegram edits do not re-notify, so an
|
|
1041
|
+
// edit-over is a silent double-loss: the handback never surfaces AND the answer
|
|
1042
|
+
// is destroyed — the #3429 incident). This pins the invariant's OUTCOME: the
|
|
1043
|
+
// stamp (driven by the single chokepoint predicate) keeps the gate, and the
|
|
1044
|
+
// foreign reply never touches the delivered answer.
|
|
1045
|
+
it('F1: a decoupled foreign-content reply on a non-live tier resolving a ' +
|
|
1046
|
+
'DIFFERENT ended turn sends FRESH — the flushed answer is never edited/deleted', async () => {
|
|
1047
|
+
const h = makeHarness()
|
|
1048
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
1049
|
+
seedRecord(h, owner)
|
|
1050
|
+
// The reply resolves the flush-delivered ENDED turn via the ambiguous
|
|
1051
|
+
// latest-ended tier (no live turn, no positive attribution) and carries
|
|
1052
|
+
// FOREIGN content (a worker report, not this turn's answer nor a rewording).
|
|
1053
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
|
|
1054
|
+
// A decoupled completion IS in the window (the marker stamped it) — so this
|
|
1055
|
+
// late reply might BE it. The invariant keeps the content gate.
|
|
1056
|
+
h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
|
|
1057
|
+
|
|
1058
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
1059
|
+
|
|
1060
|
+
// FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
|
|
1061
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
1062
|
+
expect(fresh).toHaveLength(1)
|
|
1063
|
+
expect(fresh[0]!.text).toContain('migration audit')
|
|
1064
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
1065
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
1066
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
1067
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
1068
|
+
// The flush record is NOT consumed — the flushed answer stands, and the
|
|
1069
|
+
// turn's OWN canonical replay can still correct it.
|
|
1070
|
+
expect(
|
|
1071
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
1072
|
+
liveTurnId: OWNER_TURN_ID,
|
|
1073
|
+
replyText: CANONICAL_REPLY,
|
|
1074
|
+
now: Date.now(),
|
|
1075
|
+
}).supersede,
|
|
1076
|
+
).toBe(true)
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
// Structural: the stamp membership lives in exactly ONE predicate, consulted
|
|
1080
|
+
// at the ONE buffer chokepoint. If a future refactor inlines a bare
|
|
1081
|
+
// `=== 'subagent_handback'` check at the chokepoint (bypassing the predicate),
|
|
1082
|
+
// the invariant's single-extension-point guarantee is lost — fail loudly.
|
|
1083
|
+
it('F1: the buffer chokepoint stamps via the stampsHandbackMarker predicate, ' +
|
|
1084
|
+
'not an inlined source literal', () => {
|
|
1085
|
+
const bufferSrc = readFileSync(new URL('../gateway/pending-inbound-buffer.ts', import.meta.url), 'utf8')
|
|
1086
|
+
expect(bufferSrc).toContain('stampsHandbackMarker(msg.meta?.source)')
|
|
1087
|
+
// The predicate itself is the single membership definition.
|
|
1088
|
+
expect(stampsHandbackMarker('subagent_handback')).toBe(true)
|
|
1089
|
+
expect(stampsHandbackMarker('cron')).toBe(false)
|
|
1090
|
+
expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
|
|
1091
|
+
expect(stampsHandbackMarker(undefined)).toBe(false)
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
// ─── MUST-FIX 1 (dup-audit / Fable): model-steerable tiers NEVER bypass ───
|
|
1095
|
+
//
|
|
1096
|
+
// The `quoted`/`origin` tiers resolve from MODEL-supplied args (reply_to /
|
|
1097
|
+
// origin_turn_id), so a reply can steer ITSELF onto a different ended turn's
|
|
1098
|
+
// record. Marker-absence proves "own answer" only for the framework-derived
|
|
1099
|
+
// latest-ended tier — a quoted/origin resolution with no marker is NOT
|
|
1100
|
+
// ownership evidence. Fable executed this exact path (quoted tier, no marker,
|
|
1101
|
+
// foreign content → editMessageText ×1 over the flushed answer). With the tier
|
|
1102
|
+
// restriction, quoted/origin ALWAYS traverse the content gate → foreign content
|
|
1103
|
+
// sends FRESH, the flushed answer untouched. RED on pre-fix code
|
|
1104
|
+
// (replyIsOwnAnswer = tier==='live' || !handbackCouldOwnReply → quoted bypassed).
|
|
1105
|
+
it('MUST-FIX 1: a quoted-tier reply with NO marker and FOREIGN content does NOT ' +
|
|
1106
|
+
'bypass the content gate — FRESH send, flushed answer never edited/deleted', async () => {
|
|
1107
|
+
const h = makeHarness()
|
|
1108
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
1109
|
+
seedRecord(h, owner)
|
|
1110
|
+
// Model-steered `quoted` attribution to a DIFFERENT ended flushed turn, with
|
|
1111
|
+
// NO decoupled completion anywhere in the chat (the residual F1 vector).
|
|
1112
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
|
|
1113
|
+
h.deps.getLastSubagentHandbackAt = () => null
|
|
1114
|
+
|
|
1115
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
1116
|
+
|
|
1117
|
+
// FRESH notifying send; the flushed answer is NEITHER edited nor deleted.
|
|
1118
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
1119
|
+
expect(fresh).toHaveLength(1)
|
|
1120
|
+
expect(fresh[0]!.text).toContain('migration audit')
|
|
1121
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
1122
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
1123
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
1124
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
1125
|
+
// Flush record NOT consumed — the delivered answer stands.
|
|
1126
|
+
expect(
|
|
1127
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
1128
|
+
liveTurnId: OWNER_TURN_ID,
|
|
1129
|
+
replyText: CANONICAL_REPLY,
|
|
1130
|
+
now: Date.now(),
|
|
1131
|
+
}).supersede,
|
|
1132
|
+
).toBe(true)
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
// Sibling positive: a quoted-tier reply carrying the turn's OWN answer (same
|
|
1136
|
+
// content, contained in the flushed blob) still collapses through the content
|
|
1137
|
+
// gate — the tier restriction only blocks FOREIGN content, not genuine replays.
|
|
1138
|
+
it('MUST-FIX 1 sibling: a quoted-tier reply with the SAME answer still collapses ' +
|
|
1139
|
+
'(content gate passes) — one message', async () => {
|
|
1140
|
+
const h = makeHarness()
|
|
1141
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
1142
|
+
seedRecord(h, owner)
|
|
1143
|
+
h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
|
|
1144
|
+
h.deps.getLastSubagentHandbackAt = () => null
|
|
1145
|
+
|
|
1146
|
+
const res = await sendReply(h.deps, req(CANONICAL_REPLY))
|
|
1147
|
+
|
|
1148
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
1149
|
+
expect(edits).toHaveLength(1)
|
|
1150
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
1151
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
1152
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
939
1153
|
})
|
|
940
1154
|
})
|
|
@@ -56,7 +56,12 @@ function makeFakeBot() {
|
|
|
56
56
|
const api = {
|
|
57
57
|
sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
|
|
58
58
|
sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
|
|
59
|
-
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
|
|
59
|
+
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
|
|
60
|
+
// Preserve the REAL edit target id (m) so tests can assert an edit-in-place
|
|
61
|
+
// hit the flushed message, not a synthetic fresh id.
|
|
62
|
+
calls.push({ method: 'editMessageText', chat_id: c, text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, opts: o, reply_markup: o.reply_markup ?? null, message_id: m })
|
|
63
|
+
return {}
|
|
64
|
+
},
|
|
60
65
|
deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
|
|
61
66
|
}
|
|
62
67
|
return { api, calls }
|
|
@@ -100,6 +105,7 @@ function makeStreamDeps(opts?: {
|
|
|
100
105
|
dedup?: OutboundDedupCache
|
|
101
106
|
turn?: CurrentTurn | null
|
|
102
107
|
deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
|
|
108
|
+
flushedTurnSupersede?: FlushedTurnSupersedeRegistry
|
|
103
109
|
}): StreamHarness {
|
|
104
110
|
const { api, calls } = makeFakeBot()
|
|
105
111
|
const dedup = opts?.dedup ?? new OutboundDedupCache()
|
|
@@ -135,7 +141,7 @@ function makeStreamDeps(opts?: {
|
|
|
135
141
|
activeTurnStartedAt: new Map(),
|
|
136
142
|
backstopDeliveryLedger: ledger,
|
|
137
143
|
bot: { api },
|
|
138
|
-
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
144
|
+
flushedTurnSupersede: opts?.flushedTurnSupersede ?? new FlushedTurnSupersedeRegistry(),
|
|
139
145
|
idleTracker: { noteEvent: noop },
|
|
140
146
|
lastPtyPreviewByChat: new Map(),
|
|
141
147
|
obligationLedger: { close: noop, noteTurnEnded: noop },
|
|
@@ -206,12 +212,12 @@ function makeStreamDeps(opts?: {
|
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
// ── the P2 sendReply harness (same content, sharing the ONE cache) ─────────
|
|
209
|
-
function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
215
|
+
function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedTurnSupersedeRegistry) {
|
|
210
216
|
const { api, calls } = makeFakeBot()
|
|
211
217
|
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
212
218
|
const deps = {
|
|
213
219
|
outboundDedup: dedup,
|
|
214
|
-
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
220
|
+
flushedTurnSupersede: sharedSupersede ?? new FlushedTurnSupersedeRegistry(),
|
|
215
221
|
firstTextReplyLogged: new Set<string>(),
|
|
216
222
|
suppressPtyPreview: new Set<string>(),
|
|
217
223
|
activeDraftStreams: new Map(),
|
|
@@ -386,6 +392,136 @@ describe('cross-surface dedup — ONE OutboundDedupCache across P4 stream + P2 r
|
|
|
386
392
|
})
|
|
387
393
|
})
|
|
388
394
|
|
|
395
|
+
// ── F3 (dup-audit 2026-07-21): the flush RECORD wiring is load-bearing ──────
|
|
396
|
+
//
|
|
397
|
+
// The entire flush→reply dedup depends on stream-render.ts recording the
|
|
398
|
+
// flush's delivered ids into the shared FlushedTurnSupersedeRegistry
|
|
399
|
+
// (`flushedTurnSupersede.record(...)`). Every OTHER outcome test seeds that
|
|
400
|
+
// record by hand (send-reply-golden's seedRecord/seedFlushRecord), so DELETING
|
|
401
|
+
// the record call would reintroduce the dominant flush→reply duplicate with all
|
|
402
|
+
// those tests still green. This drives the REAL flush record() end-to-end (no
|
|
403
|
+
// pre-seed) across ONE shared registry and asserts the reworded same-turn reply
|
|
404
|
+
// collapses to EXACTLY ONE message — so it goes RED if the record() call is
|
|
405
|
+
// removed. This is the guard the audit flagged as missing.
|
|
406
|
+
describe('F3 — flush record() → same-turn reworded reply collapse (end-to-end, no pre-seed)', () => {
|
|
407
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
408
|
+
const FLUSHED_ANSWER =
|
|
409
|
+
'All twelve agents are healthy right now. The gateway, the vault broker and the ' +
|
|
410
|
+
'approval kernel all report green health checks, and no container has restarted in ' +
|
|
411
|
+
'the last twenty four hours, so there is nothing that needs your attention at the moment.'
|
|
412
|
+
const REWORDED =
|
|
413
|
+
'Good news on the fleet. Every one of the twelve agents is running fine at the moment. ' +
|
|
414
|
+
'Gateway, vault broker and approval kernel are all green, and nothing has restarted in ' +
|
|
415
|
+
'the past day, so you do not need to do anything right now.'
|
|
416
|
+
|
|
417
|
+
it('flush → record → late reworded reply collapses to ONE message ' +
|
|
418
|
+
'(RED if stream-render flushedTurnSupersede.record is removed)', async () => {
|
|
419
|
+
// ONE shared supersede registry across BOTH surfaces — exactly the gateway
|
|
420
|
+
// singleton wiring. The flush RECORDS (the real stream-render.ts:1828 call);
|
|
421
|
+
// the reply CONSUMES. Nothing is pre-seeded.
|
|
422
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
423
|
+
const turn = makeTurn({ capturedText: [FLUSHED_ANSWER], capturedBlockMeta: [true] })
|
|
424
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
425
|
+
|
|
426
|
+
// Drive the REAL turn-flush path: it delivers message A (deliverAnswer → id
|
|
427
|
+
// 4242) AND records {turnId, [4242]} into the shared registry.
|
|
428
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
429
|
+
await settleFlush()
|
|
430
|
+
expect(sh.delivered).toContain(FLUSHED_ANSWER)
|
|
431
|
+
// The record actually landed (this is what a removal breaks first).
|
|
432
|
+
expect(
|
|
433
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
|
|
434
|
+
).toBe('supersede')
|
|
435
|
+
|
|
436
|
+
// The model's REAL reply lands late with a REWORDED version of the same
|
|
437
|
+
// answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
|
|
438
|
+
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
439
|
+
s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
|
|
440
|
+
// (getLastSubagentHandbackAt returns null in the base deps → own answer.)
|
|
441
|
+
|
|
442
|
+
const res = await sendReply(s.deps, req(REWORDED))
|
|
443
|
+
|
|
444
|
+
// EXACTLY ONE client-visible message: the flushed message (4242) edited in
|
|
445
|
+
// place into the reworded reply — NO fresh second bubble. Without the
|
|
446
|
+
// record(), the reply finds no record, falls to the latch branch, sees
|
|
447
|
+
// reworded ≠ flushed content, does NOT suppress, and ships a DUPLICATE
|
|
448
|
+
// sendRichMessage (edits=0, sends=1) → these assertions fail.
|
|
449
|
+
const edits = s.calls.filter((c) => c.method === 'editMessageText')
|
|
450
|
+
expect(edits).toHaveLength(1)
|
|
451
|
+
expect(edits[0]!.message_id).toBe(4242)
|
|
452
|
+
expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
453
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// ── F5 (dup-audit): true take()-before-record() interleaving ────────────────
|
|
458
|
+
//
|
|
459
|
+
// The residual race the supersede registry cannot reach: a reply whose
|
|
460
|
+
// supersede take() runs in the window AFTER the flush FIRED but BEFORE it
|
|
461
|
+
// recorded its message ids. The flush arms `turn.answerDelivered='flush'` (+
|
|
462
|
+
// flushedAnswerText) SYNCHRONOUSLY at fire time — before its async deliver and
|
|
463
|
+
// before record — so a same-answer reply landing in that window is suppressed by
|
|
464
|
+
// the latch, not shipped as a duplicate. This drives a genuine two-emitter
|
|
465
|
+
// interleave (inverted ordering: reply take() strictly before flush record())
|
|
466
|
+
// and asserts exactly one delivered message.
|
|
467
|
+
describe('F5 — take()-before-record() interleaving delivers exactly one message', () => {
|
|
468
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
469
|
+
// ≥200 chars so the late reply is a substantive final answer (the floor the
|
|
470
|
+
// flush latch is scoped to) — else it would never trip the suppression.
|
|
471
|
+
const ANSWER =
|
|
472
|
+
'Yes, that is all done and confirmed. The migration ran cleanly against the staging ' +
|
|
473
|
+
'database, every integration check passed on the first attempt, the rollback plan is ' +
|
|
474
|
+
'staged in case it is ever needed, and I have written the full run log to the shared ' +
|
|
475
|
+
'drive so the team can review exactly what changed and when it happened.'
|
|
476
|
+
|
|
477
|
+
it('a reply whose take() runs BEFORE the flush record() is suppressed by the ' +
|
|
478
|
+
'flush-armed latch — one delivered message, not two', async () => {
|
|
479
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
480
|
+
const turn = makeTurn({ capturedText: [ANSWER], capturedBlockMeta: [true] })
|
|
481
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
482
|
+
|
|
483
|
+
// Fire the flush. Its latch is set SYNCHRONOUSLY here; deliverAnswer + record
|
|
484
|
+
// run in the async IIFE that has NOT completed — the pre-record window.
|
|
485
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
486
|
+
// INVERTED ORDERING: the reply's take() runs now, before the flush record().
|
|
487
|
+
expect(
|
|
488
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
|
|
489
|
+
).toBe('no-record') // record genuinely not written yet
|
|
490
|
+
|
|
491
|
+
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
492
|
+
s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
|
|
493
|
+
// The same answer landing again in the race window → latch backstop suppresses.
|
|
494
|
+
const res = await sendReply(s.deps, req(ANSWER))
|
|
495
|
+
|
|
496
|
+
expect(s.calls).toHaveLength(0) // the reply shipped nothing
|
|
497
|
+
expect(res.content[0]!.text).toContain('deduped')
|
|
498
|
+
|
|
499
|
+
await settleFlush() // let the flush's async deliver + record complete
|
|
500
|
+
// Exactly one message reached the user: the flush's message A.
|
|
501
|
+
expect(sh.delivered).toHaveLength(1)
|
|
502
|
+
expect(sh.delivered[0]).toContain('done and confirmed')
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ── Double-flush idempotency at the delivery primitive (audit §3.5) ─────────
|
|
507
|
+
//
|
|
508
|
+
// The E2-vs-E3 double flush (answer-ready quiescence THEN turn-end backstop for
|
|
509
|
+
// the SAME turn) is guarded by backstopDeliveryLedger.claim. The ledger is
|
|
510
|
+
// unit-tested, but the audit wanted it pinned at the SEND-COUNT level in the
|
|
511
|
+
// flush integration. Two turn_end dispatches for one turn must deliver once.
|
|
512
|
+
describe('double-flush idempotency — deliverAnswer fires once (send-count level)', () => {
|
|
513
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
514
|
+
it('two turn_end dispatches for the same turn deliver the answer EXACTLY once', async () => {
|
|
515
|
+
const turn = makeTurn()
|
|
516
|
+
const answer = turn.capturedText.join('')
|
|
517
|
+
const sh = makeStreamDeps({ turn })
|
|
518
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claims the latch
|
|
519
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claim fails → no-op
|
|
520
|
+
await settleFlush()
|
|
521
|
+
expect(sh.delivered).toEqual([answer])
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
389
525
|
describe('structural — the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
|
|
390
526
|
const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
391
527
|
const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
|
|
@@ -1,36 +1,165 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit coverage for the per-chat subagent-handback marker
|
|
3
|
-
* (fix/backstop-duplicate-reply
|
|
4
|
-
* supersede path uses to tell a flushed
|
|
5
|
-
* handback in flight → supersede) from a
|
|
6
|
-
* ended turn (handback in flight → keep
|
|
2
|
+
* Unit coverage for the per-chat/thread subagent-handback marker
|
|
3
|
+
* (fix/backstop-duplicate-reply; thread-keying — dup-audit F2 2026-07-21). The
|
|
4
|
+
* marker is the deterministic signal the supersede path uses to tell a flushed
|
|
5
|
+
* turn's OWN reworded late reply (no handback in flight → supersede) from a
|
|
6
|
+
* background handback attributed to that ended turn (handback in flight → keep
|
|
7
|
+
* the #3429 content gate).
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { describe, it, expect } from 'vitest'
|
|
10
|
-
import {
|
|
11
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
SubagentHandbackMarker,
|
|
16
|
+
stampsHandbackMarker,
|
|
17
|
+
INBOUND_SOURCE_CLASSIFICATION,
|
|
18
|
+
} from '../gateway/subagent-handback-marker.js'
|
|
11
19
|
|
|
12
20
|
describe('SubagentHandbackMarker', () => {
|
|
13
21
|
it('returns null for a chat with no recorded handback', () => {
|
|
14
22
|
const m = new SubagentHandbackMarker()
|
|
15
|
-
expect(m.lastAt('chatA')).toBe(null)
|
|
23
|
+
expect(m.lastAt('chatA', undefined)).toBe(null)
|
|
16
24
|
})
|
|
17
25
|
|
|
18
26
|
it('returns the recorded enqueue ts for the chat', () => {
|
|
19
27
|
const m = new SubagentHandbackMarker()
|
|
20
|
-
m.record('chatA', 1_000_000)
|
|
21
|
-
expect(m.lastAt('chatA')).toBe(1_000_000)
|
|
28
|
+
m.record('chatA', undefined, 1_000_000)
|
|
29
|
+
expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
|
|
22
30
|
})
|
|
23
31
|
|
|
24
32
|
it('is per-chat — one chat never leaks into another', () => {
|
|
25
33
|
const m = new SubagentHandbackMarker()
|
|
26
|
-
m.record('chatA', 1_000_000)
|
|
27
|
-
expect(m.lastAt('chatB')).toBe(null)
|
|
34
|
+
m.record('chatA', undefined, 1_000_000)
|
|
35
|
+
expect(m.lastAt('chatB', undefined)).toBe(null)
|
|
28
36
|
})
|
|
29
37
|
|
|
30
38
|
it('keeps only the MOST RECENT enqueue (overwrites)', () => {
|
|
31
39
|
const m = new SubagentHandbackMarker()
|
|
32
|
-
m.record('chatA', 1_000_000)
|
|
33
|
-
m.record('chatA', 1_050_000)
|
|
34
|
-
expect(m.lastAt('chatA')).toBe(1_050_000)
|
|
40
|
+
m.record('chatA', undefined, 1_000_000)
|
|
41
|
+
m.record('chatA', undefined, 1_050_000)
|
|
42
|
+
expect(m.lastAt('chatA', undefined)).toBe(1_050_000)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ── F2: thread-keying (dup-audit 2026-07-21) ────────────────────────────
|
|
46
|
+
it('is per-THREAD — a handback in topic A does not leak into topic B', () => {
|
|
47
|
+
const m = new SubagentHandbackMarker()
|
|
48
|
+
m.record('chatA', 111, 1_000_000)
|
|
49
|
+
// Same chat, different topic → no marker: topic B's CASE-A collapse is
|
|
50
|
+
// untouched by topic A's handback (the visible-dup gap F2 closed).
|
|
51
|
+
expect(m.lastAt('chatA', 222)).toBe(null)
|
|
52
|
+
expect(m.lastAt('chatA', 111)).toBe(1_000_000)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('a thread handback does not leak into the DM (no-thread) lane of the same chat', () => {
|
|
56
|
+
const m = new SubagentHandbackMarker()
|
|
57
|
+
m.record('chatA', 111, 1_000_000)
|
|
58
|
+
expect(m.lastAt('chatA', undefined)).toBe(null)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('the no-thread lane keys identically to the supersede registry (chat only)', () => {
|
|
62
|
+
const m = new SubagentHandbackMarker()
|
|
63
|
+
m.record('chatA', undefined, 1_000_000)
|
|
64
|
+
// A later thread read must NOT see the DM stamp, and vice-versa.
|
|
65
|
+
expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
|
|
66
|
+
expect(m.lastAt('chatA', 111)).toBe(null)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// ── MUST-FIX 2 (dup-audit / Fable): chat-wide gate read ─────────────────
|
|
70
|
+
it('lastAtInChat returns the MOST RECENT handback across ALL topics of a chat', () => {
|
|
71
|
+
const m = new SubagentHandbackMarker()
|
|
72
|
+
m.record('chatA', 111, 1_000)
|
|
73
|
+
m.record('chatA', 222, 3_000)
|
|
74
|
+
m.record('chatA', undefined, 2_000)
|
|
75
|
+
expect(m.lastAtInChat('chatA')).toBe(3_000)
|
|
76
|
+
expect(m.lastAtInChat('chatB')).toBe(null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('lastAtInChat makes the gate un-steerable: a topic-A stamp is seen chat-wide ' +
|
|
80
|
+
'even when a thread-specific read of topic B would miss it', () => {
|
|
81
|
+
const m = new SubagentHandbackMarker()
|
|
82
|
+
m.record('chatA', 111, 5_000)
|
|
83
|
+
// The content gate reads chat-wide → sees topic A's handback…
|
|
84
|
+
expect(m.lastAtInChat('chatA')).toBe(5_000)
|
|
85
|
+
// …whereas a thread-specific read of topic 222 (the F2 regression) missed it,
|
|
86
|
+
// which is exactly how a steered `message_thread_id` bypassed the gate.
|
|
87
|
+
expect(m.lastAt('chatA', 222)).toBe(null)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ── MUST-FIX 3 (dup-audit / Fable): fail-safe classification + exhaustiveness ──
|
|
92
|
+
describe('inbound source classification (F1 durability)', () => {
|
|
93
|
+
it('stampsHandbackMarker: subagent_handback stamps; other known sources do not', () => {
|
|
94
|
+
expect(stampsHandbackMarker('subagent_handback')).toBe(true)
|
|
95
|
+
expect(stampsHandbackMarker('cron')).toBe(false)
|
|
96
|
+
expect(stampsHandbackMarker('reaction')).toBe(false)
|
|
97
|
+
expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
|
|
98
|
+
expect(stampsHandbackMarker('vault_grant_approved')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('a normal user inbound (no meta.source) never stamps', () => {
|
|
102
|
+
expect(stampsHandbackMarker(null)).toBe(false)
|
|
103
|
+
expect(stampsHandbackMarker(undefined)).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('FAIL-SAFE default: an UNKNOWN synthesized source STAMPS (visible dup, never ' +
|
|
107
|
+
'silent edit-over)', () => {
|
|
108
|
+
// The unsafe old default (deny → no stamp → bypass eligible → silent loss)
|
|
109
|
+
// is inverted: an unclassified future decoupled source keeps the content gate.
|
|
110
|
+
expect(stampsHandbackMarker('some_future_decoupled_source')).toBe(true)
|
|
111
|
+
expect(stampsHandbackMarker('another_unclassified_source')).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// The real tripwire: scan the gateway for meta.source literals and FAIL when a
|
|
115
|
+
// new one is added without a registry classification. The grep-the-predicate
|
|
116
|
+
// structural test could not catch rot; this does.
|
|
117
|
+
it('exhaustiveness: every gateway-inbound meta.source literal is classified in the registry', () => {
|
|
118
|
+
const gatewayDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'gateway')
|
|
119
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
120
|
+
// `source:` fields that are NOT inbound meta.source tags (config cascade, the
|
|
121
|
+
// model-source selector, doc comments, typeof guards). Allowlisted so a
|
|
122
|
+
// genuinely new INBOUND source still trips this guard.
|
|
123
|
+
const NON_INBOUND_SOURCE_LITERALS = new Set([
|
|
124
|
+
'env', 'config', 'default', 'transcript', 'override', 'gateway', 'cli', 'string', 'github',
|
|
125
|
+
])
|
|
126
|
+
// The scan surface = every gateway module PLUS the out-of-gateway inbound
|
|
127
|
+
// builders whose synthesized inbounds are delivered THROUGH the gateway
|
|
128
|
+
// (dup-audit pass-2 / Fable): `src/web/webhook-dispatch.ts` builds the
|
|
129
|
+
// `webhook` / `linear` inbounds injected via `webhookInject` → the same
|
|
130
|
+
// buffer chokepoint. Grep for other `meta:`+`source:` inbound builders if new
|
|
131
|
+
// dirs appear.
|
|
132
|
+
const files: string[] = [
|
|
133
|
+
...readdirSync(gatewayDir)
|
|
134
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
|
135
|
+
.map((f) => join(gatewayDir, f)),
|
|
136
|
+
join(repoRoot, 'src', 'web', 'webhook-dispatch.ts'),
|
|
137
|
+
]
|
|
138
|
+
const found = new Set<string>()
|
|
139
|
+
// Catch BOTH quote styles and full source spellings (digits / underscores /
|
|
140
|
+
// any case) — a single-quote-only, `[a-z_]+`-only regex silently missed the
|
|
141
|
+
// double-quoted `mental_model_proposal_*` and the out-of-gateway webhook
|
|
142
|
+
// sources (pass-2 porosity). Variable-valued `source: expr` stays structurally
|
|
143
|
+
// invisible; the fail-safe stamp default is the safety boundary, not this scan.
|
|
144
|
+
const patterns = [
|
|
145
|
+
/source:\s*['"]([A-Za-z0-9_]+)['"]/g,
|
|
146
|
+
/meta\??\.source\s*===\s*['"]([A-Za-z0-9_]+)['"]/g,
|
|
147
|
+
]
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
const src = readFileSync(f, 'utf8')
|
|
150
|
+
for (const re of patterns) {
|
|
151
|
+
let m: RegExpExecArray | null
|
|
152
|
+
while ((m = re.exec(src)) != null) found.add(m[1]!)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const unclassified = [...found]
|
|
156
|
+
.filter((s) => !NON_INBOUND_SOURCE_LITERALS.has(s))
|
|
157
|
+
.filter((s) => INBOUND_SOURCE_CLASSIFICATION[s] == null)
|
|
158
|
+
.sort()
|
|
159
|
+
// If this fails: a new meta.source literal was added. Classify it in
|
|
160
|
+
// INBOUND_SOURCE_CLASSIFICATION (decoupledCompletion true ONLY if its reply
|
|
161
|
+
// can land as a late reply with no live turn of its own), or — if it is a
|
|
162
|
+
// non-inbound `source:` field — add it to NON_INBOUND_SOURCE_LITERALS.
|
|
163
|
+
expect(unclassified).toEqual([])
|
|
35
164
|
})
|
|
36
165
|
})
|