switchroom 0.15.45 → 0.16.4
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 +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3158 -1178
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7479 -7439
- package/dist/vault/broker/server.js +11312 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import {
|
|
6
|
+
initHistory,
|
|
7
|
+
recordOutbound,
|
|
8
|
+
hasOutboundDeliveredSince,
|
|
9
|
+
_resetForTests,
|
|
10
|
+
} from '../history.js'
|
|
11
|
+
import {
|
|
12
|
+
mayOpenActivityCard,
|
|
13
|
+
computeCrossTurnAnswerDelivered as realComputeCrossTurnAnswerDelivered,
|
|
14
|
+
computeFeedOpenVerdict,
|
|
15
|
+
type FeedOpenGateView,
|
|
16
|
+
type FeedOpenGateDeps,
|
|
17
|
+
type FeedOpenProducer,
|
|
18
|
+
} from '../gateway/feed-open-gate.js'
|
|
19
|
+
import { FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* PR1 — cross-turn stale-card guard (design `docs/message-emission-determinism.md`
|
|
23
|
+
* §9 lever 4 / race C/D).
|
|
24
|
+
*
|
|
25
|
+
* Scenario this pins: a substantive answer is delivered in turn N (the original
|
|
26
|
+
* obligation turn). The obligation does NOT close (its reply routing didn't
|
|
27
|
+
* resolve back to the origin), so `obligationSweep` later RE-PRESENTS it as a
|
|
28
|
+
* synthetic owed-reply turn N+1. That synthetic turn (and the liveness/heartbeat
|
|
29
|
+
* timer firing on it) starts with a CLEARED per-turn `finalAnswerEverDelivered`
|
|
30
|
+
* latch, so lever 1 alone can't see the prior answer. Lever 4 closes the gap by
|
|
31
|
+
* composing, exactly as `drainActivitySummary` does, the
|
|
32
|
+
* `crossTurnAnswerDelivered` flag from the SAME `hasOutboundDeliveredSince`
|
|
33
|
+
* history predicate the represent guard uses — with the obligation's `openedAt`
|
|
34
|
+
* as the cutoff and the SUBSTANTIVE 200-char threshold — and passing it to
|
|
35
|
+
* `mayOpenActivityCard`.
|
|
36
|
+
*
|
|
37
|
+
* This exercises the wired composition (cutoff = obligation openedAt, threshold =
|
|
38
|
+
* FINAL_ANSWER_MIN_CHARS) against a real history DB, the part the pure
|
|
39
|
+
* feed-open-gate unit test cannot cover. The gateway computes the same expression
|
|
40
|
+
* inline in the OPEN branch of `drainActivitySummary`.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
let stateDir: string
|
|
44
|
+
|
|
45
|
+
const SUBSTANTIVE = 'A'.repeat(FINAL_ANSWER_MIN_CHARS) // ≥ 200 chars → counts
|
|
46
|
+
const ACK = 'On it.' // < 200 chars → never counts (the #2141 carve-out)
|
|
47
|
+
|
|
48
|
+
const CHAT = '-100777'
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
stateDir = mkdtempSync(join(tmpdir(), 'cross-turn-card-gate-'))
|
|
52
|
+
initHistory(stateDir, 30)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
_resetForTests()
|
|
57
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* PR-4b: drive the REAL extracted helper (`computeCrossTurnAnswerDelivered` in
|
|
62
|
+
* feed-open-gate.ts) — the same pure function the gateway drain AND the
|
|
63
|
+
* emission-authority façade now call — rather than a parallel local mirror.
|
|
64
|
+
* Maps this test's scenario shape onto the helper's `(view, deps)` signature:
|
|
65
|
+
* `aboutToOpen` ⇒ `activityMessageId == null`, `hasCrossTurnGate` ⇒ the
|
|
66
|
+
* `crossTurnGate` presence, `openedAt` ⇒ `crossTurnGate.sinceMs` (the
|
|
67
|
+
* obligation cutoff). Deps inject the SAME `hasOutboundDeliveredSince` predicate
|
|
68
|
+
* + the substantive 200-char floor the gateway centralizes.
|
|
69
|
+
*/
|
|
70
|
+
function computeCrossTurnAnswerDelivered(opts: {
|
|
71
|
+
aboutToOpen: boolean
|
|
72
|
+
hasCrossTurnGate: boolean
|
|
73
|
+
openedAt: number
|
|
74
|
+
threadId?: number
|
|
75
|
+
}): boolean {
|
|
76
|
+
return realComputeCrossTurnAnswerDelivered(
|
|
77
|
+
{
|
|
78
|
+
activityMessageId: opts.aboutToOpen ? null : 1,
|
|
79
|
+
finalAnswerEverDelivered: false,
|
|
80
|
+
labeledToolCount: 0,
|
|
81
|
+
crossTurnGate: opts.hasCrossTurnGate ? { sinceMs: opts.openedAt } : undefined,
|
|
82
|
+
sessionChatId: CHAT,
|
|
83
|
+
sessionThreadId: opts.threadId,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
hasOutboundDeliveredSince,
|
|
87
|
+
historyEnabled: true,
|
|
88
|
+
finalAnswerMinChars: FINAL_ANSWER_MIN_CHARS,
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mirror the gateway's per-turn identity (`deriveTurnId`) and the
|
|
95
|
+
* `pendingCrossTurnGate` arm/consume keying. The gate is keyed on the
|
|
96
|
+
* obligation's `originTurnId` — which equals `deriveTurnId(chat, thread, msgId)`
|
|
97
|
+
* for the originating inbound, and which the represent inbound reconstructs
|
|
98
|
+
* (it reuses the original chat/thread/messageId). The consume side looks the
|
|
99
|
+
* gate up by the ENQUEUE-time turn id of whatever turn is starting, deleting
|
|
100
|
+
* on read. So a turn consumes (and thus carries) the gate iff its own
|
|
101
|
+
* derived turn id equals the armed obligation's `originTurnId` — which is true
|
|
102
|
+
* for the matching represent turn and false for any unrelated foreground turn.
|
|
103
|
+
*/
|
|
104
|
+
function deriveTurnIdMirror(
|
|
105
|
+
chatId: string,
|
|
106
|
+
threadId: number | null | undefined,
|
|
107
|
+
messageId: string | number | null | undefined,
|
|
108
|
+
): string | null {
|
|
109
|
+
if (messageId == null || messageId === '' || String(messageId) === '0') return null
|
|
110
|
+
return `${chatId}:${threadId ?? '_'}#${messageId}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Arm side (`obligationSweep`): key by the obligation's originTurnId. */
|
|
114
|
+
function armGate(
|
|
115
|
+
gate: Map<string, { sinceMs: number }>,
|
|
116
|
+
originTurnId: string,
|
|
117
|
+
openedAt: number,
|
|
118
|
+
): void {
|
|
119
|
+
gate.set(originTurnId, { sinceMs: openedAt })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Consume side (enqueue / turn ctor): look up + delete by the STARTING turn's
|
|
124
|
+
* own derived turn id. Returns whether this turn carries a cross-turn gate.
|
|
125
|
+
*/
|
|
126
|
+
function consumeGate(
|
|
127
|
+
gate: Map<string, { sinceMs: number }>,
|
|
128
|
+
turnId: string,
|
|
129
|
+
): { sinceMs: number } | undefined {
|
|
130
|
+
const hit = gate.get(turnId)
|
|
131
|
+
if (hit != null) gate.delete(turnId)
|
|
132
|
+
return hit
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('cross-turn card gate — synthetic represent turn AFTER a substantive answer', () => {
|
|
136
|
+
it('does NOT open a card when a substantive answer was delivered since the obligation was raised', () => {
|
|
137
|
+
const openedAt = 1_000_000 * 1000 // ms — obligation raised
|
|
138
|
+
// Turn N delivered a substantive answer 1s after the obligation was raised.
|
|
139
|
+
recordOutbound({
|
|
140
|
+
chat_id: CHAT,
|
|
141
|
+
thread_id: null,
|
|
142
|
+
message_ids: [42],
|
|
143
|
+
texts: [SUBSTANTIVE],
|
|
144
|
+
ts: 1_000_001, // sec — after openedAt
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Turn N+1 is the synthetic represent turn: fresh latch, about to OPEN.
|
|
148
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
149
|
+
aboutToOpen: true,
|
|
150
|
+
hasCrossTurnGate: true,
|
|
151
|
+
openedAt,
|
|
152
|
+
})
|
|
153
|
+
expect(crossTurnAnswerDelivered).toBe(true)
|
|
154
|
+
|
|
155
|
+
// Any producer's OPEN is refused — no "thinking…" card beneath the answer.
|
|
156
|
+
for (const producer of ['tool', 'liveness', 'narrative'] as const) {
|
|
157
|
+
expect(
|
|
158
|
+
mayOpenActivityCard({
|
|
159
|
+
producer,
|
|
160
|
+
finalAnswerEverDelivered: false, // the synthetic turn's own latch is clear
|
|
161
|
+
labeledToolCount: producer === 'narrative' ? 0 : 1,
|
|
162
|
+
crossTurnAnswerDelivered,
|
|
163
|
+
}),
|
|
164
|
+
).toBe(false)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('DOES open a card when the obligation is genuinely unanswered (no reply since it was raised) — represent surface preserved', () => {
|
|
169
|
+
const openedAt = 1_000_000 * 1000
|
|
170
|
+
// No outbound recorded at all → genuinely unanswered.
|
|
171
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
172
|
+
aboutToOpen: true,
|
|
173
|
+
hasCrossTurnGate: true,
|
|
174
|
+
openedAt,
|
|
175
|
+
})
|
|
176
|
+
expect(crossTurnAnswerDelivered).toBe(false)
|
|
177
|
+
|
|
178
|
+
// The represent turn's card opens normally (a tool label / liveness OPEN).
|
|
179
|
+
expect(
|
|
180
|
+
mayOpenActivityCard({
|
|
181
|
+
producer: 'tool',
|
|
182
|
+
finalAnswerEverDelivered: false,
|
|
183
|
+
labeledToolCount: 1,
|
|
184
|
+
crossTurnAnswerDelivered,
|
|
185
|
+
}),
|
|
186
|
+
).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('does NOT regress #2141: only an ACK was delivered since the obligation was raised → card still opens', () => {
|
|
190
|
+
const openedAt = 1_000_000 * 1000
|
|
191
|
+
// Turn N sent only a short ack ("On it.") — NOT substantive.
|
|
192
|
+
recordOutbound({
|
|
193
|
+
chat_id: CHAT,
|
|
194
|
+
thread_id: null,
|
|
195
|
+
message_ids: [42],
|
|
196
|
+
texts: [ACK],
|
|
197
|
+
ts: 1_000_001,
|
|
198
|
+
})
|
|
199
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
200
|
+
aboutToOpen: true,
|
|
201
|
+
hasCrossTurnGate: true,
|
|
202
|
+
openedAt,
|
|
203
|
+
})
|
|
204
|
+
// Ack is below the substantive floor → not counted → the feed still opens.
|
|
205
|
+
expect(crossTurnAnswerDelivered).toBe(false)
|
|
206
|
+
expect(
|
|
207
|
+
mayOpenActivityCard({
|
|
208
|
+
producer: 'tool',
|
|
209
|
+
finalAnswerEverDelivered: false,
|
|
210
|
+
labeledToolCount: 1,
|
|
211
|
+
crossTurnAnswerDelivered,
|
|
212
|
+
}),
|
|
213
|
+
).toBe(true)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('an answer that PREDATES the obligation does not suppress (cutoff is the obligation openedAt)', () => {
|
|
217
|
+
const openedAt = 1_000_002 * 1000 // ms — obligation raised AFTER the reply
|
|
218
|
+
recordOutbound({
|
|
219
|
+
chat_id: CHAT,
|
|
220
|
+
thread_id: null,
|
|
221
|
+
message_ids: [42],
|
|
222
|
+
texts: [SUBSTANTIVE],
|
|
223
|
+
ts: 1_000_001, // sec — BEFORE openedAt
|
|
224
|
+
})
|
|
225
|
+
// The reply predates the obligation → it is not evidence THIS obligation was
|
|
226
|
+
// answered → the represent surface is allowed (no false suppression).
|
|
227
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
228
|
+
aboutToOpen: true,
|
|
229
|
+
hasCrossTurnGate: true,
|
|
230
|
+
openedAt,
|
|
231
|
+
})
|
|
232
|
+
expect(crossTurnAnswerDelivered).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('is inert on a normal foreground turn (no cross-turn gate) even with a prior substantive answer', () => {
|
|
236
|
+
const openedAt = 1_000_000 * 1000
|
|
237
|
+
recordOutbound({
|
|
238
|
+
chat_id: CHAT,
|
|
239
|
+
thread_id: null,
|
|
240
|
+
message_ids: [42],
|
|
241
|
+
texts: [SUBSTANTIVE],
|
|
242
|
+
ts: 1_000_001,
|
|
243
|
+
})
|
|
244
|
+
// A foreground turn has NO cross-turn gate, so the gateway never computes the
|
|
245
|
+
// history check (hasCrossTurnGate=false) → the flag is false → its own card
|
|
246
|
+
// opens, governed only by the per-turn lever-1 latch.
|
|
247
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
248
|
+
aboutToOpen: true,
|
|
249
|
+
hasCrossTurnGate: false, // foreground turn: crossTurnGate undefined
|
|
250
|
+
openedAt,
|
|
251
|
+
})
|
|
252
|
+
expect(crossTurnAnswerDelivered).toBe(false)
|
|
253
|
+
expect(
|
|
254
|
+
mayOpenActivityCard({
|
|
255
|
+
producer: 'tool',
|
|
256
|
+
finalAnswerEverDelivered: false,
|
|
257
|
+
labeledToolCount: 1,
|
|
258
|
+
crossTurnAnswerDelivered,
|
|
259
|
+
}),
|
|
260
|
+
).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('originTurnId-keyed: an UNRELATED foreground turn does NOT consume a gate armed for another obligation — its card opens even after a substantive answer', () => {
|
|
264
|
+
const openedAt = 1_000_000 * 1000 // ms — some obligation raised
|
|
265
|
+
// A substantive answer landed in this chat since that obligation's openedAt.
|
|
266
|
+
recordOutbound({
|
|
267
|
+
chat_id: CHAT,
|
|
268
|
+
thread_id: null,
|
|
269
|
+
message_ids: [42],
|
|
270
|
+
texts: [SUBSTANTIVE],
|
|
271
|
+
ts: 1_000_001,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const gate = new Map<string, { sinceMs: number }>()
|
|
275
|
+
// obligationSweep armed the gate for obligation whose origin was message 100.
|
|
276
|
+
const armedOriginTurnId = deriveTurnIdMirror(CHAT, null, 100)!
|
|
277
|
+
armGate(gate, armedOriginTurnId, openedAt)
|
|
278
|
+
|
|
279
|
+
// The MATCHING represent turn reconstructs the same id (reuses message 100)
|
|
280
|
+
// → consumes the gate → carries it.
|
|
281
|
+
const representTurnId = deriveTurnIdMirror(CHAT, null, 100)!
|
|
282
|
+
expect(representTurnId).toBe(armedOriginTurnId)
|
|
283
|
+
|
|
284
|
+
// But FIRST an unrelated foreground turn (different message 200, same chat)
|
|
285
|
+
// enqueues. Under the old chat/thread keying its statusKey would collide with
|
|
286
|
+
// the armed gate and wrongly consume it. Under originTurnId keying its derived
|
|
287
|
+
// turn id differs → no entry → it carries NO gate.
|
|
288
|
+
const foregroundTurnId = deriveTurnIdMirror(CHAT, null, 200)!
|
|
289
|
+
expect(foregroundTurnId).not.toBe(armedOriginTurnId)
|
|
290
|
+
const foregroundConsumed = consumeGate(gate, foregroundTurnId)
|
|
291
|
+
expect(foregroundConsumed).toBeUndefined()
|
|
292
|
+
|
|
293
|
+
// → the foreground turn has no cross-turn gate → its card OPENS even though a
|
|
294
|
+
// ≥200-char answer landed since the OTHER obligation's openedAt (correct: that
|
|
295
|
+
// answer is not evidence THIS unrelated turn was already answered).
|
|
296
|
+
const fgCrossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
297
|
+
aboutToOpen: true,
|
|
298
|
+
hasCrossTurnGate: foregroundConsumed != null,
|
|
299
|
+
openedAt,
|
|
300
|
+
})
|
|
301
|
+
expect(fgCrossTurnAnswerDelivered).toBe(false)
|
|
302
|
+
expect(
|
|
303
|
+
mayOpenActivityCard({
|
|
304
|
+
producer: 'tool',
|
|
305
|
+
finalAnswerEverDelivered: false,
|
|
306
|
+
labeledToolCount: 1,
|
|
307
|
+
crossTurnAnswerDelivered: fgCrossTurnAnswerDelivered,
|
|
308
|
+
}),
|
|
309
|
+
).toBe(true)
|
|
310
|
+
|
|
311
|
+
// The armed gate is still intact (the foreground turn did NOT eat it), so the
|
|
312
|
+
// matching represent turn can still consume it and suppress ITS card.
|
|
313
|
+
const representConsumed = consumeGate(gate, representTurnId)
|
|
314
|
+
expect(representConsumed).toEqual({ sinceMs: openedAt })
|
|
315
|
+
const reCrossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
316
|
+
aboutToOpen: true,
|
|
317
|
+
hasCrossTurnGate: representConsumed != null,
|
|
318
|
+
openedAt,
|
|
319
|
+
})
|
|
320
|
+
expect(reCrossTurnAnswerDelivered).toBe(true)
|
|
321
|
+
expect(
|
|
322
|
+
mayOpenActivityCard({
|
|
323
|
+
producer: 'tool',
|
|
324
|
+
finalAnswerEverDelivered: false,
|
|
325
|
+
labeledToolCount: 1,
|
|
326
|
+
crossTurnAnswerDelivered: reCrossTurnAnswerDelivered,
|
|
327
|
+
}),
|
|
328
|
+
).toBe(false)
|
|
329
|
+
|
|
330
|
+
// Consume-once: the gate is now drained.
|
|
331
|
+
expect(gate.size).toBe(0)
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('thread-scoped: an answer in a DIFFERENT topic does not suppress a represent in this topic', () => {
|
|
335
|
+
const openedAt = 1_000_000 * 1000
|
|
336
|
+
// Substantive answer landed in thread 5, but this obligation is in thread 9.
|
|
337
|
+
recordOutbound({
|
|
338
|
+
chat_id: CHAT,
|
|
339
|
+
thread_id: 5,
|
|
340
|
+
message_ids: [42],
|
|
341
|
+
texts: [SUBSTANTIVE],
|
|
342
|
+
ts: 1_000_001,
|
|
343
|
+
})
|
|
344
|
+
const crossTurnAnswerDelivered = computeCrossTurnAnswerDelivered({
|
|
345
|
+
aboutToOpen: true,
|
|
346
|
+
hasCrossTurnGate: true,
|
|
347
|
+
openedAt,
|
|
348
|
+
threadId: 9,
|
|
349
|
+
})
|
|
350
|
+
expect(crossTurnAnswerDelivered).toBe(false)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* PR-4b verdict-equivalence — the CORE flag-parity proof at the pure layer.
|
|
356
|
+
*
|
|
357
|
+
* `computeFeedOpenVerdict` (the function the emission-authority façade calls in
|
|
358
|
+
* its enabled branch) MUST return exactly the same `mayOpen` a DIRECT
|
|
359
|
+
* `mayOpenActivityCard(...)` call would — over the full cross-product of every
|
|
360
|
+
* input that feeds the OPEN decision, against the REAL history DB. If these ever
|
|
361
|
+
* diverge, flag-ON would emit a different card set than flag-OFF, which is the
|
|
362
|
+
* one thing 4b must not do. `isOpen` must equal `activityMessageId != null`.
|
|
363
|
+
*/
|
|
364
|
+
describe('computeFeedOpenVerdict ≡ direct mayOpenActivityCard (full cross-product, real history)', () => {
|
|
365
|
+
const deps: FeedOpenGateDeps = {
|
|
366
|
+
hasOutboundDeliveredSince,
|
|
367
|
+
historyEnabled: true,
|
|
368
|
+
finalAnswerMinChars: FINAL_ANSWER_MIN_CHARS,
|
|
369
|
+
}
|
|
370
|
+
const OPENED_AT_MS = 1_000_000 * 1000
|
|
371
|
+
|
|
372
|
+
// crossTurnAnswerDelivered is driven by the DB row, not a raw boolean, so we
|
|
373
|
+
// seed each value: a substantive row (since openedAt) → true; no row → false.
|
|
374
|
+
for (const crossTurnTrue of [false, true]) {
|
|
375
|
+
for (const finalAnswerEverDelivered of [false, true]) {
|
|
376
|
+
for (const producer of ['narrative', 'tool', 'liveness'] as FeedOpenProducer[]) {
|
|
377
|
+
for (const labeledToolCount of [0, 1]) {
|
|
378
|
+
for (const activityMessageId of [null, 123] as (number | null)[]) {
|
|
379
|
+
const label =
|
|
380
|
+
`crossTurn=${crossTurnTrue} final=${finalAnswerEverDelivered} ` +
|
|
381
|
+
`producer=${producer} tools=${labeledToolCount} ` +
|
|
382
|
+
`msgId=${activityMessageId ?? 'null'}`
|
|
383
|
+
it(`matches direct mayOpenActivityCard — ${label}`, () => {
|
|
384
|
+
if (crossTurnTrue) {
|
|
385
|
+
// Seed a substantive answer AFTER the obligation openedAt so the
|
|
386
|
+
// real predicate returns true (only meaningful when about to OPEN
|
|
387
|
+
// on a gated cross-turn surface — exercised below).
|
|
388
|
+
recordOutbound({
|
|
389
|
+
chat_id: CHAT,
|
|
390
|
+
thread_id: null,
|
|
391
|
+
message_ids: [42],
|
|
392
|
+
texts: [SUBSTANTIVE],
|
|
393
|
+
ts: 1_000_001,
|
|
394
|
+
})
|
|
395
|
+
}
|
|
396
|
+
// A cross-turn surface always carries the gate; the helper's own
|
|
397
|
+
// `activityMessageId == null` conjunct gates it to the OPEN case.
|
|
398
|
+
const view: FeedOpenGateView = {
|
|
399
|
+
activityMessageId,
|
|
400
|
+
finalAnswerEverDelivered,
|
|
401
|
+
labeledToolCount,
|
|
402
|
+
crossTurnGate: { sinceMs: OPENED_AT_MS },
|
|
403
|
+
sessionChatId: CHAT,
|
|
404
|
+
sessionThreadId: null,
|
|
405
|
+
}
|
|
406
|
+
const verdict = computeFeedOpenVerdict(view, producer, deps)
|
|
407
|
+
// Recompute the cross-turn flag the SAME way the helper does, then
|
|
408
|
+
// call mayOpenActivityCard directly — the oracle.
|
|
409
|
+
const crossTurnAnswerDelivered = realComputeCrossTurnAnswerDelivered(view, deps)
|
|
410
|
+
const direct = mayOpenActivityCard({
|
|
411
|
+
producer,
|
|
412
|
+
finalAnswerEverDelivered,
|
|
413
|
+
labeledToolCount,
|
|
414
|
+
crossTurnAnswerDelivered,
|
|
415
|
+
})
|
|
416
|
+
expect(verdict.mayOpen).toBe(direct)
|
|
417
|
+
expect(verdict.isOpen).toBe(activityMessageId != null)
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for demo-mask.ts — the PII masking behind the `demo` suffix on
|
|
3
|
+
* Telegram status commands. Pure functions with a per-process stable map;
|
|
4
|
+
* the determinism + distinctness + shape contracts are what callers rely on.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
maskEmail,
|
|
9
|
+
maskUsername,
|
|
10
|
+
maskVaultKey,
|
|
11
|
+
__resetDemoMaskCachesForTest,
|
|
12
|
+
} from '../demo-mask.js';
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
__resetDemoMaskCachesForTest();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('maskEmail', () => {
|
|
19
|
+
it('is deterministic — same input maps to the same output', () => {
|
|
20
|
+
const first = maskEmail('alice@realcorp.com');
|
|
21
|
+
const second = maskEmail('alice@realcorp.com');
|
|
22
|
+
expect(second).toBe(first);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('maps distinct inputs to distinct outputs', () => {
|
|
26
|
+
const a = maskEmail('alice@realcorp.com');
|
|
27
|
+
const b = maskEmail('bob@realcorp.com');
|
|
28
|
+
const c = maskEmail('carol@realcorp.com');
|
|
29
|
+
expect(new Set([a, b, c]).size).toBe(3);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('produces an email-shaped fake on the reserved example.com domain', () => {
|
|
33
|
+
const masked = maskEmail('alice@realcorp.com');
|
|
34
|
+
expect(masked).toMatch(/^[^@\s]+@example\.com$/);
|
|
35
|
+
expect(masked).not.toContain('realcorp');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never echoes the real address', () => {
|
|
39
|
+
const real = 'someone.private@acme.io';
|
|
40
|
+
expect(maskEmail(real)).not.toBe(real);
|
|
41
|
+
expect(maskEmail(real)).not.toContain('acme');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('assigns the fixed pool in order from a clean cache', () => {
|
|
45
|
+
expect(maskEmail('one@x.com')).toBe('ada@example.com');
|
|
46
|
+
expect(maskEmail('two@x.com')).toBe('grace@example.com');
|
|
47
|
+
expect(maskEmail('three@x.com')).toBe('linus@example.com');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('falls back to a stable distinct form past the pool', () => {
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
for (let i = 0; i < 15; i++) seen.add(maskEmail(`user${i}@x.com`));
|
|
53
|
+
// 15 distinct inputs → 15 distinct masked emails (pool of 10 + overflow).
|
|
54
|
+
expect(seen.size).toBe(15);
|
|
55
|
+
// The overflow form is still example.com-shaped.
|
|
56
|
+
for (const m of seen) expect(m).toMatch(/^[^@\s]+@example\.com$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('masks a non-email label to a stable fake email too', () => {
|
|
60
|
+
const a = maskEmail('work-account');
|
|
61
|
+
const b = maskEmail('work-account');
|
|
62
|
+
expect(a).toBe(b);
|
|
63
|
+
expect(a).toMatch(/^[^@\s]+@example\.com$/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('maskUsername', () => {
|
|
68
|
+
it('is deterministic for a @handle input', () => {
|
|
69
|
+
expect(maskUsername('@ken_real')).toBe(maskUsername('@ken_real'));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is deterministic for a numeric id input', () => {
|
|
73
|
+
expect(maskUsername('12345')).toBe(maskUsername('12345'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('maps the first distinct input to @demo_user', () => {
|
|
77
|
+
expect(maskUsername('@ken_real')).toBe('@demo_user');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('maps a second distinct input to @demo_user2', () => {
|
|
81
|
+
maskUsername('@ken_real');
|
|
82
|
+
expect(maskUsername('12345')).toBe('@demo_user2');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('always yields a @handle shape and never echoes the real id', () => {
|
|
86
|
+
const masked = maskUsername('@ken_real');
|
|
87
|
+
expect(masked).toMatch(/^@demo_user\d*$/);
|
|
88
|
+
expect(masked).not.toContain('ken_real');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('normalises a numeric id to a @handle (not a raw number)', () => {
|
|
92
|
+
expect(maskUsername('12345')).toMatch(/^@demo_user\d*$/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('maskVaultKey', () => {
|
|
97
|
+
it('is deterministic — same key maps to the same masked name', () => {
|
|
98
|
+
expect(maskVaultKey('coolify/api-token')).toBe(maskVaultKey('coolify/api-token'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('maps distinct keys to distinct masked names', () => {
|
|
102
|
+
const a = maskVaultKey('coolify/api-token');
|
|
103
|
+
const b = maskVaultKey('github/token');
|
|
104
|
+
expect(a).not.toBe(b);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps the namespace/key shape and hides the real service', () => {
|
|
108
|
+
const masked = maskVaultKey('coolify/api-token');
|
|
109
|
+
expect(masked).toMatch(/^demo\/secret-\d+$/);
|
|
110
|
+
expect(masked).not.toContain('coolify');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('assigns demo/secret-N in order from a clean cache', () => {
|
|
114
|
+
expect(maskVaultKey('coolify/api-token')).toBe('demo/secret-1');
|
|
115
|
+
expect(maskVaultKey('github/token')).toBe('demo/secret-2');
|
|
116
|
+
expect(maskVaultKey('fatsecret/client_id')).toBe('demo/secret-3');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('cross-category isolation', () => {
|
|
121
|
+
it('keeps each category in its own namespace', () => {
|
|
122
|
+
// Identical raw text in different categories must not collide caches.
|
|
123
|
+
expect(maskEmail('x')).toMatch(/@example\.com$/);
|
|
124
|
+
expect(maskUsername('x')).toMatch(/^@demo_user/);
|
|
125
|
+
expect(maskVaultKey('x')).toMatch(/^demo\/secret-/);
|
|
126
|
+
});
|
|
127
|
+
});
|