switchroom 0.19.1 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -17792,6 +17792,7 @@ function startSessionTail(config2) {
|
|
|
17792
17792
|
let stopped = false;
|
|
17793
17793
|
let pendingPartial = "";
|
|
17794
17794
|
const fileCursors = new Map;
|
|
17795
|
+
const replayUntilByFile = new Map;
|
|
17795
17796
|
function readNew() {
|
|
17796
17797
|
if (stopped || !currentFile)
|
|
17797
17798
|
return;
|
|
@@ -17800,11 +17801,15 @@ function startSessionTail(config2) {
|
|
|
17800
17801
|
if (stat.size < cursor) {
|
|
17801
17802
|
cursor = 0;
|
|
17802
17803
|
pendingPartial = "";
|
|
17803
|
-
if (currentFile != null)
|
|
17804
|
+
if (currentFile != null) {
|
|
17804
17805
|
fileCursors.delete(currentFile);
|
|
17806
|
+
replayUntilByFile.delete(currentFile);
|
|
17807
|
+
}
|
|
17805
17808
|
}
|
|
17806
17809
|
if (stat.size === cursor)
|
|
17807
17810
|
return;
|
|
17811
|
+
const chunkStart = cursor;
|
|
17812
|
+
const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0);
|
|
17808
17813
|
const buf = Buffer.alloc(stat.size - cursor);
|
|
17809
17814
|
const fd = openSync(currentFile, "r");
|
|
17810
17815
|
try {
|
|
@@ -17824,7 +17829,7 @@ function startSessionTail(config2) {
|
|
|
17824
17829
|
const sid = sessionIdForFile(currentFile);
|
|
17825
17830
|
for (const ev of events) {
|
|
17826
17831
|
try {
|
|
17827
|
-
onEvent(decorate(ev, sid));
|
|
17832
|
+
onEvent(decorate(isReplayChunk && ev.kind === "model" ? { ...ev, replayed: true } : ev, sid));
|
|
17828
17833
|
} catch (err) {
|
|
17829
17834
|
log?.(`session-tail: onEvent threw: ${err.message}`);
|
|
17830
17835
|
}
|
|
@@ -17877,6 +17882,7 @@ function startSessionTail(config2) {
|
|
|
17877
17882
|
const size = statSync4(file).size;
|
|
17878
17883
|
cursor = computeFirstAttachCursor(file, size);
|
|
17879
17884
|
if (cursor < size) {
|
|
17885
|
+
replayUntilByFile.set(file, size);
|
|
17880
17886
|
log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
|
|
17881
17887
|
} else {
|
|
17882
17888
|
log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External (non-Claude / OpenRouter cash) spend for the `/usage` card.
|
|
3
|
+
*
|
|
4
|
+
* Layout B (operator-locked 2026-07-19):
|
|
5
|
+
* ```
|
|
6
|
+
* - 💸 External
|
|
7
|
+
* - 24h `$X.XX` · 7d `$Y.YY`
|
|
8
|
+
* - top `model $a` · `model $b` · `model $c`
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* **Durable path:** the auth-broker holds the LiteLLM master key and
|
|
12
|
+
* serves a sanitized summary via `get-external-spend`. Agents never
|
|
13
|
+
* receive the master key. Soft-fail → omit the External block.
|
|
14
|
+
*
|
|
15
|
+
* Pure helpers (filter/format) live in `src/litellm/external-spend.ts`
|
|
16
|
+
* and are re-exported here for card rendering + tests.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
formatUsd,
|
|
21
|
+
type ExternalSpendSummary,
|
|
22
|
+
type ExternalSpendTopModel,
|
|
23
|
+
} from '../src/litellm/external-spend.js';
|
|
24
|
+
|
|
25
|
+
export type { ExternalSpendSummary, ExternalSpendTopModel };
|
|
26
|
+
export {
|
|
27
|
+
isExternalModel,
|
|
28
|
+
shortModelLabel,
|
|
29
|
+
formatUsd,
|
|
30
|
+
summarizeExternalSpend,
|
|
31
|
+
normalizeSpendLogRows,
|
|
32
|
+
utcDateString,
|
|
33
|
+
addUtcDays,
|
|
34
|
+
EXTERNAL_SPEND_CACHE_TTL_MS,
|
|
35
|
+
type LiteLLMDaySpendRow,
|
|
36
|
+
} from '../src/litellm/external-spend.js';
|
|
37
|
+
|
|
38
|
+
/** Soft client-side cache so strip double-taps of /usage within a process. */
|
|
39
|
+
const CLIENT_CACHE_TTL_MS = 30_000;
|
|
40
|
+
let clientCache: { atMs: number; summary: ExternalSpendSummary } | null = null;
|
|
41
|
+
|
|
42
|
+
/** Test seam. */
|
|
43
|
+
export function clearExternalSpendCache(): void {
|
|
44
|
+
clientCache = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render layout-B bullet lines (no trailing freshness).
|
|
49
|
+
* Returns `[]` when summary is null/undefined so callers can omit.
|
|
50
|
+
* Zero totals still render — honest "no external spend".
|
|
51
|
+
*/
|
|
52
|
+
export function formatExternalSpendBlock(
|
|
53
|
+
summary: ExternalSpendSummary | null | undefined,
|
|
54
|
+
): string[] {
|
|
55
|
+
if (summary == null) return [];
|
|
56
|
+
const lines = [
|
|
57
|
+
'- 💸 External',
|
|
58
|
+
`- 24h \`${formatUsd(summary.day24hUsd)}\` · 7d \`${formatUsd(summary.day7dUsd)}\``,
|
|
59
|
+
];
|
|
60
|
+
if (summary.top.length > 0) {
|
|
61
|
+
const topParts = summary.top.map((t) => `\`${t.label} ${formatUsd(t.usd)}\``);
|
|
62
|
+
lines.push(`- top ${topParts.join(' · ')}`);
|
|
63
|
+
}
|
|
64
|
+
return lines;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface FetchExternalSpendDeps {
|
|
68
|
+
now?: Date;
|
|
69
|
+
/** Prefer injected client for tests; defaults to env agent socket. */
|
|
70
|
+
getExternalSpend?: (forceLive?: boolean) => Promise<{
|
|
71
|
+
available: boolean;
|
|
72
|
+
day24hUsd?: number;
|
|
73
|
+
day7dUsd?: number;
|
|
74
|
+
top?: Array<{ label: string; usd: number }>;
|
|
75
|
+
capturedAtMs?: number;
|
|
76
|
+
served?: 'live' | 'cache';
|
|
77
|
+
reason?: string;
|
|
78
|
+
}>;
|
|
79
|
+
forceLive?: boolean;
|
|
80
|
+
bypassCache?: boolean;
|
|
81
|
+
cacheTtlMs?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Best-effort fleet External spend for `/usage`.
|
|
86
|
+
* Primary: auth-broker `get-external-spend` (master key stays in broker).
|
|
87
|
+
* Returns null when unavailable — caller omits the block.
|
|
88
|
+
*/
|
|
89
|
+
export async function fetchExternalSpendSummary(
|
|
90
|
+
nowOrDeps: Date | FetchExternalSpendDeps = {},
|
|
91
|
+
): Promise<ExternalSpendSummary | null> {
|
|
92
|
+
const deps: FetchExternalSpendDeps =
|
|
93
|
+
nowOrDeps instanceof Date ? { now: nowOrDeps } : nowOrDeps;
|
|
94
|
+
const ttl = deps.cacheTtlMs ?? CLIENT_CACHE_TTL_MS;
|
|
95
|
+
if (!deps.bypassCache && clientCache && Date.now() - clientCache.atMs < ttl) {
|
|
96
|
+
return clientCache.summary;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
let data: Awaited<ReturnType<NonNullable<FetchExternalSpendDeps['getExternalSpend']>>>;
|
|
101
|
+
if (deps.getExternalSpend) {
|
|
102
|
+
data = await deps.getExternalSpend(deps.forceLive);
|
|
103
|
+
} else {
|
|
104
|
+
// Lazy import avoids pulling the full client graph into pure test paths.
|
|
105
|
+
const { AuthBrokerClient } = await import('../src/auth/broker/client.js');
|
|
106
|
+
const client = new AuthBrokerClient();
|
|
107
|
+
try {
|
|
108
|
+
data = await client.getExternalSpend(deps.forceLive);
|
|
109
|
+
} finally {
|
|
110
|
+
try {
|
|
111
|
+
await client.close();
|
|
112
|
+
} catch {
|
|
113
|
+
/* ignore */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!data?.available) return null;
|
|
118
|
+
if (
|
|
119
|
+
typeof data.day24hUsd !== 'number' ||
|
|
120
|
+
typeof data.day7dUsd !== 'number' ||
|
|
121
|
+
!Array.isArray(data.top)
|
|
122
|
+
) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const summary: ExternalSpendSummary = {
|
|
126
|
+
day24hUsd: data.day24hUsd,
|
|
127
|
+
day7dUsd: data.day7dUsd,
|
|
128
|
+
top: data.top.map((t) => ({ label: String(t.label), usd: Number(t.usd) })),
|
|
129
|
+
};
|
|
130
|
+
clientCache = { atMs: Date.now(), summary };
|
|
131
|
+
return summary;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -31,7 +31,16 @@
|
|
|
31
31
|
* `record()` (a much smaller window), `take` finds no record and the duplicate
|
|
32
32
|
* can still slip through. We deliberately trade that residual window for the
|
|
33
33
|
* safety guarantee that we NEVER delete a message we cannot positively attribute
|
|
34
|
-
* to the reply's own turn (identity-
|
|
34
|
+
* to the reply's own turn (identity-scoped supersede — see `decideSupersede`).
|
|
35
|
+
*
|
|
36
|
+
* #3429 content gate: identity is NECESSARY but not sufficient. Because the
|
|
37
|
+
* flush ends its turn before recording, an async sub-agent handback landing
|
|
38
|
+
* within the TTL resolves the flush-delivered ENDED turn as its owner too —
|
|
39
|
+
* same identity as the turn's own late replay. When the caller supplies the
|
|
40
|
+
* landing reply's text, `decideSupersede` additionally requires it to BE the
|
|
41
|
+
* flushed answer (`flushedAnswerMatchesReply`); otherwise the decision is
|
|
42
|
+
* 'new-content' and the gateway sends fresh (a notifying new message) with the
|
|
43
|
+
* record left intact.
|
|
35
44
|
*
|
|
36
45
|
* Pure module: no I/O, no globals, no clock reads beyond the caller-supplied
|
|
37
46
|
* `now`. Fully unit-testable; the gateway wires the actual delete/send.
|
|
@@ -51,9 +60,11 @@ export interface FlushedTurnRecord {
|
|
|
51
60
|
/** The Telegram message id(s) the flush posted (edit target + any extra
|
|
52
61
|
* chunk messages). Superseding deletes all of them. */
|
|
53
62
|
messageIds: number[]
|
|
54
|
-
/** The text the flush delivered
|
|
55
|
-
*
|
|
56
|
-
*
|
|
63
|
+
/** The text the flush delivered. Originally diagnostics-only; since #3429 it
|
|
64
|
+
* also feeds the new-content gate (`flushedAnswerMatchesReply`): an
|
|
65
|
+
* identity-matched reply that is NOT the same answer (neither equal nor a
|
|
66
|
+
* bounded containment) is an async handback and must send fresh instead of
|
|
67
|
+
* editing/deleting the flushed message. */
|
|
57
68
|
text: string
|
|
58
69
|
/** Wall-clock ms when recorded. */
|
|
59
70
|
ts: number
|
|
@@ -71,8 +82,67 @@ export interface SupersedeDecision {
|
|
|
71
82
|
/** Message ids the gateway must delete before/instead of the fresh send.
|
|
72
83
|
* Empty when `supersede` is false. */
|
|
73
84
|
deleteMessageIds: number[]
|
|
74
|
-
/** Machine-readable reason (for logs / tests).
|
|
75
|
-
|
|
85
|
+
/** Machine-readable reason (for logs / tests). `'new-content'` (#3429): the
|
|
86
|
+
* record matched by identity + TTL but the landing reply carries genuinely
|
|
87
|
+
* DIFFERENT content than the flushed answer — an async handback attributed
|
|
88
|
+
* to the flush-delivered ended turn, NOT that turn's own answer landing
|
|
89
|
+
* late. The gateway must send FRESH (a notifying new message), never
|
|
90
|
+
* edit/delete the flushed message; the record is NOT consumed. */
|
|
91
|
+
reason: 'supersede' | 'no-record' | 'expired' | 'different-turn' | 'new-content'
|
|
92
|
+
/** The matched record's flushed text — populated whenever a fresh record for
|
|
93
|
+
* the reply's turn identity was found (`reason` 'supersede' or
|
|
94
|
+
* 'new-content'). The gateway stashes it on the owner turn atom so the
|
|
95
|
+
* answer-delivered latch can make the same content-vs-flush discrimination
|
|
96
|
+
* on the no-record retry/race paths (#3429). */
|
|
97
|
+
recordText?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* #3429 — minimum length a CONTAINED text must have for a containment match.
|
|
102
|
+
* The legitimate containment class is a flush that delivered
|
|
103
|
+
* `narration\n\nanswer` being corrected by the clean `answer`-only reply — the
|
|
104
|
+
* contained side is a full answer, comfortably long. A trivially short
|
|
105
|
+
* contained string (e.g. a reply "Done." that happens to appear inside the
|
|
106
|
+
* flushed blob) is NOT positive evidence of the same answer, and a false match
|
|
107
|
+
* here silently edits/suppresses genuinely new content — the exact #3429
|
|
108
|
+
* failure. Below this floor only whitespace-normalized EQUALITY matches; the
|
|
109
|
+
* worst case of declining is a rare duplicate message, which beats a silent
|
|
110
|
+
* drop (the #3426/#3428 precedent).
|
|
111
|
+
*/
|
|
112
|
+
export const SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS = 32
|
|
113
|
+
|
|
114
|
+
/** Collapse whitespace runs so flush-pipeline vs reply-pipeline spacing
|
|
115
|
+
* (paragraph spacers, hard-break promotion) never defeats the comparison. */
|
|
116
|
+
function normalizeForMatch(text: string): string {
|
|
117
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* #3429 — is the landing reply the SAME ANSWER the flush already delivered
|
|
122
|
+
* (a late canonical correction), as opposed to genuinely new content (an async
|
|
123
|
+
* handback that merely resolved the flush-delivered ended turn as its owner)?
|
|
124
|
+
*
|
|
125
|
+
* Deterministic string decision, no timing:
|
|
126
|
+
* - whitespace-normalized equality → same answer;
|
|
127
|
+
* - containment either direction (flush = `narration\n\nanswer` ⊇ reply, or
|
|
128
|
+
* reply ⊇ a partially-delivered flush), guarded by
|
|
129
|
+
* `SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS` on the CONTAINED side so a short
|
|
130
|
+
* coincidental substring can never claim a match.
|
|
131
|
+
*
|
|
132
|
+
* A model-REGENERATED paraphrase of the same answer is indistinguishable from
|
|
133
|
+
* new content and therefore sends fresh — a rare duplicate message, the same
|
|
134
|
+
* conscious trade #3428 shipped for the reply-armed latch. A silent
|
|
135
|
+
* drop/edit-in-place of a genuinely new handback is the strictly worse
|
|
136
|
+
* failure.
|
|
137
|
+
*/
|
|
138
|
+
export function flushedAnswerMatchesReply(flushedText: string, replyText: string): boolean {
|
|
139
|
+
const flushed = normalizeForMatch(flushedText)
|
|
140
|
+
const reply = normalizeForMatch(replyText)
|
|
141
|
+
if (flushed.length === 0 || reply.length === 0) return false
|
|
142
|
+
if (flushed === reply) return true
|
|
143
|
+
if (reply.length >= SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS && flushed.includes(reply)) return true
|
|
144
|
+
if (flushed.length >= SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS && reply.includes(flushed)) return true
|
|
145
|
+
return false
|
|
76
146
|
}
|
|
77
147
|
|
|
78
148
|
/**
|
|
@@ -93,10 +163,25 @@ export interface SupersedeDecision {
|
|
|
93
163
|
* now resolves a last-known turnId for the reply before calling in, so the
|
|
94
164
|
* common late-replay case still matches by identity rather than relying on the
|
|
95
165
|
* promiscuous null branch.)
|
|
166
|
+
*
|
|
167
|
+
* #3429 content gate: identity alone is NOT sufficient. The flush ends its
|
|
168
|
+
* turn synchronously BEFORE recording (stream-render `endCurrentTurnAtomic` →
|
|
169
|
+
* `record`), so EVERY superseding reply is a late reply, and an async
|
|
170
|
+
* sub-agent handback landing within the TTL with no live gateway turn resolves
|
|
171
|
+
* the flush-delivered ENDED turn as its owner via the latest-ended tier —
|
|
172
|
+
* exactly the same identity as the turn's own canonical late replay. The
|
|
173
|
+
* observed failure (msgs 10482/10486, 2026-07-20): the handback consumed the
|
|
174
|
+
* record and EDITED the flushed message in place with unrelated new content —
|
|
175
|
+
* Telegram edits do not re-notify, so the handback never surfaced client-side
|
|
176
|
+
* AND the flushed answer was destroyed. When the caller supplies `replyText`
|
|
177
|
+
* and it is NOT the same answer (`flushedAnswerMatchesReply`), the decision is
|
|
178
|
+
* `'new-content'`: no supersede, record left intact for the genuine replay,
|
|
179
|
+
* and the gateway sends the reply FRESH. Callers that omit `replyText`
|
|
180
|
+
* (identity-only legacy shape) keep the pre-#3429 behaviour.
|
|
96
181
|
*/
|
|
97
182
|
export function decideSupersede(
|
|
98
183
|
record: FlushedTurnRecord | undefined,
|
|
99
|
-
args: { liveTurnId: string | null; now: number; ttlMs?: number },
|
|
184
|
+
args: { liveTurnId: string | null; replyText?: string | null; now: number; ttlMs?: number },
|
|
100
185
|
): SupersedeDecision {
|
|
101
186
|
const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
|
|
102
187
|
if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
|
|
@@ -110,7 +195,18 @@ export function decideSupersede(
|
|
|
110
195
|
if (!sameTurn) {
|
|
111
196
|
return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
|
|
112
197
|
}
|
|
113
|
-
|
|
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)) {
|
|
202
|
+
return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
supersede: true,
|
|
206
|
+
deleteMessageIds: [...record.messageIds],
|
|
207
|
+
reason: 'supersede',
|
|
208
|
+
recordText: record.text,
|
|
209
|
+
}
|
|
114
210
|
}
|
|
115
211
|
|
|
116
212
|
/**
|
|
@@ -225,23 +321,31 @@ export class FlushedTurnSupersedeRegistry {
|
|
|
225
321
|
|
|
226
322
|
/** Decide supersede for a landing reply WITHOUT consuming the record. Selects
|
|
227
323
|
* the record whose turnId matches the reply's resolved `liveTurnId` (or the
|
|
228
|
-
* null-turnId record when `liveTurnId == null`).
|
|
324
|
+
* null-turnId record when `liveTurnId == null`). `replyText` (#3429) enables
|
|
325
|
+
* the new-content gate; omitting it keeps the identity-only legacy shape. */
|
|
229
326
|
peek(
|
|
230
327
|
chatId: string,
|
|
231
328
|
threadId: number | undefined,
|
|
232
|
-
args: { liveTurnId: string | null; now: number },
|
|
329
|
+
args: { liveTurnId: string | null; replyText?: string | null; now: number },
|
|
233
330
|
): SupersedeDecision {
|
|
234
331
|
const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
|
|
235
|
-
return decideSupersede(rec, {
|
|
332
|
+
return decideSupersede(rec, {
|
|
333
|
+
liveTurnId: args.liveTurnId,
|
|
334
|
+
replyText: args.replyText,
|
|
335
|
+
now: args.now,
|
|
336
|
+
ttlMs: this.ttlMs,
|
|
337
|
+
})
|
|
236
338
|
}
|
|
237
339
|
|
|
238
340
|
/** Decide supersede AND, on a supersede, consume the matched record (so a
|
|
239
341
|
* second replay of the same reply doesn't try to delete the same — now gone —
|
|
240
|
-
* messages again).
|
|
342
|
+
* messages again). A `'new-content'` decision (#3429) does NOT consume: the
|
|
343
|
+
* record stays live for the turn's own canonical replay until the TTL.
|
|
344
|
+
* Returns the same decision `peek` would. */
|
|
241
345
|
take(
|
|
242
346
|
chatId: string,
|
|
243
347
|
threadId: number | undefined,
|
|
244
|
-
args: { liveTurnId: string | null; now: number },
|
|
348
|
+
args: { liveTurnId: string | null; replyText?: string | null; now: number },
|
|
245
349
|
): SupersedeDecision {
|
|
246
350
|
const lane = makeKey(chatId, threadId)
|
|
247
351
|
const decision = this.peek(chatId, threadId, args)
|