switchroom 0.18.30 → 0.18.32
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 -2
- package/dist/auth-broker/index.js +4 -2
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +708 -255
- package/dist/host-control/main.js +5 -3
- package/dist/vault/approvals/kernel-server.js +4 -2
- package/dist/vault/broker/server.js +4 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +142 -7
- package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +5479 -7069
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/subagent-watcher.ts +111 -28
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +27 -3
- package/telegram-plugin/worker-activity-feed.ts +24 -0
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -101,14 +101,17 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
|
|
|
101
101
|
expect(fn).toMatch(/hasPendingApproval\s*=\s*pendingPermissions\.size\s*>\s*0/)
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
-
it('turnInFlightMachineOnly
|
|
104
|
+
it('turnInFlightMachineOnly reads the delivery machine (sole gate, #2996 P1)', () => {
|
|
105
105
|
// #3262: the machine-in-turn signal moved here (read by the /model & /effort
|
|
106
|
-
// resolver without the approval hold).
|
|
107
|
-
// and the
|
|
108
|
-
//
|
|
106
|
+
// resolver without the approval hold). #2996 P1 deleted the legacy
|
|
107
|
+
// claudeBusyKeys branch and the gate-parity probe — the machine read is
|
|
108
|
+
// the ONLY busy source now; a reintroduced legacy branch would silently
|
|
109
|
+
// fork the busy read again.
|
|
109
110
|
const fn = slice(GATEWAY, 'function turnInFlightMachineOnly()', 1400)
|
|
110
|
-
expect(fn).
|
|
111
|
-
|
|
111
|
+
expect(fn).toContain('return isMachineInTurn()')
|
|
112
|
+
// Code-shape pins (comments may still narrate the deleted legacy design):
|
|
113
|
+
expect(fn).not.toMatch(/claudeBusyKeys\.size/)
|
|
114
|
+
expect(fn).not.toMatch(/probeGateParity\(/)
|
|
112
115
|
// NOT gated by the approval hold — that stays in turnInFlightForGate.
|
|
113
116
|
expect(fn).not.toContain('hasPendingApproval')
|
|
114
117
|
})
|
|
@@ -21,12 +21,15 @@ import { describe, it, expect } from 'vitest'
|
|
|
21
21
|
import { readFileSync } from 'node:fs'
|
|
22
22
|
import { resolve } from 'node:path'
|
|
23
23
|
|
|
24
|
+
// #2996 P2: executeReply's body (incl. the file-send + result + history
|
|
25
|
+
// block this suite pins) moved VERBATIM to outbound-send-path.ts
|
|
26
|
+
// (`sendReply`); the slice reads there now. Assertions unchanged.
|
|
24
27
|
const gatewaySrc = readFileSync(
|
|
25
|
-
resolve(__dirname, '..', 'gateway', '
|
|
28
|
+
resolve(__dirname, '..', 'gateway', 'outbound-send-path.ts'),
|
|
26
29
|
'utf-8',
|
|
27
30
|
)
|
|
28
31
|
|
|
29
|
-
// Slice
|
|
32
|
+
// Slice sendReply's file-send + result + history block: from the
|
|
30
33
|
// photo-precheck anchor to the recordOutbound call for replies.
|
|
31
34
|
const sliceStart = gatewaySrc.indexOf('const photoPrecheck = new Map')
|
|
32
35
|
const sliceEnd = gatewaySrc.indexOf('history recordOutbound (reply) failed')
|
|
@@ -43,8 +43,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
|
43
43
|
const fs = require('node:fs') as typeof import('node:fs')
|
|
44
44
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
45
|
const path = require('node:path') as typeof import('node:path')
|
|
46
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
47
|
+
// (`sendReply`); the post-send block anchors live there now.
|
|
46
48
|
const src = fs.readFileSync(
|
|
47
|
-
path.resolve(__dirname, '../gateway/
|
|
49
|
+
path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
|
|
48
50
|
'utf8',
|
|
49
51
|
)
|
|
50
52
|
// Find the executeReply post-send block — anchored on the
|
|
@@ -75,8 +77,10 @@ describe('#1713 + #1728 — reply tool reaction contract', () => {
|
|
|
75
77
|
const fs = require('node:fs') as typeof import('node:fs')
|
|
76
78
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
77
79
|
const path = require('node:path') as typeof import('node:path')
|
|
80
|
+
// #2996 P2: executeReply's body moved verbatim to outbound-send-path.ts
|
|
81
|
+
// (`sendReply`); the post-send block anchors live there now.
|
|
78
82
|
const src = fs.readFileSync(
|
|
79
|
-
path.resolve(__dirname, '../gateway/
|
|
83
|
+
path.resolve(__dirname, '../gateway/outbound-send-path.ts'),
|
|
80
84
|
'utf8',
|
|
81
85
|
)
|
|
82
86
|
const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
|
|
@@ -145,8 +145,9 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
|
|
|
145
145
|
})
|
|
146
146
|
|
|
147
147
|
it('reaps awaitingAuthCodeAt in the TTL reaper alongside other pending-state maps', () => {
|
|
148
|
-
// The pendingStateReaper interval must sweep expired auth-code-context entries
|
|
149
|
-
|
|
148
|
+
// The pendingStateReaper interval must sweep expired auth-code-context entries.
|
|
149
|
+
// Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
|
|
150
|
+
const reaperIdx = src.indexOf('pendingStateReaper = ')
|
|
150
151
|
expect(reaperIdx).toBeGreaterThan(0)
|
|
151
152
|
const reaperBlock = src.slice(reaperIdx, reaperIdx + 800)
|
|
152
153
|
expect(reaperBlock).toMatch(/awaitingAuthCodeAt/)
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden-transcript harness for the extracted send-orchestration façade
|
|
3
|
+
* (#2996 P2, plan Amendments 9/10).
|
|
4
|
+
*
|
|
5
|
+
* ── Oracle standard (Amendment 10 — read this before "improving" it) ──────
|
|
6
|
+
* The coordinator-accepted oracle for P2 is the EXTRACTED-MODULE golden
|
|
7
|
+
* harness (the merged `outbound-send-chunks.test.ts` precedent, scaled to the
|
|
8
|
+
* full façade): `sendReply` / `deliverCapturedProse` are driven against a
|
|
9
|
+
* fake bot API recording `(method, chat_id, text, opts, reply_markup)` plus
|
|
10
|
+
* the REAL `OutboundDedupCache`. Pinning the literal IN-PLACE `executeReply`
|
|
11
|
+
* first was not possible: it is unexported and its `lockedBot` handle is
|
|
12
|
+
* assigned only inside the `isGatewayMain`-gated boot, so the in-place body
|
|
13
|
+
* cannot be invoked from any test runner even though gateway.ts now imports
|
|
14
|
+
* cleanly (P0e). Behavior preservation therefore rests on (a) the VERBATIM
|
|
15
|
+
* relocation (8 enumerated pin-vs-live spellings, see the module's section
|
|
16
|
+
* comment), (b) the pre-existing structural wiring tests re-pointed at the
|
|
17
|
+
* module, and (c) this behavioral harness.
|
|
18
|
+
*
|
|
19
|
+
* ── What is pinned here ───────────────────────────────────────────────────
|
|
20
|
+
* - multi-chunk send order + keyboard-on-last-chunk
|
|
21
|
+
* - oversize re-split (MESSAGE_TOO_LONG → hard-cap pieces, never dropped)
|
|
22
|
+
* - code fences survive the normalize pipeline to the wire
|
|
23
|
+
* - outbound secret redaction (REAL `redact()`) before the wire + dedup key
|
|
24
|
+
* - partial-failure contract ("reply failed after N of M chunk(s) sent")
|
|
25
|
+
* - voice: voice+text ordering; voice-only text suppression
|
|
26
|
+
* - photo route vs document route file sends
|
|
27
|
+
* - deliverCapturedProse silent-end recovery send + its dedup record
|
|
28
|
+
* - CROSS-SURFACE stream-then-reply dedup through the ONE shared
|
|
29
|
+
* OutboundDedupCache instance (Amendment 1) — with a negative control
|
|
30
|
+
* proving a second instance would reinstate the duplicate-reply class
|
|
31
|
+
* - structural: gateway constructs EXACTLY ONE OutboundDedupCache and the
|
|
32
|
+
* extracted module constructs NONE (the re-`new` hard-fail rule)
|
|
33
|
+
*/
|
|
34
|
+
import { describe, it, expect } from 'vitest'
|
|
35
|
+
import { GrammyError } from 'grammy'
|
|
36
|
+
import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
37
|
+
import { tmpdir } from 'node:os'
|
|
38
|
+
import { join } from 'node:path'
|
|
39
|
+
import {
|
|
40
|
+
sendReply,
|
|
41
|
+
deliverCapturedProse,
|
|
42
|
+
type SendReplyGatewayDeps,
|
|
43
|
+
type DeliverCapturedProseDeps,
|
|
44
|
+
type SendReplyRequest,
|
|
45
|
+
type VoiceOutPlan,
|
|
46
|
+
} from '../gateway/outbound-send-path.js'
|
|
47
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
48
|
+
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
49
|
+
import { redact } from '../secret-detect/redact.js'
|
|
50
|
+
import type { CurrentTurn, Access } from '../gateway/gateway.js'
|
|
51
|
+
|
|
52
|
+
// ── fake bot API ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface RecordedCall {
|
|
55
|
+
method: string
|
|
56
|
+
chat_id: string
|
|
57
|
+
/** text for literal sends; the rich `{ markdown }` body's text for rich. */
|
|
58
|
+
text: string | null
|
|
59
|
+
opts: Record<string, unknown>
|
|
60
|
+
reply_markup: unknown
|
|
61
|
+
message_id?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function grammy400(description: string, method = 'sendRichMessage'): GrammyError {
|
|
65
|
+
return new GrammyError(
|
|
66
|
+
`Call to '${method}' failed! (400: ${description})`,
|
|
67
|
+
{ ok: false, error_code: 400, description },
|
|
68
|
+
method,
|
|
69
|
+
{},
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeFakeBot(failNext?: Partial<Record<string, unknown[]>>) {
|
|
74
|
+
const calls: RecordedCall[] = []
|
|
75
|
+
let nextId = 1000
|
|
76
|
+
const queues = failNext ?? {}
|
|
77
|
+
const maybeFail = (method: string): void => {
|
|
78
|
+
const q = queues[method]
|
|
79
|
+
if (q && q.length > 0) {
|
|
80
|
+
const e = q.shift()
|
|
81
|
+
if (e != null) throw e
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const record = (
|
|
85
|
+
method: string,
|
|
86
|
+
chat_id: string,
|
|
87
|
+
text: string | null,
|
|
88
|
+
opts: Record<string, unknown>,
|
|
89
|
+
): { message_id: number; message_thread_id?: number } => {
|
|
90
|
+
maybeFail(method)
|
|
91
|
+
const id = ++nextId
|
|
92
|
+
calls.push({ method, chat_id, text, opts, reply_markup: opts.reply_markup ?? null, message_id: id })
|
|
93
|
+
const tid = opts.message_thread_id
|
|
94
|
+
return { message_id: id, ...(tid != null ? { message_thread_id: Number(tid) } : {}) }
|
|
95
|
+
}
|
|
96
|
+
const api = {
|
|
97
|
+
sendRichMessage: async (chat: string, body: { markdown: string }, opts: Record<string, unknown> = {}) =>
|
|
98
|
+
record('sendRichMessage', chat, body.markdown, opts),
|
|
99
|
+
sendMessage: async (chat: string, text: string, opts: Record<string, unknown> = {}) =>
|
|
100
|
+
record('sendMessage', chat, text, opts),
|
|
101
|
+
editMessageText: async (chat: string, mid: number, body: unknown, opts: Record<string, unknown> = {}) => {
|
|
102
|
+
maybeFail('editMessageText')
|
|
103
|
+
calls.push({
|
|
104
|
+
method: 'editMessageText',
|
|
105
|
+
chat_id: chat,
|
|
106
|
+
text: typeof body === 'string' ? body : (body as { markdown: string }).markdown,
|
|
107
|
+
opts,
|
|
108
|
+
reply_markup: opts.reply_markup ?? null,
|
|
109
|
+
message_id: mid,
|
|
110
|
+
})
|
|
111
|
+
return {}
|
|
112
|
+
},
|
|
113
|
+
deleteMessage: async (chat: string, mid: number) => {
|
|
114
|
+
calls.push({ method: 'deleteMessage', chat_id: chat, text: null, opts: {}, reply_markup: null, message_id: mid })
|
|
115
|
+
return true
|
|
116
|
+
},
|
|
117
|
+
sendVoice: async (chat: string, _file: unknown, opts: Record<string, unknown> = {}) =>
|
|
118
|
+
record('sendVoice', chat, null, opts),
|
|
119
|
+
sendPhoto: async (chat: string, _file: unknown, opts: Record<string, unknown> = {}) =>
|
|
120
|
+
record('sendPhoto', chat, null, opts),
|
|
121
|
+
sendDocument: async (chat: string, _file: unknown, opts: Record<string, unknown> = {}) =>
|
|
122
|
+
record('sendDocument', chat, null, opts),
|
|
123
|
+
sendMediaGroup: async (chat: string, media: unknown[], opts: Record<string, unknown> = {}) => {
|
|
124
|
+
maybeFail('sendMediaGroup')
|
|
125
|
+
const out: Array<{ message_id: number }> = []
|
|
126
|
+
for (let i = 0; i < media.length; i++) {
|
|
127
|
+
const id = ++nextId
|
|
128
|
+
calls.push({ method: 'sendMediaGroup', chat_id: chat, text: null, opts, reply_markup: null, message_id: id })
|
|
129
|
+
out.push({ message_id: id })
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
return { api, calls }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── harness deps ──────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
const CHAT = '1001'
|
|
140
|
+
|
|
141
|
+
function makeAccess(over: Partial<Record<string, unknown>> = {}): Access {
|
|
142
|
+
return {
|
|
143
|
+
allowFrom: [CHAT],
|
|
144
|
+
groups: {},
|
|
145
|
+
disableLinkPreview: true,
|
|
146
|
+
...over,
|
|
147
|
+
} as unknown as Access
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface Harness {
|
|
151
|
+
deps: SendReplyGatewayDeps
|
|
152
|
+
calls: RecordedCall[]
|
|
153
|
+
dedup: OutboundDedupCache
|
|
154
|
+
effects: string[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeHarness(opts?: {
|
|
158
|
+
access?: Access
|
|
159
|
+
dedup?: OutboundDedupCache
|
|
160
|
+
turnProvider?: () => CurrentTurn | null
|
|
161
|
+
failNext?: Partial<Record<string, unknown[]>>
|
|
162
|
+
voicePlan?: VoiceOutPlan | null
|
|
163
|
+
synth?: (plan: { ttsText: string }) => Promise<Uint8Array | null>
|
|
164
|
+
}): Harness {
|
|
165
|
+
const { api, calls } = makeFakeBot(opts?.failNext)
|
|
166
|
+
const dedup = opts?.dedup ?? new OutboundDedupCache()
|
|
167
|
+
const effects: string[] = []
|
|
168
|
+
const access = opts?.access ?? makeAccess()
|
|
169
|
+
const getCurrentTurn = opts?.turnProvider ?? (() => null)
|
|
170
|
+
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
171
|
+
const deps: SendReplyGatewayDeps = {
|
|
172
|
+
outboundDedup: dedup,
|
|
173
|
+
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
174
|
+
firstTextReplyLogged: new Set<string>(),
|
|
175
|
+
suppressPtyPreview: new Set<string>(),
|
|
176
|
+
activeDraftStreams: new Map(),
|
|
177
|
+
lastPtyPreviewByChat: new Map(),
|
|
178
|
+
voiceOnDemandCache: { put: () => {} } as unknown as SendReplyGatewayDeps['voiceOnDemandCache'],
|
|
179
|
+
voicePreSynthQueue: { enqueue: () => {} } as unknown as SendReplyGatewayDeps['voicePreSynthQueue'],
|
|
180
|
+
pendingProgress: {
|
|
181
|
+
clearPending: (k, r) => effects.push(`pendingProgress.clearPending:${k}:${r}`),
|
|
182
|
+
noteOutbound: (k) => effects.push(`pendingProgress.noteOutbound:${k}`),
|
|
183
|
+
},
|
|
184
|
+
signalTracker: {
|
|
185
|
+
noteOutbound: (k) => effects.push(`signalTracker.noteOutbound:${k}`),
|
|
186
|
+
noteSignal: (k) => effects.push(`signalTracker.noteSignal:${k}`),
|
|
187
|
+
},
|
|
188
|
+
silencePoke: { noteOutbound: (k) => effects.push(`silencePoke.noteOutbound:${k}`) },
|
|
189
|
+
getCurrentTurn,
|
|
190
|
+
getLastActiveTurnChatId: () => undefined,
|
|
191
|
+
HISTORY_ENABLED: false,
|
|
192
|
+
TURN_ORIGIN_ROUTING_ENABLED: true,
|
|
193
|
+
AUTOCLASSIFY_MIDTURN_SHADOW: false,
|
|
194
|
+
MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
|
|
195
|
+
MAX_CHUNK_LIMIT: 4096,
|
|
196
|
+
PHOTO_EXTS: new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']),
|
|
197
|
+
lockedBot: { api } as unknown as SendReplyGatewayDeps['lockedBot'],
|
|
198
|
+
robustApiCall: (fn) => fn(),
|
|
199
|
+
swallowingApiCall: async (fn) => {
|
|
200
|
+
try {
|
|
201
|
+
return await fn()
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
loadAccess: () => access,
|
|
207
|
+
redactOutboundText: (text) => redact(text),
|
|
208
|
+
assertAllowedChat: () => {},
|
|
209
|
+
assertSendable: () => {},
|
|
210
|
+
statusKey: key,
|
|
211
|
+
streamKey: key,
|
|
212
|
+
resolveReplyOwnerTurn: () => null,
|
|
213
|
+
findTurnByOriginId: () => null,
|
|
214
|
+
findTurnByQuotedMessageId: () => null,
|
|
215
|
+
resolveAnswerThreadWithLog: (_c, explicit) => explicit,
|
|
216
|
+
resolveThreadId: (_c, explicit) => (explicit != null ? Number(explicit) : undefined),
|
|
217
|
+
getLatestInboundMessageId: () => null,
|
|
218
|
+
recordOutbound: () => effects.push('recordOutbound'),
|
|
219
|
+
emissionAuthorityFor: () =>
|
|
220
|
+
({
|
|
221
|
+
claimOrDowngradePing: (
|
|
222
|
+
_input: unknown,
|
|
223
|
+
_state: unknown,
|
|
224
|
+
_apply: unknown,
|
|
225
|
+
disabled: () => void,
|
|
226
|
+
) => disabled(),
|
|
227
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
228
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
229
|
+
}) as unknown as ReturnType<SendReplyGatewayDeps['emissionAuthorityFor']>,
|
|
230
|
+
clearActivitySummary: () => effects.push('clearActivitySummary'),
|
|
231
|
+
startTypingLoop: (c, t) => effects.push(`startTypingLoop:${key(c, t)}`),
|
|
232
|
+
stopTypingLoop: (c, t) => effects.push(`stopTypingLoop:${key(c, t)}`),
|
|
233
|
+
logOutbound: () => {},
|
|
234
|
+
closeObligationOnSubstantiveReply: () => effects.push('closeObligation'),
|
|
235
|
+
finalizeStatusReaction: (c, t, o) => effects.push(`finalizeStatusReaction:${key(c, t)}:${o}`),
|
|
236
|
+
releaseTurnBufferGate: (k) => effects.push(`releaseTurnBufferGate:${k}`),
|
|
237
|
+
reapQueuedStatus: () => effects.push('reapQueuedStatus'),
|
|
238
|
+
noteAgentOutputAt: () => {},
|
|
239
|
+
rememberAgentButtonMeta: () => effects.push('rememberAgentButtonMeta'),
|
|
240
|
+
resolveVoiceOutPlan: () => opts?.voicePlan ?? null,
|
|
241
|
+
synthesizeVoiceOut: opts?.synth ?? (async () => null),
|
|
242
|
+
publishToTelegraph: async () => null,
|
|
243
|
+
clearSilentEndState: (k) => effects.push(`clearSilentEndState:${k}`),
|
|
244
|
+
emitRuntimeMetric: () => {},
|
|
245
|
+
shadowEmit: () => {},
|
|
246
|
+
progressDriver: null,
|
|
247
|
+
}
|
|
248
|
+
return { deps, calls, dedup, effects }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function req(text: string, extra: Record<string, unknown> = {}, turn: CurrentTurn | null = null): SendReplyRequest {
|
|
252
|
+
return { args: { chat_id: CHAT, text, ...extra }, turn }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── the cases ─────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe('sendReply golden harness — text pipeline to the wire', () => {
|
|
258
|
+
it('single-chunk happy path: one rich send, normalized text on the wire', async () => {
|
|
259
|
+
const h = makeHarness()
|
|
260
|
+
const res = await sendReply(h.deps, req('Hello there. This is the answer.'))
|
|
261
|
+
const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
262
|
+
expect(sends).toHaveLength(1)
|
|
263
|
+
expect(sends[0]!.chat_id).toBe(CHAT)
|
|
264
|
+
expect(sends[0]!.text).toContain('Hello there. This is the answer.')
|
|
265
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
266
|
+
// typing loop started + stopped around the send
|
|
267
|
+
expect(h.effects).toContain(`startTypingLoop:${CHAT}:main`)
|
|
268
|
+
expect(h.effects).toContain(`stopTypingLoop:${CHAT}:main`)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('multi-chunk: ordered sends; keyboard attaches to the LAST chunk only', async () => {
|
|
272
|
+
const para = (n: number) => `Paragraph ${n} ${'x'.repeat(60)}.`
|
|
273
|
+
const text = [para(1), para(2), para(3)].join('\n\n')
|
|
274
|
+
const h = makeHarness({ access: makeAccess({ textChunkLimit: 80 }) })
|
|
275
|
+
const keyboard = [[{ text: 'Open', url: 'https://example.com' }]]
|
|
276
|
+
const res = await sendReply(h.deps, req(text, { inline_keyboard: keyboard }))
|
|
277
|
+
const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
278
|
+
expect(sends.length).toBeGreaterThanOrEqual(2)
|
|
279
|
+
// order: chunk i is sent before chunk i+1 (message ids ascend with order)
|
|
280
|
+
for (let i = 1; i < sends.length; i++) {
|
|
281
|
+
expect(sends[i]!.message_id!).toBeGreaterThan(sends[i - 1]!.message_id!)
|
|
282
|
+
}
|
|
283
|
+
// keyboard only on the last chunk
|
|
284
|
+
for (let i = 0; i < sends.length - 1; i++) expect(sends[i]!.reply_markup).toBeNull()
|
|
285
|
+
expect(sends[sends.length - 1]!.reply_markup).toMatchObject({
|
|
286
|
+
inline_keyboard: [[{ text: 'Open', url: 'https://example.com' }]],
|
|
287
|
+
})
|
|
288
|
+
expect(res.content[0]!.text).toMatch(new RegExp(`^sent ${sends.length} parts`))
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('code fences survive the normalize pipeline to the wire intact', async () => {
|
|
292
|
+
const fence = '```ts\nconst x = a - b // em-dash-free zone\n```'
|
|
293
|
+
const h = makeHarness()
|
|
294
|
+
await sendReply(h.deps, req(`Here is the code:\n\n${fence}`))
|
|
295
|
+
const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
296
|
+
expect(sends).toHaveLength(1)
|
|
297
|
+
expect(sends[0]!.text).toContain('```ts\nconst x = a - b')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('secrets are redacted (REAL redact()) before the wire', async () => {
|
|
301
|
+
// Runtime-concatenated so push-protection never sees a token-shaped literal.
|
|
302
|
+
const token = 'sk-ant-' + 'api03-' + 'A'.repeat(93) + 'AA'
|
|
303
|
+
const h = makeHarness()
|
|
304
|
+
await sendReply(h.deps, req(`Your key is ${token} - keep it safe.`))
|
|
305
|
+
const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
306
|
+
expect(sends).toHaveLength(1)
|
|
307
|
+
expect(sends[0]!.text).not.toContain(token)
|
|
308
|
+
// Mask shape is `[REDACTED:<detector>]` (underscores may be markdown-escaped
|
|
309
|
+
// downstream of the scrub — the token bytes are what must never survive).
|
|
310
|
+
expect(sends[0]!.text).toMatch(/\[REDACTED/)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('oversize chunk: MESSAGE_TOO_LONG re-splits to hard-cap pieces instead of dropping', async () => {
|
|
314
|
+
const h = makeHarness({
|
|
315
|
+
failNext: { sendRichMessage: [grammy400('MESSAGE_TOO_LONG: message is too long')] },
|
|
316
|
+
})
|
|
317
|
+
const res = await sendReply(h.deps, req('word '.repeat(40).trim()))
|
|
318
|
+
// first rich attempt failed; the re-split path delivered >= 1 piece
|
|
319
|
+
const sends = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
320
|
+
expect(sends.length).toBeGreaterThanOrEqual(1)
|
|
321
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('partial failure: unrecoverable error surfaces the N-of-M contract', async () => {
|
|
325
|
+
const para = (n: number) => `Paragraph ${n} ${'y'.repeat(60)}.`
|
|
326
|
+
const text = [para(1), para(2), para(3)].join('\n\n')
|
|
327
|
+
const h = makeHarness({
|
|
328
|
+
access: makeAccess({ textChunkLimit: 80 }),
|
|
329
|
+
failNext: { sendRichMessage: [null, new Error('boom: network down')] },
|
|
330
|
+
})
|
|
331
|
+
await expect(sendReply(h.deps, req(text))).rejects.toThrow(
|
|
332
|
+
/reply failed after 1 of \d+ chunk\(s\) sent: boom: network down/,
|
|
333
|
+
)
|
|
334
|
+
// the typing loop is still stopped by the finally
|
|
335
|
+
expect(h.effects.filter((e) => e.startsWith('stopTypingLoop'))).toHaveLength(1)
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
describe('sendReply golden harness — voice', () => {
|
|
340
|
+
const plan: VoiceOutPlan = {
|
|
341
|
+
engine: 'kokoro',
|
|
342
|
+
speed: 1.1,
|
|
343
|
+
replyMode: 'voice+text',
|
|
344
|
+
ttsChunks: ['Spoken answer.'],
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
it('voice+text: text chunks first, then the voice note', async () => {
|
|
348
|
+
const h = makeHarness({
|
|
349
|
+
voicePlan: plan,
|
|
350
|
+
synth: async () => new Uint8Array([1, 2, 3]),
|
|
351
|
+
})
|
|
352
|
+
await sendReply(h.deps, req('The spoken answer, also as text.'))
|
|
353
|
+
const methods = h.calls.map((c) => c.method)
|
|
354
|
+
expect(methods).toContain('sendRichMessage')
|
|
355
|
+
expect(methods).toContain('sendVoice')
|
|
356
|
+
expect(methods.indexOf('sendVoice')).toBeGreaterThan(methods.indexOf('sendRichMessage'))
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('voice-only with full synthesis: text body suppressed, voice note IS the reply', async () => {
|
|
360
|
+
const h = makeHarness({
|
|
361
|
+
voicePlan: { ...plan, replyMode: 'voice-only' },
|
|
362
|
+
synth: async () => new Uint8Array([1, 2, 3]),
|
|
363
|
+
})
|
|
364
|
+
const res = await sendReply(h.deps, req('Only spoken.'))
|
|
365
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
366
|
+
expect(h.calls.filter((c) => c.method === 'sendVoice')).toHaveLength(1)
|
|
367
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('voice-only with FAILED synthesis: text path still delivers (never drop the answer)', async () => {
|
|
371
|
+
const h = makeHarness({
|
|
372
|
+
voicePlan: { ...plan, replyMode: 'voice-only' },
|
|
373
|
+
synth: async () => null,
|
|
374
|
+
})
|
|
375
|
+
await sendReply(h.deps, req('Fell back to text.'))
|
|
376
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
|
|
377
|
+
expect(h.calls.filter((c) => c.method === 'sendVoice')).toHaveLength(0)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('sendReply golden harness — file sends', () => {
|
|
382
|
+
const dir = mkdtempSync(join(tmpdir(), 'p2-golden-'))
|
|
383
|
+
|
|
384
|
+
it('a photo-extension file routes via sendPhoto; a document via sendDocument', async () => {
|
|
385
|
+
const png = join(dir, 'shot.png')
|
|
386
|
+
const txt = join(dir, 'notes.txt')
|
|
387
|
+
// Garbage PNG bytes: the photo PRE-check probe fails open (keeps the
|
|
388
|
+
// photo route), which is exactly the #3033 contract we want pinned.
|
|
389
|
+
writeFileSync(png, Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]))
|
|
390
|
+
writeFileSync(txt, 'hello')
|
|
391
|
+
const h = makeHarness()
|
|
392
|
+
await sendReply(h.deps, req('files attached', { files: [png] }))
|
|
393
|
+
expect(h.calls.filter((c) => c.method === 'sendPhoto')).toHaveLength(1)
|
|
394
|
+
|
|
395
|
+
const h2 = makeHarness()
|
|
396
|
+
await sendReply(h2.deps, req('doc attached', { files: [txt] }))
|
|
397
|
+
expect(h2.calls.filter((c) => c.method === 'sendDocument')).toHaveLength(1)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it('2-10 photos batch into ONE sendMediaGroup album', async () => {
|
|
401
|
+
const a = join(dir, 'a.png')
|
|
402
|
+
const b = join(dir, 'b.png')
|
|
403
|
+
writeFileSync(a, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
404
|
+
writeFileSync(b, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
405
|
+
const h = makeHarness()
|
|
406
|
+
await sendReply(h.deps, req('album', { files: [a, b] }))
|
|
407
|
+
expect(h.calls.filter((c) => c.method === 'sendMediaGroup')).toHaveLength(2) // one record per album item
|
|
408
|
+
expect(h.calls.filter((c) => c.method === 'sendPhoto')).toHaveLength(0)
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
describe('cross-surface dedup — the ONE OutboundDedupCache instance (Amendment 1/9)', () => {
|
|
413
|
+
it('a stream-surface record suppresses the same-content reply (zero wire calls)', async () => {
|
|
414
|
+
const dedup = new OutboundDedupCache()
|
|
415
|
+
const text = 'The final answer, already streamed.'
|
|
416
|
+
// Simulate the handleSessionEvent answer-stream record site (P4 surface):
|
|
417
|
+
// it records into the SAME cache instance the reply path checks.
|
|
418
|
+
dedup.record(CHAT, undefined, text, Date.now(), null)
|
|
419
|
+
const h = makeHarness({ dedup })
|
|
420
|
+
const res = await sendReply(h.deps, req(text))
|
|
421
|
+
expect(res.content[0]!.text).toBe('sent (deduped — same content sent via earlier path)')
|
|
422
|
+
expect(h.calls).toHaveLength(0)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('NEGATIVE CONTROL: a second cache instance reinstates the duplicate send', async () => {
|
|
426
|
+
const streamSideCache = new OutboundDedupCache()
|
|
427
|
+
const text = 'The final answer, already streamed.'
|
|
428
|
+
streamSideCache.record(CHAT, undefined, text, Date.now(), null)
|
|
429
|
+
// The reply path holding a DIFFERENT instance (the forbidden re-new)
|
|
430
|
+
// does NOT see the record — the duplicate ships. This is why the
|
|
431
|
+
// injected singleton contract is load-bearing.
|
|
432
|
+
const h = makeHarness({ dedup: new OutboundDedupCache() })
|
|
433
|
+
await sendReply(h.deps, req(text))
|
|
434
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('a delivered reply records into the shared cache (later flush/stream is suppressed)', async () => {
|
|
438
|
+
const dedup = new OutboundDedupCache()
|
|
439
|
+
const h = makeHarness({ dedup })
|
|
440
|
+
await sendReply(h.deps, req('Recorded for dedup across surfaces.'))
|
|
441
|
+
const hit = dedup.check(CHAT, undefined, 'Recorded for dedup across surfaces.', Date.now(), null)
|
|
442
|
+
expect(hit).not.toBeNull()
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
describe('deliverCapturedProse — silent-end recovery (routed into the send module)', () => {
|
|
447
|
+
function makeProseDeps(dedup: OutboundDedupCache) {
|
|
448
|
+
const { api, calls } = makeFakeBot()
|
|
449
|
+
const effects: string[] = []
|
|
450
|
+
const deps: DeliverCapturedProseDeps = {
|
|
451
|
+
outboundDedup: dedup,
|
|
452
|
+
bot: { api } as unknown as DeliverCapturedProseDeps['bot'],
|
|
453
|
+
robustApiCall: (fn) => fn(),
|
|
454
|
+
redactOutboundText: (text) => redact(text),
|
|
455
|
+
recordOutbound: () => effects.push('recordOutbound'),
|
|
456
|
+
HISTORY_ENABLED: false,
|
|
457
|
+
OBLIGATION_LEDGER_ENABLED: true,
|
|
458
|
+
obligationLedger: { close: (id) => effects.push(`obligation.close:${id}`) },
|
|
459
|
+
clearSilentEndState: (k) => effects.push(`clearSilentEndState:${k}`),
|
|
460
|
+
recordUndeliveredTurnEnd: () => {
|
|
461
|
+
effects.push('recordUndeliveredTurnEnd')
|
|
462
|
+
return { exhausted: false }
|
|
463
|
+
},
|
|
464
|
+
hasOutboundDeliveredSince: () => false,
|
|
465
|
+
}
|
|
466
|
+
return { deps, calls, effects }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
it('recovers the captured answer: rich send + dedup record + obligation close + state clear', async () => {
|
|
470
|
+
const dedup = new OutboundDedupCache()
|
|
471
|
+
const { deps, calls, effects } = makeProseDeps(dedup)
|
|
472
|
+
await deliverCapturedProse(deps, {
|
|
473
|
+
chatId: CHAT,
|
|
474
|
+
threadId: undefined,
|
|
475
|
+
statusKeyStr: `${CHAT}:main`,
|
|
476
|
+
registryKey: null,
|
|
477
|
+
originTurnId: 'turn-42',
|
|
478
|
+
text: 'The recovered terminal answer.',
|
|
479
|
+
})
|
|
480
|
+
const sends = calls.filter((c) => c.method === 'sendRichMessage')
|
|
481
|
+
expect(sends).toHaveLength(1)
|
|
482
|
+
expect(sends[0]!.text).toContain('The recovered terminal answer.')
|
|
483
|
+
expect(effects).toContain('obligation.close:turn-42')
|
|
484
|
+
expect(effects).toContain(`clearSilentEndState:${CHAT}:main`)
|
|
485
|
+
// recorded into the SHARED cache
|
|
486
|
+
expect(dedup.check(CHAT, undefined, 'The recovered terminal answer.', Date.now(), null)).not.toBeNull()
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('CROSS-PATH: a captured-prose delivery suppresses the model\'s late reply retry', async () => {
|
|
490
|
+
const dedup = new OutboundDedupCache()
|
|
491
|
+
const { deps } = makeProseDeps(dedup)
|
|
492
|
+
const text = 'Answer delivered via silent-end recovery.'
|
|
493
|
+
await deliverCapturedProse(deps, {
|
|
494
|
+
chatId: CHAT,
|
|
495
|
+
threadId: undefined,
|
|
496
|
+
statusKeyStr: `${CHAT}:main`,
|
|
497
|
+
registryKey: null,
|
|
498
|
+
originTurnId: 'turn-43',
|
|
499
|
+
text,
|
|
500
|
+
})
|
|
501
|
+
// The model's late `reply` with the same content hits the SAME instance.
|
|
502
|
+
const h = makeHarness({ dedup })
|
|
503
|
+
const res = await sendReply(h.deps, req(text))
|
|
504
|
+
expect(res.content[0]!.text).toBe('sent (deduped — same content sent via earlier path)')
|
|
505
|
+
expect(h.calls).toHaveLength(0)
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
it('already-deduped content settles bookkeeping WITHOUT a wire send', async () => {
|
|
509
|
+
const dedup = new OutboundDedupCache()
|
|
510
|
+
const text = 'Already went out earlier via the stream.'
|
|
511
|
+
dedup.record(CHAT, undefined, text, Date.now(), null)
|
|
512
|
+
const { deps, calls, effects } = makeProseDeps(dedup)
|
|
513
|
+
await deliverCapturedProse(deps, {
|
|
514
|
+
chatId: CHAT,
|
|
515
|
+
threadId: undefined,
|
|
516
|
+
statusKeyStr: `${CHAT}:main`,
|
|
517
|
+
registryKey: null,
|
|
518
|
+
originTurnId: 'turn-44',
|
|
519
|
+
text,
|
|
520
|
+
})
|
|
521
|
+
expect(calls).toHaveLength(0)
|
|
522
|
+
expect(effects).toContain('obligation.close:turn-44')
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
describe('structural wiring — the singleton + the gateway entry point (#2996 P2)', () => {
|
|
527
|
+
const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
528
|
+
const moduleSrc = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
|
|
529
|
+
|
|
530
|
+
it('gateway constructs EXACTLY ONE OutboundDedupCache; the module constructs NONE', () => {
|
|
531
|
+
// Amendment 1/9 hard-fail rule: a re-`new` in an extracted module
|
|
532
|
+
// reinstates the duplicate-reply class. The one instance lives in
|
|
533
|
+
// gateway.ts and is INJECTED everywhere else.
|
|
534
|
+
expect(gatewaySrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(1)
|
|
535
|
+
expect(moduleSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('executeReply is a thin wrapper: pins the turn and delegates to sendReply', () => {
|
|
539
|
+
const after = gatewaySrc.split('async function executeReply(')[1] ?? ''
|
|
540
|
+
const body = after.split('\nasync function ')[0] ?? after
|
|
541
|
+
expect(body).toContain('return sendReply(gatewaySendReplyDeps(), { args, turn: currentTurn })')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('the injected deps carry the singleton + BOTH turn accessors (Amendment 9)', () => {
|
|
545
|
+
const after = gatewaySrc.split('function gatewaySendReplyDeps(')[1] ?? ''
|
|
546
|
+
const body = after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
|
|
547
|
+
expect(body).toMatch(/\n\s{4}outboundDedup,/) // the ONE instance, shorthand
|
|
548
|
+
expect(body).toContain('getCurrentTurn: () => currentTurn')
|
|
549
|
+
expect(body).toContain('getLastActiveTurnChatId: () => lastActiveTurnChatId')
|
|
550
|
+
// The dead deps stay dead (Amendment 9): no ledger / send gate injected.
|
|
551
|
+
expect(body).not.toMatch(/backstopDeliveryLedger/)
|
|
552
|
+
expect(body).not.toMatch(/\bsendGate\b/)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('deliverCapturedProse is a thin wrapper delegating to the module with the singleton', () => {
|
|
556
|
+
const after = gatewaySrc.split('async function deliverCapturedProse(')[1] ?? ''
|
|
557
|
+
const body = after.split('\nasync function ')[0]?.split('\n/** Live gateway deps')[0] ?? after
|
|
558
|
+
expect(body).toContain('return deliverCapturedProseCore(gatewayCapturedProseDeps(), args)')
|
|
559
|
+
const depsAfter = gatewaySrc.split('function gatewayCapturedProseDeps(')[1] ?? ''
|
|
560
|
+
const depsBody = depsAfter.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? depsAfter
|
|
561
|
+
expect(depsBody).toMatch(/\n\s{4}outboundDedup,/)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('the module keeps the pin-vs-live contract: entry pin from req, live reads via getCurrentTurn()', () => {
|
|
565
|
+
const win = moduleSrc.split('export async function sendReply(')[1]?.split('\nexport ')[0] ?? ''
|
|
566
|
+
expect(win).toContain('const turn = req.turn')
|
|
567
|
+
// The load-bearing live re-reads survived the move (#1664 class):
|
|
568
|
+
expect(win).toContain('const finalizeTurn = getCurrentTurn()')
|
|
569
|
+
expect(win.match(/getCurrentTurn\(\)/g)!.length).toBeGreaterThanOrEqual(5)
|
|
570
|
+
})
|
|
571
|
+
})
|