switchroom 0.19.5 → 0.19.6

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.5", COMMIT_SHA = "c5feaef8";
2123
+ var VERSION = "0.19.6", COMMIT_SHA = "ae57dd83";
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.5";
26666
+ var VERSION = "0.19.6";
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.5",
4
+ "version": "0.19.6",
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": {
@@ -40426,7 +40426,7 @@ function decideSupersede(record, args) {
40426
40426
  if (!sameTurn) {
40427
40427
  return { supersede: false, deleteMessageIds: [], reason: "different-turn" };
40428
40428
  }
40429
- if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
40429
+ if (!args.positiveAttribution && args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
40430
40430
  return { supersede: false, deleteMessageIds: [], reason: "new-content", recordText: record.text };
40431
40431
  }
40432
40432
  return {
@@ -40469,6 +40469,7 @@ class FlushedTurnSupersedeRegistry {
40469
40469
  return decideSupersede(rec, {
40470
40470
  liveTurnId: args.liveTurnId,
40471
40471
  replyText: args.replyText,
40472
+ positiveAttribution: args.positiveAttribution,
40472
40473
  now: args.now,
40473
40474
  ttlMs: this.ttlMs
40474
40475
  });
@@ -73954,7 +73955,7 @@ function decideSupersede2(record, args) {
73954
73955
  if (!sameTurn) {
73955
73956
  return { supersede: false, deleteMessageIds: [], reason: "different-turn" };
73956
73957
  }
73957
- if (args.replyText != null && !flushedAnswerMatchesReply2(record.text, args.replyText)) {
73958
+ if (!args.positiveAttribution && args.replyText != null && !flushedAnswerMatchesReply2(record.text, args.replyText)) {
73958
73959
  return { supersede: false, deleteMessageIds: [], reason: "new-content", recordText: record.text };
73959
73960
  }
73960
73961
  return {
@@ -74004,6 +74005,7 @@ class FlushedTurnSupersedeRegistry2 {
74004
74005
  return decideSupersede2(rec, {
74005
74006
  liveTurnId: args.liveTurnId,
74006
74007
  replyText: args.replyText,
74008
+ positiveAttribution: args.positiveAttribution,
74007
74009
  now: args.now,
74008
74010
  ttlMs: this.ttlMs
74009
74011
  });
@@ -74583,6 +74585,7 @@ async function sendReply(deps, req) {
74583
74585
  resolveAnswerThreadWithLog,
74584
74586
  resolveThreadId,
74585
74587
  getLatestInboundMessageId: getLatestInboundMessageId2,
74588
+ getLastSubagentHandbackAt,
74586
74589
  recordOutbound: recordOutbound2,
74587
74590
  emissionAuthorityFor,
74588
74591
  clearActivitySummary,
@@ -74656,9 +74659,14 @@ async function sendReply(deps, req) {
74656
74659
  let supersedeFlushIds = [];
74657
74660
  {
74658
74661
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
74659
- const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args);
74662
+ const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
74660
74663
  const resolvedTurnId = ownerTurn?.turnId ?? null;
74661
- const decision = flushedTurnSupersede.take(chat_id, replyThreadId, { liveTurnId: resolvedTurnId, replyText: text4, now: Date.now() });
74664
+ const ownerEndedAt = ownerTurn?.endedAt ?? null;
74665
+ const handbackAt = getLastSubagentHandbackAt(chat_id);
74666
+ const now = Date.now();
74667
+ 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, replyThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
74662
74670
  if (decision.supersede) {
74663
74671
  process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
74664
74672
  `);
@@ -78142,8 +78150,41 @@ function latestEndedAccepted(candidates) {
78142
78150
  return true;
78143
78151
  return age <= ttl;
78144
78152
  }
78153
+ function resolveReplyOwnerTier(candidates) {
78154
+ if (candidates.liveTurnId != null)
78155
+ return "live";
78156
+ if (candidates.originTurnId != null)
78157
+ return "origin";
78158
+ if (candidates.quotedTurnId != null)
78159
+ return "quoted";
78160
+ if (latestEndedAccepted(candidates))
78161
+ return "latest-ended";
78162
+ return "none";
78163
+ }
78145
78164
  function resolveReplyOwnerTurnId(candidates) {
78146
- return candidates.liveTurnId ?? candidates.originTurnId ?? candidates.quotedTurnId ?? (latestEndedAccepted(candidates) ? candidates.latestEndedTurnId : null) ?? null;
78165
+ switch (resolveReplyOwnerTier(candidates)) {
78166
+ case "live":
78167
+ return candidates.liveTurnId;
78168
+ case "origin":
78169
+ return candidates.originTurnId;
78170
+ case "quoted":
78171
+ return candidates.quotedTurnId;
78172
+ case "latest-ended":
78173
+ return candidates.latestEndedTurnId;
78174
+ case "none":
78175
+ return null;
78176
+ }
78177
+ }
78178
+
78179
+ // gateway/subagent-handback-marker.ts
78180
+ class SubagentHandbackMarker {
78181
+ lastAtByChat = new Map;
78182
+ record(chatId, now) {
78183
+ this.lastAtByChat.set(chatId, now);
78184
+ }
78185
+ lastAt(chatId) {
78186
+ return this.lastAtByChat.get(chatId) ?? null;
78187
+ }
78147
78188
  }
78148
78189
 
78149
78190
  // answer-ready-flush.ts
@@ -84736,6 +84777,11 @@ function createPendingInboundBuffer(opts = {}) {
84736
84777
  }
84737
84778
  }
84738
84779
  q.push(msg);
84780
+ if (msg.meta?.source === "subagent_handback" && opts.onHandbackEnqueue != null) {
84781
+ try {
84782
+ opts.onHandbackEnqueue(msg.chatId, msg.ts);
84783
+ } catch {}
84784
+ }
84739
84785
  spool?.put(agent, msg);
84740
84786
  log(`pending-inbound-buffer: agent=${agent} buffered source=${msg.meta?.source ?? "-"} ` + `depth_after=${q.length} evicted=${evicted}
84741
84787
  `);
@@ -93414,10 +93460,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
93414
93460
  }
93415
93461
 
93416
93462
  // ../src/build-info.ts
93417
- var VERSION = "0.19.5";
93418
- var COMMIT_SHA = "c5feaef8";
93419
- var COMMIT_DATE = "2026-07-20T10:20:34Z";
93420
- var LATEST_PR = 3474;
93463
+ var VERSION = "0.19.6";
93464
+ var COMMIT_SHA = "ae57dd83";
93465
+ var COMMIT_DATE = "2026-07-20T16:11:50Z";
93466
+ var LATEST_PR = 3476;
93421
93467
  var COMMITS_AHEAD_OF_TAG = 0;
93422
93468
 
93423
93469
  // gateway/boot-version.ts
@@ -96335,6 +96381,8 @@ function cardDrainGate(turn, ea, run4) {
96335
96381
  var RECENT_TURNS_MAX = 32;
96336
96382
  var recentTurnsById = new Map;
96337
96383
  var recentTurnIdBySourceMessageId = new Map;
96384
+ var subagentHandbackMarker = new SubagentHandbackMarker;
96385
+ var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAt(chatId);
96338
96386
  function rememberRecentTurn(turn) {
96339
96387
  recentTurnsById.set(turn.turnId, turn);
96340
96388
  if (turn.sourceMessageId != null) {
@@ -96401,15 +96449,17 @@ function resolveReplyOwnerTurn(liveTurn, chatId, args) {
96401
96449
  byId.set(t.turnId, t);
96402
96450
  }
96403
96451
  const latestEndedAgeMs = latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null;
96404
- const winnerId = resolveReplyOwnerTurnId({
96452
+ const candidates = {
96405
96453
  liveTurnId: liveTurn?.turnId ?? null,
96406
96454
  originTurnId: origin?.turnId ?? null,
96407
96455
  quotedTurnId: quoted?.turnId ?? null,
96408
96456
  latestEndedTurnId: latestEnded?.turnId ?? null,
96409
96457
  latestEndedAgeMs,
96410
96458
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS
96411
- });
96412
- return winnerId != null ? byId.get(winnerId) ?? null : null;
96459
+ };
96460
+ const tier = resolveReplyOwnerTier(candidates);
96461
+ const winnerId = resolveReplyOwnerTurnId(candidates);
96462
+ return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier };
96413
96463
  }
96414
96464
  function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
96415
96465
  const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
@@ -99121,7 +99171,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
99121
99171
  evictNoticeByChat.set(key, nowMs3);
99122
99172
  const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
99123
99173
  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" });
99124
- }
99174
+ },
99175
+ onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts)
99125
99176
  });
99126
99177
  function agentHasInFlightBackgroundWork(now) {
99127
99178
  if (countRunningWorkers() > 0)
@@ -100499,6 +100550,7 @@ function gatewaySendReplyDeps() {
100499
100550
  resolveAnswerThreadWithLog,
100500
100551
  resolveThreadId,
100501
100552
  getLatestInboundMessageId,
100553
+ getLastSubagentHandbackAt,
100502
100554
  recordOutbound,
100503
100555
  emissionAuthorityFor,
100504
100556
  clearActivitySummary,
@@ -181,7 +181,13 @@ export function flushedAnswerMatchesReply(flushedText: string, replyText: string
181
181
  */
182
182
  export function decideSupersede(
183
183
  record: FlushedTurnRecord | undefined,
184
- args: { liveTurnId: string | null; replyText?: string | null; now: number; ttlMs?: number },
184
+ args: {
185
+ liveTurnId: string | null
186
+ replyText?: string | null
187
+ positiveAttribution?: boolean
188
+ now: number
189
+ ttlMs?: number
190
+ },
185
191
  ): SupersedeDecision {
186
192
  const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
187
193
  if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
@@ -195,10 +201,29 @@ export function decideSupersede(
195
201
  if (!sameTurn) {
196
202
  return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
197
203
  }
198
- // #3429 — same turn identity, but genuinely different content: an async
199
- // handback attributed to the flush-delivered ended turn, not the turn's own
200
- // answer landing late. Never edit/delete the flushed message for it.
201
- if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
204
+ // #3429 content gate but ONLY when the caller is UNSURE the reply is this
205
+ // turn's own answer. `positiveAttribution === true` means the gateway
206
+ // established the reply IS this turn's OWN late answer either by a positive
207
+ // owner-resolution tier (live atom / `origin_turn_id` echo / quoted message
208
+ // id) OR because no background `subagent_handback` could own it (none enqueued
209
+ // for the chat after the flushed turn ended within the supersede TTL; see
210
+ // outbound-send-path). Then supersede the flushed provisional draft REGARDLESS
211
+ // of text, so a model that RE-WORDED the answer between the narration that
212
+ // flushed and the `reply` tool call still collapses to ONE message (the
213
+ // duplicate-reply regression #3429 introduced on the reworded same-turn path).
214
+ //
215
+ // The content gate remains when `positiveAttribution` is falsey — the
216
+ // ambiguous residual (latest-ended tier AND a handback in flight) where
217
+ // identity alone cannot tell the turn's own late reply from a background
218
+ // sub-agent handback that resolved this flush-delivered ended turn as its
219
+ // owner. There, genuinely different content is a handback and must send FRESH
220
+ // (both messages surface), never edit/delete the flushed answer. Legacy
221
+ // identity-only callers (no `replyText`) keep the pre-#3429 behaviour.
222
+ if (
223
+ !args.positiveAttribution &&
224
+ args.replyText != null &&
225
+ !flushedAnswerMatchesReply(record.text, args.replyText)
226
+ ) {
202
227
  return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
203
228
  }
204
229
  return {
@@ -326,12 +351,18 @@ export class FlushedTurnSupersedeRegistry {
326
351
  peek(
327
352
  chatId: string,
328
353
  threadId: number | undefined,
329
- args: { liveTurnId: string | null; replyText?: string | null; now: number },
354
+ args: {
355
+ liveTurnId: string | null
356
+ replyText?: string | null
357
+ positiveAttribution?: boolean
358
+ now: number
359
+ },
330
360
  ): SupersedeDecision {
331
361
  const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
332
362
  return decideSupersede(rec, {
333
363
  liveTurnId: args.liveTurnId,
334
364
  replyText: args.replyText,
365
+ positiveAttribution: args.positiveAttribution,
335
366
  now: args.now,
336
367
  ttlMs: this.ttlMs,
337
368
  })
@@ -345,7 +376,12 @@ export class FlushedTurnSupersedeRegistry {
345
376
  take(
346
377
  chatId: string,
347
378
  threadId: number | undefined,
348
- args: { liveTurnId: string | null; replyText?: string | null; now: number },
379
+ args: {
380
+ liveTurnId: string | null
381
+ replyText?: string | null
382
+ positiveAttribution?: boolean
383
+ now: number
384
+ },
349
385
  ): SupersedeDecision {
350
386
  const lane = makeKey(chatId, threadId)
351
387
  const decision = this.peek(chatId, threadId, args)
@@ -485,8 +485,11 @@ import {
485
485
  } from '../turn-flush-safety.js'
486
486
  import {
487
487
  resolveReplyOwnerTurnId,
488
+ resolveReplyOwnerTier,
489
+ type ReplyOwnerTier,
488
490
  type AnswerDeliveredLatch,
489
491
  } from '../reply-owner-resolve.js'
492
+ import { SubagentHandbackMarker } from './subagent-handback-marker.js'
490
493
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
491
494
  import {
492
495
  AnswerReadyFlushController,
@@ -3885,6 +3888,14 @@ const recentTurnsById = new Map<string, CurrentTurn>()
3885
3888
  // real message_id the framework stamped, never a model-asserted thread.
3886
3889
  // Evicted in lock-step with recentTurnsById so it can't outgrow it.
3887
3890
  const recentTurnIdBySourceMessageId = new Map<number, string>()
3891
+
3892
+ // fix/backstop-duplicate-reply — per-chat marker of the most recent
3893
+ // gateway-synthesized `subagent_handback` enqueue (logic in
3894
+ // subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
3895
+ const subagentHandbackMarker = new SubagentHandbackMarker()
3896
+ const getLastSubagentHandbackAt = (chatId: string): number | null =>
3897
+ subagentHandbackMarker.lastAt(chatId)
3898
+
3888
3899
  function rememberRecentTurn(turn: CurrentTurn): void {
3889
3900
  recentTurnsById.set(turn.turnId, turn)
3890
3901
  if (turn.sourceMessageId != null) {
@@ -4009,7 +4020,7 @@ function resolveReplyOwnerTurn(
4009
4020
  liveTurn: CurrentTurn | null,
4010
4021
  chatId: string,
4011
4022
  args: Record<string, unknown>,
4012
- ): CurrentTurn | null {
4023
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
4013
4024
  const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4014
4025
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4015
4026
  const latestEnded = findLatestEndedTurnForChat(chatId)
@@ -4026,15 +4037,23 @@ function resolveReplyOwnerTurn(
4026
4037
  // fabricate one.
4027
4038
  const latestEndedAgeMs =
4028
4039
  latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4029
- const winnerId = resolveReplyOwnerTurnId({
4040
+ const candidates = {
4030
4041
  liveTurnId: liveTurn?.turnId ?? null,
4031
4042
  originTurnId: origin?.turnId ?? null,
4032
4043
  quotedTurnId: quoted?.turnId ?? null,
4033
4044
  latestEndedTurnId: latestEnded?.turnId ?? null,
4034
4045
  latestEndedAgeMs,
4035
4046
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4036
- })
4037
- return winnerId != null ? (byId.get(winnerId) ?? null) : null
4047
+ }
4048
+ // #3429 the WINNING tier travels with the turn. A positive tier
4049
+ // (live/origin/quoted) means the reply is this turn's own answer and the
4050
+ // supersede fires regardless of text; the ambiguous `latest-ended` fallback
4051
+ // keeps the content gate (it cannot tell a late own-reply from an async
4052
+ // sub-agent handback). Both derive from the SAME candidates, so the id and the
4053
+ // tier can never disagree.
4054
+ const tier = resolveReplyOwnerTier(candidates)
4055
+ const winnerId = resolveReplyOwnerTurnId(candidates)
4056
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
4038
4057
  }
4039
4058
 
4040
4059
  /**
@@ -9962,6 +9981,10 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9962
9981
  { chat_id: chat, verb: 'inbound-buffer-eviction' },
9963
9982
  )
9964
9983
  },
9984
+ // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
9985
+ // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9986
+ // push) populates it. Every `subagent_handback` push funnels through here.
9987
+ onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
9965
9988
  })
9966
9989
 
9967
9990
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -12567,6 +12590,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12567
12590
  resolveAnswerThreadWithLog,
12568
12591
  resolveThreadId,
12569
12592
  getLatestInboundMessageId,
12593
+ getLastSubagentHandbackAt,
12570
12594
  recordOutbound,
12571
12595
  emissionAuthorityFor,
12572
12596
  clearActivitySummary,
@@ -24458,6 +24482,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24458
24482
  // The drain only releases at an idle prompt (no active
24459
24483
  // turn), so the handback always lands as a clean fresh
24460
24484
  // turn and never races a turn-in-flight composer (#1556).
24485
+ // The handback marker is stamped inside pendingInboundBuffer.push
24486
+ // (the enqueue chokepoint) so BOTH this live synthesis push and
24487
+ // the boot-replay re-push populate it — see MUST-FIX 2.
24461
24488
  pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
24462
24489
  process.stderr.write(
24463
24490
  `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
@@ -56,9 +56,10 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
56
56
  import {
57
57
  decideSupersedeCorrection,
58
58
  flushedAnswerMatchesReply,
59
+ DEFAULT_SUPERSEDE_TTL_MS,
59
60
  type FlushedTurnSupersedeRegistry,
60
61
  } from '../flushed-turn-supersede.js'
61
- import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
62
+ import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
62
63
  import { deriveTelegraphTitle } from '../telegraph.js'
63
64
  import {
64
65
  mintVoiceOnDemandToken,
@@ -658,7 +659,7 @@ export interface SendReplyGatewayDeps {
658
659
  assertSendable(f: string): void
659
660
  statusKey(chatId: string, threadId?: number | null): string
660
661
  streamKey(chatId: string, threadId?: number | null): string
661
- resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): CurrentTurn | null
662
+ resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier }
662
663
  findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
663
664
  findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
664
665
  resolveAnswerThreadWithLog(
@@ -671,6 +672,7 @@ export interface SendReplyGatewayDeps {
671
672
  ): number | undefined
672
673
  resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
673
674
  getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
675
+ getLastSubagentHandbackAt(chatId: string): number | null
674
676
  recordOutbound(rec: {
675
677
  chat_id: string
676
678
  thread_id: number | null
@@ -735,7 +737,7 @@ export async function sendReply(
735
737
  statusKey, streamKey,
736
738
  resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
737
739
  resolveAnswerThreadWithLog, resolveThreadId,
738
- getLatestInboundMessageId, recordOutbound,
740
+ getLatestInboundMessageId, getLastSubagentHandbackAt, recordOutbound,
739
741
  emissionAuthorityFor, clearActivitySummary,
740
742
  startTypingLoop, stopTypingLoop, logOutbound,
741
743
  closeObligationOnSubstantiveReply, finalizeStatusReaction,
@@ -874,18 +876,56 @@ export async function sendReply(
874
876
  // double-send). The quoted / latest-ended recoveries are precisely what the
875
877
  // router already did for the same reply, so unifying here makes the two
876
878
  // resolvers agree and the late-reply supersede fires by identity.
877
- const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
879
+ const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
878
880
  const resolvedTurnId = ownerTurn?.turnId ?? null
879
- // #3429 — pass the (normalized) reply text so the registry can apply the
880
- // new-content gate: identity match + TTL alone also fits an async handback
881
- // that merely resolved this flush-delivered ENDED turn as its owner via
882
- // the latest-ended tier. Editing the flushed message in place with that
883
- // handback's text does not re-notify client-side (Telegram edits never
884
- // push) the observed silent non-surfacing of msgs 10482/10486.
881
+ // #3429 — pass the (normalized) reply text so the registry CAN apply the
882
+ // new-content gate: identity match + TTL alone also fits a background
883
+ // sub-agent handback that merely resolved this flush-delivered ENDED turn as
884
+ // its owner via the latest-ended tier. Editing/deleting the flushed message
885
+ // for that handback's unrelated text is the #3429 silent client-side drop
886
+ // (msgs 10482/10486). But that gate ALSO declines the turn's OWN reworded
887
+ // late reply (model narrated → flushed → fired `reply` with a paraphrase),
888
+ // which is the dominant real duplicate (agent:marko 2026-07-20: 11/11
889
+ // declines were own-replies, turns #1177/#1182/#1201 double-sent). So we
890
+ // BYPASS the content gate ONLY when the reply is confidently the flushed
891
+ // turn's OWN answer. The primary, deterministic signal is the absence of a
892
+ // background sub-agent handback that could own this reply: the gateway
893
+ // records when it synthesizes a `subagent_handback` inbound per chat, and if
894
+ // NONE was enqueued for this chat AFTER the flushed turn ended within the
895
+ // supersede TTL, the reply is that turn's own answer → supersede regardless
896
+ // of a model rewording (closes the DM default-reply duplicate, where every
897
+ // own-reply resolves via the latest-ended tier).
898
+ //
899
+ // MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
900
+ // tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
901
+ // `args.origin_turn_id`), so a background handback turn can STEER them —
902
+ // pass `reply_to = <the user's original msg id>` and it resolves the PRIOR
903
+ // flushed turn via `quoted`, which used to force the bypass and silently
904
+ // edit-over that turn's delivered answer (#3429-class, model-steerable). So
905
+ // a positive tier NEVER overrides an in-window handback; only the
906
+ // FRAMEWORK-owned `live` tier (the live `currentTurn`, not model-derived,
907
+ // and structurally unable to collide with an ended turn's flush record —
908
+ // `decideSupersede` requires same turnId) may bypass a handback window.
909
+ //
910
+ // The content gate is therefore kept whenever a handback WAS enqueued in the
911
+ // window (any non-live tier): the late reply might BE that handback, so
912
+ // genuinely-new content sends fresh (two messages, #3429 preserved).
913
+ // Concurrency note: a case-A own reply coinciding with an unrelated
914
+ // background handback in the same ≤TTL window degrades to today's behaviour
915
+ // (two messages) — safe (never a silent drop/edit), just not collapsed.
916
+ const ownerEndedAt = ownerTurn?.endedAt ?? null
917
+ const handbackAt = getLastSubagentHandbackAt(chat_id)
918
+ const now = Date.now()
919
+ const handbackCouldOwnReply =
920
+ handbackAt != null &&
921
+ ownerEndedAt != null &&
922
+ handbackAt > ownerEndedAt &&
923
+ now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
924
+ const replyIsOwnAnswer = ownerTier === 'live' || !handbackCouldOwnReply
885
925
  const decision = flushedTurnSupersede.take(
886
926
  chat_id,
887
927
  replyThreadId,
888
- { liveTurnId: resolvedTurnId, replyText: text, now: Date.now() },
928
+ { liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
889
929
  )
890
930
  if (decision.supersede) {
891
931
  process.stderr.write(
@@ -79,6 +79,16 @@ export interface PendingInboundBufferOptions {
79
79
  * never breaks the push hot path.
80
80
  */
81
81
  onEvict?: (agent: string, evicted: InboundMessage) => void
82
+ /**
83
+ * fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
84
+ * `subagent_handback` envelope (live synthesis AND boot-replay re-push),
85
+ * carrying the envelope's `chatId` and its own `ts` (ms). The gateway wires
86
+ * this to the per-chat subagent-handback marker so the supersede path can tell
87
+ * a flushed turn's own late reply from a background handback attributed to it
88
+ * — INCLUDING after a restart, where the only handback push is the replay.
89
+ * Best-effort: a throw here never breaks the push hot path.
90
+ */
91
+ onHandbackEnqueue?: (chatId: string, ts: number) => void
82
92
  }
83
93
 
84
94
  /**
@@ -342,6 +352,23 @@ export function createPendingInboundBuffer(
342
352
  }
343
353
  }
344
354
  q.push(msg)
355
+ // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the subagent-handback
356
+ // marker at THIS chokepoint, not at the live onFinish enqueue site alone.
357
+ // Every handback enqueue funnels through here — the live synthesis push
358
+ // AND the boot-replay re-push of un-acked spooled inbounds — so stamping
359
+ // here (rather than only at the live site) means a handback replayed after
360
+ // a restart still populates the marker. Otherwise the Map is empty
361
+ // post-boot and a replayed handback's late reply bypasses the #3429
362
+ // content gate → silent edit-over-answer. Uses the envelope's own `ts`
363
+ // (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
364
+ // handback actually happened, not the replay moment. Best-effort.
365
+ if (msg.meta?.source === 'subagent_handback' && opts.onHandbackEnqueue != null) {
366
+ try {
367
+ opts.onHandbackEnqueue(msg.chatId, msg.ts)
368
+ } catch {
369
+ /* marker stamp is best-effort; never break the push hot path */
370
+ }
371
+ }
345
372
  // Durable record FIRST-class to the in-memory queue: spool BEFORE
346
373
  // returning, regardless of the cap eviction above — an entry the
347
374
  // in-memory cap drops still survives in the spool (boot-replayed /
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-chat marker of the most recent gateway-synthesized `subagent_handback`
3
+ * enqueue (fix/backstop-duplicate-reply).
4
+ *
5
+ * A BACKGROUND sub-agent completion is a GATEWAY-SYNTHESIZED event, not model
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, is the
8
+ * ONE deterministic signal that distinguishes the two late-reply cases that both
9
+ * resolve a flush-delivered ENDED turn via the latest-ended tier — the case the
10
+ * owner-resolution tier alone cannot separate (a DM late reply has no
11
+ * live/origin/quoted attribution, so both land on latest-ended):
12
+ *
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, so the
15
+ * reply is that turn's own answer → the supersede path collapses the
16
+ * provisional flush REGARDLESS of the model's rewording (closes the #3429
17
+ * reworded-duplicate regression: agent:marko 2026-07-20, turns
18
+ * #1177/#1182/#1201 double-sent).
19
+ * - CASE B — a background handback attributed to that ended turn. A
20
+ * `subagent_handback` WAS enqueued after the turn ended and within the
21
+ * supersede TTL, so the late reply might BE it → keep the #3429 content gate
22
+ * and send fresh (two messages), never silently edit/delete the flushed
23
+ * answer.
24
+ *
25
+ * One entry per chat (overwritten on each enqueue), so bounded by chat count. No
26
+ * clock reads beyond the caller-supplied `now`; the gateway wires the actual
27
+ * enqueue site and the supersede-path read. Deterministic — keyed on a
28
+ * gateway-emitted event, never on model discipline (Ken's controls-in-code rule).
29
+ */
30
+ export class SubagentHandbackMarker {
31
+ private readonly lastAtByChat = new Map<string, number>()
32
+
33
+ /** Record that a `subagent_handback` was enqueued for `chatId` at `now` (ms). */
34
+ record(chatId: string, now: number): void {
35
+ this.lastAtByChat.set(chatId, now)
36
+ }
37
+
38
+ /** Wall-clock ms of the most recent handback enqueue for `chatId`, or null. */
39
+ lastAt(chatId: string): number | null {
40
+ return this.lastAtByChat.get(chatId) ?? null
41
+ }
42
+ }
@@ -84,6 +84,37 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
84
84
  return age <= ttl
85
85
  }
86
86
 
87
+ /**
88
+ * Which precedence tier resolved a reply's owner turn.
89
+ *
90
+ * - `'live'` / `'origin'` / `'quoted'` are POSITIVE attributions: the reply
91
+ * is tied to a specific turn by a live atom, the model's own echo, or the
92
+ * framework-owned quoted message id — it IS that turn's answer.
93
+ * - `'latest-ended'` is the ambiguous FALLBACK: no positive link exists, so
94
+ * the reply is merely attributed to the chat's most-recently-ended turn.
95
+ * This tier cannot tell the turn's OWN late reply from an async sub-agent
96
+ * handback that has no live turn and no quote linkage — both land here with
97
+ * the same resolved turnId (#3429). The supersede content gate
98
+ * (`flushedAnswerMatchesReply`) is therefore applied ONLY on this tier.
99
+ * - `'none'` — every lookup missed (a genuinely unattributable reply).
100
+ */
101
+ export type ReplyOwnerTier = 'live' | 'origin' | 'quoted' | 'latest-ended' | 'none'
102
+
103
+ /**
104
+ * The tier that WINS owner resolution for these candidates — the single source
105
+ * of the precedence order, consumed by both `resolveReplyOwnerTurnId` (which id)
106
+ * and the gateway (which discrimination the supersede applies). Same precedence,
107
+ * first non-null wins; the destructive latest-ended tier is honoured only when
108
+ * fresh (`latestEndedAccepted`).
109
+ */
110
+ export function resolveReplyOwnerTier(candidates: ReplyOwnerCandidates): ReplyOwnerTier {
111
+ if (candidates.liveTurnId != null) return 'live'
112
+ if (candidates.originTurnId != null) return 'origin'
113
+ if (candidates.quotedTurnId != null) return 'quoted'
114
+ if (latestEndedAccepted(candidates)) return 'latest-ended'
115
+ return 'none'
116
+ }
117
+
87
118
  /**
88
119
  * Resolve the turnId that OWNS a landing reply, using the SAME full chain the
89
120
  * thread-router uses. Precedence, first non-null wins:
@@ -98,13 +129,18 @@ function latestEndedAccepted(candidates: ReplyOwnerCandidates): boolean {
98
129
  * to delete any turnId-bearing flush record.
99
130
  */
100
131
  export function resolveReplyOwnerTurnId(candidates: ReplyOwnerCandidates): string | null {
101
- return (
102
- candidates.liveTurnId ??
103
- candidates.originTurnId ??
104
- candidates.quotedTurnId ??
105
- (latestEndedAccepted(candidates) ? candidates.latestEndedTurnId : null) ??
106
- null
107
- )
132
+ switch (resolveReplyOwnerTier(candidates)) {
133
+ case 'live':
134
+ return candidates.liveTurnId
135
+ case 'origin':
136
+ return candidates.originTurnId
137
+ case 'quoted':
138
+ return candidates.quotedTurnId
139
+ case 'latest-ended':
140
+ return candidates.latestEndedTurnId
141
+ case 'none':
142
+ return null
143
+ }
108
144
  }
109
145
 
110
146
  /**
@@ -381,3 +381,92 @@ describe('#3429 — decideSupersede new-content gate', () => {
381
381
  expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
382
382
  })
383
383
  })
384
+
385
+ /**
386
+ * The tier discriminator (fix/backstop-duplicate-reply). `positiveAttribution`
387
+ * lets the gateway bypass the #3429 content gate when the reply's owner turn was
388
+ * resolved by POSITIVE attribution (live / origin echo / quoted) rather than the
389
+ * ambiguous latest-ended fallback. On a positive tier a genuinely-DIFFERENT
390
+ * (reworded) reply for the same turn still supersedes — closing the reworded
391
+ * same-turn duplicate — while the latest-ended path keeps the content gate so a
392
+ * true async handback still sends fresh.
393
+ */
394
+ describe('positiveAttribution — tier-gated content check', () => {
395
+ const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
396
+ const REWORDED =
397
+ 'So, to wrap up: the incident boiled down to a stale cache entry, and it is fully resolved now.'
398
+
399
+ it('positiveAttribution=true: same turn + DIFFERENT (reworded) content STILL supersedes', () => {
400
+ // Guard: the reworded text is genuinely new vs the flushed blob (would be
401
+ // declined new-content without the tier bypass).
402
+ expect(flushedAnswerMatchesReply(FLUSHED, REWORDED)).toBe(false)
403
+
404
+ const d = decideSupersede(rec({ text: FLUSHED }), {
405
+ liveTurnId: 'turn-A',
406
+ replyText: REWORDED,
407
+ positiveAttribution: true,
408
+ now: 1_000_010,
409
+ })
410
+ expect(d.supersede).toBe(true)
411
+ expect(d.reason).toBe('supersede')
412
+ expect(d.deleteMessageIds).toEqual([101, 102])
413
+ })
414
+
415
+ it('positiveAttribution=false (latest-ended): the SAME reworded content declines as new-content', () => {
416
+ const d = decideSupersede(rec({ text: FLUSHED }), {
417
+ liveTurnId: 'turn-A',
418
+ replyText: REWORDED,
419
+ positiveAttribution: false,
420
+ now: 1_000_010,
421
+ })
422
+ expect(d.supersede).toBe(false)
423
+ expect(d.reason).toBe('new-content')
424
+ })
425
+
426
+ it('positiveAttribution=true still requires turn IDENTITY (different turn never supersedes)', () => {
427
+ const d = decideSupersede(rec({ turnId: 'turn-A', text: FLUSHED }), {
428
+ liveTurnId: 'turn-B',
429
+ replyText: REWORDED,
430
+ positiveAttribution: true,
431
+ now: 1_000_010,
432
+ })
433
+ expect(d.supersede).toBe(false)
434
+ expect(d.reason).toBe('different-turn')
435
+ })
436
+
437
+ it('positiveAttribution=true still respects the TTL (expired record never supersedes)', () => {
438
+ const d = decideSupersede(rec({ text: FLUSHED }), {
439
+ liveTurnId: 'turn-A',
440
+ replyText: REWORDED,
441
+ positiveAttribution: true,
442
+ now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
443
+ })
444
+ expect(d.supersede).toBe(false)
445
+ expect(d.reason).toBe('expired')
446
+ })
447
+
448
+ it('registry take() threads positiveAttribution through to the decision', () => {
449
+ const reg = new FlushedTurnSupersedeRegistry()
450
+ const now = 1_000_000
451
+ reg.record('chatT', undefined, { turnId: 'turn-Q', messageIds: [8001], text: FLUSHED }, now)
452
+
453
+ // latest-ended (positiveAttribution falsey): reworded → new-content, kept.
454
+ const ambiguous = reg.peek('chatT', undefined, {
455
+ liveTurnId: 'turn-Q',
456
+ replyText: REWORDED,
457
+ now: now + 5_000,
458
+ })
459
+ expect(ambiguous.reason).toBe('new-content')
460
+
461
+ // positive tier: same reworded reply supersedes and consumes the record.
462
+ const positive = reg.take('chatT', undefined, {
463
+ liveTurnId: 'turn-Q',
464
+ replyText: REWORDED,
465
+ positiveAttribution: true,
466
+ now: now + 6_000,
467
+ })
468
+ expect(positive.supersede).toBe(true)
469
+ expect(positive.deleteMessageIds).toEqual([8001])
470
+ expect(reg.peek('chatT', undefined, { liveTurnId: 'turn-Q', now: now + 7_000 }).reason).toBe('no-record')
471
+ })
472
+ })
@@ -371,7 +371,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
371
371
  assertSendable: () => {},
372
372
  statusKey: key,
373
373
  streamKey: key,
374
- resolveReplyOwnerTurn: () => null,
374
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
375
+ getLastSubagentHandbackAt: () => null,
375
376
  findTurnByOriginId: () => null,
376
377
  findTurnByQuotedMessageId: () => null,
377
378
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
@@ -32,6 +32,7 @@
32
32
  import { describe, it, expect } from 'vitest'
33
33
  import {
34
34
  resolveReplyOwnerTurnId,
35
+ resolveReplyOwnerTier,
35
36
  decideAnswerLatchSuppression,
36
37
  type ReplyOwnerCandidates,
37
38
  type AnswerDeliveredLatch,
@@ -611,3 +612,76 @@ describe('#3429 — flush-armed latch with content evidence', () => {
611
612
  ).toBe(false)
612
613
  })
613
614
  })
615
+
616
+ /**
617
+ * `resolveReplyOwnerTier` (fix/backstop-duplicate-reply) exposes WHICH precedence
618
+ * tier won, so the gateway can apply the #3429 content gate ONLY on the ambiguous
619
+ * `latest-ended` fallback. It shares one precedence with `resolveReplyOwnerTurnId`
620
+ * (asserted equivalent below), so the winning id and the winning tier can never
621
+ * disagree.
622
+ */
623
+ describe('resolveReplyOwnerTier — precedence + latest-ended TTL bound', () => {
624
+ const base: ReplyOwnerCandidates = {
625
+ liveTurnId: null,
626
+ originTurnId: null,
627
+ quotedTurnId: null,
628
+ latestEndedTurnId: null,
629
+ }
630
+
631
+ it('live wins over every lower tier', () => {
632
+ expect(
633
+ resolveReplyOwnerTier({ ...base, liveTurnId: 'L', originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' }),
634
+ ).toBe('live')
635
+ })
636
+
637
+ it('origin wins when no live turn', () => {
638
+ expect(resolveReplyOwnerTier({ ...base, originTurnId: 'O', quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('origin')
639
+ })
640
+
641
+ it('quoted wins when no live/origin turn (the positive DM recovery tier)', () => {
642
+ expect(resolveReplyOwnerTier({ ...base, quotedTurnId: 'Q', latestEndedTurnId: 'E' })).toBe('quoted')
643
+ })
644
+
645
+ it('latest-ended is the ambiguous FALLBACK when only it resolves', () => {
646
+ expect(resolveReplyOwnerTier({ ...base, latestEndedTurnId: 'E' })).toBe('latest-ended')
647
+ })
648
+
649
+ it('none when every lookup missed', () => {
650
+ expect(resolveReplyOwnerTier(base)).toBe('none')
651
+ })
652
+
653
+ it('a STALE latest-ended (age > TTL) is NOT accepted → none, never latest-ended', () => {
654
+ const stale: ReplyOwnerCandidates = {
655
+ ...base,
656
+ latestEndedTurnId: 'E',
657
+ latestEndedAgeMs: DEFAULT_SUPERSEDE_TTL_MS + 1,
658
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
659
+ }
660
+ expect(resolveReplyOwnerTier(stale)).toBe('none')
661
+ // And the id resolver agrees (no destructive authority granted to a stale turn).
662
+ expect(resolveReplyOwnerTurnId(stale)).toBe(null)
663
+ })
664
+
665
+ it('tier and id resolver share one precedence for every candidate shape', () => {
666
+ const shapes: ReplyOwnerCandidates[] = [
667
+ base,
668
+ { ...base, liveTurnId: 'L', quotedTurnId: 'Q', latestEndedTurnId: 'E' },
669
+ { ...base, originTurnId: 'O', latestEndedTurnId: 'E' },
670
+ { ...base, quotedTurnId: 'Q' },
671
+ { ...base, latestEndedTurnId: 'E' },
672
+ ]
673
+ const idForTier = (s: ReplyOwnerCandidates): string | null => {
674
+ switch (resolveReplyOwnerTier(s)) {
675
+ case 'live': return s.liveTurnId
676
+ case 'origin': return s.originTurnId
677
+ case 'quoted': return s.quotedTurnId
678
+ case 'latest-ended': return s.latestEndedTurnId
679
+ case 'none': return null
680
+ }
681
+ }
682
+ for (const s of shapes) {
683
+ // The id the winning tier points at equals what the id resolver returns.
684
+ expect(resolveReplyOwnerTurnId(s)).toBe(idForTier(s))
685
+ }
686
+ })
687
+ })
@@ -46,6 +46,8 @@ import {
46
46
  } from '../gateway/outbound-send-path.js'
47
47
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
48
48
  import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
49
+ import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
50
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer.js'
49
51
  import { redact } from '../secret-detect/redact.js'
50
52
  import type { CurrentTurn, Access } from '../gateway/gateway.js'
51
53
 
@@ -209,7 +211,8 @@ function makeHarness(opts?: {
209
211
  assertSendable: () => {},
210
212
  statusKey: key,
211
213
  streamKey: key,
212
- resolveReplyOwnerTurn: () => null,
214
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
215
+ getLastSubagentHandbackAt: () => null,
213
216
  findTurnByOriginId: () => null,
214
217
  findTurnByQuotedMessageId: () => null,
215
218
  resolveAnswerThreadWithLog: (_c, explicit) => explicit,
@@ -633,8 +636,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
633
636
  Date.now(),
634
637
  )
635
638
  // The late reply resolves the ENDED flush-delivered turn as its owner
636
- // (latest-ended tier — no live turn, no origin echo, no quote in a DM).
637
- h.deps.resolveReplyOwnerTurn = () => owner
639
+ // (latest-ended tier — no live turn, no origin echo, no quote in a DM). This
640
+ // is the AMBIGUOUS fallback tier: the content gate applies here, so a
641
+ // genuinely-new handback sends fresh while the turn's own contained answer
642
+ // still supersedes.
643
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
638
644
  }
639
645
 
640
646
  it('CORE REGRESSION (red pre-fix): a handback with genuinely NEW content sends a ' +
@@ -642,6 +648,11 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
642
648
  const h = makeHarness()
643
649
  const owner = makeFlushDeliveredEndedTurn()
644
650
  seedFlushRecord(h, owner)
651
+ // A background sub-agent handback WAS enqueued for this chat after the
652
+ // flushed turn ended and within the supersede TTL — so this late reply might
653
+ // BE that handback. The marker keeps the #3429 content gate. (owner.endedAt
654
+ // = now-30s; handback at now-15s is after it and within the 60s TTL.)
655
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
645
656
 
646
657
  // The handback lands LATE: req.turn = null (no live gateway turn — a
647
658
  // sub-agent completion is not a new inbound).
@@ -700,8 +711,8 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
700
711
  const h = makeHarness()
701
712
  const owner = makeFlushDeliveredEndedTurn()
702
713
  // NO registry record (the post-fire pre-record window); owner resolution
703
- // still recovers the ended flush-armed turn.
704
- h.deps.resolveReplyOwnerTurn = () => owner
714
+ // still recovers the ended flush-armed turn via the latest-ended tier.
715
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
705
716
 
706
717
  const res = await sendReply(h.deps, req(HANDBACK))
707
718
 
@@ -715,11 +726,215 @@ describe('#3429 — post-turn-end handback vs flush-delivered supersede (real se
715
726
  'window is still suppressed (the #2996 Part 2 backstop holds)', async () => {
716
727
  const h = makeHarness()
717
728
  const owner = makeFlushDeliveredEndedTurn()
718
- h.deps.resolveReplyOwnerTurn = () => owner
729
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
719
730
 
720
731
  const res = await sendReply(h.deps, req(FLUSHED_TEXT))
721
732
 
722
733
  expect(h.calls).toHaveLength(0) // nothing sent, nothing edited
723
734
  expect(res.content[0]!.text).toContain('deduped')
724
735
  })
736
+
737
+ // ─── The REAL marko DM incident (fix/backstop-duplicate-reply) ───
738
+ //
739
+ // The dominant real duplicate: the model narrates its answer as prose (flushed
740
+ // as message A), then fires the `reply` tool with a RE-WORDED version of the
741
+ // same answer. On a DM this late reply has NO live turn, NO `origin_turn_id`
742
+ // echo, and NO explicit `reply_to`, so it resolves its owner via the ambiguous
743
+ // `latest-ended` tier — the #3429 content gate then declines (reworded text ⊄
744
+ // flushed blob) and a fresh SECOND bubble ships = the visible duplicate
745
+ // (agent:marko 2026-07-20: turns #1177/#1182/#1201 double-sent; 11/11 declines
746
+ // were own-replies with NO handback in flight).
747
+ //
748
+ // The two cases are indistinguishable by tier (both latest-ended) and by
749
+ // content (rewording). The deterministic discriminator is the gateway-
750
+ // synthesized `subagent_handback` marker: case A has NONE enqueued after the
751
+ // flushed turn ended; a genuine background handback (case B) does. These two
752
+ // tests drive the REAL send path with the REAL supersede registry over the
753
+ // SAME reworded text and the SAME latest-ended tier, differing ONLY in the
754
+ // handback marker — proving it is the marker that flips the outcome.
755
+ const REWORDED_SAME_TURN_ANSWER =
756
+ 'Good news on the fleet: every one of the twelve agents is running fine right now. ' +
757
+ 'The gateway, the vault broker and the approval kernel are all green, and nothing has ' +
758
+ 'restarted in the past day — so there is nothing you need to do.'
759
+
760
+ function seedRecord(h: ReturnType<typeof makeHarness>, owner: CurrentTurn): void {
761
+ h.deps.flushedTurnSupersede.record(
762
+ CHAT,
763
+ undefined,
764
+ { turnId: owner.turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
765
+ Date.now(),
766
+ )
767
+ }
768
+
769
+ it('CASE A — REAL marko DM incident: reworded own reply, latest-ended tier, ' +
770
+ 'NO handback in flight → supersedes the flushed draft, collapses to ONE message', async () => {
771
+ const h = makeHarness()
772
+ const owner = makeFlushDeliveredEndedTurn()
773
+ seedRecord(h, owner)
774
+ // Late DM reply: latest-ended tier (the path the tier discriminator MISSES),
775
+ // and crucially NO subagent_handback was enqueued for this chat after the
776
+ // turn ended — so the reply is the flushed turn's OWN answer.
777
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
778
+ h.deps.getLastSubagentHandbackAt = () => null
779
+
780
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
781
+
782
+ // Exactly ONE client-visible message: the flushed message edited in place
783
+ // into the reworded reply — NO fresh second bubble (duplicate closed).
784
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
785
+ expect(edits).toHaveLength(1)
786
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
787
+ expect(edits[0]!.text).toContain('every one of the twelve agents')
788
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
789
+ expect(res.content[0]!.text).toMatch(/^sent/)
790
+ // Record consumed.
791
+ expect(
792
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
793
+ liveTurnId: OWNER_TURN_ID,
794
+ now: Date.now(),
795
+ }).reason,
796
+ ).toBe('no-record')
797
+ })
798
+
799
+ it('CASE B — genuine background handback: SAME reworded text, SAME latest-ended tier, ' +
800
+ 'but a handback WAS enqueued in-window → keeps the gate, sends FRESH (TWO messages)', async () => {
801
+ const h = makeHarness()
802
+ const owner = makeFlushDeliveredEndedTurn()
803
+ seedRecord(h, owner)
804
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
805
+ // A background sub-agent handback was enqueued for this chat AFTER the turn
806
+ // ended (now-30s) and within the 60s TTL (now-15s) — this reply might BE it.
807
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
808
+
809
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
810
+
811
+ // A fresh notifying bubble ships; the flushed message is NEITHER edited nor
812
+ // deleted — flush (A) + fresh (B) = two surfaced messages (#3429 preserved).
813
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
814
+ expect(fresh).toHaveLength(1)
815
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
816
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
817
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
818
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
819
+ // Record NOT consumed.
820
+ expect(
821
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
822
+ liveTurnId: OWNER_TURN_ID,
823
+ replyText: CANONICAL_REPLY,
824
+ now: Date.now(),
825
+ }).supersede,
826
+ ).toBe(true)
827
+ })
828
+
829
+ it('MUST-FIX 1 (silent-data-loss): a handback reply model-STEERED to the quoted ' +
830
+ 'tier (reply_to = the flushed turn\'s source msg) must NOT edit/delete the flushed ' +
831
+ 'answer while a handback is in flight — sends FRESH (two messages)', async () => {
832
+ const h = makeHarness()
833
+ const owner = makeFlushDeliveredEndedTurn()
834
+ seedRecord(h, owner)
835
+ // The `quoted` tier is derived from the MODEL-SUPPLIED `args.reply_to`, so a
836
+ // background handback turn can point reply_to at the user's original message
837
+ // (which the prior turn's flush is recorded against) to resolve THAT turn via
838
+ // `quoted`. A handback IS in flight, so a positive tier must NOT override the
839
+ // #3429 content gate — else the reworded handback text silently edits over
840
+ // the flushed turn's DELIVERED answer (Telegram edits don't re-notify).
841
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'quoted' })
842
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
843
+
844
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
845
+
846
+ // Fresh notifying send; the flushed answer is NEITHER edited nor deleted.
847
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
848
+ expect(fresh).toHaveLength(1)
849
+ expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
850
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
851
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
852
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
853
+ // Flush record NOT consumed — the flushed answer stands.
854
+ expect(
855
+ h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
856
+ liveTurnId: OWNER_TURN_ID,
857
+ replyText: CANONICAL_REPLY,
858
+ now: Date.now(),
859
+ }).supersede,
860
+ ).toBe(true)
861
+ })
862
+
863
+ it('the framework-owned `live` tier MAY still bypass a handback window (a live ' +
864
+ 'currentTurn is not model-derived and cannot collide with an ended turn\'s record)', async () => {
865
+ const h = makeHarness()
866
+ const owner = makeFlushDeliveredEndedTurn()
867
+ seedRecord(h, owner)
868
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'live' })
869
+ h.deps.getLastSubagentHandbackAt = () => Date.now() - 15_000
870
+
871
+ const res = await sendReply(h.deps, req(REWORDED_SAME_TURN_ANSWER))
872
+
873
+ // Live tier bypasses → supersede via edit-in-place (one message).
874
+ const edits = h.calls.filter((c) => c.method === 'editMessageText')
875
+ expect(edits).toHaveLength(1)
876
+ expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
877
+ expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
878
+ expect(res.content[0]!.text).toMatch(/^sent/)
879
+ })
880
+
881
+ it('MUST-FIX 2 (boot-replay): a handback re-pushed through the buffer chokepoint ' +
882
+ 'stamps the marker (with the envelope ts) → gate preserved → FRESH send, no silent edit', async () => {
883
+ // Real marker + real buffer wired exactly as the gateway wires them. The
884
+ // boot-replay loop re-pushes un-acked spooled inbounds — including handback
885
+ // envelopes — through this SAME push(); the chokepoint stamp is what makes a
886
+ // replayed handback populate the (post-restart empty) marker.
887
+ const marker = new SubagentHandbackMarker()
888
+ const buffer = createPendingInboundBuffer({
889
+ log: () => {},
890
+ onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
891
+ })
892
+
893
+ // Simulate the boot-replay re-push of an un-acked spooled handback envelope.
894
+ // The envelope carries its own ms `ts` (after the flushed turn ended, within
895
+ // TTL) — owner.endedAt below is now-30s, this handback is now-15s.
896
+ const handbackTs = Date.now() - 15_000
897
+ buffer.push('marko', {
898
+ type: 'inbound',
899
+ chatId: CHAT,
900
+ messageId: handbackTs,
901
+ user: 'subagent-watcher',
902
+ userId: 0,
903
+ ts: handbackTs,
904
+ text: 'handback result',
905
+ meta: { source: 'subagent_handback' },
906
+ })
907
+ // The chokepoint stamped the marker from the replay push (pre-fix: empty).
908
+ expect(marker.lastAt(CHAT)).toBe(handbackTs)
909
+
910
+ const h = makeHarness()
911
+ const owner = makeFlushDeliveredEndedTurn()
912
+ seedRecord(h, owner)
913
+ h.deps.resolveReplyOwnerTurn = () => ({ turn: owner, tier: 'latest-ended' })
914
+ // The gateway reads the marker the boot-replay stamped.
915
+ h.deps.getLastSubagentHandbackAt = (chatId) => marker.lastAt(chatId)
916
+
917
+ const res = await sendReply(h.deps, req(HANDBACK))
918
+
919
+ // Gate preserved → the replayed handback delivers FRESH; the post-boot
920
+ // flushed answer is NEITHER edited nor deleted (no silent #3429 on restart).
921
+ const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
922
+ expect(fresh).toHaveLength(1)
923
+ expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
924
+ expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
925
+ expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
926
+ })
927
+
928
+ it('the buffer chokepoint stamps ONLY handback envelopes (a normal inbound does not)', () => {
929
+ const marker = new SubagentHandbackMarker()
930
+ const buffer = createPendingInboundBuffer({
931
+ log: () => {},
932
+ onHandbackEnqueue: (chatId, ts) => marker.record(chatId, ts),
933
+ })
934
+ buffer.push('marko', {
935
+ type: 'inbound', chatId: CHAT, messageId: 5, user: 'ken', userId: 1,
936
+ ts: Date.now(), text: 'hi', meta: {},
937
+ })
938
+ expect(marker.lastAt(CHAT)).toBe(null)
939
+ })
725
940
  })
@@ -238,7 +238,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
238
238
  assertSendable: () => {},
239
239
  statusKey: key,
240
240
  streamKey: key,
241
- resolveReplyOwnerTurn: () => null,
241
+ resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
242
+ getLastSubagentHandbackAt: () => null,
242
243
  findTurnByOriginId: () => null,
243
244
  findTurnByQuotedMessageId: () => null,
244
245
  resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Unit coverage for the per-chat subagent-handback marker
3
+ * (fix/backstop-duplicate-reply). The marker is the deterministic signal the
4
+ * supersede path uses to tell a flushed turn's OWN reworded late reply (no
5
+ * handback in flight → supersede) from a background handback attributed to that
6
+ * ended turn (handback in flight → keep the #3429 content gate).
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
11
+
12
+ describe('SubagentHandbackMarker', () => {
13
+ it('returns null for a chat with no recorded handback', () => {
14
+ const m = new SubagentHandbackMarker()
15
+ expect(m.lastAt('chatA')).toBe(null)
16
+ })
17
+
18
+ it('returns the recorded enqueue ts for the chat', () => {
19
+ const m = new SubagentHandbackMarker()
20
+ m.record('chatA', 1_000_000)
21
+ expect(m.lastAt('chatA')).toBe(1_000_000)
22
+ })
23
+
24
+ it('is per-chat — one chat never leaks into another', () => {
25
+ const m = new SubagentHandbackMarker()
26
+ m.record('chatA', 1_000_000)
27
+ expect(m.lastAt('chatB')).toBe(null)
28
+ })
29
+
30
+ it('keeps only the MOST RECENT enqueue (overwrites)', () => {
31
+ 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)
35
+ })
36
+ })