switchroom 0.19.42 → 0.19.44
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 +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -0,0 +1,1126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-provenance-4141.test.ts — the FOREGROUND reply-throw case (issue
|
|
3
|
+
* #4141, follow-up to #3865 / PR #4140).
|
|
4
|
+
*
|
|
5
|
+
* THE GAP THIS GUARDS
|
|
6
|
+
* -------------------
|
|
7
|
+
* #4140's audience gate marks a record `'internal'` only on positive evidence:
|
|
8
|
+
* the reply tool threw AND no inbound obligation is open. An obligation only
|
|
9
|
+
* closes on a substantive DELIVERED reply (`gateway/obligation-ledger.ts:153`),
|
|
10
|
+
* so on a FOREGROUND Telegram turn where the reply tool threw, the obligation
|
|
11
|
+
* is still OPEN, the record classifies `'user'`, and the agent's trailing
|
|
12
|
+
* working notes are delivered to the waiting human presented as if they were
|
|
13
|
+
* the answer.
|
|
14
|
+
*
|
|
15
|
+
* THE DESIGN UNDER TEST — FRAMING, NOT SUPPRESSION
|
|
16
|
+
* -----------------------------------------------
|
|
17
|
+
* Suppressing here would convert a visible wrong-content failure into an
|
|
18
|
+
* invisible no-answer one, against a human who is provably still waiting. So
|
|
19
|
+
* the rule is: state the provenance in front of the text and deliver it. The
|
|
20
|
+
* two invariants this file exists to hold are therefore:
|
|
21
|
+
*
|
|
22
|
+
* A. FRAMING NEVER SILENCES. Every framed case sends exactly as many
|
|
23
|
+
* chat-visible calls as the unframed case did, and the body still
|
|
24
|
+
* contains the captured prose verbatim.
|
|
25
|
+
* B. FRAMING IS OBSERVABLE. Every framed delivery carries a durable terminal
|
|
26
|
+
* stamp on the journal line plus one structured telemetry line.
|
|
27
|
+
*
|
|
28
|
+
* Every assertion drives REAL machinery: the REAL Stop hook spawned as a
|
|
29
|
+
* subprocess against a REAL transcript, the REAL `sweepOutbox` with the REAL
|
|
30
|
+
* `createOutboxSend` adapter against a RECORDING fake Bot API, the REAL flood
|
|
31
|
+
* producer, and the REAL fleet-health detectors.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
35
|
+
import { spawnSync } from 'node:child_process'
|
|
36
|
+
import {
|
|
37
|
+
mkdtempSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
readdirSync,
|
|
41
|
+
readFileSync,
|
|
42
|
+
existsSync,
|
|
43
|
+
rmSync,
|
|
44
|
+
} from 'node:fs'
|
|
45
|
+
import { tmpdir } from 'node:os'
|
|
46
|
+
import { join, resolve } from 'node:path'
|
|
47
|
+
|
|
48
|
+
import { sweepOutbox, createOutboxSend, turnIdFromNonce } from '../gateway/outbox-sweep.js'
|
|
49
|
+
import { decideOutboxSweep, sha256Hex } from '../outbox.js'
|
|
50
|
+
import { queueFloodBlockedReply } from '../gateway/flood-reply-queue.js'
|
|
51
|
+
import {
|
|
52
|
+
REPLY_THROW_PROVENANCE_NOTICE,
|
|
53
|
+
applyReplyThrowFraming,
|
|
54
|
+
decideCaptureAudience,
|
|
55
|
+
formatReplyThrowFraming,
|
|
56
|
+
resolveOpenObligation,
|
|
57
|
+
shouldFrameReplyThrow,
|
|
58
|
+
} from '../hooks/audience-classify.mjs'
|
|
59
|
+
import { scanForOutboxCapture } from '../hooks/silent-end-scan.mjs'
|
|
60
|
+
import { deliverCapturedProse } from '../gateway/outbound-send-path.js'
|
|
61
|
+
import { decideCapturedProseDelivery } from '../silent-end.js'
|
|
62
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
63
|
+
import { decideTurnFlush } from '../turn-flush-safety.js'
|
|
64
|
+
import { buildTurnRecord } from '../gateway/turn-record-status.js'
|
|
65
|
+
import {
|
|
66
|
+
detectTurnFindings,
|
|
67
|
+
detectGatewayFindings,
|
|
68
|
+
extractTurnId,
|
|
69
|
+
GATEWAY_SIGNATURES,
|
|
70
|
+
} from '../../src/fleet-health/detect.js'
|
|
71
|
+
|
|
72
|
+
const HOOK = resolve(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
73
|
+
|
|
74
|
+
// Synthetic ids only (check-no-pii-secrets forbids real chat/user ids).
|
|
75
|
+
const DM_CHAT = '5550001'
|
|
76
|
+
const INBOUND_MSG_ID = 7311
|
|
77
|
+
const NONCE = `${DM_CHAT}:_#${INBOUND_MSG_ID}`
|
|
78
|
+
|
|
79
|
+
/** Internal working notes — the text that must never masquerade as an answer. */
|
|
80
|
+
const WORKING_NOTES = [
|
|
81
|
+
'Dispatched the worker onto the rebase and it came back clean, so the next',
|
|
82
|
+
'step is to re-run the scoped suite before touching the sweep at all. If the',
|
|
83
|
+
'suite is still red after that I will bisect from the merge base rather than',
|
|
84
|
+
'guessing, and I should double-check whether the earlier claim about the',
|
|
85
|
+
'adapter ordering actually holds, because I never verified it against HEAD.',
|
|
86
|
+
].join(' ')
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A genuine answer written as plain prose AFTER the reply tool errored — the
|
|
90
|
+
* shape that makes suppression unsafe here. Structurally indistinguishable
|
|
91
|
+
* from WORKING_NOTES to every classifier in this pipeline.
|
|
92
|
+
*/
|
|
93
|
+
const REAL_ANSWER = [
|
|
94
|
+
'The rebase landed on the merge base and the suite is green. Two files moved:',
|
|
95
|
+
'the sweep now checks the record before it routes anything, and the capture',
|
|
96
|
+
'hook stamps the field it checks. Nothing else changed, and the flood queue',
|
|
97
|
+
'behaves exactly as it did before because it stamps the same value it implied.',
|
|
98
|
+
].join(' ')
|
|
99
|
+
|
|
100
|
+
function makeStateDir(): string {
|
|
101
|
+
// NEVER ~/.switchroom — a test that writes there corrupts production state.
|
|
102
|
+
return mkdtempSync(join(tmpdir(), 'w1d-provenance-'))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeTranscript(dir: string, lines: object[]): string {
|
|
106
|
+
const p = join(dir, 'transcript.jsonl')
|
|
107
|
+
writeFileSync(p, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
|
|
108
|
+
return p
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runHook(
|
|
112
|
+
transcriptPath: string,
|
|
113
|
+
stateDir: string,
|
|
114
|
+
extraEnv: Record<string, string> = {},
|
|
115
|
+
) {
|
|
116
|
+
return spawnSync('node', [HOOK], {
|
|
117
|
+
input: JSON.stringify({ session_id: 's', transcript_path: transcriptPath }),
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
timeout: 10_000,
|
|
120
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: stateDir, ...extraEnv },
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface RecordShape {
|
|
125
|
+
turnNonce: string
|
|
126
|
+
text: string
|
|
127
|
+
audience?: string
|
|
128
|
+
replyToolThrewThisTurn?: unknown
|
|
129
|
+
chatId: string | null
|
|
130
|
+
source: string
|
|
131
|
+
textSha256: string
|
|
132
|
+
createdAt: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function outboxRecords(dir: string): RecordShape[] {
|
|
136
|
+
const outbox = join(dir, 'outbox')
|
|
137
|
+
if (!existsSync(outbox)) return []
|
|
138
|
+
return readdirSync(outbox)
|
|
139
|
+
.filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl')
|
|
140
|
+
.map((f) => JSON.parse(readFileSync(join(outbox, f), 'utf8')) as RecordShape)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function journalLines(dir: string): Array<Record<string, unknown>> {
|
|
144
|
+
const p = join(dir, 'outbox', 'delivered.jsonl')
|
|
145
|
+
if (!existsSync(p)) return []
|
|
146
|
+
return readFileSync(p, 'utf8')
|
|
147
|
+
.split('\n')
|
|
148
|
+
.filter((l) => l.trim().length > 0)
|
|
149
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The OPEN inbound obligation that makes this the foreground case. */
|
|
153
|
+
function writeOpenObligation(dir: string): void {
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(dir, 'obligations.json'),
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
v: 1,
|
|
158
|
+
obligations: [
|
|
159
|
+
{
|
|
160
|
+
originTurnId: NONCE,
|
|
161
|
+
chatId: DM_CHAT,
|
|
162
|
+
messageId: INBOUND_MSG_ID,
|
|
163
|
+
text: 'where did the rebase land?',
|
|
164
|
+
openedAt: 1_000,
|
|
165
|
+
representCount: 0,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
'utf8',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A RECORDING fake Bot API. Every method that can put bytes into a chat is
|
|
175
|
+
* counted, so a send-count assertion covers ALL of them, not the one method
|
|
176
|
+
* the test remembered to stub.
|
|
177
|
+
*/
|
|
178
|
+
function recordingBot() {
|
|
179
|
+
const calls: Array<{ method: string; chatId: string; text: string }> = []
|
|
180
|
+
let nextId = 900
|
|
181
|
+
return {
|
|
182
|
+
calls,
|
|
183
|
+
chatCalls: () => calls,
|
|
184
|
+
api: {
|
|
185
|
+
sendRichMessage: async (chatId: string, body: { markdown: string }) => {
|
|
186
|
+
calls.push({ method: 'sendRichMessage', chatId, text: body.markdown })
|
|
187
|
+
return { message_id: nextId++ }
|
|
188
|
+
},
|
|
189
|
+
sendMessage: async (chatId: string, text: string) => {
|
|
190
|
+
calls.push({ method: 'sendMessage', chatId, text })
|
|
191
|
+
return { message_id: nextId++ }
|
|
192
|
+
},
|
|
193
|
+
editMessageText: async (chatId: string, _mid: number, text: string) => {
|
|
194
|
+
calls.push({ method: 'editMessageText', chatId, text })
|
|
195
|
+
return { message_id: nextId++ }
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const passthroughRetry = <U>(fn: () => Promise<U>): Promise<U> => fn()
|
|
202
|
+
|
|
203
|
+
/** Drive ONE real sweep tick against the recording bot. */
|
|
204
|
+
async function realSweep(
|
|
205
|
+
stateDir: string,
|
|
206
|
+
bot: ReturnType<typeof recordingBot>,
|
|
207
|
+
opts: { framingEnabled?: boolean } = {},
|
|
208
|
+
) {
|
|
209
|
+
const logLines: string[] = []
|
|
210
|
+
const framingLines: string[] = []
|
|
211
|
+
const escalations: string[] = []
|
|
212
|
+
const summary = await sweepOutbox({
|
|
213
|
+
send: createOutboxSend({ getBot: () => bot, retry: passthroughRetry }),
|
|
214
|
+
textAlreadyDelivered: () => false,
|
|
215
|
+
stateDir,
|
|
216
|
+
now: () => Date.now() + 60_000,
|
|
217
|
+
quietMs: 0,
|
|
218
|
+
log: (l) => logLines.push(l),
|
|
219
|
+
...(opts.framingEnabled === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { provenanceFramingEnabled: () => opts.framingEnabled! }),
|
|
222
|
+
logProvenanceFraming: (l) => framingLines.push(l),
|
|
223
|
+
escalateInternalSuppression: (l) => escalations.push(l),
|
|
224
|
+
})
|
|
225
|
+
return { summary, logLines, framingLines, escalations }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* THE FOREGROUND SHAPE (#4141), verbatim in structure:
|
|
230
|
+
* 1. a Telegram inbound — the anchor for THIS turn, and the reason an
|
|
231
|
+
* obligation is open;
|
|
232
|
+
* 2. a reply-tool call, which THROWS;
|
|
233
|
+
* 3. trailing prose, which capture then selects as "the answer".
|
|
234
|
+
*
|
|
235
|
+
* Note there is NO background wake here — that is the whole difference from
|
|
236
|
+
* #4140's leak transcript, and it is what keeps the obligation open.
|
|
237
|
+
*/
|
|
238
|
+
function foregroundThrowTranscript(dir: string, prose: string): string {
|
|
239
|
+
return writeTranscript(dir, [
|
|
240
|
+
{
|
|
241
|
+
type: 'queue-operation',
|
|
242
|
+
operation: 'enqueue',
|
|
243
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
|
|
244
|
+
timestamp: 1000,
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: 'assistant',
|
|
248
|
+
message: {
|
|
249
|
+
content: [
|
|
250
|
+
{
|
|
251
|
+
type: 'tool_use',
|
|
252
|
+
id: 'toolu_reply_1',
|
|
253
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
254
|
+
input: { text: 'On it.', disable_notification: true },
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
type: 'user',
|
|
261
|
+
message: {
|
|
262
|
+
content: [
|
|
263
|
+
{
|
|
264
|
+
type: 'tool_result',
|
|
265
|
+
tool_use_id: 'toolu_reply_1',
|
|
266
|
+
is_error: true,
|
|
267
|
+
content: 'Error: FLOOD_WAIT_ACTIVE — send rejected',
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
|
|
273
|
+
])
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
describe('#4141 — the framing rule is pure, positive-evidence-only, and additive', () => {
|
|
277
|
+
it('frames ONLY on an exact boolean true', () => {
|
|
278
|
+
expect(shouldFrameReplyThrow({ replyToolThrewThisTurn: true })).toBe(true)
|
|
279
|
+
// Anything else is no evidence → no change at all. A legacy record (the
|
|
280
|
+
// field did not exist before this PR) is the `undefined` row.
|
|
281
|
+
for (const v of [undefined, null, false, 'true', 'internal', 1, {}, []]) {
|
|
282
|
+
expect(shouldFrameReplyThrow({ replyToolThrewThisTurn: v })).toBe(false)
|
|
283
|
+
}
|
|
284
|
+
expect(shouldFrameReplyThrow({})).toBe(false)
|
|
285
|
+
// Kill switch — the seam the revert-check flips.
|
|
286
|
+
expect(
|
|
287
|
+
shouldFrameReplyThrow({ replyToolThrewThisTurn: true }, { frameEnabled: false }),
|
|
288
|
+
).toBe(false)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('framing is ADDITIVE: the captured prose survives verbatim', () => {
|
|
292
|
+
const framed = applyReplyThrowFraming(WORKING_NOTES)
|
|
293
|
+
expect(framed).toContain(WORKING_NOTES)
|
|
294
|
+
expect(framed.startsWith(REPLY_THROW_PROVENANCE_NOTICE)).toBe(true)
|
|
295
|
+
expect(framed.length).toBeGreaterThan(WORKING_NOTES.length)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('the banner carries no markdown specials that could parse-reject a rich send', () => {
|
|
299
|
+
expect(REPLY_THROW_PROVENANCE_NOTICE).not.toMatch(/[*_`[\]]/)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('an empty body is left alone — a banner about nothing is not a message', () => {
|
|
303
|
+
expect(applyReplyThrowFraming('')).toBe('')
|
|
304
|
+
expect(applyReplyThrowFraming(' \n ')).toBe(' \n ')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('the pure sweep decision can never turn a send into a skip because of framing', () => {
|
|
308
|
+
const base = {
|
|
309
|
+
now: 100_000,
|
|
310
|
+
deliveredNonces: new Set<string>(),
|
|
311
|
+
textAlreadyDelivered: false,
|
|
312
|
+
routable: true,
|
|
313
|
+
quietMs: 0,
|
|
314
|
+
}
|
|
315
|
+
const rec = {
|
|
316
|
+
turnNonce: NONCE,
|
|
317
|
+
text: WORKING_NOTES,
|
|
318
|
+
createdAt: 1000,
|
|
319
|
+
replyToolThrewThisTurn: true,
|
|
320
|
+
}
|
|
321
|
+
const on = decideOutboxSweep({ ...base, record: rec })
|
|
322
|
+
const off = decideOutboxSweep({ ...base, record: rec, provenanceFraming: false })
|
|
323
|
+
expect(on.action).toBe(off.action)
|
|
324
|
+
expect(on.action).toBe('send')
|
|
325
|
+
expect(on.framedProvenance).toBe('reply-throw')
|
|
326
|
+
expect(off.framedProvenance).toBeUndefined()
|
|
327
|
+
expect(on.text).toContain(off.text!)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('the banner sits between the delivery prefixes and the body, not in front of them', () => {
|
|
331
|
+
const d = decideOutboxSweep({
|
|
332
|
+
record: {
|
|
333
|
+
turnNonce: NONCE,
|
|
334
|
+
text: WORKING_NOTES,
|
|
335
|
+
createdAt: 0,
|
|
336
|
+
replyToolThrewThisTurn: true,
|
|
337
|
+
},
|
|
338
|
+
now: 60 * 60_000,
|
|
339
|
+
deliveredNonces: new Set<string>(),
|
|
340
|
+
textAlreadyDelivered: false,
|
|
341
|
+
routable: true,
|
|
342
|
+
routePrefix: '(from background task) ',
|
|
343
|
+
quietMs: 0,
|
|
344
|
+
})
|
|
345
|
+
expect(d.action).toBe('send-delayed')
|
|
346
|
+
expect(d.text!.startsWith('(delayed) (from background task) ')).toBe(true)
|
|
347
|
+
expect(d.text).toContain(REPLY_THROW_PROVENANCE_NOTICE)
|
|
348
|
+
expect(d.text).toContain(WORKING_NOTES)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('#4141 — capture persists the raw reply-throw signal onto the record', () => {
|
|
353
|
+
let dir: string
|
|
354
|
+
beforeEach(() => {
|
|
355
|
+
dir = makeStateDir()
|
|
356
|
+
})
|
|
357
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
358
|
+
|
|
359
|
+
it('the REAL hook stamps replyToolThrewThisTurn alongside audience:user', () => {
|
|
360
|
+
writeOpenObligation(dir)
|
|
361
|
+
const t = foregroundThrowTranscript(dir, WORKING_NOTES)
|
|
362
|
+
const scan = scanForOutboxCapture(readFileSync(t, 'utf8')) as {
|
|
363
|
+
capture: boolean
|
|
364
|
+
replyToolThrewThisTurn: boolean
|
|
365
|
+
}
|
|
366
|
+
expect(scan.capture).toBe(true)
|
|
367
|
+
expect(scan.replyToolThrewThisTurn).toBe(true)
|
|
368
|
+
|
|
369
|
+
expect(runHook(t, dir).status).toBe(0)
|
|
370
|
+
const records = outboxRecords(dir)
|
|
371
|
+
expect(records).toHaveLength(1)
|
|
372
|
+
// The #4141 premise, confirmed against the real hook: the foreground case
|
|
373
|
+
// classifies `user` (someone is waiting) — #4140's gate does NOT bind here.
|
|
374
|
+
expect(records[0].audience).toBe('user')
|
|
375
|
+
// ...and the raw signal survives the write. THIS is the hop that would
|
|
376
|
+
// silently make the whole feature inert if it were dropped.
|
|
377
|
+
expect(records[0].replyToolThrewThisTurn).toBe(true)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('a turn where the reply tool did NOT throw stamps false — no framing anywhere', () => {
|
|
381
|
+
writeOpenObligation(dir)
|
|
382
|
+
const t = writeTranscript(dir, [
|
|
383
|
+
{
|
|
384
|
+
type: 'queue-operation',
|
|
385
|
+
operation: 'enqueue',
|
|
386
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
|
|
387
|
+
timestamp: 1000,
|
|
388
|
+
},
|
|
389
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: REAL_ANSWER }] } },
|
|
390
|
+
])
|
|
391
|
+
expect(runHook(t, dir).status).toBe(0)
|
|
392
|
+
const records = outboxRecords(dir)
|
|
393
|
+
expect(records).toHaveLength(1)
|
|
394
|
+
expect(records[0].replyToolThrewThisTurn).toBe(false)
|
|
395
|
+
expect(shouldFrameReplyThrow(records[0])).toBe(false)
|
|
396
|
+
})
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
describe('#4141 — the foreground leak, end to end through the real sweep', () => {
|
|
400
|
+
let dir: string
|
|
401
|
+
beforeEach(() => {
|
|
402
|
+
dir = makeStateDir()
|
|
403
|
+
})
|
|
404
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
405
|
+
|
|
406
|
+
/** Capture the foreground reply-throw shape through the REAL hook. */
|
|
407
|
+
function captureForeground(prose: string): RecordShape {
|
|
408
|
+
writeOpenObligation(dir)
|
|
409
|
+
const t = foregroundThrowTranscript(dir, prose)
|
|
410
|
+
expect(runHook(t, dir).status).toBe(0)
|
|
411
|
+
const records = outboxRecords(dir)
|
|
412
|
+
expect(records).toHaveLength(1)
|
|
413
|
+
expect(records[0].audience).toBe('user')
|
|
414
|
+
expect(records[0].replyToolThrewThisTurn).toBe(true)
|
|
415
|
+
return records[0]
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
it('PRIMARY: the prose is delivered WITH its provenance stated, journaled terminally, and telemetered once', async () => {
|
|
419
|
+
const rec = captureForeground(WORKING_NOTES)
|
|
420
|
+
const bot = recordingBot()
|
|
421
|
+
|
|
422
|
+
const run = await realSweep(dir, bot)
|
|
423
|
+
|
|
424
|
+
// INVARIANT A — framing never silences. Exactly one chat-visible call.
|
|
425
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
426
|
+
expect(run.summary.delivered).toBe(1)
|
|
427
|
+
expect(run.summary.audienceSuppressed).toBeUndefined()
|
|
428
|
+
|
|
429
|
+
// The user is told what this text IS before they read it...
|
|
430
|
+
const body = bot.chatCalls()[0].text
|
|
431
|
+
expect(body).toContain(REPLY_THROW_PROVENANCE_NOTICE)
|
|
432
|
+
// ...and none of the captured prose was destroyed to say so.
|
|
433
|
+
expect(body).toContain('I will bisect from the merge base')
|
|
434
|
+
expect(body.indexOf(REPLY_THROW_PROVENANCE_NOTICE)).toBeLessThan(
|
|
435
|
+
body.indexOf('Dispatched the worker'),
|
|
436
|
+
)
|
|
437
|
+
expect(bot.chatCalls()[0].chatId).toBe(DM_CHAT)
|
|
438
|
+
|
|
439
|
+
// INVARIANT B — observable. A durable terminal stamp on the journal line...
|
|
440
|
+
const journal = journalLines(dir)
|
|
441
|
+
expect(journal).toHaveLength(1)
|
|
442
|
+
expect(journal[0].turnNonce).toBe(rec.turnNonce)
|
|
443
|
+
expect(journal[0].framedProvenance).toBe('reply-throw')
|
|
444
|
+
expect(journal[0].deliverySource).toBe('sweep')
|
|
445
|
+
// A framed delivery IS a delivery — it carries a message id, unlike a
|
|
446
|
+
// #4140 suppression line.
|
|
447
|
+
expect(journal[0].tgMessageId).toBeDefined()
|
|
448
|
+
expect(journal[0].suppressedAudience).toBeUndefined()
|
|
449
|
+
|
|
450
|
+
// ...plus exactly one structured telemetry line, on its own channel.
|
|
451
|
+
expect(run.framingLines).toHaveLength(1)
|
|
452
|
+
expect(run.framingLines[0]).toContain(`nonce=${rec.turnNonce}`)
|
|
453
|
+
expect(run.framingLines[0]).toContain('replyToolThrew=true')
|
|
454
|
+
expect(run.escalations).toHaveLength(0)
|
|
455
|
+
expect(run.summary.provenanceFramed).toBe(1)
|
|
456
|
+
|
|
457
|
+
// A SECOND tick sends nothing more (exactly-once is untouched).
|
|
458
|
+
const second = await realSweep(dir, bot)
|
|
459
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
460
|
+
expect(second.summary.scanned).toBe(0)
|
|
461
|
+
expect(second.framingLines).toHaveLength(0)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('REVERT CHECK: with framing disabled the leak reproduces UNLABELLED (so the assertion above is load-bearing)', async () => {
|
|
465
|
+
captureForeground(WORKING_NOTES)
|
|
466
|
+
const bot = recordingBot()
|
|
467
|
+
|
|
468
|
+
const run = await realSweep(dir, bot, { framingEnabled: false })
|
|
469
|
+
|
|
470
|
+
// The pre-change behaviour, verbatim: the working notes go to the waiting
|
|
471
|
+
// human with nothing to say they are not the answer.
|
|
472
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
473
|
+
expect(run.summary.delivered).toBe(1)
|
|
474
|
+
expect(bot.chatCalls()[0].text).not.toContain(REPLY_THROW_PROVENANCE_NOTICE)
|
|
475
|
+
expect(bot.chatCalls()[0].text).toContain('Dispatched the worker')
|
|
476
|
+
expect(run.summary.provenanceFramed).toBeUndefined()
|
|
477
|
+
expect(run.framingLines).toHaveLength(0)
|
|
478
|
+
expect(journalLines(dir)[0].framedProvenance).toBeUndefined()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('ANTI-SILENCE: a genuine answer re-written as prose after the throw still reaches the user in full', async () => {
|
|
482
|
+
// This is why the rule is framing and not suppression. The transcript is
|
|
483
|
+
// structurally IDENTICAL to the working-notes case; only the prose differs,
|
|
484
|
+
// and no classifier in this pipeline can tell them apart. A suppression
|
|
485
|
+
// rule would silently swallow this answer from a waiting human.
|
|
486
|
+
captureForeground(REAL_ANSWER)
|
|
487
|
+
const bot = recordingBot()
|
|
488
|
+
|
|
489
|
+
const run = await realSweep(dir, bot)
|
|
490
|
+
|
|
491
|
+
expect(run.summary.delivered).toBe(1)
|
|
492
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
493
|
+
expect(bot.chatCalls()[0].text).toContain(REAL_ANSWER)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('LEGACY: a record with no replyToolThrewThisTurn field delivers byte-for-byte unframed', async () => {
|
|
497
|
+
const outbox = join(dir, 'outbox')
|
|
498
|
+
mkdirSync(outbox, { recursive: true })
|
|
499
|
+
const nonce = `${DM_CHAT}:_#5150`
|
|
500
|
+
const legacy = {
|
|
501
|
+
turnNonce: nonce,
|
|
502
|
+
chatId: DM_CHAT,
|
|
503
|
+
threadId: null,
|
|
504
|
+
text: REAL_ANSWER,
|
|
505
|
+
textSha256: sha256Hex(REAL_ANSWER),
|
|
506
|
+
// Inside the max-age window, so the body carries NO prefix at all and
|
|
507
|
+
// "byte-for-byte unframed" can be asserted as exact equality.
|
|
508
|
+
createdAt: Date.now(),
|
|
509
|
+
source: 'channel',
|
|
510
|
+
audience: 'user',
|
|
511
|
+
}
|
|
512
|
+
expect(Object.keys(legacy)).not.toContain('replyToolThrewThisTurn')
|
|
513
|
+
writeFileSync(join(outbox, `${nonce}.json`), JSON.stringify(legacy), 'utf8')
|
|
514
|
+
|
|
515
|
+
const bot = recordingBot()
|
|
516
|
+
const run = await realSweep(dir, bot)
|
|
517
|
+
|
|
518
|
+
expect(run.summary.delivered).toBe(1)
|
|
519
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
520
|
+
expect(bot.chatCalls()[0].text).toBe(REAL_ANSWER)
|
|
521
|
+
expect(run.framingLines).toHaveLength(0)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('#4140 still wins where it binds: an internal record is suppressed, never framed-and-sent', async () => {
|
|
525
|
+
// Interaction check between the two gates. A background record can carry
|
|
526
|
+
// BOTH signals (reply threw, nobody waiting); suppression is upstream at
|
|
527
|
+
// entry selection, so framing must never resurrect it as a send.
|
|
528
|
+
const outbox = join(dir, 'outbox')
|
|
529
|
+
mkdirSync(outbox, { recursive: true })
|
|
530
|
+
const nonce = `${DM_CHAT}:_#6160`
|
|
531
|
+
writeFileSync(
|
|
532
|
+
join(outbox, `${nonce}.json`),
|
|
533
|
+
JSON.stringify({
|
|
534
|
+
turnNonce: nonce,
|
|
535
|
+
chatId: DM_CHAT,
|
|
536
|
+
threadId: null,
|
|
537
|
+
text: WORKING_NOTES,
|
|
538
|
+
textSha256: sha256Hex(WORKING_NOTES),
|
|
539
|
+
createdAt: 1000,
|
|
540
|
+
source: 'task-notification',
|
|
541
|
+
audience: 'internal',
|
|
542
|
+
replyToolThrewThisTurn: true,
|
|
543
|
+
}),
|
|
544
|
+
'utf8',
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
const bot = recordingBot()
|
|
548
|
+
const run = await realSweep(dir, bot)
|
|
549
|
+
|
|
550
|
+
expect(bot.chatCalls()).toEqual([])
|
|
551
|
+
expect(run.summary.audienceSuppressed).toBe(1)
|
|
552
|
+
expect(run.summary.provenanceFramed).toBeUndefined()
|
|
553
|
+
expect(run.framingLines).toHaveLength(0)
|
|
554
|
+
expect(journalLines(dir)[0].suppressedAudience).toBe('internal')
|
|
555
|
+
expect(journalLines(dir)[0].framedProvenance).toBeUndefined()
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('the OTHER live producer (flood-reply-queue) writes no throw signal and delivers unframed', async () => {
|
|
559
|
+
// `gateway/flood-reply-queue.ts` is LIVE (imported by
|
|
560
|
+
// gateway/outbound-send-path.ts). Its records are replies the agent
|
|
561
|
+
// ALREADY chose to send — framing them would be a lie.
|
|
562
|
+
const res = queueFloodBlockedReply({
|
|
563
|
+
err: new Error('FLOOD_WAIT_ACTIVE — send rejected, retry_after 30'),
|
|
564
|
+
chatId: DM_CHAT,
|
|
565
|
+
threadId: null,
|
|
566
|
+
text: REAL_ANSWER,
|
|
567
|
+
createdAt: 1000,
|
|
568
|
+
stateDir: dir,
|
|
569
|
+
})
|
|
570
|
+
expect(res).not.toBeNull()
|
|
571
|
+
const rec = outboxRecords(dir)[0]
|
|
572
|
+
expect(rec.audience).toBe('user')
|
|
573
|
+
expect(rec.replyToolThrewThisTurn).toBeUndefined()
|
|
574
|
+
|
|
575
|
+
const bot = recordingBot()
|
|
576
|
+
const run = await realSweep(dir, bot)
|
|
577
|
+
|
|
578
|
+
expect(run.summary.delivered).toBe(1)
|
|
579
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
580
|
+
expect(bot.chatCalls()[0].text).not.toContain(REPLY_THROW_PROVENANCE_NOTICE)
|
|
581
|
+
expect(bot.chatCalls()[0].text).toContain(REAL_ANSWER)
|
|
582
|
+
expect(run.framingLines).toHaveLength(0)
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The BACKGROUND shape #4140's gate is built for: a task-notification wake
|
|
588
|
+
* (never opens an obligation), a reply-tool throw, trailing working notes.
|
|
589
|
+
* Used here to prove the stale-snapshot hole (#4146), because this is the only
|
|
590
|
+
* shape where a bad "nobody is waiting" verdict can actually suppress.
|
|
591
|
+
*/
|
|
592
|
+
function backgroundThrowTranscript(dir: string, prose: string): string {
|
|
593
|
+
return writeTranscript(dir, [
|
|
594
|
+
{
|
|
595
|
+
type: 'queue-operation',
|
|
596
|
+
operation: 'enqueue',
|
|
597
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
|
|
598
|
+
timestamp: 1000,
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
type: 'queue-operation',
|
|
602
|
+
operation: 'enqueue',
|
|
603
|
+
content: '<task-notification><task-id>a7cc7a0fa8f</task-id> worker done</task-notification>',
|
|
604
|
+
timestamp: 2000,
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
type: 'assistant',
|
|
608
|
+
message: {
|
|
609
|
+
content: [
|
|
610
|
+
{
|
|
611
|
+
type: 'tool_use',
|
|
612
|
+
id: 'toolu_reply_1',
|
|
613
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
614
|
+
input: { text: 'On it.', disable_notification: true },
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
type: 'user',
|
|
621
|
+
message: {
|
|
622
|
+
content: [
|
|
623
|
+
{
|
|
624
|
+
type: 'tool_result',
|
|
625
|
+
tool_use_id: 'toolu_reply_1',
|
|
626
|
+
is_error: true,
|
|
627
|
+
content: 'Error: FLOOD_WAIT_ACTIVE — send rejected',
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: prose }] } },
|
|
633
|
+
])
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
describe('#4146 — a snapshot that is not being maintained is not evidence', () => {
|
|
637
|
+
let dir: string
|
|
638
|
+
beforeEach(() => {
|
|
639
|
+
dir = makeStateDir()
|
|
640
|
+
})
|
|
641
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
642
|
+
|
|
643
|
+
it('an explicit distrust beats anything on disk, including an empty open set', () => {
|
|
644
|
+
const emptyButStale = JSON.stringify({ v: 1, obligations: [] })
|
|
645
|
+
// Trusted: the pre-#4146 reading — positive proof that nobody is waiting.
|
|
646
|
+
expect(
|
|
647
|
+
resolveOpenObligation({ snapshotRaw: emptyButStale, chatId: DM_CHAT }),
|
|
648
|
+
).toBe(false)
|
|
649
|
+
// Distrusted: the SAME bytes prove nothing.
|
|
650
|
+
expect(
|
|
651
|
+
resolveOpenObligation({
|
|
652
|
+
snapshotRaw: emptyButStale,
|
|
653
|
+
chatId: DM_CHAT,
|
|
654
|
+
snapshotTrusted: false,
|
|
655
|
+
}),
|
|
656
|
+
).toBe('unknown')
|
|
657
|
+
// ...which flips the classifier back to the delivering direction.
|
|
658
|
+
expect(
|
|
659
|
+
decideCaptureAudience({
|
|
660
|
+
replyToolThrewThisTurn: true,
|
|
661
|
+
openInboundObligation: resolveOpenObligation({
|
|
662
|
+
snapshotRaw: emptyButStale,
|
|
663
|
+
chatId: DM_CHAT,
|
|
664
|
+
snapshotTrusted: false,
|
|
665
|
+
}),
|
|
666
|
+
}),
|
|
667
|
+
).toBe('user')
|
|
668
|
+
// Only an exact `false` distrusts — an unaware caller cannot weaken it.
|
|
669
|
+
expect(
|
|
670
|
+
resolveOpenObligation({ snapshotRaw: emptyButStale, chatId: DM_CHAT, snapshotTrusted: true }),
|
|
671
|
+
).toBe(false)
|
|
672
|
+
expect(
|
|
673
|
+
resolveOpenObligation({
|
|
674
|
+
snapshotRaw: emptyButStale,
|
|
675
|
+
chatId: DM_CHAT,
|
|
676
|
+
snapshotTrusted: undefined,
|
|
677
|
+
}),
|
|
678
|
+
).toBe(false)
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('OUTCOME: with the ledger off, a lingering empty snapshot no longer suppresses — the text still reaches the chat', async () => {
|
|
682
|
+
// The exact #4146 configuration: the agent once ran with the ledger on, so
|
|
683
|
+
// an empty `obligations.json` is on disk; the ledger is now off, so
|
|
684
|
+
// obligations are going untracked and that file is a leftover.
|
|
685
|
+
writeFileSync(join(dir, 'obligations.json'), JSON.stringify({ v: 1, obligations: [] }), 'utf8')
|
|
686
|
+
const t = backgroundThrowTranscript(dir, REAL_ANSWER)
|
|
687
|
+
expect(runHook(t, dir, { SWITCHROOM_OBLIGATION_LEDGER: '0' }).status).toBe(0)
|
|
688
|
+
|
|
689
|
+
const rec = outboxRecords(dir)[0]
|
|
690
|
+
expect(rec.audience).toBe('user')
|
|
691
|
+
|
|
692
|
+
const bot = recordingBot()
|
|
693
|
+
const run = await realSweep(dir, bot)
|
|
694
|
+
expect(run.summary.delivered).toBe(1)
|
|
695
|
+
expect(run.summary.audienceSuppressed).toBeUndefined()
|
|
696
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
697
|
+
expect(bot.chatCalls()[0].text).toContain(REAL_ANSWER)
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('REVERT CHECK (#4146): with the ledger ON, the same lingering snapshot DOES suppress', async () => {
|
|
701
|
+
// Same bytes, same transcript — only the trust signal differs. This is what
|
|
702
|
+
// makes the assertion above load-bearing: without the guard the message is
|
|
703
|
+
// swallowed with zero chat calls.
|
|
704
|
+
writeFileSync(join(dir, 'obligations.json'), JSON.stringify({ v: 1, obligations: [] }), 'utf8')
|
|
705
|
+
const t = backgroundThrowTranscript(dir, REAL_ANSWER)
|
|
706
|
+
expect(runHook(t, dir, { SWITCHROOM_OBLIGATION_LEDGER: '1' }).status).toBe(0)
|
|
707
|
+
|
|
708
|
+
expect(outboxRecords(dir)[0].audience).toBe('internal')
|
|
709
|
+
|
|
710
|
+
const bot = recordingBot()
|
|
711
|
+
const run = await realSweep(dir, bot)
|
|
712
|
+
expect(bot.chatCalls()).toEqual([])
|
|
713
|
+
expect(run.summary.audienceSuppressed).toBe(1)
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
describe('#4141 — framing is honest telemetry and cannot launder a broken turn', () => {
|
|
718
|
+
it('the framing line matches NO gateway alarm signature (a framed delivery is a delivery)', () => {
|
|
719
|
+
const line = formatReplyThrowFraming({
|
|
720
|
+
turnNonce: NONCE,
|
|
721
|
+
turnId: NONCE,
|
|
722
|
+
chatId: DM_CHAT,
|
|
723
|
+
textSha256: sha256Hex(WORKING_NOTES),
|
|
724
|
+
source: 'channel',
|
|
725
|
+
})
|
|
726
|
+
for (const [name, re] of Object.entries(GATEWAY_SIGNATURES)) {
|
|
727
|
+
expect(re.test(line), `${name} must not match a by-design framed delivery`).toBe(false)
|
|
728
|
+
}
|
|
729
|
+
expect(detectGatewayFindings('klanker', line).findings).toEqual([])
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
it('the framing line carries a turn id the fleet-health scanner can join on', () => {
|
|
733
|
+
expect(turnIdFromNonce(NONCE)).toBe(NONCE)
|
|
734
|
+
const line = formatReplyThrowFraming({ turnNonce: NONCE, turnId: NONCE, chatId: DM_CHAT })
|
|
735
|
+
expect(extractTurnId(line)).toBe(NONCE)
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it('DISJOINTNESS: a framed turn has tools>=1, so it can never enter the silent-no-op branch', () => {
|
|
739
|
+
// Checked through the REAL builder + REAL detector, not by inspection.
|
|
740
|
+
// Framing is only reachable when the reply tool was CALLED and threw, so
|
|
741
|
+
// the turn has >= 1 tool call; the silent-no-op branch requires
|
|
742
|
+
// `tools === 0` (src/fleet-health/detect.ts:305-333).
|
|
743
|
+
const now = Math.floor(Date.now() / 1000)
|
|
744
|
+
const row = buildTurnRecord(
|
|
745
|
+
{
|
|
746
|
+
agent: 'klanker',
|
|
747
|
+
startedAt: 1000,
|
|
748
|
+
toolCallCount: 1, // the reply tool call that threw
|
|
749
|
+
turnId: NONCE,
|
|
750
|
+
finalAnswerDelivered: false,
|
|
751
|
+
replyCalled: true,
|
|
752
|
+
},
|
|
753
|
+
Date.now(),
|
|
754
|
+
)
|
|
755
|
+
expect(row.tools).toBe(1)
|
|
756
|
+
expect(row.route).toBe('none')
|
|
757
|
+
const findings = detectTurnFindings('klanker', [{ ...row, ts: now }])
|
|
758
|
+
expect(findings.filter((f) => f.signal === 'silent-no-op-candidate')).toEqual([])
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('CONTROL: the silent-no-op detector is NOT blinded — a zero-tool no-op still scores', () => {
|
|
762
|
+
// The disjointness claim above is only meaningful if the detector would
|
|
763
|
+
// otherwise fire on the SAME call. This is that control: same detector,
|
|
764
|
+
// tools=0, complete, and no honest `route` — the regression shape
|
|
765
|
+
// `detect.ts:333-343` treats as `none` and scores sev-3.
|
|
766
|
+
const now = Math.floor(Date.now() / 1000)
|
|
767
|
+
const row = buildTurnRecord(
|
|
768
|
+
{
|
|
769
|
+
agent: 'klanker',
|
|
770
|
+
startedAt: 1000,
|
|
771
|
+
toolCallCount: 0,
|
|
772
|
+
turnId: `${DM_CHAT}:_#9999`,
|
|
773
|
+
finalAnswerDelivered: true,
|
|
774
|
+
},
|
|
775
|
+
Date.now(),
|
|
776
|
+
)
|
|
777
|
+
expect(row.tools).toBe(0)
|
|
778
|
+
expect(row.status).toBe('complete')
|
|
779
|
+
const { route: _dropped, ...legacyRow } = row
|
|
780
|
+
const findings = detectTurnFindings('klanker', [{ ...legacyRow, ts: now }])
|
|
781
|
+
expect(findings.filter((f) => f.signal === 'silent-no-op-candidate')).toHaveLength(1)
|
|
782
|
+
})
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
786
|
+
// #4141 REVIEW FINDING — THE ELECTED PATH
|
|
787
|
+
//
|
|
788
|
+
// The sweep-path framing above binds only when the Stop hook writes an outbox
|
|
789
|
+
// RECORD. On the dominant live shape it does not:
|
|
790
|
+
//
|
|
791
|
+
// The reply tool is called with a QUALIFYING final answer and it THROWS.
|
|
792
|
+
// `scanForOutboxCapture` classifies a qualifying reply-tool call from its
|
|
793
|
+
// INPUT (`isFinalAnswerReply`, silent-end-scan.mjs:1008) and never consults
|
|
794
|
+
// the tool_result, so `replyAlreadyDeliveredThisTurn` is true even though the
|
|
795
|
+
// call errored. The hook therefore takes the #3510 branch, writes NO outbox
|
|
796
|
+
// record, and falls through to the single-writer election, which hands the
|
|
797
|
+
// trailing prose to the gateway's captured-prose bridge.
|
|
798
|
+
//
|
|
799
|
+
// So on a healthy gateway the FIRST, common occurrence goes out through a
|
|
800
|
+
// machine the outbox sweep never touches. These tests pin the label onto THAT
|
|
801
|
+
// machine, driving the real hook, the real `decideCapturedProseDelivery`, and
|
|
802
|
+
// the real `deliverCapturedProse` against the recording bot.
|
|
803
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The ELECTED foreground shape: a QUALIFYING final-answer reply (no
|
|
807
|
+
* `disable_notification`, so `isFinalAnswerReply` is true on the input alone)
|
|
808
|
+
* that throws, followed by trailing prose over the bridge's 200-char floor.
|
|
809
|
+
*/
|
|
810
|
+
function electedThrowTranscript(dir: string, prose: string, opts: { throws?: boolean } = {}) {
|
|
811
|
+
const lines: object[] = [
|
|
812
|
+
{
|
|
813
|
+
type: 'queue-operation',
|
|
814
|
+
operation: 'enqueue',
|
|
815
|
+
content: `<channel source="telegram" chat_id="${DM_CHAT}" message_id="${INBOUND_MSG_ID}">where did the rebase land?</channel>`,
|
|
816
|
+
timestamp: 1000,
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
type: 'assistant',
|
|
820
|
+
message: {
|
|
821
|
+
content: [
|
|
822
|
+
{
|
|
823
|
+
type: 'tool_use',
|
|
824
|
+
id: 'toolu_reply_elected',
|
|
825
|
+
name: 'mcp__switchroom-telegram__reply',
|
|
826
|
+
// No `disable_notification` ⇒ qualifying final answer by input.
|
|
827
|
+
input: { text: 'The rebase landed cleanly on the merge base.' },
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
]
|
|
833
|
+
if (opts.throws !== false) {
|
|
834
|
+
lines.push({
|
|
835
|
+
type: 'user',
|
|
836
|
+
message: {
|
|
837
|
+
content: [
|
|
838
|
+
{
|
|
839
|
+
type: 'tool_result',
|
|
840
|
+
tool_use_id: 'toolu_reply_elected',
|
|
841
|
+
is_error: true,
|
|
842
|
+
content: 'Error: request timed out after 30000ms',
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
},
|
|
846
|
+
})
|
|
847
|
+
} else {
|
|
848
|
+
lines.push({
|
|
849
|
+
type: 'user',
|
|
850
|
+
message: {
|
|
851
|
+
content: [
|
|
852
|
+
{ type: 'tool_result', tool_use_id: 'toolu_reply_elected', content: 'sent' },
|
|
853
|
+
],
|
|
854
|
+
},
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
lines.push({ type: 'assistant', message: { content: [{ type: 'text', text: prose }] } })
|
|
858
|
+
return writeTranscript(dir, lines)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Make the election's gateway-liveness gate pass. */
|
|
862
|
+
function writeFreshHeartbeat(dir: string): void {
|
|
863
|
+
writeFileSync(join(dir, 'gateway-heartbeat'), 'up', 'utf8')
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function readElectedState(dir: string): Record<string, unknown> | null {
|
|
867
|
+
const p = join(dir, 'silent-end-pending.json')
|
|
868
|
+
if (!existsSync(p)) return null
|
|
869
|
+
return JSON.parse(readFileSync(p, 'utf8')) as Record<string, unknown>
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Drive the REAL captured-prose bridge against the recording bot. */
|
|
873
|
+
async function realBridge(
|
|
874
|
+
stateDir: string,
|
|
875
|
+
bot: ReturnType<typeof recordingBot>,
|
|
876
|
+
decision: { text?: string; replyToolThrewThisTurn?: boolean },
|
|
877
|
+
) {
|
|
878
|
+
const stderr: string[] = []
|
|
879
|
+
// `journalExternalDelivery` resolves its state dir from the env (the bridge
|
|
880
|
+
// takes no stateDir arg), so point it at this test's dir for the duration.
|
|
881
|
+
const prevStateDir = process.env.TELEGRAM_STATE_DIR
|
|
882
|
+
process.env.TELEGRAM_STATE_DIR = stateDir
|
|
883
|
+
const origWrite = process.stderr.write.bind(process.stderr)
|
|
884
|
+
;(process.stderr as unknown as { write: (s: string) => boolean }).write = (s: string) => {
|
|
885
|
+
stderr.push(String(s))
|
|
886
|
+
return true
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
await deliverCapturedProse(
|
|
890
|
+
{
|
|
891
|
+
outboundDedup: new OutboundDedupCache({}),
|
|
892
|
+
bot: bot as never,
|
|
893
|
+
robustApiCall: passthroughRetry,
|
|
894
|
+
redactOutboundText: (t: string) => t,
|
|
895
|
+
recordOutbound: () => {},
|
|
896
|
+
HISTORY_ENABLED: false,
|
|
897
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
898
|
+
obligationLedger: { close: () => {} },
|
|
899
|
+
clearSilentEndState: () => {},
|
|
900
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
901
|
+
hasOutboundDeliveredSince: () => false,
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
chatId: DM_CHAT,
|
|
905
|
+
threadId: undefined,
|
|
906
|
+
statusKeyStr: `${DM_CHAT}:_`,
|
|
907
|
+
registryKey: null,
|
|
908
|
+
originTurnId: NONCE,
|
|
909
|
+
text: decision.text!,
|
|
910
|
+
replyToolThrewThisTurn: decision.replyToolThrewThisTurn === true,
|
|
911
|
+
},
|
|
912
|
+
)
|
|
913
|
+
} finally {
|
|
914
|
+
;(process.stderr as unknown as { write: typeof origWrite }).write = origWrite
|
|
915
|
+
if (prevStateDir === undefined) delete process.env.TELEGRAM_STATE_DIR
|
|
916
|
+
else process.env.TELEGRAM_STATE_DIR = prevStateDir
|
|
917
|
+
}
|
|
918
|
+
return { stderr }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
describe('#4141 elected path — the bridge, not the sweep, is the common deliverer', () => {
|
|
922
|
+
let dir: string
|
|
923
|
+
beforeEach(() => {
|
|
924
|
+
dir = makeStateDir()
|
|
925
|
+
writeFreshHeartbeat(dir)
|
|
926
|
+
writeOpenObligation(dir)
|
|
927
|
+
})
|
|
928
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
929
|
+
|
|
930
|
+
it('PREMISE: a thrown QUALIFYING reply writes no outbox record and elects the bridge', () => {
|
|
931
|
+
const t = electedThrowTranscript(dir, WORKING_NOTES)
|
|
932
|
+
const res = runHook(t, dir)
|
|
933
|
+
expect(res.status).toBe(0)
|
|
934
|
+
// The sweep-path record — the only thing the outbox framing can label —
|
|
935
|
+
// is never written on this shape. This is exactly why the sweep-side fix
|
|
936
|
+
// alone left #4141 open.
|
|
937
|
+
expect(outboxRecords(dir)).toEqual([])
|
|
938
|
+
expect(res.stderr).toContain('deferring to single-writer election')
|
|
939
|
+
expect(res.stderr).toContain('single-writer election ALLOWED stop')
|
|
940
|
+
// ...and a real sweep tick therefore has nothing at all to deliver.
|
|
941
|
+
expect(outboxRecords(dir)).toHaveLength(0)
|
|
942
|
+
})
|
|
943
|
+
|
|
944
|
+
it('the elected state file carries the reply-throw signal to the bridge', () => {
|
|
945
|
+
runHook(electedThrowTranscript(dir, WORKING_NOTES), dir)
|
|
946
|
+
const state = readElectedState(dir)
|
|
947
|
+
expect(state).not.toBeNull()
|
|
948
|
+
expect(state!.replyToolThrewThisTurn).toBe(true)
|
|
949
|
+
expect(state!.pendingText).toBe(WORKING_NOTES)
|
|
950
|
+
expect(state!.turnId).toBe(NONCE)
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
it('a turn whose reply did NOT throw carries no signal — no stale carryover', () => {
|
|
954
|
+
// Seed a state file that already claims a throw, then run a CLEAN turn.
|
|
955
|
+
// `buildNextState` must DELETE the field, not spread it forward.
|
|
956
|
+
writeFileSync(
|
|
957
|
+
join(dir, 'silent-end-pending.json'),
|
|
958
|
+
JSON.stringify({ retryCount: 0, replyToolThrewThisTurn: true, turnKey: 'stale' }),
|
|
959
|
+
'utf8',
|
|
960
|
+
)
|
|
961
|
+
runHook(electedThrowTranscript(dir, WORKING_NOTES, { throws: false }), dir)
|
|
962
|
+
const state = readElectedState(dir)
|
|
963
|
+
expect(state).not.toBeNull()
|
|
964
|
+
expect(state!.replyToolThrewThisTurn).toBeUndefined()
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it('OUTCOME: the bridge delivers the prose WITH its provenance stated', async () => {
|
|
968
|
+
runHook(electedThrowTranscript(dir, WORKING_NOTES), dir)
|
|
969
|
+
const decision = decideCapturedProseDelivery(
|
|
970
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
971
|
+
{ stateDir: dir },
|
|
972
|
+
)
|
|
973
|
+
expect(decision.deliver).toBe(true)
|
|
974
|
+
expect(decision.text).toBe(WORKING_NOTES)
|
|
975
|
+
expect(decision.replyToolThrewThisTurn).toBe(true)
|
|
976
|
+
|
|
977
|
+
const bot = recordingBot()
|
|
978
|
+
const { stderr } = await realBridge(dir, bot, decision)
|
|
979
|
+
|
|
980
|
+
// A. FRAMING NEVER SILENCES — exactly one chat-visible call, prose intact.
|
|
981
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
982
|
+
const sent = bot.chatCalls()[0]!.text
|
|
983
|
+
expect(sent).toContain(WORKING_NOTES)
|
|
984
|
+
expect(sent).toContain(REPLY_THROW_PROVENANCE_NOTICE)
|
|
985
|
+
expect(sent.indexOf(REPLY_THROW_PROVENANCE_NOTICE)).toBeLessThan(sent.indexOf(WORKING_NOTES))
|
|
986
|
+
|
|
987
|
+
// B. FRAMING IS OBSERVABLE — durable journal stamp + one telemetry line.
|
|
988
|
+
const journal = journalLines(dir)
|
|
989
|
+
expect(journal).toHaveLength(1)
|
|
990
|
+
expect(journal[0]!.framedProvenance).toBe('reply-throw')
|
|
991
|
+
expect(journal[0]!.tgMessageId).toBeDefined()
|
|
992
|
+
// The journal key stays on the RAW text so exactly-once-among-backstops
|
|
993
|
+
// is unaffected by the label.
|
|
994
|
+
expect(journal[0]!.textSha256).toBe(sha256Hex(WORKING_NOTES))
|
|
995
|
+
expect(stderr.filter((l) => l.includes('outbox provenance framing'))).toHaveLength(1)
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('REVERT CHECK: with framing off the bridge reproduces the #4141 leak', async () => {
|
|
999
|
+
const prev = process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING
|
|
1000
|
+
process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING = '0'
|
|
1001
|
+
try {
|
|
1002
|
+
runHook(electedThrowTranscript(dir, WORKING_NOTES), dir)
|
|
1003
|
+
const decision = decideCapturedProseDelivery(
|
|
1004
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
1005
|
+
{ stateDir: dir },
|
|
1006
|
+
)
|
|
1007
|
+
const bot = recordingBot()
|
|
1008
|
+
await realBridge(dir, bot, decision)
|
|
1009
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
1010
|
+
// The bug: working notes delivered to a waiting human, unlabelled.
|
|
1011
|
+
expect(bot.chatCalls()[0]!.text).toBe(WORKING_NOTES)
|
|
1012
|
+
expect(bot.chatCalls()[0]!.text).not.toContain(REPLY_THROW_PROVENANCE_NOTICE)
|
|
1013
|
+
expect(journalLines(dir)[0]!.framedProvenance).toBeUndefined()
|
|
1014
|
+
} finally {
|
|
1015
|
+
if (prev === undefined) delete process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING
|
|
1016
|
+
else process.env.SWITCHROOM_TG_OUTBOX_PROVENANCE_FRAMING = prev
|
|
1017
|
+
}
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
it('ANTI-SILENCE: a genuine answer rewritten as prose still arrives in full', async () => {
|
|
1021
|
+
runHook(electedThrowTranscript(dir, REAL_ANSWER), dir)
|
|
1022
|
+
const decision = decideCapturedProseDelivery(
|
|
1023
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
1024
|
+
{ stateDir: dir },
|
|
1025
|
+
)
|
|
1026
|
+
const bot = recordingBot()
|
|
1027
|
+
await realBridge(dir, bot, decision)
|
|
1028
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
1029
|
+
expect(bot.chatCalls()[0]!.text).toContain(REAL_ANSWER)
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
it('LEGACY: a state file with no signal delivers byte-for-byte unframed', async () => {
|
|
1033
|
+
writeFileSync(
|
|
1034
|
+
join(dir, 'silent-end-pending.json'),
|
|
1035
|
+
JSON.stringify({
|
|
1036
|
+
retryCount: 0,
|
|
1037
|
+
turnKey: `${DM_CHAT}:_`,
|
|
1038
|
+
turnId: NONCE,
|
|
1039
|
+
chatId: DM_CHAT,
|
|
1040
|
+
threadId: null,
|
|
1041
|
+
timestamp: Date.now(),
|
|
1042
|
+
pendingText: WORKING_NOTES,
|
|
1043
|
+
}),
|
|
1044
|
+
'utf8',
|
|
1045
|
+
)
|
|
1046
|
+
const decision = decideCapturedProseDelivery(
|
|
1047
|
+
{ turnKey: `${DM_CHAT}:_`, turnId: NONCE },
|
|
1048
|
+
{ stateDir: dir },
|
|
1049
|
+
)
|
|
1050
|
+
expect(decision.replyToolThrewThisTurn).toBeUndefined()
|
|
1051
|
+
const bot = recordingBot()
|
|
1052
|
+
await realBridge(dir, bot, decision)
|
|
1053
|
+
expect(bot.chatCalls()[0]!.text).toBe(WORKING_NOTES)
|
|
1054
|
+
})
|
|
1055
|
+
|
|
1056
|
+
it('the label is presentation only — dedup still keys on the RAW prose', async () => {
|
|
1057
|
+
// Additive-by-construction, re-established on THIS path: this path has its
|
|
1058
|
+
// own send ladder and its own dedup, so the sweep's proof does not carry.
|
|
1059
|
+
// A second bridge call with the same raw text must dedup (zero new sends)
|
|
1060
|
+
// even though the first send carried the banner.
|
|
1061
|
+
const dedup = new OutboundDedupCache({})
|
|
1062
|
+
const bot = recordingBot()
|
|
1063
|
+
const deps = {
|
|
1064
|
+
outboundDedup: dedup,
|
|
1065
|
+
bot: bot as never,
|
|
1066
|
+
robustApiCall: passthroughRetry,
|
|
1067
|
+
redactOutboundText: (t: string) => t,
|
|
1068
|
+
recordOutbound: () => {},
|
|
1069
|
+
HISTORY_ENABLED: false,
|
|
1070
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
1071
|
+
obligationLedger: { close: () => {} },
|
|
1072
|
+
clearSilentEndState: () => {},
|
|
1073
|
+
recordUndeliveredTurnEnd: () => ({ exhausted: false }),
|
|
1074
|
+
hasOutboundDeliveredSince: () => false,
|
|
1075
|
+
}
|
|
1076
|
+
const args = {
|
|
1077
|
+
chatId: DM_CHAT,
|
|
1078
|
+
threadId: undefined,
|
|
1079
|
+
statusKeyStr: `${DM_CHAT}:_`,
|
|
1080
|
+
registryKey: null,
|
|
1081
|
+
originTurnId: NONCE,
|
|
1082
|
+
text: WORKING_NOTES,
|
|
1083
|
+
replyToolThrewThisTurn: true,
|
|
1084
|
+
}
|
|
1085
|
+
await deliverCapturedProse(deps, args)
|
|
1086
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
1087
|
+
// Same raw text arriving unframed (e.g. a late reply-tool retry) is still
|
|
1088
|
+
// recognised as already-sent — the banner did not change the identity.
|
|
1089
|
+
await deliverCapturedProse(deps, { ...args, replyToolThrewThisTurn: false })
|
|
1090
|
+
expect(bot.chatCalls()).toHaveLength(1)
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
it('DISJOINTNESS: the turn-flush machine cannot deliver on a reply-throw turn', () => {
|
|
1094
|
+
// The elected `no-final-reply` leg names the flush as the deliverer, so it
|
|
1095
|
+
// is fair to ask whether IT also needs the label. It does not, and this is
|
|
1096
|
+
// structural rather than a judgement: `turn.replyCalled` is set on the
|
|
1097
|
+
// reply tool's CALL event (stream-render.ts:1196), before any result, so a
|
|
1098
|
+
// thrown reply still sets it — and `decideTurnFlush` skips outright on
|
|
1099
|
+
// `replyCalled`. A reply-throw turn can therefore never reach the flush
|
|
1100
|
+
// send site, which is why framing lives on the bridge instead.
|
|
1101
|
+
const flushed = decideTurnFlush({
|
|
1102
|
+
replyCalled: true,
|
|
1103
|
+
capturedText: [WORKING_NOTES],
|
|
1104
|
+
turnFlushSafetyEnabled: true,
|
|
1105
|
+
} as never)
|
|
1106
|
+
expect(flushed.kind).toBe('skip')
|
|
1107
|
+
expect(flushed.reason).toBe('reply-called')
|
|
1108
|
+
})
|
|
1109
|
+
})
|
|
1110
|
+
|
|
1111
|
+
describe('#4141 — the elected path is WIRED, not just implemented', () => {
|
|
1112
|
+
it('stream-render forwards the signal from the decision into the bridge call', () => {
|
|
1113
|
+
// The two ends of this path are covered by outcome tests above
|
|
1114
|
+
// (`decideCapturedProseDelivery` surfaces the signal; `deliverCapturedProse`
|
|
1115
|
+
// frames on it). The ONE link between them is a single argument in
|
|
1116
|
+
// stream-render's turn_end handler, which cannot be driven in isolation —
|
|
1117
|
+
// it lives inside a ~2000-line event handler with the whole gateway as its
|
|
1118
|
+
// closure. So it is pinned structurally: without this argument the feature
|
|
1119
|
+
// is inert in production while every other test in this file still passes,
|
|
1120
|
+
// which is exactly the failure mode this pin exists to catch.
|
|
1121
|
+
const src = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf8')
|
|
1122
|
+
const call = src.slice(src.indexOf('void deliverCapturedProse({'))
|
|
1123
|
+
const body = call.slice(0, call.indexOf('\n })'))
|
|
1124
|
+
expect(body).toContain('replyToolThrewThisTurn: proseDecision.replyToolThrewThisTurn === true')
|
|
1125
|
+
})
|
|
1126
|
+
})
|