switchroom 0.19.13 → 0.19.14
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 +4 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +30 -10
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
- package/telegram-plugin/gateway/outbox-sweep.ts +42 -4
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
- package/telegram-plugin/hooks/silent-end-scan.mjs +35 -3
- package/telegram-plugin/outbox.ts +17 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
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.14", COMMIT_SHA = "1f1f69f8";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -26664,7 +26664,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
|
|
|
26664
26664
|
import { dirname as dirname5, join as join7 } from "node:path";
|
|
26665
26665
|
|
|
26666
26666
|
// src/build-info.ts
|
|
26667
|
-
var VERSION = "0.19.
|
|
26667
|
+
var VERSION = "0.19.14";
|
|
26668
26668
|
|
|
26669
26669
|
// src/cli/resolve-version.ts
|
|
26670
26670
|
function readPackageVersion() {
|
|
@@ -30310,6 +30310,8 @@ class LogTailRolloutNarrator {
|
|
|
30310
30310
|
return;
|
|
30311
30311
|
st.lastAppliedSeq = seq;
|
|
30312
30312
|
this.trackAgentProgress(st, phase);
|
|
30313
|
+
const doneNames = st.agents.filter((a) => a.status === "done").map((a) => a.name);
|
|
30314
|
+
const rolledSoFar = entry.rolled && entry.rolled.length > 0 ? entry.rolled : doneNames;
|
|
30313
30315
|
st.render = {
|
|
30314
30316
|
...st.render,
|
|
30315
30317
|
target: phase.target,
|
|
@@ -30317,7 +30319,7 @@ class LogTailRolloutNarrator {
|
|
|
30317
30319
|
...phase.n !== undefined ? { n: phase.n } : {},
|
|
30318
30320
|
...phase.m !== undefined ? { m: phase.m } : {},
|
|
30319
30321
|
...phase.agent !== undefined ? { agent: phase.agent } : {},
|
|
30320
|
-
rolled:
|
|
30322
|
+
rolled: rolledSoFar,
|
|
30321
30323
|
agents: st.agents,
|
|
30322
30324
|
requestId: entry.request_id,
|
|
30323
30325
|
...entry.prior_pin ? { fromVersion: entry.prior_pin } : {},
|
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.14",
|
|
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": {
|
|
@@ -74206,7 +74206,14 @@ function journalExternalDelivery(args, stateDir, now = Date.now()) {
|
|
|
74206
74206
|
const nonce = args.turnNonce;
|
|
74207
74207
|
if (nonce == null || nonce === "")
|
|
74208
74208
|
return;
|
|
74209
|
-
appendDelivered({
|
|
74209
|
+
appendDelivered({
|
|
74210
|
+
turnNonce: nonce,
|
|
74211
|
+
textSha256: sha256Hex(args.text),
|
|
74212
|
+
tgMessageId: args.tgMessageId,
|
|
74213
|
+
ts: now,
|
|
74214
|
+
deliverySource: "reply-tool",
|
|
74215
|
+
...args.replyAlreadyDeliveredThisTurn == null ? {} : { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }
|
|
74216
|
+
}, stateDir);
|
|
74210
74217
|
clearOutboxRecord(nonce, stateDir);
|
|
74211
74218
|
}
|
|
74212
74219
|
|
|
@@ -75377,7 +75384,7 @@ ${url}`;
|
|
|
75377
75384
|
}
|
|
75378
75385
|
outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
|
|
75379
75386
|
if (isFinalAnswerReply({ text: decision.mergedText, disableNotification: modelDisableNotification })) {
|
|
75380
|
-
journalExternalDelivery({ turnNonce: turn2?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId });
|
|
75387
|
+
journalExternalDelivery({ turnNonce: turn2?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId, replyAlreadyDeliveredThisTurn: true });
|
|
75381
75388
|
}
|
|
75382
75389
|
silentAnchorEditDone = true;
|
|
75383
75390
|
} catch (err) {
|
|
@@ -75655,7 +75662,7 @@ ${url}`;
|
|
|
75655
75662
|
const t = getCurrentTurn();
|
|
75656
75663
|
outboundDedup.record(chat_id, threadId, text4, Date.now(), t?.registryKey ?? null);
|
|
75657
75664
|
if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
|
|
75658
|
-
journalExternalDelivery({ turnNonce: t?.turnId ?? null, text: text4, tgMessageId: sentIds[sentIds.length - 1] });
|
|
75665
|
+
journalExternalDelivery({ turnNonce: t?.turnId ?? null, text: text4, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true });
|
|
75659
75666
|
}
|
|
75660
75667
|
}
|
|
75661
75668
|
return { content: [{ type: "text", text: result }] };
|
|
@@ -75709,7 +75716,7 @@ async function deliverCapturedProse(deps, args) {
|
|
|
75709
75716
|
} catch {}
|
|
75710
75717
|
}
|
|
75711
75718
|
outboundDedup.record(chatId, threadId, text4, now, registryKey);
|
|
75712
|
-
journalExternalDelivery({ turnNonce: originTurnId, text: text4, tgMessageId: sentIds[sentIds.length - 1] });
|
|
75719
|
+
journalExternalDelivery({ turnNonce: originTurnId, text: text4, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: false });
|
|
75713
75720
|
process.stderr.write(`telegram gateway: captured-prose delivery \u2014 sent ${out.length} chars recovered from ` + `transcript scan (chat=${chatId} origin=${originTurnId})
|
|
75714
75721
|
`);
|
|
75715
75722
|
outcome = "sent";
|
|
@@ -93894,7 +93901,13 @@ async function sweepOutbox(deps) {
|
|
|
93894
93901
|
if (decision.action === "skip-journaled") {
|
|
93895
93902
|
clearOutboxRecord(record2.turnNonce, deps.stateDir);
|
|
93896
93903
|
} else if (decision.action === "skip-dedup") {
|
|
93897
|
-
appendDelivered({
|
|
93904
|
+
appendDelivered({
|
|
93905
|
+
turnNonce: record2.turnNonce,
|
|
93906
|
+
textSha256: record2.textSha256,
|
|
93907
|
+
ts: now,
|
|
93908
|
+
deliverySource: "sweep",
|
|
93909
|
+
replyAlreadyDeliveredThisTurn: record2.replyAlreadyDeliveredThisTurn === true
|
|
93910
|
+
}, deps.stateDir);
|
|
93898
93911
|
clearOutboxRecord(record2.turnNonce, deps.stateDir);
|
|
93899
93912
|
}
|
|
93900
93913
|
continue;
|
|
@@ -93907,7 +93920,14 @@ async function sweepOutbox(deps) {
|
|
|
93907
93920
|
const resolvedChat = resolved;
|
|
93908
93921
|
try {
|
|
93909
93922
|
const messageId = await deps.send(resolvedChat.chatId, resolvedChat.threadId, decision.text ?? record2.text);
|
|
93910
|
-
appendDelivered({
|
|
93923
|
+
appendDelivered({
|
|
93924
|
+
turnNonce: record2.turnNonce,
|
|
93925
|
+
textSha256: record2.textSha256,
|
|
93926
|
+
tgMessageId: messageId,
|
|
93927
|
+
ts: now,
|
|
93928
|
+
deliverySource: "sweep",
|
|
93929
|
+
replyAlreadyDeliveredThisTurn: record2.replyAlreadyDeliveredThisTurn === true
|
|
93930
|
+
}, deps.stateDir);
|
|
93911
93931
|
removeClaimed(record2.turnNonce, deps.stateDir);
|
|
93912
93932
|
summary.delivered++;
|
|
93913
93933
|
log(`outbox-sweep: delivered nonce=${record2.turnNonce} via=${resolvedChat.via} ` + `source=${record2.source} chars=${record2.text.length}${decision.action === "send-delayed" ? " (delayed)" : ""}
|
|
@@ -93966,10 +93986,10 @@ function startOutboxSweep(deps) {
|
|
|
93966
93986
|
}
|
|
93967
93987
|
|
|
93968
93988
|
// ../src/build-info.ts
|
|
93969
|
-
var VERSION = "0.19.
|
|
93970
|
-
var COMMIT_SHA = "
|
|
93971
|
-
var COMMIT_DATE = "2026-07-
|
|
93972
|
-
var LATEST_PR =
|
|
93989
|
+
var VERSION = "0.19.14";
|
|
93990
|
+
var COMMIT_SHA = "1f1f69f8";
|
|
93991
|
+
var COMMIT_DATE = "2026-07-24T07:19:40+10:00";
|
|
93992
|
+
var LATEST_PR = 3514;
|
|
93973
93993
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
93974
93994
|
|
|
93975
93995
|
// gateway/boot-version.ts
|
|
@@ -1805,7 +1805,7 @@ export async function sendReply(
|
|
|
1805
1805
|
// text differs. Gated so an interim-ack edit never journals the turn
|
|
1806
1806
|
// nonce (which would suppress a later genuine answer for the turn).
|
|
1807
1807
|
if (isFinalAnswerReply({ text: decision.mergedText, disableNotification: modelDisableNotification })) {
|
|
1808
|
-
journalExternalDelivery({ turnNonce: turn?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId })
|
|
1808
|
+
journalExternalDelivery({ turnNonce: turn?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId, replyAlreadyDeliveredThisTurn: true })
|
|
1809
1809
|
}
|
|
1810
1810
|
|
|
1811
1811
|
silentAnchorEditDone = true
|
|
@@ -2372,7 +2372,7 @@ export async function sendReply(
|
|
|
2372
2372
|
// won't double-post it) while an interim ack never journals (so a later
|
|
2373
2373
|
// genuinely-undelivered final answer is delivered by the sweep).
|
|
2374
2374
|
if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
|
|
2375
|
-
journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1] })
|
|
2375
|
+
journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true })
|
|
2376
2376
|
}
|
|
2377
2377
|
}
|
|
2378
2378
|
return { content: [{ type: 'text', text: result }] }
|
|
@@ -2483,7 +2483,13 @@ export async function deliverCapturedProse(
|
|
|
2483
2483
|
outboundDedup.record(chatId, threadId, text, now, registryKey)
|
|
2484
2484
|
// F1: captured-prose delivery journals + clears under the shared nonce
|
|
2485
2485
|
// (`originTurnId` === turn.turnId === deriveTurnId, the hook's nonce).
|
|
2486
|
-
|
|
2486
|
+
// #3510/#3511: the bridge only runs when NO genuine final answer was
|
|
2487
|
+
// delivered this turn (`decideTurnEndGate` → 'reprompt' requires
|
|
2488
|
+
// `finalAnswerDelivered === false`), so stamp `false` explicitly. A
|
|
2489
|
+
// journal line from this site following a reply-tool line under the same
|
|
2490
|
+
// nonce (replyAlreadyDeliveredThisTurn:true) is direct, journal-only
|
|
2491
|
+
// proof of a bridge double-send.
|
|
2492
|
+
journalExternalDelivery({ turnNonce: originTurnId, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: false })
|
|
2487
2493
|
process.stderr.write(
|
|
2488
2494
|
`telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
|
|
2489
2495
|
`transcript scan (chat=${chatId} origin=${originTurnId})\n`,
|
|
@@ -133,7 +133,13 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
133
133
|
// exactly-once, independent of cache lifetime and surviving a restart
|
|
134
134
|
// (the record is gone from disk, the nonce is durably journaled).
|
|
135
135
|
appendDelivered(
|
|
136
|
-
{
|
|
136
|
+
{
|
|
137
|
+
turnNonce: record.turnNonce,
|
|
138
|
+
textSha256: record.textSha256,
|
|
139
|
+
ts: now,
|
|
140
|
+
deliverySource: 'sweep',
|
|
141
|
+
replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
|
|
142
|
+
},
|
|
137
143
|
deps.stateDir,
|
|
138
144
|
)
|
|
139
145
|
clearOutboxRecord(record.turnNonce, deps.stateDir)
|
|
@@ -156,7 +162,18 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
|
|
|
156
162
|
decision.text ?? record.text,
|
|
157
163
|
)
|
|
158
164
|
appendDelivered(
|
|
159
|
-
{
|
|
165
|
+
{
|
|
166
|
+
turnNonce: record.turnNonce,
|
|
167
|
+
textSha256: record.textSha256,
|
|
168
|
+
tgMessageId: messageId,
|
|
169
|
+
ts: now,
|
|
170
|
+
// #3510 instrumentation: a sweep delivery journals its machine and the
|
|
171
|
+
// record's capture-time reply-already-delivered flag, so a duplicate
|
|
172
|
+
// (deliverySource:'sweep' after a reply-tool delivery of the same
|
|
173
|
+
// turn) is provable from the journal alone.
|
|
174
|
+
deliverySource: 'sweep',
|
|
175
|
+
replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
|
|
176
|
+
},
|
|
160
177
|
deps.stateDir,
|
|
161
178
|
)
|
|
162
179
|
removeClaimed(record.turnNonce, deps.stateDir)
|
|
@@ -266,14 +283,35 @@ export function startOutboxSweep(deps: {
|
|
|
266
283
|
* Best-effort; never throws. A null/empty nonce is ignored (nothing to journal).
|
|
267
284
|
*/
|
|
268
285
|
export function journalExternalDelivery(
|
|
269
|
-
args: {
|
|
286
|
+
args: {
|
|
287
|
+
turnNonce: string | null
|
|
288
|
+
text: string
|
|
289
|
+
tgMessageId?: number
|
|
290
|
+
/**
|
|
291
|
+
* #3510 instrumentation: whether a qualifying reply had already delivered
|
|
292
|
+
* this turn at the time of this delivery. Reply-send sites pass `true`
|
|
293
|
+
* (they ARE that delivery); the captured-prose bridge omits it (its turn
|
|
294
|
+
* shape is decided upstream). Journaled so a later sweep entry under the
|
|
295
|
+
* same nonce is provably a double-send from the journal alone.
|
|
296
|
+
*/
|
|
297
|
+
replyAlreadyDeliveredThisTurn?: boolean
|
|
298
|
+
},
|
|
270
299
|
stateDir?: string,
|
|
271
300
|
now: number = Date.now(),
|
|
272
301
|
): void {
|
|
273
302
|
const nonce = args.turnNonce
|
|
274
303
|
if (nonce == null || nonce === '') return
|
|
275
304
|
appendDelivered(
|
|
276
|
-
{
|
|
305
|
+
{
|
|
306
|
+
turnNonce: nonce,
|
|
307
|
+
textSha256: sha256Hex(args.text),
|
|
308
|
+
tgMessageId: args.tgMessageId,
|
|
309
|
+
ts: now,
|
|
310
|
+
deliverySource: 'reply-tool',
|
|
311
|
+
...(args.replyAlreadyDeliveredThisTurn == null
|
|
312
|
+
? {}
|
|
313
|
+
: { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }),
|
|
314
|
+
},
|
|
277
315
|
stateDir,
|
|
278
316
|
)
|
|
279
317
|
clearOutboxRecord(nonce, stateDir)
|
|
@@ -182,6 +182,11 @@ function writeOutboxRecord(stateDir, capture) {
|
|
|
182
182
|
// F2: per-session origin chat for envelope-less routing (fail-closed).
|
|
183
183
|
originChatId: capture.chatId == null ? (capture.originChatId ?? null) : undefined,
|
|
184
184
|
originThreadId: capture.chatId == null ? (capture.originThreadId ?? null) : undefined,
|
|
185
|
+
// #3510 instrumentation: carried into every delivered.jsonl entry the
|
|
186
|
+
// sweep writes for this record, so a double-send is provable from the
|
|
187
|
+
// journal alone. Driven by the SAME boolean that gates capture-vs-election
|
|
188
|
+
// in main() — fix and telemetry cannot drift.
|
|
189
|
+
replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
|
|
185
190
|
}
|
|
186
191
|
const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
|
|
187
192
|
writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
|
|
@@ -242,13 +247,34 @@ function main() {
|
|
|
242
247
|
if (process.env.SWITCHROOM_TG_OUTBOX_DELIVERY !== '0') try {
|
|
243
248
|
const capture = scanForOutboxCapture(jsonl)
|
|
244
249
|
if (capture.capture === true) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
if (capture.replyAlreadyDeliveredThisTurn === true) {
|
|
251
|
+
// #3510: a qualifying reply ALREADY delivered through the gateway this
|
|
252
|
+
// turn, so a gateway anchor provably exists and the single-writer
|
|
253
|
+
// election below ('trailing-text-after-reply') is reachable. Writing an
|
|
254
|
+
// outbox record + self-exiting here would create a third, uncoordinated
|
|
255
|
+
// delivery path that bypasses the #3469 election and re-sends a
|
|
256
|
+
// trailing recap of the reply as a second (unformatted) message. Do NOT
|
|
257
|
+
// capture; fall through so the election is the actual single writer.
|
|
258
|
+
// The gateway-blind backstop (#3502) is untouched: it is the
|
|
259
|
+
// replyAlreadyDeliveredThisTurn === false branch below.
|
|
260
|
+
// Log both hashes so a double-send is provable from logs alone.
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
`[silent-end-interrupt] capture found trailing prose after a delivered reply ` +
|
|
263
|
+
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
|
|
264
|
+
`replyAlreadyDeliveredThisTurn=true ` +
|
|
265
|
+
`capturedTextSha256=${createHash('sha256').update(capture.text, 'utf8').digest('hex')} ` +
|
|
266
|
+
`deliveredReplySha256=${capture.deliveredReplySha256 ?? 'unknown'}) — ` +
|
|
267
|
+
`deferring to single-writer election (#3510)\n`,
|
|
268
|
+
)
|
|
269
|
+
} else {
|
|
270
|
+
writeOutboxRecord(stateDir, capture)
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`[silent-end-interrupt] captured undelivered final answer to outbox ` +
|
|
273
|
+
`(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
|
|
274
|
+
`replyAlreadyDeliveredThisTurn=false) — sweep will deliver; allowing stop\n`,
|
|
275
|
+
)
|
|
276
|
+
process.exit(0)
|
|
277
|
+
}
|
|
252
278
|
}
|
|
253
279
|
} catch (err) {
|
|
254
280
|
process.stderr.write(`[silent-end-interrupt] outbox capture error (fail-open): ${err.message}\n`)
|
|
@@ -894,7 +894,7 @@ function resolveSessionOriginChat(lines) {
|
|
|
894
894
|
*
|
|
895
895
|
* @param {string} jsonl
|
|
896
896
|
* @param {number} [now]
|
|
897
|
-
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string }}
|
|
897
|
+
* @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
|
|
898
898
|
*/
|
|
899
899
|
export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
900
900
|
const lines = jsonl.split('\n')
|
|
@@ -948,16 +948,46 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
948
948
|
disableNotification: input.disable_notification === true,
|
|
949
949
|
done: input.done === true,
|
|
950
950
|
})) {
|
|
951
|
-
|
|
951
|
+
// #3510: keep the delivered reply's text so the caller can log its
|
|
952
|
+
// sha256 next to the captured trailing prose's sha256 — a double-send
|
|
953
|
+
// becomes provable from logs alone, without transcript reconstruction.
|
|
954
|
+
blocks.push({ kind: 'deliver', reason: 'final-reply', text })
|
|
952
955
|
}
|
|
953
956
|
// interim ack — neither delivery nor undelivered prose.
|
|
954
957
|
}
|
|
955
958
|
}
|
|
956
959
|
|
|
957
960
|
let lastDeliverIdx = -1
|
|
961
|
+
let lastFinalReplyBlock = null
|
|
958
962
|
for (let i = 0; i < blocks.length; i++) {
|
|
959
|
-
if (blocks[i].kind
|
|
963
|
+
if (blocks[i].kind !== 'deliver') continue
|
|
964
|
+
lastDeliverIdx = i
|
|
965
|
+
if (blocks[i].reason === 'final-reply') lastFinalReplyBlock = blocks[i]
|
|
960
966
|
}
|
|
967
|
+
// #3510: single source of truth for BOTH the fix's branching and the
|
|
968
|
+
// instrumentation. When true, a genuine FINAL-ANSWER reply/stream_reply
|
|
969
|
+
// already went through the gateway THIS turn — so a gateway anchor provably
|
|
970
|
+
// exists (`turn.finalAnswerDelivered` is set by the SAME `isFinalAnswerReply`
|
|
971
|
+
// classifier at outbound-send-path.ts ~2293) and the single-writer election
|
|
972
|
+
// in the Stop hook is reachable and safe. The caller
|
|
973
|
+
// (silent-end-interrupt-stop.mjs) MUST NOT self-exit a capture in that case;
|
|
974
|
+
// doing so creates a third, uncoordinated delivery path that re-sends a
|
|
975
|
+
// trailing recap of the reply as a second message (the #3510 double-send).
|
|
976
|
+
//
|
|
977
|
+
// #3511 review finding 1: ONLY a `'final-reply'` deliver block counts. A
|
|
978
|
+
// `'silent-marker'` block (reply-tool NO_REPLY / HEARTBEAT_OK) delivered
|
|
979
|
+
// NOTHING to the user, and the gateway routes such turns to sentinel
|
|
980
|
+
// suppression (`decideTurnEndGate` → 'silent_end') BEFORE the captured-prose
|
|
981
|
+
// bridge ever runs — deferring to the election there would orphan the
|
|
982
|
+
// trailing answer (a drop). A silent marker still advances `lastDeliverIdx`
|
|
983
|
+
// (the trailing-prose CURSOR — H6 semantics: NO_REPLY intentionally silences
|
|
984
|
+
// what came BEFORE it), but it must keep the shape on the durable
|
|
985
|
+
// outbox/sweep path, never route it to the election.
|
|
986
|
+
const replyAlreadyDeliveredThisTurn = lastFinalReplyBlock != null
|
|
987
|
+
const deliveredReplySha256 =
|
|
988
|
+
lastFinalReplyBlock != null && typeof lastFinalReplyBlock.text === 'string'
|
|
989
|
+
? createHash('sha256').update(lastFinalReplyBlock.text, 'utf8').digest('hex')
|
|
990
|
+
: null
|
|
961
991
|
const trailing = blocks
|
|
962
992
|
.slice(lastDeliverIdx + 1)
|
|
963
993
|
.filter((b) => b.kind === 'text' && typeof b.text === 'string' && b.text.length > 0)
|
|
@@ -995,5 +1025,7 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
|
|
|
995
1025
|
anchorContent: anchor.anchorContent,
|
|
996
1026
|
originChatId: origin.chatId,
|
|
997
1027
|
originThreadId: origin.threadId,
|
|
1028
|
+
replyAlreadyDeliveredThisTurn,
|
|
1029
|
+
deliveredReplySha256,
|
|
998
1030
|
}
|
|
999
1031
|
}
|
|
@@ -104,6 +104,15 @@ export interface OutboxRecord {
|
|
|
104
104
|
originChatId?: string | null
|
|
105
105
|
/** Forum thread of the per-session origin chat (see `originChatId`). */
|
|
106
106
|
originThreadId?: number | null
|
|
107
|
+
/**
|
|
108
|
+
* #3510 instrumentation: was a qualifying reply already delivered through
|
|
109
|
+
* the gateway in the turn that produced this record? Stamped by the Stop
|
|
110
|
+
* hook from the SAME boolean that gates its capture-vs-election branch.
|
|
111
|
+
* After #3510 this is always `false` for a written record (a `true` routes
|
|
112
|
+
* to the single-writer election instead of the outbox), so a `true` here —
|
|
113
|
+
* or in a sweep journal entry — is direct evidence of a regression.
|
|
114
|
+
*/
|
|
115
|
+
replyAlreadyDeliveredThisTurn?: boolean
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
/** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
|
|
@@ -112,6 +121,14 @@ export interface DeliveredEntry {
|
|
|
112
121
|
textSha256: string
|
|
113
122
|
tgMessageId?: number
|
|
114
123
|
ts: number
|
|
124
|
+
/**
|
|
125
|
+
* #3510 instrumentation: which machine delivered — the outbox sweep, or the
|
|
126
|
+
* gateway reply-path machinery (reply/stream_reply send, silent-anchor edit,
|
|
127
|
+
* captured-prose bridge). Absent on pre-#3510 journal lines.
|
|
128
|
+
*/
|
|
129
|
+
deliverySource?: 'sweep' | 'reply-tool'
|
|
130
|
+
/** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
|
|
131
|
+
replyAlreadyDeliveredThisTurn?: boolean
|
|
115
132
|
}
|
|
116
133
|
|
|
117
134
|
export function sha256Hex(s: string): string {
|
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-reply-then-recap-e2e.test.ts — end-to-end regression net for #3510
|
|
3
|
+
* (Telegram double-send) AND its opposite failure mode (#3502 silent drop),
|
|
4
|
+
* driven from ONE harness through the real delivery decision path:
|
|
5
|
+
*
|
|
6
|
+
* transcript on disk
|
|
7
|
+
* → gateway reply-site simulation (send + conditional journal, mirroring
|
|
8
|
+
* `outbound-send-path.ts:2374-2376` — replies go out BEFORE Stop fires)
|
|
9
|
+
* → the REAL Stop hook (`hooks/silent-end-interrupt-stop.mjs`), spawned as
|
|
10
|
+
* a subprocess exactly as Claude Code runs it
|
|
11
|
+
* → the REAL `sweepOutbox` against the real on-disk outbox/journal, ticked
|
|
12
|
+
* repeatedly across the OUTBOX_QUIET_MS boundary (age-1 / age / age+1 /
|
|
13
|
+
* past the in-memory dedup TTL) to pressure-test the one-tick race
|
|
14
|
+
* window rather than a single happy-path tick.
|
|
15
|
+
*
|
|
16
|
+
* The oracle is deliberately FUNCTION-AGNOSTIC: assertions are on what the
|
|
17
|
+
* user observably received — how many messages, with what content, via which
|
|
18
|
+
* transport (`reply-tool` = the formatted reply; `sweep` = the plain-text
|
|
19
|
+
* outbox flush). No assertion names an internal decision function; renaming or
|
|
20
|
+
* refactoring the hook internals cannot green a real double-send or a real
|
|
21
|
+
* drop.
|
|
22
|
+
*
|
|
23
|
+
* Duplicate direction (#3510): a turn that delivers a reply and then emits a
|
|
24
|
+
* trailing prose recap (short / long / paraphrased — byte-exact dedup would
|
|
25
|
+
* miss the reworded ones) must yield EXACTLY ONE user-visible message: the
|
|
26
|
+
* formatted reply. On pre-fix main the sweep flushed the recap as a second,
|
|
27
|
+
* unformatted message.
|
|
28
|
+
*
|
|
29
|
+
* Silence direction (#3502 backstop): a gateway-blind turn (task-notification
|
|
30
|
+
* handback, cron, zero-reply channel turn) whose only answer is transcript
|
|
31
|
+
* prose must yield EXACTLY ONE delivery — never zero. This pins that the
|
|
32
|
+
* #3510 fix did not regress the 2026-07-22 data-loss class.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
36
|
+
import { spawnSync } from 'node:child_process'
|
|
37
|
+
import {
|
|
38
|
+
mkdtempSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
readdirSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
rmSync,
|
|
43
|
+
existsSync,
|
|
44
|
+
} from 'node:fs'
|
|
45
|
+
import { tmpdir } from 'node:os'
|
|
46
|
+
import { join, resolve } from 'node:path'
|
|
47
|
+
import { createHash } from 'node:crypto'
|
|
48
|
+
|
|
49
|
+
import { sweepOutbox, journalExternalDelivery } from '../gateway/outbox-sweep.js'
|
|
50
|
+
import { OUTBOX_QUIET_MS, type OutboxRecord, type DeliveredEntry } from '../outbox.js'
|
|
51
|
+
import { shouldJournalReplySiteDelivery, isFinalAnswerReply } from '../final-answer-detect.js'
|
|
52
|
+
import { decideTurnEndGate } from '../gateway/turn-end-gate.js'
|
|
53
|
+
import { deliverCapturedProse, type DeliverCapturedProseDeps } from '../gateway/outbound-send-path.js'
|
|
54
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
55
|
+
import type { FlushDecision } from '../turn-flush-safety.js'
|
|
56
|
+
|
|
57
|
+
const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
58
|
+
const REPLY_TOOL = 'mcp__switchroom-telegram__reply'
|
|
59
|
+
const CHAT = '111'
|
|
60
|
+
const MSG_ID = '42'
|
|
61
|
+
/** The gateway's `deriveTurnId` nonce for the CHAT/MSG_ID envelope. */
|
|
62
|
+
const GATEWAY_NONCE = `${CHAT}:_#${MSG_ID}`
|
|
63
|
+
/** Mirrors the gateway's in-memory `outboundDedup` TTL (~60s). */
|
|
64
|
+
const DEDUP_TTL_MS = 60_000
|
|
65
|
+
|
|
66
|
+
const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex')
|
|
67
|
+
|
|
68
|
+
interface Delivered {
|
|
69
|
+
via: 'reply-tool' | 'sweep' | 'bridge'
|
|
70
|
+
text: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Transcript-line builders. */
|
|
74
|
+
const enqueueChannel = (body: string, source = 'telegram', messageId = MSG_ID) => ({
|
|
75
|
+
type: 'queue-operation',
|
|
76
|
+
operation: 'enqueue',
|
|
77
|
+
content: `<channel source="${source}" chat_id="${CHAT}" message_id="${messageId}">${body}</channel>`,
|
|
78
|
+
timestamp: 1000,
|
|
79
|
+
})
|
|
80
|
+
const enqueueTask = () => ({
|
|
81
|
+
type: 'queue-operation',
|
|
82
|
+
operation: 'enqueue',
|
|
83
|
+
content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
|
|
84
|
+
timestamp: 2000,
|
|
85
|
+
})
|
|
86
|
+
const prose = (text: string) => ({
|
|
87
|
+
type: 'assistant',
|
|
88
|
+
message: { content: [{ type: 'text', text }] },
|
|
89
|
+
})
|
|
90
|
+
const reply = (text: string, input: Record<string, unknown> = {}) => ({
|
|
91
|
+
type: 'assistant',
|
|
92
|
+
message: {
|
|
93
|
+
content: [{ type: 'tool_use', name: REPLY_TOOL, input: { chat_id: CHAT, text, ...input } }],
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run one full turn through the real delivery path. Returns everything the
|
|
99
|
+
* user received, in order, plus the on-disk journal/outbox for forensics.
|
|
100
|
+
*/
|
|
101
|
+
async function runTurn(opts: {
|
|
102
|
+
dir: string
|
|
103
|
+
lines: object[]
|
|
104
|
+
gatewayAlive?: boolean
|
|
105
|
+
/** register a registry-chain hit for task anchors (default: unresolvable → origin route) */
|
|
106
|
+
registryHit?: boolean
|
|
107
|
+
}): Promise<{
|
|
108
|
+
delivered: Delivered[]
|
|
109
|
+
hookStdout: string
|
|
110
|
+
hookStderr: string
|
|
111
|
+
records: OutboxRecord[]
|
|
112
|
+
journal: DeliveredEntry[]
|
|
113
|
+
}> {
|
|
114
|
+
const { dir, lines, gatewayAlive = true } = opts
|
|
115
|
+
const delivered: Delivered[] = []
|
|
116
|
+
|
|
117
|
+
// 1. Gateway reply-site simulation — replies are sent (and conditionally
|
|
118
|
+
// journaled, same gate as outbound-send-path.ts:2374) BEFORE Stop fires.
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
const content = (line as { message?: { content?: Array<Record<string, unknown>> } }).message?.content
|
|
121
|
+
if (!Array.isArray(content)) continue
|
|
122
|
+
for (const c of content) {
|
|
123
|
+
if (c.type !== 'tool_use' || c.name !== REPLY_TOOL) continue
|
|
124
|
+
const input = c.input as { text?: string; done?: boolean; disable_notification?: boolean }
|
|
125
|
+
const text = String(input.text ?? '')
|
|
126
|
+
if (/^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i.test(text.trim())) continue
|
|
127
|
+
delivered.push({ via: 'reply-tool', text })
|
|
128
|
+
if (
|
|
129
|
+
shouldJournalReplySiteDelivery({
|
|
130
|
+
text,
|
|
131
|
+
disableNotification: input.disable_notification === true,
|
|
132
|
+
done: input.done === true,
|
|
133
|
+
})
|
|
134
|
+
) {
|
|
135
|
+
journalExternalDelivery(
|
|
136
|
+
{ turnNonce: GATEWAY_NONCE, text, tgMessageId: 1, replyAlreadyDeliveredThisTurn: true },
|
|
137
|
+
dir,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Gateway liveness heartbeat (fresh unless the case simulates a dead gateway).
|
|
144
|
+
if (gatewayAlive) writeFileSync(join(dir, 'gateway-heartbeat'), 'hb', 'utf8')
|
|
145
|
+
|
|
146
|
+
// 3. The REAL Stop hook, as a subprocess.
|
|
147
|
+
const transcriptPath = join(dir, 'transcript.jsonl')
|
|
148
|
+
writeFileSync(transcriptPath, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
|
|
149
|
+
const hook = spawnSync('node', [HOOK], {
|
|
150
|
+
input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
|
|
151
|
+
encoding: 'utf8',
|
|
152
|
+
timeout: 5000,
|
|
153
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: dir },
|
|
154
|
+
})
|
|
155
|
+
expect(hook.status).toBe(0)
|
|
156
|
+
|
|
157
|
+
// 3.5 Gateway turn-end — the REAL gate + the REAL captured-prose bridge
|
|
158
|
+
// (#3511 review finding 2). `finalAnswerDelivered` is computed with the
|
|
159
|
+
// REAL `isFinalAnswerReply` classifier at the reply site — the exact code
|
|
160
|
+
// the gateway runs at outbound-send-path.ts ~2293 — and the disposition
|
|
161
|
+
// comes from the REAL `decideTurnEndGate`. When (and only when) the gate
|
|
162
|
+
// says 'reprompt' and the hook persisted `pendingText`, the REAL
|
|
163
|
+
// `deliverCapturedProse` runs against the same state dir/journal. This
|
|
164
|
+
// makes the load-bearing `isFinalAnswerReply` ⇔ `finalAnswerDelivered`
|
|
165
|
+
// agreement an asserted outcome: if the hook defers a shape the gateway
|
|
166
|
+
// does not consider delivered (or vice versa), the bridge fires and the
|
|
167
|
+
// exactly-one-message assertions go red.
|
|
168
|
+
// Only simulated for reply-called turns — a gateway-blind turn (no
|
|
169
|
+
// CurrentTurn) has no turn_end at all, which is the whole point of the
|
|
170
|
+
// outbox path.
|
|
171
|
+
const replyInputs: Array<{ text: string; done: boolean; disableNotification: boolean }> = []
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const content = (line as { message?: { content?: Array<Record<string, unknown>> } }).message?.content
|
|
174
|
+
if (!Array.isArray(content)) continue
|
|
175
|
+
for (const c of content) {
|
|
176
|
+
if (c.type !== 'tool_use' || c.name !== REPLY_TOOL) continue
|
|
177
|
+
const input = c.input as { text?: string; done?: boolean; disable_notification?: boolean }
|
|
178
|
+
replyInputs.push({
|
|
179
|
+
text: String(input.text ?? ''),
|
|
180
|
+
done: input.done === true,
|
|
181
|
+
disableNotification: input.disable_notification === true,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const isSentinel = (t: string) => /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i.test(t.trim())
|
|
186
|
+
const nonSentinelReplies = replyInputs.filter((r) => !isSentinel(r.text))
|
|
187
|
+
if (replyInputs.length > 0) {
|
|
188
|
+
// Same classifier + same site semantics as outbound-send-path.ts ~2293.
|
|
189
|
+
const finalAnswerDelivered = nonSentinelReplies.some((r) =>
|
|
190
|
+
isFinalAnswerReply({ text: r.text, disableNotification: r.disableNotification, done: r.done }),
|
|
191
|
+
)
|
|
192
|
+
// Sentinel-only turns take the gateway's sentinel-suppression path and
|
|
193
|
+
// return before the bridge (stream-render.ts ~1977 comment; verified live
|
|
194
|
+
// in the #3511 review). Reply-called turns take the 'reply-called' skip.
|
|
195
|
+
const flushDecision: FlushDecision =
|
|
196
|
+
nonSentinelReplies.length === 0
|
|
197
|
+
? { kind: 'skip', reason: 'silent-marker' }
|
|
198
|
+
: { kind: 'skip', reason: 'reply-called' }
|
|
199
|
+
const gate = decideTurnEndGate({ flushDecision, finalAnswerDelivered })
|
|
200
|
+
if (gate === 'reprompt') {
|
|
201
|
+
const statePath = join(dir, 'silent-end-pending.json')
|
|
202
|
+
const pendingText = existsSync(statePath)
|
|
203
|
+
? (JSON.parse(readFileSync(statePath, 'utf8')) as { pendingText?: string }).pendingText
|
|
204
|
+
: undefined
|
|
205
|
+
if (typeof pendingText === 'string' && pendingText.length > 0) {
|
|
206
|
+
// The gateway's silent-end machinery hands pendingText to the REAL bridge.
|
|
207
|
+
const dedup = new OutboundDedupCache()
|
|
208
|
+
for (const r of nonSentinelReplies) dedup.record(CHAT, undefined, r.text, Date.now(), null)
|
|
209
|
+
const bridgeDeps: DeliverCapturedProseDeps = {
|
|
210
|
+
outboundDedup: dedup,
|
|
211
|
+
bot: {
|
|
212
|
+
api: {
|
|
213
|
+
sendRichMessage: async (_chatId: string, rich: { markdown?: string }, _opts: object) => {
|
|
214
|
+
delivered.push({ via: 'bridge', text: rich.markdown ?? '' })
|
|
215
|
+
return { message_id: 2000 }
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
} as unknown as DeliverCapturedProseDeps['bot'],
|
|
219
|
+
robustApiCall: (fn) => fn(),
|
|
220
|
+
redactOutboundText: (text) => text,
|
|
221
|
+
recordOutbound: () => {},
|
|
222
|
+
HISTORY_ENABLED: false,
|
|
223
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
224
|
+
obligationLedger: { close: () => {} },
|
|
225
|
+
clearSilentEndState: () => {},
|
|
226
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
227
|
+
hasOutboundDeliveredSince: () => false,
|
|
228
|
+
}
|
|
229
|
+
// deliverCapturedProse journals via TELEGRAM_STATE_DIR — point it at
|
|
230
|
+
// this test's isolated state dir for the duration of the call.
|
|
231
|
+
const prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
232
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
233
|
+
try {
|
|
234
|
+
await deliverCapturedProse(bridgeDeps, {
|
|
235
|
+
chatId: CHAT,
|
|
236
|
+
threadId: undefined,
|
|
237
|
+
statusKeyStr: `${CHAT}:main`,
|
|
238
|
+
registryKey: null,
|
|
239
|
+
originTurnId: GATEWAY_NONCE,
|
|
240
|
+
text: pendingText,
|
|
241
|
+
})
|
|
242
|
+
} finally {
|
|
243
|
+
if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
|
|
244
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 4. The REAL sweep, ticked aggressively across the quiet-window boundary.
|
|
251
|
+
// Tick times are anchored on the record's actual createdAt when one
|
|
252
|
+
// exists, so the boundary (age === quietMs - 1 / quietMs / quietMs + 1)
|
|
253
|
+
// is exercised exactly — an ordering regression is caught, not masked.
|
|
254
|
+
const recordsBefore = readOutboxRecords(dir)
|
|
255
|
+
const t0 = recordsBefore.length > 0 ? recordsBefore[0].createdAt : Date.now()
|
|
256
|
+
const tickTimes = [
|
|
257
|
+
t0, // age 0 — inside quiet window
|
|
258
|
+
t0 + OUTBOX_QUIET_MS - 1, // one ms before eligibility
|
|
259
|
+
t0 + OUTBOX_QUIET_MS, // boundary
|
|
260
|
+
t0 + OUTBOX_QUIET_MS + 1, // just past
|
|
261
|
+
t0 + DEDUP_TTL_MS + 1_000, // past the in-memory dedup TTL — journal must hold alone
|
|
262
|
+
]
|
|
263
|
+
const sentLog: Array<{ text: string; at: number }> = []
|
|
264
|
+
for (const now of tickTimes) {
|
|
265
|
+
await sweepOutbox({
|
|
266
|
+
stateDir: dir,
|
|
267
|
+
now: () => now,
|
|
268
|
+
send: async (_chatId, _threadId, text) => {
|
|
269
|
+
delivered.push({ via: 'sweep', text })
|
|
270
|
+
sentLog.push({ text, at: now })
|
|
271
|
+
return 500
|
|
272
|
+
},
|
|
273
|
+
// Mirrors the gateway's TTL-bounded exact-text `outboundDedup` cache:
|
|
274
|
+
// anything sent (reply or sweep) within the TTL dedups; older evicts.
|
|
275
|
+
textAlreadyDelivered: (_chatId, _threadId, text) =>
|
|
276
|
+
delivered.some(
|
|
277
|
+
(d) =>
|
|
278
|
+
d.text === text &&
|
|
279
|
+
(d.via === 'reply-tool' || sentLog.some((s) => s.text === text && now - s.at < DEDUP_TTL_MS)),
|
|
280
|
+
),
|
|
281
|
+
registryChainLookup: opts.registryHit ? () => ({ chatId: CHAT, threadId: null }) : () => null,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
delivered,
|
|
287
|
+
hookStdout: hook.stdout ?? '',
|
|
288
|
+
hookStderr: hook.stderr ?? '',
|
|
289
|
+
records: readOutboxRecords(dir),
|
|
290
|
+
journal: readJournal(dir),
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readOutboxRecords(dir: string): OutboxRecord[] {
|
|
295
|
+
const outbox = join(dir, 'outbox')
|
|
296
|
+
try {
|
|
297
|
+
return readdirSync(outbox)
|
|
298
|
+
.filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl' && !f.startsWith('.'))
|
|
299
|
+
.map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as OutboxRecord)
|
|
300
|
+
} catch {
|
|
301
|
+
return []
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function readJournal(dir: string): DeliveredEntry[] {
|
|
306
|
+
const p = join(dir, 'outbox', 'delivered.jsonl')
|
|
307
|
+
if (!existsSync(p)) return []
|
|
308
|
+
return readFileSync(p, 'utf8')
|
|
309
|
+
.split('\n')
|
|
310
|
+
.filter((l) => l.length > 0)
|
|
311
|
+
.map((l) => JSON.parse(l) as DeliveredEntry)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Reply/recap fixtures. The short reply pings (no disable_notification, no
|
|
315
|
+
// done) → user-visible final answer, but under the 200-char reply-site journal
|
|
316
|
+
// floor — the exact #3510 window (R1 High #1 + #2).
|
|
317
|
+
const SHORT_REPLY = 'Deployed the fix to staging — all 3 checks green. ' + 'Detail: '.padEnd(100, 'x')
|
|
318
|
+
const LONG_REPLY = 'Full answer with plenty of substance. '.padEnd(250, 'y')
|
|
319
|
+
const RECAP_LONG = 'To recap what I just sent: the fix is deployed to staging and all checks are green. '.padEnd(300, 'z')
|
|
320
|
+
const RECAP_PARAPHRASE =
|
|
321
|
+
'Summary of the work above, in different words than the reply so byte-exact dedup can never match it. '.padEnd(400, 'w')
|
|
322
|
+
const RECAP_SHORT = 'Short recap under the floor.'
|
|
323
|
+
const REAL_ANSWER = 'The actual final answer that never went through a reply tool call. '.padEnd(300, 'a')
|
|
324
|
+
const HANDBACK_ANSWER = 'B'.repeat(1647)
|
|
325
|
+
|
|
326
|
+
describe('#3510 e2e — reply-then-recap turns yield EXACTLY ONE user-visible message', () => {
|
|
327
|
+
let dir: string
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
330
|
+
})
|
|
331
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
332
|
+
|
|
333
|
+
// Table: every duplicate-direction shape must end with exactly the formatted
|
|
334
|
+
// reply — never a second sweep flush, whatever the recap's wording/length.
|
|
335
|
+
const duplicateCases: Array<{ name: string; replyText: string; replyInput?: Record<string, unknown>; recap: string }> = [
|
|
336
|
+
{ name: 'short ping-final reply + long recap', replyText: SHORT_REPLY, recap: RECAP_LONG },
|
|
337
|
+
{ name: 'short ping-final reply + paraphrased recap (defeats byte-exact dedup)', replyText: SHORT_REPLY, recap: RECAP_PARAPHRASE },
|
|
338
|
+
{ name: 'short ping-final reply + short recap (below capture floor)', replyText: SHORT_REPLY, recap: RECAP_SHORT },
|
|
339
|
+
{ name: 'substantive journaled reply + reworded recap', replyText: LONG_REPLY, recap: RECAP_PARAPHRASE },
|
|
340
|
+
{ name: 'done:true reply + long recap', replyText: SHORT_REPLY, replyInput: { done: true }, recap: RECAP_LONG },
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
for (const c of duplicateCases) {
|
|
344
|
+
it(c.name, async () => {
|
|
345
|
+
const r = await runTurn({
|
|
346
|
+
dir,
|
|
347
|
+
lines: [enqueueChannel('deploy it'), reply(c.replyText, c.replyInput), prose(c.recap)],
|
|
348
|
+
})
|
|
349
|
+
// THE user outcome: one message, it is the reply (formatted transport),
|
|
350
|
+
// and no sweep flush ever fires for this turn.
|
|
351
|
+
expect(r.delivered).toEqual([{ via: 'reply-tool', text: c.replyText }])
|
|
352
|
+
// Durable state agrees: nothing pending for the sweep to resurrect later.
|
|
353
|
+
expect(r.records).toHaveLength(0)
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
it('dead gateway: still never a second message (worst case is a re-prompt, not a duplicate)', async () => {
|
|
358
|
+
const r = await runTurn({
|
|
359
|
+
dir,
|
|
360
|
+
lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_LONG)],
|
|
361
|
+
gatewayAlive: false,
|
|
362
|
+
})
|
|
363
|
+
expect(r.delivered).toEqual([{ via: 'reply-tool', text: SHORT_REPLY }])
|
|
364
|
+
expect(r.records).toHaveLength(0)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('logs both SHAs when deferring, so a double-send is provable from logs alone', async () => {
|
|
368
|
+
const r = await runTurn({
|
|
369
|
+
dir,
|
|
370
|
+
lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_LONG)],
|
|
371
|
+
})
|
|
372
|
+
expect(r.hookStderr).toContain('replyAlreadyDeliveredThisTurn=true')
|
|
373
|
+
expect(r.hookStderr).toContain(`capturedTextSha256=${sha256(RECAP_LONG.trim())}`)
|
|
374
|
+
expect(r.hookStderr).toContain(`deliveredReplySha256=${sha256(SHORT_REPLY)}`)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('#3502 backstop e2e — gateway-blind turns yield EXACTLY ONE delivery, never zero', () => {
|
|
379
|
+
let dir: string
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
382
|
+
})
|
|
383
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
384
|
+
|
|
385
|
+
it('task-notification handback with prose-only answer (2026-07-22 incident shape) is delivered once', async () => {
|
|
386
|
+
const r = await runTurn({
|
|
387
|
+
dir,
|
|
388
|
+
// A prior real channel inbound gives the session its origin chat (F2).
|
|
389
|
+
lines: [enqueueChannel('earlier question', 'telegram', '7'), enqueueTask(), prose(HANDBACK_ANSWER)],
|
|
390
|
+
})
|
|
391
|
+
const sweeps = r.delivered.filter((d) => d.via === 'sweep')
|
|
392
|
+
expect(sweeps).toHaveLength(1)
|
|
393
|
+
expect(sweeps[0].text).toContain(HANDBACK_ANSWER)
|
|
394
|
+
expect(r.delivered).toHaveLength(1) // and nothing else — exactly one delivery
|
|
395
|
+
expect(r.records).toHaveLength(0) // record consumed, not resurrectable
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('cron turn ending in prose is delivered once', async () => {
|
|
399
|
+
const r = await runTurn({
|
|
400
|
+
dir,
|
|
401
|
+
lines: [enqueueChannel('digest time', 'cron', '9'), prose(REAL_ANSWER)],
|
|
402
|
+
})
|
|
403
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('zero-reply channel turn ending in prose is delivered once', async () => {
|
|
407
|
+
const r = await runTurn({ dir, lines: [enqueueChannel('question'), prose(REAL_ANSWER)] })
|
|
408
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('interim ack then a real prose answer: the answer is delivered exactly once (not suppressed by the ack)', async () => {
|
|
412
|
+
const ack = 'On it — digging in now.'
|
|
413
|
+
const r = await runTurn({
|
|
414
|
+
dir,
|
|
415
|
+
lines: [
|
|
416
|
+
enqueueChannel('question'),
|
|
417
|
+
reply(ack, { disable_notification: true }), // interim ack: silent, short, not done
|
|
418
|
+
prose(REAL_ANSWER),
|
|
419
|
+
],
|
|
420
|
+
})
|
|
421
|
+
// User sees the ack (sent live) and the recovered answer — once each.
|
|
422
|
+
expect(r.delivered).toEqual([
|
|
423
|
+
{ via: 'reply-tool', text: ack },
|
|
424
|
+
{ via: 'sweep', text: REAL_ANSWER.trim() },
|
|
425
|
+
])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('pure NO_REPLY turn delivers nothing', async () => {
|
|
429
|
+
const r = await runTurn({ dir, lines: [enqueueChannel('ping'), prose('NO_REPLY')] })
|
|
430
|
+
expect(r.delivered).toHaveLength(0)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('reply-tool NO_REPLY then a substantive prose answer: the answer is delivered exactly once (#3511 finding 1)', async () => {
|
|
434
|
+
// The silence regression the #3511 review caught: a `reply("NO_REPLY")`
|
|
435
|
+
// delivered NOTHING to the user, and the gateway suppresses sentinel turns
|
|
436
|
+
// BEFORE the bridge can run — so if the hook treats the sentinel as
|
|
437
|
+
// "reply already delivered" and defers to the election, the trailing
|
|
438
|
+
// answer is orphaned and DROPPED. The discriminator must count only
|
|
439
|
+
// genuine final-reply deliveries, keeping this shape on the durable
|
|
440
|
+
// outbox/sweep path.
|
|
441
|
+
const r = await runTurn({
|
|
442
|
+
dir,
|
|
443
|
+
lines: [enqueueChannel('question'), reply('NO_REPLY'), prose(REAL_ANSWER)],
|
|
444
|
+
})
|
|
445
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
446
|
+
// Durable path was used: the record existed and was consumed, not elected.
|
|
447
|
+
expect(r.journal.filter((e) => e.deliverySource === 'sweep')).toHaveLength(1)
|
|
448
|
+
expect(r.journal[r.journal.length - 1].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('reply-tool NO_REPLY then prose survives a dead gateway (outbox is gateway-liveness-independent)', async () => {
|
|
452
|
+
// The old capture path's durability property must hold for this shape:
|
|
453
|
+
// no heartbeat, no election — the record is still written and swept.
|
|
454
|
+
const r = await runTurn({
|
|
455
|
+
dir,
|
|
456
|
+
lines: [enqueueChannel('question'), reply('NO_REPLY'), prose(REAL_ANSWER)],
|
|
457
|
+
gatewayAlive: false,
|
|
458
|
+
})
|
|
459
|
+
expect(r.delivered).toEqual([{ via: 'sweep', text: REAL_ANSWER.trim() }])
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
describe('#3511 finding 2 — the gateway BRIDGE path agrees with the hook (paraphrase safe end to end)', () => {
|
|
464
|
+
let dir: string
|
|
465
|
+
beforeEach(() => {
|
|
466
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
467
|
+
})
|
|
468
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
469
|
+
|
|
470
|
+
it('genuine reply + paraphrased recap: pendingText IS persisted for the bridge, yet the bridge never fires — exactly one message', async () => {
|
|
471
|
+
// The load-bearing invariant: the hook's fall-through classifier
|
|
472
|
+
// (isFinalAnswerReply on the deliver block) and the gateway's bridge gate
|
|
473
|
+
// (turn.finalAnswerDelivered, set by the SAME classifier) must agree. The
|
|
474
|
+
// harness computes finalAnswerDelivered with the real classifier and runs
|
|
475
|
+
// the real decideTurnEndGate + real deliverCapturedProse when it says
|
|
476
|
+
// 'reprompt' — so if either side of the agreement breaks, the paraphrased
|
|
477
|
+
// recap goes out via the bridge and this goes red with 2 messages.
|
|
478
|
+
const r = await runTurn({
|
|
479
|
+
dir,
|
|
480
|
+
lines: [enqueueChannel('deploy it'), reply(SHORT_REPLY), prose(RECAP_PARAPHRASE)],
|
|
481
|
+
})
|
|
482
|
+
// The election really did elect the bridge as the would-be single writer…
|
|
483
|
+
const state = JSON.parse(readFileSync(join(dir, 'silent-end-pending.json'), 'utf8'))
|
|
484
|
+
expect(state.pendingText).toBe(RECAP_PARAPHRASE.trim())
|
|
485
|
+
// …but the gateway's gate (finalAnswerDelivered=true, same classifier)
|
|
486
|
+
// keeps the bridge off: the user got exactly the formatted reply.
|
|
487
|
+
expect(r.delivered).toEqual([{ via: 'reply-tool', text: SHORT_REPLY }])
|
|
488
|
+
expect(r.delivered.filter((d) => d.via === 'bridge')).toHaveLength(0)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('interim ack + real answer: the bridge-eligible turn still yields the answer exactly once', async () => {
|
|
492
|
+
// Counter-case proving the bridge stage in the harness is live, not inert:
|
|
493
|
+
// an ack-only turn has finalAnswerDelivered=false (real classifier), the
|
|
494
|
+
// gate says reprompt — but the hook put the answer on the OUTBOX path
|
|
495
|
+
// (no pendingText persisted), so the sweep delivers and the bridge stays
|
|
496
|
+
// quiet. One ack + one answer, no third copy from any machine.
|
|
497
|
+
const ack = 'On it.'
|
|
498
|
+
const r = await runTurn({
|
|
499
|
+
dir,
|
|
500
|
+
lines: [enqueueChannel('question'), reply(ack, { disable_notification: true }), prose(REAL_ANSWER)],
|
|
501
|
+
})
|
|
502
|
+
const answers = r.delivered.filter((d) => d.text.includes(REAL_ANSWER.trim()))
|
|
503
|
+
expect(answers).toHaveLength(1)
|
|
504
|
+
expect(answers[0].via).toBe('sweep')
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
describe('#3510 instrumentation — the journal alone proves who delivered what', () => {
|
|
509
|
+
let dir: string
|
|
510
|
+
beforeEach(() => {
|
|
511
|
+
dir = mkdtempSync(join(tmpdir(), 'outbox-e2e-'))
|
|
512
|
+
})
|
|
513
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
514
|
+
|
|
515
|
+
it('sweep deliveries journal deliverySource=sweep with the record capture-time flag', async () => {
|
|
516
|
+
const r = await runTurn({
|
|
517
|
+
dir,
|
|
518
|
+
lines: [enqueueChannel('question'), prose(REAL_ANSWER)],
|
|
519
|
+
})
|
|
520
|
+
const sweepEntries = r.journal.filter((e) => e.deliverySource === 'sweep')
|
|
521
|
+
expect(sweepEntries).toHaveLength(1)
|
|
522
|
+
expect(sweepEntries[0].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
523
|
+
expect(sweepEntries[0].textSha256).toBe(sha256(REAL_ANSWER.trim()))
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('reply-site deliveries journal deliverySource=reply-tool with replyAlreadyDeliveredThisTurn=true', async () => {
|
|
527
|
+
const r = await runTurn({
|
|
528
|
+
dir,
|
|
529
|
+
lines: [enqueueChannel('question'), reply(LONG_REPLY), prose(RECAP_PARAPHRASE)],
|
|
530
|
+
})
|
|
531
|
+
const replyEntries = r.journal.filter((e) => e.deliverySource === 'reply-tool')
|
|
532
|
+
expect(replyEntries).toHaveLength(1)
|
|
533
|
+
expect(replyEntries[0].replyAlreadyDeliveredThisTurn).toBe(true)
|
|
534
|
+
expect(replyEntries[0].textSha256).toBe(sha256(LONG_REPLY))
|
|
535
|
+
// And no sweep entry exists for the turn — one writer, one journal line.
|
|
536
|
+
expect(r.journal.filter((e) => e.deliverySource === 'sweep')).toHaveLength(0)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('a bridge delivery journals replyAlreadyDeliveredThisTurn=false (#3511 finding 3)', async () => {
|
|
540
|
+
// The bridge only runs when no genuine final answer was delivered
|
|
541
|
+
// (decideTurnEndGate 'reprompt' requires finalAnswerDelivered === false),
|
|
542
|
+
// so its journal line must stamp the flag false — making a bridge
|
|
543
|
+
// double-send after a reply provable from the journal alone.
|
|
544
|
+
const dedup = new OutboundDedupCache()
|
|
545
|
+
const deps: DeliverCapturedProseDeps = {
|
|
546
|
+
outboundDedup: dedup,
|
|
547
|
+
bot: {
|
|
548
|
+
api: { sendRichMessage: async () => ({ message_id: 3000 }) },
|
|
549
|
+
} as unknown as DeliverCapturedProseDeps['bot'],
|
|
550
|
+
robustApiCall: (fn) => fn(),
|
|
551
|
+
redactOutboundText: (text) => text,
|
|
552
|
+
recordOutbound: () => {},
|
|
553
|
+
HISTORY_ENABLED: false,
|
|
554
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
555
|
+
obligationLedger: { close: () => {} },
|
|
556
|
+
clearSilentEndState: () => {},
|
|
557
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
558
|
+
hasOutboundDeliveredSince: () => false,
|
|
559
|
+
}
|
|
560
|
+
const prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
561
|
+
process.env.TELEGRAM_STATE_DIR = dir
|
|
562
|
+
try {
|
|
563
|
+
await deliverCapturedProse(deps, {
|
|
564
|
+
chatId: CHAT,
|
|
565
|
+
threadId: undefined,
|
|
566
|
+
statusKeyStr: `${CHAT}:main`,
|
|
567
|
+
registryKey: null,
|
|
568
|
+
originTurnId: GATEWAY_NONCE,
|
|
569
|
+
text: REAL_ANSWER,
|
|
570
|
+
})
|
|
571
|
+
} finally {
|
|
572
|
+
if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
|
|
573
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
574
|
+
}
|
|
575
|
+
const journal = readJournal(dir)
|
|
576
|
+
expect(journal).toHaveLength(1)
|
|
577
|
+
expect(journal[0].deliverySource).toBe('reply-tool')
|
|
578
|
+
expect(journal[0].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('a captured (gateway-blind) record carries replyAlreadyDeliveredThisTurn=false on disk', async () => {
|
|
582
|
+
// Run only the hook (no sweep) so the record is observable before delivery.
|
|
583
|
+
const transcriptPath = join(dir, 'transcript.jsonl')
|
|
584
|
+
writeFileSync(
|
|
585
|
+
transcriptPath,
|
|
586
|
+
[enqueueTask(), prose(HANDBACK_ANSWER)].map((l) => JSON.stringify(l)).join('\n'),
|
|
587
|
+
'utf8',
|
|
588
|
+
)
|
|
589
|
+
const hook = spawnSync('node', [HOOK], {
|
|
590
|
+
input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
|
|
591
|
+
encoding: 'utf8',
|
|
592
|
+
timeout: 5000,
|
|
593
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: dir },
|
|
594
|
+
})
|
|
595
|
+
expect(hook.status).toBe(0)
|
|
596
|
+
const records = readOutboxRecords(dir)
|
|
597
|
+
expect(records).toHaveLength(1)
|
|
598
|
+
expect(records[0].replyAlreadyDeliveredThisTurn).toBe(false)
|
|
599
|
+
})
|
|
600
|
+
})
|
|
@@ -201,12 +201,18 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
201
201
|
expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
|
|
202
202
|
})
|
|
203
203
|
|
|
204
|
-
it('
|
|
205
|
-
//
|
|
206
|
-
// (
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
204
|
+
it('routes the trailing verdict through the single-writer election (NOT the outbox) when an early qualifying reply already delivered (#3510)', () => {
|
|
205
|
+
// Pre-#3510 this shape — (1) inbound, (2) an early notification-bearing
|
|
206
|
+
// (ping-final) reply, (3) a large trailing verdict as plain text — was
|
|
207
|
+
// CAPTURED to the outbox and self-exited, bypassing the #3469 election.
|
|
208
|
+
// Because the ping-final reply is delivered by the gateway but sits under
|
|
209
|
+
// the reply-site journal floor, the sweep then flushed the trailing prose
|
|
210
|
+
// as a SECOND message (the #3510 double-send). Now: a reply already
|
|
211
|
+
// delivered this turn ⇒ NO outbox record; the trailing prose goes through
|
|
212
|
+
// `decideStopHookDisposition`'s 'trailing-text-after-reply' branch, whose
|
|
213
|
+
// captured-prose bridge is the single writer (fresh heartbeat ⇒ elected
|
|
214
|
+
// allow, pendingText persisted as the bridge's input).
|
|
215
|
+
writeFileSync(join(stateDir, 'gateway-heartbeat'), 'hb', 'utf8')
|
|
210
216
|
const transcript = writeTranscript(tmp, [
|
|
211
217
|
ENQUEUE,
|
|
212
218
|
reply('running now'),
|
|
@@ -222,11 +228,13 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
|
|
|
222
228
|
stateDir,
|
|
223
229
|
})
|
|
224
230
|
expect(r.status).toBe(0)
|
|
225
|
-
expect(r.stdout.trim()).toBe('')
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
expect(
|
|
229
|
-
|
|
231
|
+
expect(r.stdout.trim()).toBe('') // elected allow — no re-prompt
|
|
232
|
+
expect(r.stderr).toMatch(/replyAlreadyDeliveredThisTurn=true/)
|
|
233
|
+
// No outbox record: the sweep must have nothing to flush as a second message.
|
|
234
|
+
expect(readOutboxRecord(stateDir)).toBeNull()
|
|
235
|
+
// The verdict is handed to the bridge (single writer) via the state file.
|
|
236
|
+
const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
|
|
237
|
+
expect(state.pendingText).toBe('Here is the actual verdict: ' + 'X'.repeat(300))
|
|
230
238
|
})
|
|
231
239
|
|
|
232
240
|
it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {
|