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.
- 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 +65 -13
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/gateway.ts +31 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
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.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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
93418
|
-
var COMMIT_SHA = "
|
|
93419
|
-
var COMMIT_DATE = "2026-07-
|
|
93420
|
-
var LATEST_PR =
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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 —
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
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: {
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
880
|
-
// new-content gate: identity match + TTL alone also fits
|
|
881
|
-
// that merely resolved this flush-delivered ENDED turn as
|
|
882
|
-
// the latest-ended tier. Editing the flushed message
|
|
883
|
-
// handback's text
|
|
884
|
-
//
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
+
})
|