switchroom 0.18.32 β 0.19.0
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/auth-broker/index.js +17 -1
- package/dist/cli/switchroom.js +847 -729
- package/dist/host-control/main.js +18 -2
- package/dist/vault/approvals/kernel-server.js +17 -1
- package/dist/vault/broker/server.js +44 -2
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +105 -18
- package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
- 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/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/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/gateway.ts +1347 -6758
- 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-wiring.ts +333 -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/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +186 -3
- 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/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 +15 -6
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -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 +47 -10
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
- 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-emit-after-intercepts.test.ts +14 -3
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- 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 +27 -9
- 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/pending-card-durability-wiring.test.ts +16 -7
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- 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/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 +28 -18
- 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 +14 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -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 +110 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +83 -35
- 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 +10 -4
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Structural test for the `
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Structural test for the `message:pinned_message` handler that suppresses the
|
|
3
|
+
* "pinned a message" service message Telegram inserts when OUR silent status-pin
|
|
4
|
+
* fires.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* guard (which would let us
|
|
13
|
-
*
|
|
6
|
+
* As of switchroom#2996 P6 cluster F the handler body lives in the extracted
|
|
7
|
+
* `gateway/pinned-message-handler.ts` module (unit-tested for real in
|
|
8
|
+
* `pinned-message-handler.test.ts`); gateway.ts keeps only the thin
|
|
9
|
+
* `bot.on('message:pinned_message', β¦)` delegation plus the injected
|
|
10
|
+
* `deleteServiceMessage` binding that wraps the raw `lockedBot.api.deleteMessage`
|
|
11
|
+
* in `robustApiCall`. The regressions this structural test still guards: a hand
|
|
12
|
+
* dropping the delegation, dropping the ownership guard (which would let us
|
|
13
|
+
* delete manual/operator pins), or moving the raw delete out from under the
|
|
14
|
+
* robust wrapper (allowlist drift).
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { describe, it, expect } from 'vitest'
|
|
@@ -25,66 +26,58 @@ const SRC = readFileSync(
|
|
|
25
26
|
'utf8',
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function pinnedMessageHandler(): string {
|
|
33
|
-
const needle = `bot.on('message:pinned_message'`
|
|
34
|
-
const start = SRC.indexOf(needle)
|
|
35
|
-
expect(start, `handler ${needle} not found`).toBeGreaterThan(0)
|
|
36
|
-
const firstBrace = SRC.indexOf('{', start)
|
|
37
|
-
let depth = 0
|
|
38
|
-
for (let i = firstBrace; i < SRC.length; i++) {
|
|
39
|
-
const c = SRC[i]
|
|
40
|
-
if (c === '{') depth++
|
|
41
|
-
else if (c === '}') {
|
|
42
|
-
depth--
|
|
43
|
-
if (depth === 0) return SRC.slice(start, i + 1)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
throw new Error('could not find end of pinned_message handler')
|
|
47
|
-
}
|
|
29
|
+
const MODULE_SRC = readFileSync(
|
|
30
|
+
new URL('../gateway/pinned-message-handler.ts', import.meta.url),
|
|
31
|
+
'utf8',
|
|
32
|
+
)
|
|
48
33
|
|
|
49
34
|
describe('status-pin service-message suppression', () => {
|
|
50
|
-
it("registers a bot.on('message:pinned_message') handler", () => {
|
|
35
|
+
it("registers a bot.on('message:pinned_message') delegation to the extracted handler", () => {
|
|
51
36
|
expect(SRC).toContain(`bot.on('message:pinned_message'`)
|
|
37
|
+
// The registration is now a thin delegation into the extracted module.
|
|
38
|
+
expect(SRC).toContain('handlePinnedMessage(ctx, pinnedMessageHandlerDeps)')
|
|
52
39
|
})
|
|
53
40
|
|
|
54
|
-
const body = pinnedMessageHandler()
|
|
55
|
-
|
|
56
41
|
it('guards on ownership via the chat-scoped pinnedMessageIsOurs helper', () => {
|
|
57
42
|
// Must consult our tracked pins β never blindly delete a pin service
|
|
58
43
|
// message (which would nuke manual/operator pins too) β AND it must be
|
|
59
44
|
// chat-scoped (pinnedMessageIsOurs requires chatId + messageId), never a
|
|
60
45
|
// messageId-only match.
|
|
61
|
-
expect(
|
|
62
|
-
expect(
|
|
63
|
-
expect(
|
|
46
|
+
expect(MODULE_SRC).toContain('deps.statusPinState')
|
|
47
|
+
expect(MODULE_SRC).toMatch(/pinned_message\?\.message_id/)
|
|
48
|
+
expect(MODULE_SRC).toContain('pinnedMessageIsOurs(')
|
|
64
49
|
// The chatId computed from the update must be fed to the guard.
|
|
65
|
-
expect(
|
|
50
|
+
expect(MODULE_SRC).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
|
|
66
51
|
// And the tracked entries must carry their chat association.
|
|
67
|
-
expect(
|
|
52
|
+
expect(MODULE_SRC).toContain('deps.statusPinChatIds.get(pinKey)')
|
|
68
53
|
})
|
|
69
54
|
|
|
70
55
|
it('bails out when the pinned message is not one of ours', () => {
|
|
71
56
|
// A `return` guard for the non-owned case must exist.
|
|
72
|
-
expect(
|
|
57
|
+
expect(MODULE_SRC).toMatch(/if \(!isOurs\(\)\) return/)
|
|
73
58
|
})
|
|
74
59
|
|
|
75
60
|
it('deletes the service message through the robust wrapper (not a raw api call)', () => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
61
|
+
// The extracted module carries no raw Bot API site β it calls the injected
|
|
62
|
+
// `deleteServiceMessage`, which gateway.ts binds through robustApiCall.
|
|
63
|
+
expect(MODULE_SRC).toContain('deps.deleteServiceMessage(chatId, serviceMsgId)')
|
|
64
|
+
// The module makes no raw Bot API call outside a comment (the docstring
|
|
65
|
+
// names the wrapped call as an example). Strip comment lines, then assert
|
|
66
|
+
// no `lockedBot.api.*(` invocation survives in executable code.
|
|
67
|
+
const codeOnly = MODULE_SRC.split('\n')
|
|
68
|
+
.filter(l => !/^\s*(\/\/|\*|\/\*)/.test(l))
|
|
69
|
+
.join('\n')
|
|
70
|
+
expect(codeOnly).not.toMatch(/lockedBot\.api\.\w+\(/)
|
|
71
|
+
// gateway.ts owns the robust wrapper + house verb tag.
|
|
72
|
+
expect(SRC).toContain('robustApiCall(')
|
|
73
|
+
expect(SRC).toContain('lockedBot.api.deleteMessage(chatId, serviceMsgId)')
|
|
74
|
+
expect(SRC).toContain("verb: 'status-pin.delete-service-message'")
|
|
82
75
|
})
|
|
83
76
|
|
|
84
77
|
it('tolerates the reconcile-store race with a short retry', () => {
|
|
85
78
|
// The service update can arrive before reconcileStatusPin stores the
|
|
86
79
|
// PinState; a single delayed re-check covers that window.
|
|
87
|
-
expect(
|
|
80
|
+
expect(MODULE_SRC).toContain('setTimeout')
|
|
88
81
|
})
|
|
89
82
|
})
|
|
90
83
|
|
|
@@ -199,9 +192,9 @@ describe('service-message deletion in supergroups / forum topics', () => {
|
|
|
199
192
|
|
|
200
193
|
it('the handler logs a missing-admin-right hint on delete failure (supergroup)', () => {
|
|
201
194
|
// Structural: the catch block names the likely supergroup cause so an
|
|
202
|
-
// operator isn't left with silent nothing.
|
|
195
|
+
// operator isn't left with silent nothing. Lives in the extracted module.
|
|
203
196
|
const src = readFileSync(
|
|
204
|
-
new URL('../gateway/
|
|
197
|
+
new URL('../gateway/pinned-message-handler.ts', import.meta.url),
|
|
205
198
|
'utf8',
|
|
206
199
|
)
|
|
207
200
|
expect(src).toContain('could not delete pin service message')
|
|
@@ -17,6 +17,13 @@ import { switchroomHelpCommandNames, switchroomHelpText } from '../welcome-text.
|
|
|
17
17
|
|
|
18
18
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
19
19
|
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
20
|
+
// P7 PR-2 (#2996): the handleInbound stop-keyword intercept block moved into
|
|
21
|
+
// inbound-interceptors.ts (`interceptStopKeyword`). The coalescer bypass stays
|
|
22
|
+
// in gateway.ts. Scrapes anchor on whichever file now owns each literal.
|
|
23
|
+
const INTERCEPTORS_SRC = readFileSync(
|
|
24
|
+
resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'),
|
|
25
|
+
'utf8',
|
|
26
|
+
)
|
|
20
27
|
|
|
21
28
|
describe('parseStopKeyword', () => {
|
|
22
29
|
it('matches the exact word stop, case-insensitively', () => {
|
|
@@ -177,18 +184,20 @@ describe('gateway wiring β /stop cancels the in-flight turn (#3020)', () => {
|
|
|
177
184
|
expect(bypassIdx).toBeGreaterThan(0)
|
|
178
185
|
const bypassWin = GATEWAY_SRC.slice(bypassIdx, bypassIdx + 800)
|
|
179
186
|
expect(bypassWin).toContain('parseStopKeyword(text)')
|
|
180
|
-
//
|
|
181
|
-
|
|
187
|
+
// interceptStopKeyword block (P7 PR-2, inbound-interceptors.ts): honest
|
|
188
|
+
// idle reply or halt + confirmation.
|
|
189
|
+
const kwIdx = INTERCEPTORS_SRC.indexOf("deps.executeHaltNow('stop-keyword')")
|
|
182
190
|
expect(kwIdx).toBeGreaterThan(0)
|
|
183
|
-
const kwWin =
|
|
184
|
-
expect(kwWin).toContain('turnInFlightForGate()')
|
|
191
|
+
const kwWin = INTERCEPTORS_SRC.slice(kwIdx - 1600, kwIdx + 900)
|
|
192
|
+
expect(kwWin).toContain('deps.turnInFlightForGate()')
|
|
185
193
|
expect(kwWin).toContain('buildStopReply(inFlight, queuedLabels)')
|
|
186
194
|
// Intercepted β never forwarded to the agent as a turn.
|
|
187
|
-
expect(kwWin).toContain('return')
|
|
195
|
+
expect(kwWin).toContain('return { handled: true }')
|
|
188
196
|
})
|
|
189
197
|
|
|
190
198
|
it('the empty-`!` interrupt routes through the same executeHaltNow helper', () => {
|
|
191
|
-
|
|
199
|
+
// P7 PR-3: the `!`-marker intercept moved to inbound-interceptors.ts.
|
|
200
|
+
expect(INTERCEPTORS_SRC).toContain("deps.executeHaltNow('bang-empty')")
|
|
192
201
|
})
|
|
193
202
|
|
|
194
203
|
it('/stop with an argument warns and does NOT halt (old container-stop muscle memory)', () => {
|
|
@@ -210,12 +219,13 @@ describe('gateway wiring β /stop cancels the in-flight turn (#3020)', () => {
|
|
|
210
219
|
'parseStopKeyword(text) && downloadImage == null && attachment == null',
|
|
211
220
|
)
|
|
212
221
|
expect(bypassIdx).toBeGreaterThan(0)
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
expect(kwWin).toContain('
|
|
218
|
-
expect(kwWin).toContain('
|
|
222
|
+
// interceptStopKeyword block (P7 PR-2): also excludes coalesced extra
|
|
223
|
+
// attachments (per-message facts arrive as the `p.` params object).
|
|
224
|
+
const kwIdx = INTERCEPTORS_SRC.indexOf("deps.executeHaltNow('stop-keyword')")
|
|
225
|
+
const kwWin = INTERCEPTORS_SRC.slice(kwIdx - 2400, kwIdx)
|
|
226
|
+
expect(kwWin).toContain('p.downloadImage == null')
|
|
227
|
+
expect(kwWin).toContain('p.attachment == null')
|
|
228
|
+
expect(kwWin).toContain('p.extraAttachments == null || p.extraAttachments.length === 0')
|
|
219
229
|
})
|
|
220
230
|
})
|
|
221
231
|
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden-transcript harness for the extracted stream/render dispatcher
|
|
3
|
+
* (#2996 P4-A, plan Amendments 1/5/9/10).
|
|
4
|
+
*
|
|
5
|
+
* ββ Oracle standard (Amendment 10) ββββββββββββββββββββββββββββββββββββββββ
|
|
6
|
+
* gateway.ts cannot be driven in-place from a test runner (`handleSessionEvent`
|
|
7
|
+
* was unexported; `bot` is assigned only inside the isGatewayMain boot). The
|
|
8
|
+
* coordinator-accepted oracle is therefore the EXTRACTED-MODULE golden harness
|
|
9
|
+
* (the merged send-reply-golden precedent): the real `handleSessionEvent` is
|
|
10
|
+
* driven with synthetic SessionEvent sequences against a fake bot recorder +
|
|
11
|
+
* the REAL `OutboundDedupCache`, with gateway-scoped closures faked.
|
|
12
|
+
*
|
|
13
|
+
* ββ The load-bearing case (Amendment 1, BLOCKING) βββββββββββββββββββββββββ
|
|
14
|
+
* CROSS-SURFACE stream-then-reply dedup spanning BOTH extracted modules on ONE
|
|
15
|
+
* shared `OutboundDedupCache`: the REAL P4 `handleSessionEvent` turn-flush path
|
|
16
|
+
* records the delivered answer into the cache, then the REAL P2 `sendReply`
|
|
17
|
+
* (a DIFFERENT module) checking that SAME instance suppresses a late same-text
|
|
18
|
+
* reply β zero wire calls. A negative control proves a second cache reinstates
|
|
19
|
+
* the duplicate. This is the exact duplicate-reply class the shared-singleton
|
|
20
|
+
* injection (never a re-`new`) exists to kill.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import { handleSessionEvent, type StreamRenderDeps } from '../gateway/stream-render.js'
|
|
26
|
+
import {
|
|
27
|
+
sendReply,
|
|
28
|
+
type SendReplyGatewayDeps,
|
|
29
|
+
type SendReplyRequest,
|
|
30
|
+
} from '../gateway/outbound-send-path.js'
|
|
31
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
32
|
+
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
33
|
+
import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
|
|
34
|
+
import { redact } from '../secret-detect/redact.js'
|
|
35
|
+
import type { CurrentTurn } from '../gateway/gateway.js'
|
|
36
|
+
|
|
37
|
+
const CHAT = '1001'
|
|
38
|
+
|
|
39
|
+
// ββ fake bot recorder (shared shape with send-reply-golden) ββββββββββββββββ
|
|
40
|
+
interface RecordedCall {
|
|
41
|
+
method: string
|
|
42
|
+
chat_id: string
|
|
43
|
+
text: string | null
|
|
44
|
+
opts: Record<string, unknown>
|
|
45
|
+
reply_markup: unknown
|
|
46
|
+
message_id?: number
|
|
47
|
+
}
|
|
48
|
+
function makeFakeBot() {
|
|
49
|
+
const calls: RecordedCall[] = []
|
|
50
|
+
let nextId = 2000
|
|
51
|
+
const rec = (method: string, chat_id: string, text: string | null, opts: Record<string, unknown> = {}) => {
|
|
52
|
+
const id = ++nextId
|
|
53
|
+
calls.push({ method, chat_id, text, opts, reply_markup: opts.reply_markup ?? null, message_id: id })
|
|
54
|
+
return { message_id: id }
|
|
55
|
+
}
|
|
56
|
+
const api = {
|
|
57
|
+
sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
|
|
58
|
+
sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
|
|
59
|
+
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => { rec('editMessageText', c, typeof b === 'string' ? b : (b as { markdown: string }).markdown, o); return {} },
|
|
60
|
+
deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
|
|
61
|
+
}
|
|
62
|
+
return { api, calls }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ββ a CurrentTurn sufficient for the turn-flush record path ββββββββββββββββ
|
|
66
|
+
function makeTurn(over: Partial<CurrentTurn> = {}): CurrentTurn {
|
|
67
|
+
return {
|
|
68
|
+
turnId: 'turn-flush-1',
|
|
69
|
+
sessionChatId: CHAT,
|
|
70
|
+
sessionThreadId: undefined,
|
|
71
|
+
registryKey: null,
|
|
72
|
+
sourceMessageId: null,
|
|
73
|
+
startedAt: Date.now() - 5000,
|
|
74
|
+
replyCalled: false,
|
|
75
|
+
lastReplyText: '',
|
|
76
|
+
answerStream: null,
|
|
77
|
+
capturedText: ['The composed terminal answer that the model never sent via reply.'],
|
|
78
|
+
capturedBlockMeta: [false],
|
|
79
|
+
finalAnswerDelivered: false,
|
|
80
|
+
finalAnswerSubstantive: false,
|
|
81
|
+
answerDelivered: false,
|
|
82
|
+
deliveryOutcome: undefined,
|
|
83
|
+
orphanedReplyTimeoutId: null,
|
|
84
|
+
orphanedReplyStreamMsgId: null,
|
|
85
|
+
activityPendingRender: null,
|
|
86
|
+
liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
|
|
87
|
+
...over,
|
|
88
|
+
} as unknown as CurrentTurn
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ββ the full injected deps, minimal but runtime-faithful fakes βββββββββββββ
|
|
92
|
+
interface StreamHarness {
|
|
93
|
+
deps: StreamRenderDeps
|
|
94
|
+
calls: RecordedCall[]
|
|
95
|
+
dedup: OutboundDedupCache
|
|
96
|
+
ledger: BackstopDeliveryLedger
|
|
97
|
+
delivered: string[]
|
|
98
|
+
}
|
|
99
|
+
function makeStreamDeps(opts?: {
|
|
100
|
+
dedup?: OutboundDedupCache
|
|
101
|
+
turn?: CurrentTurn | null
|
|
102
|
+
deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
|
|
103
|
+
}): StreamHarness {
|
|
104
|
+
const { api, calls } = makeFakeBot()
|
|
105
|
+
const dedup = opts?.dedup ?? new OutboundDedupCache()
|
|
106
|
+
const ledger = new BackstopDeliveryLedger()
|
|
107
|
+
const delivered: string[] = []
|
|
108
|
+
let curTurn = opts?.turn ?? null
|
|
109
|
+
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
110
|
+
const noop = () => {}
|
|
111
|
+
const fakeEA = {
|
|
112
|
+
claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
|
|
113
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
114
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
115
|
+
}
|
|
116
|
+
const deps = {
|
|
117
|
+
// config
|
|
118
|
+
ANSWER_LANE: { visibleEnabled: false } as unknown,
|
|
119
|
+
CAPTURED_PROSE_DELIVERY_ENABLED: false,
|
|
120
|
+
CONTEXT_EXHAUSTION_COOLDOWN_MS: 600000,
|
|
121
|
+
DELIVERY_CONFIRM_ENABLED: false,
|
|
122
|
+
FEED_REOPEN_AFTER_ACK_ENABLED: false,
|
|
123
|
+
HANDBACK_PRETURN_ENABLED: false,
|
|
124
|
+
HISTORY_ENABLED: false,
|
|
125
|
+
LIVENESS_TERMINAL_HONESTY: true,
|
|
126
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
127
|
+
ORPHANED_REPLY_STREAM_WINDOW_MS: 120000,
|
|
128
|
+
SILENCE_LIVENESS_PRODUCTION: false,
|
|
129
|
+
STATE_DIR: tmpdir(),
|
|
130
|
+
TURN_FLUSH_SAFETY_ENABLED: true,
|
|
131
|
+
TURN_PREVIEW_MAX: 200,
|
|
132
|
+
// state singletons
|
|
133
|
+
activeDraftStreams: new Map(),
|
|
134
|
+
activeStatusReactions: new Map(),
|
|
135
|
+
activeTurnStartedAt: new Map(),
|
|
136
|
+
backstopDeliveryLedger: ledger,
|
|
137
|
+
bot: { api },
|
|
138
|
+
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
139
|
+
idleTracker: { noteEvent: noop },
|
|
140
|
+
lastPtyPreviewByChat: new Map(),
|
|
141
|
+
obligationLedger: { close: noop, noteTurnEnded: noop },
|
|
142
|
+
outboundDedup: dedup,
|
|
143
|
+
pendingCrossTurnGate: new Map(),
|
|
144
|
+
preambleSuppressor: { dropNow: noop, flushNow: noop, onText: noop, onTool: noop, reset: noop },
|
|
145
|
+
progressDriver: null,
|
|
146
|
+
reactionTransitionCounts: new Map(),
|
|
147
|
+
suppressPtyPreview: new Set(),
|
|
148
|
+
toolFlightTracker: { inFlightCount: () => 0 },
|
|
149
|
+
deliveryQueue: {},
|
|
150
|
+
handbackPreturnSignal: { tryAdopt: () => null },
|
|
151
|
+
sessionModelSource: { noteTranscriptModel: noop },
|
|
152
|
+
typingWrapper: { drainAll: noop, onToolResult: noop, onToolUse: noop },
|
|
153
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
154
|
+
swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
|
|
155
|
+
// volatile accessors
|
|
156
|
+
getCurrentTurn: () => curTurn,
|
|
157
|
+
getLastContextExhaustionWarningAt: () => 0,
|
|
158
|
+
setLastContextExhaustionWarningAt: noop,
|
|
159
|
+
getPendingPtyPartial: () => null,
|
|
160
|
+
setPendingPtyPartial: noop,
|
|
161
|
+
setCurrentTurn: (t: CurrentTurn) => { curTurn = t },
|
|
162
|
+
// local-closure fakes
|
|
163
|
+
cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
|
|
164
|
+
clearActivitySummary: noop,
|
|
165
|
+
clearAnswerReadyFlushTimeout: noop,
|
|
166
|
+
closeActivityLane: noop,
|
|
167
|
+
closeProgressLane: noop,
|
|
168
|
+
completeProgressCardTurn: null,
|
|
169
|
+
composeTurnActivity: () => null,
|
|
170
|
+
confirmMemoryLegibility: noop,
|
|
171
|
+
deliverAnswer: async (a: { text: string }) => {
|
|
172
|
+
delivered.push(a.text)
|
|
173
|
+
return opts?.deliverResult ?? { sentIds: [4242], chunkCount: 1, delivered: true, exhausted: false }
|
|
174
|
+
},
|
|
175
|
+
deliverCapturedProse: async () => {},
|
|
176
|
+
drainActivitySummary: async () => {},
|
|
177
|
+
emissionAuthorityFor: () => fakeEA,
|
|
178
|
+
emitTurnRecord: noop,
|
|
179
|
+
endCurrentTurnAtomic: () => null,
|
|
180
|
+
extractUserPromptPreview: () => null,
|
|
181
|
+
finalizeStatusReaction: noop,
|
|
182
|
+
flushPendingNarrativeAtTurnEnd: noop,
|
|
183
|
+
getPinnedProgressCardMessageId: null,
|
|
184
|
+
handlePtyPartial: noop,
|
|
185
|
+
isDmChatId: () => true,
|
|
186
|
+
isLegitimatelyWorking: () => false,
|
|
187
|
+
makeNarrativeGate: () => ({ show: noop, stage: noop, resolveOnTool: noop, flushAtTurnEnd: noop }),
|
|
188
|
+
promoteQueuedStatus: noop,
|
|
189
|
+
purgeReactionTracking: noop,
|
|
190
|
+
redactOutboundText: (t: string) => redact(t),
|
|
191
|
+
rememberRecentTurn: noop,
|
|
192
|
+
resetAnswerReadyFlushTimeout: noop,
|
|
193
|
+
resetOrphanedReplyTimeout: noop,
|
|
194
|
+
resolvePendingNarrativeOnTool: noop,
|
|
195
|
+
scheduleEarlyLivenessOpen: noop,
|
|
196
|
+
stagePendingNarrative: noop,
|
|
197
|
+
startTurnTypingLoop: noop,
|
|
198
|
+
statusKey: key,
|
|
199
|
+
streamKey: key,
|
|
200
|
+
surfaceMemoryLegibility: noop,
|
|
201
|
+
turnLiveForItsTopic: () => true,
|
|
202
|
+
turnsDb: null,
|
|
203
|
+
unpinProgressCardForChat: null,
|
|
204
|
+
} as unknown as StreamRenderDeps
|
|
205
|
+
return { deps, calls, dedup, ledger, delivered }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ββ the P2 sendReply harness (same content, sharing the ONE cache) βββββββββ
|
|
209
|
+
function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
210
|
+
const { api, calls } = makeFakeBot()
|
|
211
|
+
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
212
|
+
const deps = {
|
|
213
|
+
outboundDedup: dedup,
|
|
214
|
+
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
215
|
+
firstTextReplyLogged: new Set<string>(),
|
|
216
|
+
suppressPtyPreview: new Set<string>(),
|
|
217
|
+
activeDraftStreams: new Map(),
|
|
218
|
+
lastPtyPreviewByChat: new Map(),
|
|
219
|
+
voiceOnDemandCache: { put: () => {} },
|
|
220
|
+
voicePreSynthQueue: { enqueue: () => {} },
|
|
221
|
+
pendingProgress: { clearPending: () => {}, noteOutbound: () => {} },
|
|
222
|
+
signalTracker: { noteOutbound: () => {}, noteSignal: () => {} },
|
|
223
|
+
silencePoke: { noteOutbound: () => {} },
|
|
224
|
+
getCurrentTurn: () => null,
|
|
225
|
+
getLastActiveTurnChatId: () => undefined,
|
|
226
|
+
HISTORY_ENABLED: false,
|
|
227
|
+
TURN_ORIGIN_ROUTING_ENABLED: true,
|
|
228
|
+
AUTOCLASSIFY_MIDTURN_SHADOW: false,
|
|
229
|
+
MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
|
|
230
|
+
MAX_CHUNK_LIMIT: 4096,
|
|
231
|
+
PHOTO_EXTS: new Set(['.jpg', '.png']),
|
|
232
|
+
lockedBot: { api },
|
|
233
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
234
|
+
swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
|
|
235
|
+
loadAccess: () => ({ allowFrom: [CHAT], groups: {}, disableLinkPreview: true }),
|
|
236
|
+
redactOutboundText: (t: string) => redact(t),
|
|
237
|
+
assertAllowedChat: () => {},
|
|
238
|
+
assertSendable: () => {},
|
|
239
|
+
statusKey: key,
|
|
240
|
+
streamKey: key,
|
|
241
|
+
resolveReplyOwnerTurn: () => null,
|
|
242
|
+
findTurnByOriginId: () => null,
|
|
243
|
+
findTurnByQuotedMessageId: () => null,
|
|
244
|
+
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|
|
245
|
+
resolveThreadId: (_c: string, explicit?: string | number | null) => (explicit != null ? Number(explicit) : undefined),
|
|
246
|
+
getLatestInboundMessageId: () => null,
|
|
247
|
+
recordOutbound: () => {},
|
|
248
|
+
emissionAuthorityFor: () => ({
|
|
249
|
+
claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
|
|
250
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
251
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
252
|
+
}),
|
|
253
|
+
clearActivitySummary: () => {},
|
|
254
|
+
startTypingLoop: () => {},
|
|
255
|
+
stopTypingLoop: () => {},
|
|
256
|
+
logOutbound: () => {},
|
|
257
|
+
closeObligationOnSubstantiveReply: () => {},
|
|
258
|
+
finalizeStatusReaction: () => {},
|
|
259
|
+
releaseTurnBufferGate: () => {},
|
|
260
|
+
reapQueuedStatus: () => {},
|
|
261
|
+
noteAgentOutputAt: () => {},
|
|
262
|
+
rememberAgentButtonMeta: () => {},
|
|
263
|
+
resolveVoiceOutPlan: () => null,
|
|
264
|
+
synthesizeVoiceOut: async () => null,
|
|
265
|
+
publishToTelegraph: async () => null,
|
|
266
|
+
clearSilentEndState: () => {},
|
|
267
|
+
emitRuntimeMetric: () => {},
|
|
268
|
+
shadowEmit: () => {},
|
|
269
|
+
progressDriver: null,
|
|
270
|
+
} as unknown as SendReplyGatewayDeps
|
|
271
|
+
return { deps, calls }
|
|
272
|
+
}
|
|
273
|
+
function req(text: string): SendReplyRequest {
|
|
274
|
+
return { args: { chat_id: CHAT, text }, turn: null }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// give the turn-flush IIFE (500ms internal setTimeout) time to reach the record
|
|
278
|
+
const settle = () => new Promise((r) => setTimeout(r, 650))
|
|
279
|
+
|
|
280
|
+
// ββ the cases ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
281
|
+
|
|
282
|
+
describe('stream-render β turn-flush records into the shared OutboundDedupCache', () => {
|
|
283
|
+
it('drives the REAL handleSessionEvent turn-flush path to outboundDedup.record', async () => {
|
|
284
|
+
const turn = makeTurn()
|
|
285
|
+
const answer = turn.capturedText.join('')
|
|
286
|
+
const h = makeStreamDeps({ turn })
|
|
287
|
+
handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
288
|
+
await settle()
|
|
289
|
+
expect(h.delivered).toContain(answer)
|
|
290
|
+
// the module's OWN record statement (stream-render.ts:1784) ran
|
|
291
|
+
expect(h.dedup.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// ββ #3237 HARD GATE: block provenance survives stream-render accumulation βββ
|
|
296
|
+
// The whole fix hinges on `ev.lastInMessage` surviving the `case 'text'`
|
|
297
|
+
// accumulation into `turn.capturedBlockMeta` and reaching selectFlushDeliveryText
|
|
298
|
+
// via decideTurnFlush. These drive the REAL handleSessionEvent text path (not a
|
|
299
|
+
// preset capturedText) so the accumulation code actually runs, then assert BOTH
|
|
300
|
+
// the accumulated provenance AND the flush's delivered text. Each case is
|
|
301
|
+
// engineered so the OPPOSITE outcome would result if provenance were lost and
|
|
302
|
+
// the strip fell back to the opener heuristic β proving structure is consulted.
|
|
303
|
+
describe('#3237 β block provenance survives accumulation and reaches the flush strip', () => {
|
|
304
|
+
function driveTextTurn(blocks: Array<{ text: string; lastInMessage: boolean }>) {
|
|
305
|
+
const turn = makeTurn({ capturedText: [], capturedBlockMeta: [], answerStream: null })
|
|
306
|
+
const h = makeStreamDeps({ turn })
|
|
307
|
+
blocks.forEach((b, i) =>
|
|
308
|
+
handleSessionEvent(h.deps, {
|
|
309
|
+
kind: 'text',
|
|
310
|
+
text: b.text,
|
|
311
|
+
blockIndex: i,
|
|
312
|
+
lastInMessage: b.lastInMessage,
|
|
313
|
+
}),
|
|
314
|
+
)
|
|
315
|
+
return { turn, h }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
it('narration FOLLOWED by a tool_use (lastInMessage=false) β provenance=[true,false], flush strips the preamble', async () => {
|
|
319
|
+
// "Here are the figures." matches NO opener regex β if provenance were lost
|
|
320
|
+
// the opener-only strip would KEEP it and deliver the whole blob. Structure
|
|
321
|
+
// ([true,false]) strips it. Asserting answer-only proves structure was used.
|
|
322
|
+
const narration = 'Here are the figures you asked about.'
|
|
323
|
+
const answer = 'All three services are green.'
|
|
324
|
+
const { turn, h } = driveTextTurn([
|
|
325
|
+
{ text: narration, lastInMessage: false }, // a tool_use followed it in its message
|
|
326
|
+
{ text: answer, lastInMessage: true }, // terminal
|
|
327
|
+
])
|
|
328
|
+
// Provenance survived the accumulation into the parallel array.
|
|
329
|
+
expect(turn.capturedBlockMeta).toEqual([true, false])
|
|
330
|
+
handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
331
|
+
await settle()
|
|
332
|
+
expect(h.delivered.join('\n')).toContain(answer)
|
|
333
|
+
expect(h.delivered.join('\n')).not.toContain('Here are the figures')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('#3237 real answer: two paragraphs, NEITHER followed by a tool_use (lastInMessage=true) β provenance=[false,false], flush keeps BOTH (no truncation)', async () => {
|
|
337
|
+
// p1 OPENS with "Let me explainβ¦" β the opener strip WOULD drop it if
|
|
338
|
+
// provenance were lost. Structure ([false,false]) keeps the full answer.
|
|
339
|
+
const p1 = 'Let me explain the plan in two parts.'
|
|
340
|
+
const p2 = 'Part two: we ship it behind the existing flag.'
|
|
341
|
+
const { turn, h } = driveTextTurn([
|
|
342
|
+
{ text: p1, lastInMessage: true },
|
|
343
|
+
{ text: p2, lastInMessage: true },
|
|
344
|
+
])
|
|
345
|
+
expect(turn.capturedBlockMeta).toEqual([false, false])
|
|
346
|
+
handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
347
|
+
await settle()
|
|
348
|
+
const out = h.delivered.join('\n')
|
|
349
|
+
expect(out).toContain('Let me explain the plan')
|
|
350
|
+
expect(out).toContain('Part two')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
describe('cross-surface dedup β ONE OutboundDedupCache across P4 stream + P2 reply (Amendment 1)', () => {
|
|
355
|
+
it('a P4 turn-flush record suppresses the P2 same-text reply (zero wire calls)', async () => {
|
|
356
|
+
const dedup = new OutboundDedupCache()
|
|
357
|
+
const turn = makeTurn()
|
|
358
|
+
const answer = turn.capturedText.join('')
|
|
359
|
+
// P4 surface (real handleSessionEvent) records into the shared cache.
|
|
360
|
+
const h = makeStreamDeps({ dedup, turn })
|
|
361
|
+
handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
362
|
+
await settle()
|
|
363
|
+
expect(h.dedup.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
|
|
364
|
+
|
|
365
|
+
// P2 surface (real sendReply) holding the SAME instance dedups the late reply.
|
|
366
|
+
const s = makeSendReplyDeps(dedup)
|
|
367
|
+
const res = await sendReply(s.deps, req(answer))
|
|
368
|
+
expect(res.content[0]!.text).toBe('sent (deduped β same content sent via earlier path)')
|
|
369
|
+
expect(s.calls).toHaveLength(0)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('NEGATIVE CONTROL: a SECOND cache reinstates the duplicate reply', async () => {
|
|
373
|
+
const streamCache = new OutboundDedupCache()
|
|
374
|
+
const turn = makeTurn()
|
|
375
|
+
const answer = turn.capturedText.join('')
|
|
376
|
+
const h = makeStreamDeps({ dedup: streamCache, turn })
|
|
377
|
+
handleSessionEvent(h.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
378
|
+
await settle()
|
|
379
|
+
expect(streamCache.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
|
|
380
|
+
|
|
381
|
+
// A DIFFERENT instance (the forbidden re-`new`) never saw the P4 record.
|
|
382
|
+
const s = makeSendReplyDeps(new OutboundDedupCache())
|
|
383
|
+
await sendReply(s.deps, req(answer))
|
|
384
|
+
expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('structural β the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
|
|
389
|
+
const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
390
|
+
const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
|
|
391
|
+
const sendSrc = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
|
|
392
|
+
|
|
393
|
+
it('gateway constructs EXACTLY ONE OutboundDedupCache; neither extracted module constructs any', () => {
|
|
394
|
+
expect(gatewaySrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(1)
|
|
395
|
+
expect(streamSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
|
|
396
|
+
expect(sendSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('both gateway deps builders inject the SAME outboundDedup binding (never a re-new)', () => {
|
|
400
|
+
for (const builder of ['function gatewayStreamRenderDeps(', 'function gatewaySendReplyDeps(']) {
|
|
401
|
+
const after = gatewaySrc.split(builder)[1] ?? ''
|
|
402
|
+
const body = after.split('\nfunction ')[0]?.split('\nexport ')[0] ?? after
|
|
403
|
+
expect(body).toMatch(/\n\s{4}outboundDedup,/)
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('handleSessionEvent is a thin wrapper delegating to the extracted module', () => {
|
|
408
|
+
const after = gatewaySrc.split('function handleSessionEvent(ev: SessionEvent): void {')[1] ?? ''
|
|
409
|
+
const body = after.split('\n}')[0] ?? after
|
|
410
|
+
expect(body).toContain('handleSessionEventCore(gatewayStreamRenderDeps(), ev)')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('the module never reads the currentTurn global β only getCurrentTurn() (Amendment 9)', () => {
|
|
414
|
+
const fnBody = streamSrc.split('export function handleSessionEvent(')[1] ?? ''
|
|
415
|
+
// No bare `currentTurn` value reads survive outside comments; the live-read
|
|
416
|
+
// contract routes through the injected accessor.
|
|
417
|
+
const codeOnly = fnBody
|
|
418
|
+
.split('\n')
|
|
419
|
+
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
|
|
420
|
+
.join('\n')
|
|
421
|
+
expect(codeOnly).not.toMatch(/[^.\w]currentTurn\b(?!\s*[:,)])/)
|
|
422
|
+
expect(codeOnly).toMatch(/getCurrentTurn\(\)/)
|
|
423
|
+
})
|
|
424
|
+
})
|