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
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden harness for the extracted narrative/activity lane
|
|
3
|
+
* (#2996 P4-B, plan Amendments 1/5/9/10).
|
|
4
|
+
*
|
|
5
|
+
* ── Oracle standard (Amendment 10) ────────────────────────────────────────
|
|
6
|
+
* gateway.ts cannot be driven in-place from a test runner, so the oracle is
|
|
7
|
+
* the EXTRACTED-MODULE golden harness (the outbound-send-path /
|
|
8
|
+
* stream-render-golden precedent): the REAL `createNarrativeLane` factory is
|
|
9
|
+
* driven directly against a fake bot recorder, real leaf modules
|
|
10
|
+
* (renderActivityFeedWithNested, NarrativeFlushController, narrative-dedup,
|
|
11
|
+
* feed-open-gate), and fake gateway closures.
|
|
12
|
+
*
|
|
13
|
+
* ── What is pinned ────────────────────────────────────────────────────────
|
|
14
|
+
* - narrative SHOW → feed card OPEN (sendRichMessage) with the clipped line
|
|
15
|
+
* - second SHOW → in-place EDIT of the SAME card (no second bubble)
|
|
16
|
+
* - composeTurnActivity: live vs final render (step-count on final only)
|
|
17
|
+
* - the narrative-dedup gate: a trailing draft-of-reply is SUPPRESSED at
|
|
18
|
+
* turn end; genuine trailing narration is SHOWN
|
|
19
|
+
* - clearActivitySummary finalize: terminal EDIT, no delete (default path)
|
|
20
|
+
* - THREE-MODULE cross-surface dedup (Amendment 1): the REAL P4-A
|
|
21
|
+
* handleSessionEvent turn-flush driven with the REAL P4-B lane wired into
|
|
22
|
+
* its deps records into ONE OutboundDedupCache, and the REAL P2 sendReply
|
|
23
|
+
* holding the SAME instance suppresses the late same-text reply
|
|
24
|
+
* - structural: the lane constructs NO OutboundDedupCache / ledger, never
|
|
25
|
+
* reads the `currentTurn` global, and gateway's wrappers delegate lazily
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect } from 'vitest'
|
|
28
|
+
import { readFileSync } from 'node:fs'
|
|
29
|
+
import { tmpdir } from 'node:os'
|
|
30
|
+
import { createNarrativeLane } from '../gateway/narrative-lane.js'
|
|
31
|
+
import { handleSessionEvent, type StreamRenderDeps } from '../gateway/stream-render.js'
|
|
32
|
+
import { sendReply, type SendReplyGatewayDeps, type SendReplyRequest } from '../gateway/outbound-send-path.js'
|
|
33
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
34
|
+
import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
35
|
+
import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
|
|
36
|
+
import { redact } from '../secret-detect/redact.js'
|
|
37
|
+
import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
|
|
38
|
+
|
|
39
|
+
const CHAT = '1001'
|
|
40
|
+
|
|
41
|
+
// ── fake bot recorder ─────────────────────────────────────────────────────
|
|
42
|
+
interface RecordedCall {
|
|
43
|
+
method: string
|
|
44
|
+
chat_id: string
|
|
45
|
+
text: string | null
|
|
46
|
+
message_id?: number
|
|
47
|
+
}
|
|
48
|
+
function makeFakeBot() {
|
|
49
|
+
const calls: RecordedCall[] = []
|
|
50
|
+
let nextId = 3000
|
|
51
|
+
const api = {
|
|
52
|
+
sendRichMessage: async (c: string, b: { markdown: string }) => {
|
|
53
|
+
const id = ++nextId
|
|
54
|
+
calls.push({ method: 'sendRichMessage', chat_id: c, text: b.markdown, message_id: id })
|
|
55
|
+
return { message_id: id }
|
|
56
|
+
},
|
|
57
|
+
sendMessage: async (c: string, t: string) => {
|
|
58
|
+
const id = ++nextId
|
|
59
|
+
calls.push({ method: 'sendMessage', chat_id: c, text: t, message_id: id })
|
|
60
|
+
return { message_id: id }
|
|
61
|
+
},
|
|
62
|
+
editMessageText: async (c: string, m: number, b: unknown) => {
|
|
63
|
+
calls.push({
|
|
64
|
+
method: 'editMessageText', chat_id: c,
|
|
65
|
+
text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, message_id: m,
|
|
66
|
+
})
|
|
67
|
+
return {}
|
|
68
|
+
},
|
|
69
|
+
deleteMessage: async (c: string, m: number) => {
|
|
70
|
+
calls.push({ method: 'deleteMessage', chat_id: c, text: null, message_id: m })
|
|
71
|
+
return true
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
return { api, calls }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── a CurrentTurn sufficient for the lane paths ───────────────────────────
|
|
78
|
+
function makeLaneTurn(lane: ReturnType<typeof createNarrativeLane>, over: Partial<CurrentTurn> = {}): CurrentTurn {
|
|
79
|
+
const turn = {
|
|
80
|
+
turnId: 'turn-lane-1',
|
|
81
|
+
sessionChatId: CHAT,
|
|
82
|
+
sessionThreadId: undefined,
|
|
83
|
+
sourceMessageId: null,
|
|
84
|
+
registryKey: null,
|
|
85
|
+
startedAt: Date.now() - 3000,
|
|
86
|
+
currentModel: null,
|
|
87
|
+
totalTokens: 0,
|
|
88
|
+
labeledToolCount: 0,
|
|
89
|
+
mirrorLines: [] as string[],
|
|
90
|
+
foregroundSubAgents: new Map<string, string[]>(),
|
|
91
|
+
activityPendingRender: null as string | null,
|
|
92
|
+
activityLastSentRender: null as string | null,
|
|
93
|
+
activityMessageId: null as number | null,
|
|
94
|
+
activityInFlight: null as Promise<void> | null,
|
|
95
|
+
activityEverOpened: false,
|
|
96
|
+
activityDrainFailures: 0,
|
|
97
|
+
finalAnswerEverDelivered: false,
|
|
98
|
+
finalAnswerDelivered: false,
|
|
99
|
+
replyCalled: false,
|
|
100
|
+
capturedText: [] as string[],
|
|
101
|
+
lastReplyText: '',
|
|
102
|
+
answerStream: null,
|
|
103
|
+
liveness: { recentlyStreaming: () => false, onStreamEvent: () => {}, note: () => {} },
|
|
104
|
+
...over,
|
|
105
|
+
} as unknown as CurrentTurn
|
|
106
|
+
;(turn as { narrativeGate?: unknown }).narrativeGate = lane.makeNarrativeGate(turn)
|
|
107
|
+
return turn
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── lane deps (minimal runtime-faithful fakes; real leaf modules imported by
|
|
111
|
+
// the lane itself) ──────────────────────────────────────────────────────
|
|
112
|
+
function makeLane(opts?: { getCurrentTurn?: () => CurrentTurn | null }) {
|
|
113
|
+
const { api, calls } = makeFakeBot()
|
|
114
|
+
const noop = () => {}
|
|
115
|
+
const fakeEA = {
|
|
116
|
+
mayDrain: () => true,
|
|
117
|
+
openOrEditCard: (_p: string, fn: () => void) => fn(),
|
|
118
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
119
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
120
|
+
}
|
|
121
|
+
const deps = {
|
|
122
|
+
ACTIVITY_CARD_STORE_PATH: `${tmpdir()}/lane-golden-activity-cards.json`,
|
|
123
|
+
CLEAR_STATUS_ON_COMPLETION: false,
|
|
124
|
+
FEED_HEARTBEAT_ENABLED: false,
|
|
125
|
+
FEED_HEARTBEAT_MIN_STALE_MS: 6000,
|
|
126
|
+
FEED_LIVENESS_OPEN_ENABLED: false,
|
|
127
|
+
FEED_LIVENESS_OPEN_MS: 5000,
|
|
128
|
+
PIN_STATUS_WHILE_WORKING: false,
|
|
129
|
+
POST_ANSWER_LIVENESS_STALE_MS: 90000,
|
|
130
|
+
STATIC: false,
|
|
131
|
+
activeDraftStreams: new Map(),
|
|
132
|
+
activityCardPersistEnabled: false,
|
|
133
|
+
activityCardStoreFs: { readFileSync: () => '', writeFileSync: noop, mkdirSync: noop, renameSync: noop, unlinkSync: noop },
|
|
134
|
+
bot: { api },
|
|
135
|
+
cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
|
|
136
|
+
currentTurnMap: { get: () => null, byKey: new Map() },
|
|
137
|
+
earlyLivenessOpenTimers: new Map(),
|
|
138
|
+
emissionAuthorityFor: () => fakeEA,
|
|
139
|
+
feedOpenGateDeps: () => ({ hasOutboundDeliveredSince: () => false, historyEnabled: false, finalAnswerMinChars: 200 }),
|
|
140
|
+
getCurrentTurn: opts?.getCurrentTurn ?? (() => null),
|
|
141
|
+
reconcileStatusPin: noop,
|
|
142
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
143
|
+
statusKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
|
|
144
|
+
} as unknown as NarrativeLaneDeps
|
|
145
|
+
const lane = createNarrativeLane(deps)
|
|
146
|
+
return { lane, calls }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const settle = () => new Promise((r) => setTimeout(r, 20))
|
|
150
|
+
|
|
151
|
+
// ── the cases ─────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
describe('narrative-lane golden — SHOW path opens then edits ONE feed card', () => {
|
|
154
|
+
it('first SHOW opens the card (sendRichMessage) with the clipped narrative line', async () => {
|
|
155
|
+
const { lane, calls } = makeLane()
|
|
156
|
+
const turn = makeLaneTurn(lane)
|
|
157
|
+
lane.showNarrativeStep(turn, 'Checking the gateway config for stale entries')
|
|
158
|
+
await turn.activityInFlight
|
|
159
|
+
const sends = calls.filter((c) => c.method === 'sendRichMessage')
|
|
160
|
+
expect(sends).toHaveLength(1)
|
|
161
|
+
expect(sends[0]!.text).toContain('Checking the gateway config for stale entries')
|
|
162
|
+
expect(turn.activityMessageId).toBe(sends[0]!.message_id)
|
|
163
|
+
expect(turn.activityEverOpened).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('second SHOW edits the SAME card in place — never a second bubble', async () => {
|
|
167
|
+
const { lane, calls } = makeLane()
|
|
168
|
+
const turn = makeLaneTurn(lane)
|
|
169
|
+
lane.showNarrativeStep(turn, 'Step one of the work')
|
|
170
|
+
await turn.activityInFlight
|
|
171
|
+
lane.showNarrativeStep(turn, 'Step two of the work')
|
|
172
|
+
await turn.activityInFlight
|
|
173
|
+
expect(calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(1)
|
|
174
|
+
const edits = calls.filter((c) => c.method === 'editMessageText')
|
|
175
|
+
expect(edits.length).toBeGreaterThanOrEqual(1)
|
|
176
|
+
const last = edits[edits.length - 1]!
|
|
177
|
+
expect(last.message_id).toBe(turn.activityMessageId)
|
|
178
|
+
expect(last.text).toContain('Step two of the work')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('composeTurnActivity: live render omits the step total; final render carries it', () => {
|
|
182
|
+
const { lane } = makeLane()
|
|
183
|
+
const turn = makeLaneTurn(lane, { labeledToolCount: 3 })
|
|
184
|
+
turn.mirrorLines.push('Read the file', 'Ran the tests', 'Pushed the branch')
|
|
185
|
+
const live = lane.composeTurnActivity(turn)
|
|
186
|
+
const final = lane.composeTurnActivity(turn, true)
|
|
187
|
+
expect(live).toBeTruthy()
|
|
188
|
+
expect(final).toBeTruthy()
|
|
189
|
+
expect(final!).toMatch(/3 steps/)
|
|
190
|
+
expect(live!).not.toMatch(/3 steps/)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('narrative-lane golden — the narrative-dedup gate at turn end', () => {
|
|
195
|
+
it('genuine trailing narration is SHOWN (lands in the feed card)', async () => {
|
|
196
|
+
const { lane, calls } = makeLane()
|
|
197
|
+
const turn = makeLaneTurn(lane)
|
|
198
|
+
lane.stagePendingNarrative(turn, 'Done - all checks green, wrapping up.')
|
|
199
|
+
lane.flushPendingNarrativeAtTurnEnd(turn, 'Here is your answer: the fix is deployed.')
|
|
200
|
+
await turn.activityInFlight
|
|
201
|
+
const sends = calls.filter((c) => c.method === 'sendRichMessage')
|
|
202
|
+
expect(sends).toHaveLength(1)
|
|
203
|
+
expect(sends[0]!.text).toContain('Done - all checks green')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('a trailing draft-of-reply is SUPPRESSED (zero card writes)', async () => {
|
|
207
|
+
const { lane, calls } = makeLane()
|
|
208
|
+
const turn = makeLaneTurn(lane)
|
|
209
|
+
const answer = 'The fix is deployed and the tests are green.'
|
|
210
|
+
lane.stagePendingNarrative(turn, answer)
|
|
211
|
+
lane.flushPendingNarrativeAtTurnEnd(turn, answer)
|
|
212
|
+
await settle()
|
|
213
|
+
expect(calls).toHaveLength(0)
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('narrative-lane golden — clearActivitySummary finalize', () => {
|
|
218
|
+
it('finalizes the open card with a terminal EDIT (default: no delete)', async () => {
|
|
219
|
+
const { lane, calls } = makeLane()
|
|
220
|
+
const turn = makeLaneTurn(lane, { labeledToolCount: 1 })
|
|
221
|
+
lane.showNarrativeStep(turn, 'Doing the work now')
|
|
222
|
+
await turn.activityInFlight
|
|
223
|
+
const cardId = turn.activityMessageId
|
|
224
|
+
expect(cardId).not.toBeNull()
|
|
225
|
+
lane.clearActivitySummary(turn)
|
|
226
|
+
await settle()
|
|
227
|
+
const edits = calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)
|
|
228
|
+
expect(edits.length).toBeGreaterThanOrEqual(1)
|
|
229
|
+
expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
230
|
+
// tracking released so a late render can't resurrect the card
|
|
231
|
+
expect(turn.activityMessageId).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
|
|
236
|
+
// The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
|
|
237
|
+
// into its deps, records the delivered answer into ONE OutboundDedupCache;
|
|
238
|
+
// the REAL P2 sendReply holding the SAME instance then suppresses the late
|
|
239
|
+
// same-text reply. One shared cache across all three extracted modules.
|
|
240
|
+
|
|
241
|
+
function makeStreamDepsWithRealLane(dedup: OutboundDedupCache, turn: CurrentTurn, lane: ReturnType<typeof createNarrativeLane>) {
|
|
242
|
+
const { api } = makeFakeBot()
|
|
243
|
+
const noop = () => {}
|
|
244
|
+
const delivered: string[] = []
|
|
245
|
+
const fakeEA = {
|
|
246
|
+
claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
|
|
247
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
248
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
249
|
+
mayDrain: () => true,
|
|
250
|
+
openOrEditCard: (_p: string, fn: () => void) => fn(),
|
|
251
|
+
}
|
|
252
|
+
const deps = {
|
|
253
|
+
ANSWER_LANE: { visibleEnabled: false },
|
|
254
|
+
CAPTURED_PROSE_DELIVERY_ENABLED: false,
|
|
255
|
+
CONTEXT_EXHAUSTION_COOLDOWN_MS: 600000,
|
|
256
|
+
DELIVERY_CONFIRM_ENABLED: false,
|
|
257
|
+
FEED_REOPEN_AFTER_ACK_ENABLED: false,
|
|
258
|
+
HANDBACK_PRETURN_ENABLED: false,
|
|
259
|
+
HISTORY_ENABLED: false,
|
|
260
|
+
LIVENESS_TERMINAL_HONESTY: true,
|
|
261
|
+
OBLIGATION_LEDGER_ENABLED: false,
|
|
262
|
+
ORPHANED_REPLY_STREAM_WINDOW_MS: 120000,
|
|
263
|
+
SILENCE_LIVENESS_PRODUCTION: false,
|
|
264
|
+
STATE_DIR: tmpdir(),
|
|
265
|
+
TURN_FLUSH_SAFETY_ENABLED: true,
|
|
266
|
+
TURN_PREVIEW_MAX: 200,
|
|
267
|
+
activeDraftStreams: new Map(),
|
|
268
|
+
activeStatusReactions: new Map(),
|
|
269
|
+
activeTurnStartedAt: new Map(),
|
|
270
|
+
backstopDeliveryLedger: new BackstopDeliveryLedger(),
|
|
271
|
+
bot: { api },
|
|
272
|
+
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
273
|
+
idleTracker: { noteEvent: noop },
|
|
274
|
+
lastPtyPreviewByChat: new Map(),
|
|
275
|
+
obligationLedger: { close: noop, noteTurnEnded: noop },
|
|
276
|
+
outboundDedup: dedup,
|
|
277
|
+
pendingCrossTurnGate: new Map(),
|
|
278
|
+
preambleSuppressor: { dropNow: noop, flushNow: noop, onText: noop, onTool: noop, reset: noop },
|
|
279
|
+
progressDriver: null,
|
|
280
|
+
reactionTransitionCounts: new Map(),
|
|
281
|
+
suppressPtyPreview: new Set(),
|
|
282
|
+
toolFlightTracker: { inFlightCount: () => 0 },
|
|
283
|
+
deliveryQueue: {},
|
|
284
|
+
handbackPreturnSignal: { tryAdopt: () => null },
|
|
285
|
+
sessionModelSource: { noteTranscriptModel: noop },
|
|
286
|
+
typingWrapper: { drainAll: noop, onToolResult: noop, onToolUse: noop },
|
|
287
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
288
|
+
swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
|
|
289
|
+
getCurrentTurn: () => turn,
|
|
290
|
+
getLastContextExhaustionWarningAt: () => 0,
|
|
291
|
+
setLastContextExhaustionWarningAt: noop,
|
|
292
|
+
getPendingPtyPartial: () => null,
|
|
293
|
+
setPendingPtyPartial: noop,
|
|
294
|
+
setCurrentTurn: noop,
|
|
295
|
+
cardDrainGate: (_t: unknown, _ea: unknown, run: () => void) => run(),
|
|
296
|
+
// ── the REAL P4-B lane wired in (not fakes) ──
|
|
297
|
+
clearActivitySummary: lane.clearActivitySummary,
|
|
298
|
+
closeActivityLane: lane.closeActivityLane,
|
|
299
|
+
closeProgressLane: lane.closeProgressLane,
|
|
300
|
+
composeTurnActivity: lane.composeTurnActivity,
|
|
301
|
+
drainActivitySummary: lane.drainActivitySummary,
|
|
302
|
+
flushPendingNarrativeAtTurnEnd: lane.flushPendingNarrativeAtTurnEnd,
|
|
303
|
+
makeNarrativeGate: lane.makeNarrativeGate,
|
|
304
|
+
resolvePendingNarrativeOnTool: lane.resolvePendingNarrativeOnTool,
|
|
305
|
+
stagePendingNarrative: lane.stagePendingNarrative,
|
|
306
|
+
scheduleEarlyLivenessOpen: lane.scheduleEarlyLivenessOpen,
|
|
307
|
+
// ────────────────────────────────────────────
|
|
308
|
+
clearAnswerReadyFlushTimeout: noop,
|
|
309
|
+
completeProgressCardTurn: null,
|
|
310
|
+
confirmMemoryLegibility: noop,
|
|
311
|
+
deliverAnswer: async (a: { text: string }) => {
|
|
312
|
+
delivered.push(a.text)
|
|
313
|
+
return { sentIds: [4242], chunkCount: 1, delivered: true, exhausted: false }
|
|
314
|
+
},
|
|
315
|
+
deliverCapturedProse: async () => {},
|
|
316
|
+
emissionAuthorityFor: () => fakeEA,
|
|
317
|
+
emitTurnRecord: noop,
|
|
318
|
+
endCurrentTurnAtomic: () => null,
|
|
319
|
+
extractUserPromptPreview: () => null,
|
|
320
|
+
finalizeStatusReaction: noop,
|
|
321
|
+
getPinnedProgressCardMessageId: null,
|
|
322
|
+
handlePtyPartial: noop,
|
|
323
|
+
isDmChatId: () => true,
|
|
324
|
+
isLegitimatelyWorking: () => false,
|
|
325
|
+
promoteQueuedStatus: noop,
|
|
326
|
+
purgeReactionTracking: noop,
|
|
327
|
+
redactOutboundText: (t: string) => redact(t),
|
|
328
|
+
rememberRecentTurn: noop,
|
|
329
|
+
resetAnswerReadyFlushTimeout: noop,
|
|
330
|
+
resetOrphanedReplyTimeout: noop,
|
|
331
|
+
startTurnTypingLoop: noop,
|
|
332
|
+
statusKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
|
|
333
|
+
streamKey: (c: string, t?: number | null) => `${c}:${t ?? 'main'}`,
|
|
334
|
+
surfaceMemoryLegibility: noop,
|
|
335
|
+
turnLiveForItsTopic: () => true,
|
|
336
|
+
turnsDb: null,
|
|
337
|
+
unpinProgressCardForChat: null,
|
|
338
|
+
} as unknown as StreamRenderDeps
|
|
339
|
+
return { deps, delivered }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
343
|
+
const { api, calls } = makeFakeBot()
|
|
344
|
+
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
345
|
+
const deps = {
|
|
346
|
+
outboundDedup: dedup,
|
|
347
|
+
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
348
|
+
firstTextReplyLogged: new Set<string>(),
|
|
349
|
+
suppressPtyPreview: new Set<string>(),
|
|
350
|
+
activeDraftStreams: new Map(),
|
|
351
|
+
lastPtyPreviewByChat: new Map(),
|
|
352
|
+
voiceOnDemandCache: { put: () => {} },
|
|
353
|
+
voicePreSynthQueue: { enqueue: () => {} },
|
|
354
|
+
pendingProgress: { clearPending: () => {}, noteOutbound: () => {} },
|
|
355
|
+
signalTracker: { noteOutbound: () => {}, noteSignal: () => {} },
|
|
356
|
+
silencePoke: { noteOutbound: () => {} },
|
|
357
|
+
getCurrentTurn: () => null,
|
|
358
|
+
getLastActiveTurnChatId: () => undefined,
|
|
359
|
+
HISTORY_ENABLED: false,
|
|
360
|
+
TURN_ORIGIN_ROUTING_ENABLED: true,
|
|
361
|
+
AUTOCLASSIFY_MIDTURN_SHADOW: false,
|
|
362
|
+
MAX_ATTACHMENT_BYTES: 50 * 1024 * 1024,
|
|
363
|
+
MAX_CHUNK_LIMIT: 4096,
|
|
364
|
+
PHOTO_EXTS: new Set(['.jpg', '.png']),
|
|
365
|
+
lockedBot: { api },
|
|
366
|
+
robustApiCall: (fn: () => Promise<unknown>) => fn(),
|
|
367
|
+
swallowingApiCall: async (fn: () => Promise<unknown>) => { try { return await fn() } catch { return undefined } },
|
|
368
|
+
loadAccess: () => ({ allowFrom: [CHAT], groups: {}, disableLinkPreview: true }),
|
|
369
|
+
redactOutboundText: (t: string) => redact(t),
|
|
370
|
+
assertAllowedChat: () => {},
|
|
371
|
+
assertSendable: () => {},
|
|
372
|
+
statusKey: key,
|
|
373
|
+
streamKey: key,
|
|
374
|
+
resolveReplyOwnerTurn: () => null,
|
|
375
|
+
findTurnByOriginId: () => null,
|
|
376
|
+
findTurnByQuotedMessageId: () => null,
|
|
377
|
+
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|
|
378
|
+
resolveThreadId: (_c: string, explicit?: string | number | null) => (explicit != null ? Number(explicit) : undefined),
|
|
379
|
+
getLatestInboundMessageId: () => null,
|
|
380
|
+
recordOutbound: () => {},
|
|
381
|
+
emissionAuthorityFor: () => ({
|
|
382
|
+
claimOrDowngradePing: (_i: unknown, _s: unknown, _a: unknown, disabled: () => void) => disabled(),
|
|
383
|
+
markSubstantiveFinalDelivered: (fn: () => void) => fn(),
|
|
384
|
+
finalizeCard: (fn: () => void) => fn(),
|
|
385
|
+
}),
|
|
386
|
+
clearActivitySummary: () => {},
|
|
387
|
+
startTypingLoop: () => {},
|
|
388
|
+
stopTypingLoop: () => {},
|
|
389
|
+
logOutbound: () => {},
|
|
390
|
+
closeObligationOnSubstantiveReply: () => {},
|
|
391
|
+
finalizeStatusReaction: () => {},
|
|
392
|
+
releaseTurnBufferGate: () => {},
|
|
393
|
+
reapQueuedStatus: () => {},
|
|
394
|
+
noteAgentOutputAt: () => {},
|
|
395
|
+
rememberAgentButtonMeta: () => {},
|
|
396
|
+
resolveVoiceOutPlan: () => null,
|
|
397
|
+
synthesizeVoiceOut: async () => null,
|
|
398
|
+
publishToTelegraph: async () => null,
|
|
399
|
+
clearSilentEndState: () => {},
|
|
400
|
+
emitRuntimeMetric: () => {},
|
|
401
|
+
shadowEmit: () => {},
|
|
402
|
+
progressDriver: null,
|
|
403
|
+
} as unknown as SendReplyGatewayDeps
|
|
404
|
+
return { deps, calls }
|
|
405
|
+
}
|
|
406
|
+
function req(text: string): SendReplyRequest {
|
|
407
|
+
return { args: { chat_id: CHAT, text }, turn: null }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
describe('THREE-MODULE cross-surface dedup — one OutboundDedupCache across P4-B lane + P4-A stream + P2 reply', () => {
|
|
411
|
+
it('turn-flush through the REAL stream module (REAL lane wired in) records; the REAL sendReply dedups', async () => {
|
|
412
|
+
const dedup = new OutboundDedupCache()
|
|
413
|
+
const { lane } = makeLane()
|
|
414
|
+
const answer = 'The lane-wired terminal answer the model never sent via reply.'
|
|
415
|
+
const turn = makeLaneTurn(lane, { capturedText: [answer], turnId: 'turn-3mod' })
|
|
416
|
+
const { deps } = makeStreamDepsWithRealLane(dedup, turn, lane)
|
|
417
|
+
handleSessionEvent(deps, { kind: 'turn_end', durationMs: 1200 })
|
|
418
|
+
await new Promise((r) => setTimeout(r, 650))
|
|
419
|
+
expect(dedup.check(CHAT, undefined, answer, Date.now(), null)).not.toBeNull()
|
|
420
|
+
|
|
421
|
+
const s = makeSendReplyDeps(dedup)
|
|
422
|
+
const res = await sendReply(s.deps, req(answer))
|
|
423
|
+
expect(res.content[0]!.text).toBe('sent (deduped — same content sent via earlier path)')
|
|
424
|
+
expect(s.calls).toHaveLength(0)
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe('structural — singleton + wrapper discipline (#2996 P4-B)', () => {
|
|
429
|
+
const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
430
|
+
const laneSrc = readFileSync(new URL('../gateway/narrative-lane.ts', import.meta.url), 'utf8')
|
|
431
|
+
|
|
432
|
+
it('the lane constructs NO OutboundDedupCache and NO BackstopDeliveryLedger', () => {
|
|
433
|
+
expect(laneSrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(0)
|
|
434
|
+
expect(laneSrc.match(/new BackstopDeliveryLedger\(/g) ?? []).toHaveLength(0)
|
|
435
|
+
// gateway still constructs exactly ONE dedup cache (the injected singleton).
|
|
436
|
+
expect(gatewaySrc.match(/new OutboundDedupCache\(/g) ?? []).toHaveLength(1)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('the lane never reads the currentTurn global — only the injected accessor', () => {
|
|
440
|
+
const codeOnly = laneSrc
|
|
441
|
+
.split('\n')
|
|
442
|
+
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*'))
|
|
443
|
+
.join('\n')
|
|
444
|
+
expect(codeOnly).not.toMatch(/[^.\w]currentTurn\b(?!\s*[:,)])/)
|
|
445
|
+
expect(codeOnly).toMatch(/getCurrentTurn\(\)/)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('gateway builds the lane LAZILY with the injected deps and delegates via same-name wrappers', () => {
|
|
449
|
+
expect(gatewaySrc).toContain('narrativeLaneInstance ??= createNarrativeLane(gatewayNarrativeLaneDeps())')
|
|
450
|
+
for (const fn of ['composeTurnActivity', 'drainActivitySummary', 'clearActivitySummary', 'feedHeartbeatTick', 'flushPendingNarrativeAtTurnEnd']) {
|
|
451
|
+
expect(gatewaySrc).toMatch(new RegExp(`narrativeLane\\(\\)\\.${fn}\\(`))
|
|
452
|
+
}
|
|
453
|
+
// the deps builder injects the live-turn accessor (Amendment 9)
|
|
454
|
+
const after = gatewaySrc.split('function gatewayNarrativeLaneDeps(')[1] ?? ''
|
|
455
|
+
const body = after.split('\nexport ')[0] ?? after
|
|
456
|
+
expect(body).toContain('getCurrentTurn: () => currentTurn')
|
|
457
|
+
})
|
|
458
|
+
})
|
|
@@ -125,9 +125,15 @@ describe('gateway wiring (source-level guards) — the gateway IIFE is too entan
|
|
|
125
125
|
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
126
126
|
'utf-8',
|
|
127
127
|
)
|
|
128
|
+
// #2996 P8 PR-B: the funnel bodies moved verbatim to turn-end.ts; the
|
|
129
|
+
// body-structure guards read there (gateway keeps delegating wrappers).
|
|
130
|
+
const turnEndSrc = readFileSync(
|
|
131
|
+
resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
|
|
132
|
+
'utf-8',
|
|
133
|
+
)
|
|
128
134
|
|
|
129
135
|
it('endCurrentTurnAtomic arms the no-reply drain timer after the serialize-gated purge', () => {
|
|
130
|
-
const fn =
|
|
136
|
+
const fn = turnEndSrc.split('function endCurrentTurnAtomic')[1]?.split('\nfunction ')[0] ?? ''
|
|
131
137
|
expect(fn).toMatch(/purgeReactionTracking\(/)
|
|
132
138
|
expect(fn).toMatch(/armNoReplyDrainTimer\(turn\)/)
|
|
133
139
|
// The arm must come AFTER the purge (the purge attempts the gated
|
|
@@ -136,7 +142,7 @@ describe('gateway wiring (source-level guards) — the gateway IIFE is too entan
|
|
|
136
142
|
})
|
|
137
143
|
|
|
138
144
|
it('armNoReplyDrainTimer uses the shared predicate + a bounded setTimeout that force-drains', () => {
|
|
139
|
-
const fn =
|
|
145
|
+
const fn = turnEndSrc.split('function armNoReplyDrainTimer')[1]?.split('\nfunction ')[0] ?? ''
|
|
140
146
|
expect(fn).toMatch(/shouldArmNoReplyDrain\(/)
|
|
141
147
|
expect(fn).toMatch(/setTimeout\(/)
|
|
142
148
|
expect(fn).toMatch(/SERIALIZE_NOREPLY_DRAIN_MS/)
|
|
@@ -151,6 +157,11 @@ describe('gateway wiring (source-level guards) — the gateway IIFE is too entan
|
|
|
151
157
|
// The unwedge fallback calls redeliverBufferedInbound directly — it
|
|
152
158
|
// must NOT be gated by the serialize predicate, or a stuck no-reply
|
|
153
159
|
// turn whose bounded timer somehow failed would never recover.
|
|
154
|
-
|
|
160
|
+
// #2996 P8 PR-C3: the fallback body moved verbatim to liveness-wiring.ts.
|
|
161
|
+
const livenessSrc = readFileSync(
|
|
162
|
+
resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'),
|
|
163
|
+
'utf-8',
|
|
164
|
+
)
|
|
165
|
+
expect(livenessSrc).toMatch(/silence-poke framework-fallback ended wedged turn/)
|
|
155
166
|
})
|
|
156
167
|
})
|
|
@@ -30,6 +30,10 @@ const CARD_STORES = read('gateway/approval-card-stores.ts')
|
|
|
30
30
|
// moved verbatim behind callback-query-handlers.ts; handler-body pins read
|
|
31
31
|
// that module while staging/boot/sweep wiring stays pinned on gateway.ts.
|
|
32
32
|
const CB_HANDLERS = read('gateway/callback-query-handlers.ts')
|
|
33
|
+
// #2996 P5-tail: the agent-facing card-STAGING execute* tool handlers moved
|
|
34
|
+
// verbatim behind card-tool-handlers.ts; their staging pins read that module
|
|
35
|
+
// while the stores/boot/sweep wiring stays pinned on gateway.ts.
|
|
36
|
+
const CARD_TOOL = read('gateway/card-tool-handlers.ts')
|
|
33
37
|
|
|
34
38
|
function slice(src: string, header: string, span = 3000): string {
|
|
35
39
|
const start = src.indexOf(header)
|
|
@@ -62,22 +66,23 @@ describe('pending-card-store wiring', () => {
|
|
|
62
66
|
|
|
63
67
|
describe('staging persists card metadata (Defect A)', () => {
|
|
64
68
|
it('vault_request_access stages into the durable store', () => {
|
|
65
|
-
const fn = slice(
|
|
69
|
+
const fn = slice(CARD_TOOL, 'async function executeVaultRequestAccess', 6000)
|
|
66
70
|
expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'vault_request_access'/)
|
|
67
71
|
})
|
|
68
72
|
|
|
69
73
|
it('request_secret stages into the durable store', () => {
|
|
70
|
-
const fn = slice(
|
|
74
|
+
const fn = slice(CARD_TOOL, 'async function executeRequestSecret', 3000)
|
|
71
75
|
expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'request_secret'/)
|
|
72
76
|
})
|
|
73
77
|
|
|
74
78
|
it('mental_model_propose stages into the durable store', () => {
|
|
75
|
-
|
|
79
|
+
// 7000: the factory indent puts the add block ~6.1k chars past the def.
|
|
80
|
+
const fn = slice(CARD_TOOL, 'async function executeMentalModelPropose', 7000)
|
|
76
81
|
expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'mental_model_propose'/)
|
|
77
82
|
})
|
|
78
83
|
|
|
79
84
|
it('vault_request_save stages metadata but NEVER the secret value (hygiene)', () => {
|
|
80
|
-
const fn = slice(
|
|
85
|
+
const fn = slice(CARD_TOOL, 'async function executeVaultRequestSave', 6000)
|
|
81
86
|
const addBlock = slice(fn, "pendingCardStore.add({", 700)
|
|
82
87
|
expect(addBlock).toMatch(/family: 'vault_request_save'/)
|
|
83
88
|
// The staged secret `value` must NOT be copied into the persisted record.
|
|
@@ -127,9 +132,13 @@ describe('resolution clears the durable store (Defect A)', () => {
|
|
|
127
132
|
})
|
|
128
133
|
|
|
129
134
|
it('secret request provide/decline remove from the store', () => {
|
|
130
|
-
// capture (provide → value arrives) removes via armed.stageId
|
|
135
|
+
// capture (provide → value arrives) removes via armed.stageId — the value
|
|
136
|
+
// arrival path (captureProvidedSecret) stays in gateway.ts.
|
|
131
137
|
expect(GATEWAY).toMatch(/pendingCardStore\.remove\(armed\.stageId\)/)
|
|
132
|
-
|
|
138
|
+
// The decline branch moved with handleSecretRequestCallback into
|
|
139
|
+
// callback-query-handlers.ts (#2996 P5) — pin the body there, mirroring the
|
|
140
|
+
// handleMentalModelProposeCallback pin below.
|
|
141
|
+
const decline = slice(CB_HANDLERS, 'async function handleSecretRequestCallback', 3000)
|
|
133
142
|
expect(decline).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
134
143
|
})
|
|
135
144
|
|
|
@@ -215,7 +224,7 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
|
215
224
|
|
|
216
225
|
describe('vault_request_access copy derives the window from the real TTL', () => {
|
|
217
226
|
it('no hard-coded "10 min" claim; uses VAULT_REQUEST_ACCESS_TTL_MS', () => {
|
|
218
|
-
const fn = slice(
|
|
227
|
+
const fn = slice(CARD_TOOL, 'async function executeVaultRequestAccess', 12000)
|
|
219
228
|
expect(fn).not.toMatch(/times out \(10 min\)/)
|
|
220
229
|
expect(fn).toMatch(/Math\.round\(VAULT_REQUEST_ACCESS_TTL_MS \/ 60000\)/)
|
|
221
230
|
})
|
|
@@ -29,6 +29,17 @@ import { readFileSync } from 'node:fs'
|
|
|
29
29
|
import { resolve } from 'node:path'
|
|
30
30
|
|
|
31
31
|
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
32
|
+
// #2996 P4-A: the enqueue turn-ctor SET (`setCurrentTurn(next, statusKey(…))`)
|
|
33
|
+
// and two of the three keyed-liveness comparison sites moved VERBATIM into
|
|
34
|
+
// stream-render.ts with handleSessionEvent. In the extracted module the
|
|
35
|
+
// flag-OFF liveness read routes through the injected accessor
|
|
36
|
+
// (`getCurrentTurn() === turn`) rather than the `currentTurn` module global —
|
|
37
|
+
// same invariant, accessor spelling. These wiring oracles span both files.
|
|
38
|
+
const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
|
|
39
|
+
// #2996 P8 PR-C3: the silence-poke fallback body (keyed delete + purgeChatStale
|
|
40
|
+
// self-heal) moved verbatim to liveness-wiring.ts — those oracles read there.
|
|
41
|
+
const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
|
|
42
|
+
const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
|
|
32
43
|
|
|
33
44
|
// ---------------------------------------------------------------------------
|
|
34
45
|
// (a) SOURCE-READ STRUCTURAL ORACLE
|
|
@@ -51,19 +62,27 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
|
|
|
51
62
|
const rawAssigns = gatewaySrc.match(/^[^/\n]*\bcurrentTurn = (?!currentTurnMap\.get\(\)|null$)/gm) ?? []
|
|
52
63
|
expect(rawAssigns, `unexpected raw currentTurn assignment(s): ${JSON.stringify(rawAssigns)}`).toEqual([])
|
|
53
64
|
|
|
54
|
-
// The turn ctor SET routes through setCurrentTurn with the statusKey
|
|
55
|
-
|
|
65
|
+
// The turn ctor SET routes through setCurrentTurn with the statusKey
|
|
66
|
+
// (now in stream-render.ts's `enqueue` branch — #2996 P4-A).
|
|
67
|
+
expect(gatewayAndStreamSrc).toMatch(/setCurrentTurn\(next, statusKey\(ev\.chatId, enqThreadIdNum\)\)/)
|
|
56
68
|
// The disconnect-flush clears the WHOLE map (every entry is a ghost).
|
|
57
69
|
expect(gatewaySrc).toMatch(/clearAllCurrentTurns\(\)/)
|
|
58
|
-
// The silence-poke fallback does a keyed delete for the wedged turn's key
|
|
59
|
-
|
|
70
|
+
// The silence-poke fallback does a keyed delete for the wedged turn's key
|
|
71
|
+
// (#2996 P8 PR-C3: the fallback body lives in liveness-wiring.ts).
|
|
72
|
+
expect(livenessSrc).toMatch(/endCurrentTurnForKey\(wedgedTurn, fbKey\)/)
|
|
60
73
|
})
|
|
61
74
|
|
|
62
75
|
it('endCurrentTurnAtomic closes the leak AT ORIGIN — keyed liveness guard + keyed delete', () => {
|
|
63
76
|
// Anchor on the `function ` prefix only — the signature is multi-line
|
|
64
77
|
// (gained an `opts?` param + `number | null` return in the send-honesty
|
|
65
78
|
// work), and `function ` disambiguates the definition from its call sites.
|
|
66
|
-
|
|
79
|
+
// #2996 P8 PR-B: the funnel body moved verbatim to turn-end.ts (gateway
|
|
80
|
+
// keeps a thin delegating wrapper) — read the body there.
|
|
81
|
+
const turnEndSrc = readFileSync(
|
|
82
|
+
resolve(__dirname, '..', 'gateway', 'turn-end.ts'),
|
|
83
|
+
'utf-8',
|
|
84
|
+
)
|
|
85
|
+
const body = turnEndSrc
|
|
67
86
|
.split('function endCurrentTurnAtomic(')[1]
|
|
68
87
|
?.split('\n}')[0] ?? ''
|
|
69
88
|
expect(body.length).toBeGreaterThan(50)
|
|
@@ -88,8 +107,12 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
|
|
|
88
107
|
// the flag, resolve THIS turn by ITS OWN topic key. We assert the keyed
|
|
89
108
|
// form appears the expected number of times AND the literal flag-OFF form is
|
|
90
109
|
// retained (the silence-liveness-wiring oracle requires `currentTurn === turn`).
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
// #2996 P4-A: two of the three sites moved to stream-render.ts, where the
|
|
111
|
+
// flag-OFF read is spelled `getCurrentTurn() === turn` (injected accessor);
|
|
112
|
+
// the one remaining in gateway.ts keeps `currentTurn === turn`. Match either
|
|
113
|
+
// spelling across both files — the population is still exactly 3.
|
|
114
|
+
const keyedLiveness = gatewayAndStreamSrc.match(
|
|
115
|
+
/EMISSION_AUTHORITY_ENABLED \? turnLiveForItsTopic\(turn\) : (?:currentTurn|getCurrentTurn\(\)) === turn/g,
|
|
93
116
|
) ?? []
|
|
94
117
|
expect(keyedLiveness.length).toBe(3)
|
|
95
118
|
})
|
|
@@ -105,7 +128,8 @@ describe('PR-4e source-read oracle — the wiring the per-topic map depends on',
|
|
|
105
128
|
})
|
|
106
129
|
|
|
107
130
|
it('the self-heal backstop drops per-topic entries for the swept chat (purgeChatStale)', () => {
|
|
108
|
-
|
|
131
|
+
// #2996 P8 PR-C3: the self-heal lives in the extracted fallback body.
|
|
132
|
+
expect(livenessSrc).toMatch(/currentTurnMap\.purgeChatStale\(fbChatId,/)
|
|
109
133
|
})
|
|
110
134
|
})
|
|
111
135
|
|