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,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
|
})
|
|
@@ -620,3 +620,43 @@ describe("ObligationLedger — escalation suppression predicate (Fix 4)", () =>
|
|
|
620
620
|
expect(snapshots[1]).toEqual([]);
|
|
621
621
|
});
|
|
622
622
|
});
|
|
623
|
+
|
|
624
|
+
// #3282 — the durable captured-answer snapshot attaches to the OPEN obligation
|
|
625
|
+
// so a partial backstop delivery is resumed (tail only) rather than regenerated.
|
|
626
|
+
describe("ObligationLedger — noteCapturedDelivery (#3282)", () => {
|
|
627
|
+
function input(id: string, openedAt: number) {
|
|
628
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: 1, text: "q", openedAt };
|
|
629
|
+
}
|
|
630
|
+
const snap = { chunks: ["c0", "c1"], chunkStates: [{ index: 0, messageIds: [10], confirmed: true }] };
|
|
631
|
+
|
|
632
|
+
it("attaches the snapshot to an OPEN obligation and persists it", () => {
|
|
633
|
+
const snapshots: Obligation[][] = [];
|
|
634
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
635
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
636
|
+
L.noteCapturedDelivery("c:3#1", snap);
|
|
637
|
+
expect(L.list()[0]!.capturedDelivery).toEqual(snap);
|
|
638
|
+
// The mutation persisted (onChange fired again after open).
|
|
639
|
+
expect(snapshots.at(-1)![0]!.capturedDelivery).toEqual(snap);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("is a no-op for an obligation that is not open (a delivered turn has none to resume)", () => {
|
|
643
|
+
const L = new ObligationLedger(2);
|
|
644
|
+
L.noteCapturedDelivery("c:3#missing", snap); // never opened
|
|
645
|
+
expect(L.isOpen("c:3#missing")).toBe(false);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("ignores an EMPTY snapshot (nothing to resume)", () => {
|
|
649
|
+
const L = new ObligationLedger(2);
|
|
650
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
651
|
+
L.noteCapturedDelivery("c:3#1", { chunks: [], chunkStates: [] });
|
|
652
|
+
expect(L.list()[0]!.capturedDelivery).toBeUndefined();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("close drops the snapshot with the obligation (bounded lifetime, A6)", () => {
|
|
656
|
+
const L = new ObligationLedger(2);
|
|
657
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
658
|
+
L.noteCapturedDelivery("c:3#1", snap);
|
|
659
|
+
expect(L.close("c:3#1")).toBe(true);
|
|
660
|
+
expect(L.isOpen("c:3#1")).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
});
|
|
@@ -118,6 +118,49 @@ describe("obligation-store", () => {
|
|
|
118
118
|
expect(loaded[0]!.representCount).toBe(1);
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it("round-trips a valid capturedDelivery snapshot through persist → load (#3282)", () => {
|
|
122
|
+
const { fs } = memFs();
|
|
123
|
+
const capturedDelivery = {
|
|
124
|
+
chunks: ["c0", "c1"],
|
|
125
|
+
chunkStates: [
|
|
126
|
+
{ index: 0, messageIds: [10], confirmed: true },
|
|
127
|
+
{ index: 1, messageIds: [11, 12], confirmed: false },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
persistObligations(PATH, fs, [ob("c:3#715", { capturedDelivery })]);
|
|
131
|
+
const loaded = loadObligations(PATH, fs);
|
|
132
|
+
expect(loaded).toHaveLength(1);
|
|
133
|
+
expect(loaded[0]!.capturedDelivery).toEqual(capturedDelivery);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("STRIPS a malformed capturedDelivery (fail-open to fresh-generation represent) (#3282)", () => {
|
|
137
|
+
// A partial write / forward-incompatible shape must not crash the resume: the
|
|
138
|
+
// row survives (obligation preserved), the bad blob is dropped.
|
|
139
|
+
const bad = {
|
|
140
|
+
v: 1,
|
|
141
|
+
obligations: [
|
|
142
|
+
{ ...ob("c:3#715"), capturedDelivery: { chunks: "not-an-array", chunkStates: [] } },
|
|
143
|
+
{ ...ob("c:3#716"), capturedDelivery: { chunks: ["c0"], chunkStates: [{ index: 0 }] } },
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
|
|
147
|
+
const loaded = loadObligations(PATH, fs);
|
|
148
|
+
expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:3#716"]);
|
|
149
|
+
expect(loaded[0]!.capturedDelivery).toBeUndefined();
|
|
150
|
+
expect(loaded[1]!.capturedDelivery).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("STRIPS an empty-chunks capturedDelivery (nothing to resume → fresh-gen, bounded) (#3282)", () => {
|
|
154
|
+
const bad = {
|
|
155
|
+
v: 1,
|
|
156
|
+
obligations: [{ ...ob("c:3#715"), capturedDelivery: { chunks: [], chunkStates: [] } }],
|
|
157
|
+
};
|
|
158
|
+
const { fs } = memFs({ [PATH]: JSON.stringify(bad) });
|
|
159
|
+
const loaded = loadObligations(PATH, fs);
|
|
160
|
+
expect(loaded).toHaveLength(1);
|
|
161
|
+
expect(loaded[0]!.capturedDelivery).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
121
164
|
it("never throws on a write failure — degrades to in-memory (logs)", () => {
|
|
122
165
|
const logs: string[] = [];
|
|
123
166
|
const fs: ObligationStoreFsSeam = {
|
|
@@ -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
|
|
|
@@ -143,7 +152,8 @@ describe('resolution clears the durable store (Defect A)', () => {
|
|
|
143
152
|
|
|
144
153
|
describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
145
154
|
it('the periodic reaper runs the approval-card expiry sweep inside try/catch', () => {
|
|
146
|
-
|
|
155
|
+
// Anchor tolerates the #2996 P0c `isGatewayMain ? setInterval(...)` boot gate.
|
|
156
|
+
const reaper = slice(GATEWAY, 'const pendingStateReaper = ', 12000)
|
|
147
157
|
// The sweep call must be guarded like the sibling sweepStaleTurnActiveMarker:
|
|
148
158
|
// an escaped throw inside this setInterval callback reaches uncaughtException
|
|
149
159
|
// and takes the whole gateway down.
|
|
@@ -214,7 +224,7 @@ describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
|
214
224
|
|
|
215
225
|
describe('vault_request_access copy derives the window from the real TTL', () => {
|
|
216
226
|
it('no hard-coded "10 min" claim; uses VAULT_REQUEST_ACCESS_TTL_MS', () => {
|
|
217
|
-
const fn = slice(
|
|
227
|
+
const fn = slice(CARD_TOOL, 'async function executeVaultRequestAccess', 12000)
|
|
218
228
|
expect(fn).not.toMatch(/times out \(10 min\)/)
|
|
219
229
|
expect(fn).toMatch(/Math\.round\(VAULT_REQUEST_ACCESS_TTL_MS \/ 60000\)/)
|
|
220
230
|
})
|