switchroom 0.18.31 → 0.18.33
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 +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -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 +6403 -13456
- 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/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- 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/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -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 +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- 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 +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -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 +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- 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
|
@@ -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
|
+
})
|
|
@@ -24,6 +24,15 @@ import { readFileSync } from 'node:fs'
|
|
|
24
24
|
import { resolve } from 'node:path'
|
|
25
25
|
|
|
26
26
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
27
|
+
// #2996 P4-A: the model-driven reset sites (tool_label append, answer-stream
|
|
28
|
+
// onMetric) live inside handleSessionEvent, which moved VERBATIM to
|
|
29
|
+
// stream-render.ts. There the live-turn guard reads the injected accessor
|
|
30
|
+
// (`getCurrentTurn() === turn`) rather than the `currentTurn` module global.
|
|
31
|
+
const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
|
|
32
|
+
// #2996 P4-B: drainActivitySummary / feedHeartbeatTick moved VERBATIM into
|
|
33
|
+
// narrative-lane.ts (factory scope, bodies indented +2; gateway keeps thin
|
|
34
|
+
// same-name wrappers) — the heartbeat-safety windows read the module source.
|
|
35
|
+
const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
|
|
27
36
|
|
|
28
37
|
function between(src: string, startMarker: string, endMarker: string): string {
|
|
29
38
|
const after = src.split(startMarker)[1] ?? ''
|
|
@@ -32,13 +41,13 @@ function between(src: string, startMarker: string, endMarker: string): string {
|
|
|
32
41
|
|
|
33
42
|
describe('silence-poke production-liveness — heartbeat safety', () => {
|
|
34
43
|
it('drainActivitySummary must NOT reset the silence clock (the framework heartbeat drains here)', () => {
|
|
35
|
-
const body = between(
|
|
44
|
+
const body = between(laneSrc, 'async function drainActivitySummary', '\n function feedHeartbeatTick')
|
|
36
45
|
expect(body.length).toBeGreaterThan(100) // sanity: the slice found the function body
|
|
37
46
|
expect(body).not.toMatch(/noteProduction/)
|
|
38
47
|
})
|
|
39
48
|
|
|
40
49
|
it('feedHeartbeatTick itself must NOT reset the silence clock (model-independent re-render)', () => {
|
|
41
|
-
const body = between(
|
|
50
|
+
const body = between(laneSrc, 'function feedHeartbeatTick(): void {', '\n }')
|
|
42
51
|
expect(body.length).toBeGreaterThan(50)
|
|
43
52
|
expect(body).not.toMatch(/noteProduction/)
|
|
44
53
|
})
|
|
@@ -47,18 +56,18 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
|
|
|
47
56
|
// appendActivityLabel returns a fresh render only when the model emits a NEW
|
|
48
57
|
// labelled step — the genuine liveness signal the heartbeat can never forge.
|
|
49
58
|
const block = between(
|
|
50
|
-
|
|
59
|
+
streamSrc,
|
|
51
60
|
'const rendered = appendActivityLabel(turn.mirrorLines, ev.label)',
|
|
52
61
|
'\n return',
|
|
53
62
|
)
|
|
54
63
|
expect(block).toMatch(/silencePoke\.noteProduction/)
|
|
55
|
-
expect(block).toMatch(/
|
|
64
|
+
expect(block).toMatch(/getCurrentTurn\(\) === turn/)
|
|
56
65
|
})
|
|
57
66
|
|
|
58
67
|
it('the answer-stream draft onMetric reset is model-driven and gated on the live turn', () => {
|
|
59
|
-
const block = between(
|
|
68
|
+
const block = between(streamSrc, 'onMetric: (metricEv) => {', '\n },')
|
|
60
69
|
expect(block).toMatch(/silencePoke\.noteProduction/)
|
|
61
|
-
expect(block).toMatch(/
|
|
70
|
+
expect(block).toMatch(/getCurrentTurn\(\) === turn/)
|
|
62
71
|
})
|
|
63
72
|
|
|
64
73
|
it('production-liveness is behind the default-ON SWITCHROOM_SILENCE_LIVENESS_PRODUCTION kill switch', () => {
|
|
@@ -74,12 +83,17 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
|
|
|
74
83
|
// Structural guard so a refactor can't silently re-silence the re-ping
|
|
75
84
|
// (the gateway IIFE can't be instantiated in-process — same pattern as the
|
|
76
85
|
// heartbeat-safety assertions above).
|
|
77
|
-
|
|
86
|
+
// #2996 P8 PR-C3: the fallback body moved verbatim to liveness-wiring.ts;
|
|
87
|
+
// the send itself is the injected `sendSilenceText(chat, thread, text,
|
|
88
|
+
// silent)` closure (gateway keeps the bot-api touch), so the gate now
|
|
89
|
+
// rides the closure's `silent` argument at the callsite.
|
|
90
|
+
const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
|
|
91
|
+
const block = between(livenessSrc, 'onFrameworkFallback: async (ctx) => {', '\n }\n}')
|
|
78
92
|
expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler body
|
|
79
93
|
// The signal is derived once, hoisted above the update-status branch so it's
|
|
80
94
|
// in scope at the send site.
|
|
81
95
|
expect(block).toMatch(/const blockedOnApproval = activeStatusReactions/)
|
|
82
96
|
// The send gates on it rather than hard-coding silent.
|
|
83
|
-
expect(block).toMatch(/
|
|
97
|
+
expect(block).toMatch(/sendSilenceText\(ctx\.chatId, ctx\.threadId \?\? null, text, blockedOnApproval \? false : true\)/)
|
|
84
98
|
})
|
|
85
99
|
})
|