switchroom 0.17.6 → 0.18.3
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity-card durability WIRING (deterministic-turn-liveness.md Known Gap 1 +
|
|
3
|
+
* the #2895 review fix). The 20 behavioural tests in activity-card-store.test.ts
|
|
4
|
+
* drive the pure store/reaper in ISOLATION — they prove the routine is correct
|
|
5
|
+
* but say nothing about whether the GATEWAY actually calls it. That is exactly
|
|
6
|
+
* the decision-vs-delivery gap the whole RFC exists to close, applied to its own
|
|
7
|
+
* follow-up: a refactor that drops the `writeActivityCardRecord` call on card
|
|
8
|
+
* open would keep every store test green while silently un-delivering the
|
|
9
|
+
* guarantee.
|
|
10
|
+
*
|
|
11
|
+
* The gateway IIFE can't be instantiated in-process (same constraint as
|
|
12
|
+
* silence-liveness-wiring.test.ts / multitopic-routing-wiring.test.ts), so these
|
|
13
|
+
* are STRUCTURAL assertions that pin the three load-bearing call sites. They
|
|
14
|
+
* COMPLEMENT (never replace) the behavioural store tests — the RFC's "structural
|
|
15
|
+
* grep may not be the SOLE coverage" bar is met because the outcome is proven at
|
|
16
|
+
* the routine level; this only guards the wiring the routine depends on.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from 'vitest'
|
|
19
|
+
import { readFileSync } from 'node:fs'
|
|
20
|
+
import { resolve } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
|
|
23
|
+
|
|
24
|
+
function between(src: string, startMarker: string, endMarker: string): string {
|
|
25
|
+
const after = src.split(startMarker)[1] ?? ''
|
|
26
|
+
return after.split(endMarker)[0] ?? ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('activity-card durability wiring', () => {
|
|
30
|
+
it('the card-OPEN path persists the durable handle (writeActivityCardRecord)', () => {
|
|
31
|
+
// The open branch runs when activityMessageId transitions null → set.
|
|
32
|
+
const openBranch = between(
|
|
33
|
+
gatewaySrc,
|
|
34
|
+
'if (turn.activityMessageId == null) {',
|
|
35
|
+
'turn.activityLastSentRender = target',
|
|
36
|
+
)
|
|
37
|
+
expect(openBranch.length).toBeGreaterThan(100)
|
|
38
|
+
expect(openBranch).toMatch(/writeActivityCardRecord\(/)
|
|
39
|
+
// Persist is gated on the feature flag, not unconditional.
|
|
40
|
+
expect(openBranch).toMatch(/activityCardPersistEnabled/)
|
|
41
|
+
// Pin honesty: the persisted RECORD mirrors the ACTUAL pin decision, never
|
|
42
|
+
// a bare `pinned: true` (the #2895 review fix). Scope the negative check to
|
|
43
|
+
// the writeActivityCardRecord argument block — the reconcileStatusPin call
|
|
44
|
+
// further down legitimately passes `{ pinned: true }` as the desired state.
|
|
45
|
+
const recordBlock = between(openBranch, 'writeActivityCardRecord(', 'void reconcileStatusPin(')
|
|
46
|
+
expect(recordBlock).toMatch(/pinned: PIN_STATUS_WHILE_WORKING/)
|
|
47
|
+
// Strip comment lines (the code comment legitimately quotes the old
|
|
48
|
+
// `pinned: true` shape it's warning against) before checking the CODE.
|
|
49
|
+
const recordCode = recordBlock
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter((l) => !l.trim().startsWith('//'))
|
|
52
|
+
.join('\n')
|
|
53
|
+
expect(recordCode).not.toMatch(/pinned: true/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('the normal-CLOSE path clears the durable handle, id-scoped (reap-race guard)', () => {
|
|
57
|
+
const closeBody = between(
|
|
58
|
+
gatewaySrc,
|
|
59
|
+
'const id = turn.activityMessageId\n turn.activityMessageId = null',
|
|
60
|
+
'if (CLEAR_STATUS_ON_COMPLETION)',
|
|
61
|
+
)
|
|
62
|
+
expect(closeBody.length).toBeGreaterThan(50)
|
|
63
|
+
expect(closeBody).toMatch(/clearActivityCardRecord\(/)
|
|
64
|
+
// Must pass the id so it only drops THIS card's row (not a fresher upsert).
|
|
65
|
+
expect(closeBody).toMatch(/\n\s*id,\s*\n/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
|
|
69
|
+
// Both invocation sites (mutex-won + mutex-fallback) sit AFTER
|
|
70
|
+
// acquireStartupLock, alongside statusPinBootCleanup.
|
|
71
|
+
const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
|
|
72
|
+
expect(afterLock).toMatch(/void activityCardBootReaper\(\)/)
|
|
73
|
+
// And it must NOT be invoked at module import time — the same losing-double-
|
|
74
|
+
// boot hazard the NOTE at statusPinBootCleanup documents.
|
|
75
|
+
const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
|
|
76
|
+
expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {
|
|
80
|
+
const wrapper = between(
|
|
81
|
+
gatewaySrc,
|
|
82
|
+
'async function activityCardBootReaper()',
|
|
83
|
+
'\n// NOTE: statusPinBootCleanup()',
|
|
84
|
+
)
|
|
85
|
+
expect(wrapper).toMatch(/vanished/)
|
|
86
|
+
expect(wrapper).toMatch(/at-most-once/)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -94,7 +94,7 @@ describe('renderShowText — Format 2 vs legacy', () => {
|
|
|
94
94
|
expect(out).toContain('🔋 **Auth — fleet status**');
|
|
95
95
|
expect(out).toContain('Recommendation:');
|
|
96
96
|
// GFM table card (#2700): State emoji per row, not group headers.
|
|
97
|
-
expect(out).toContain('| State | Account | 5h | 7d |
|
|
97
|
+
expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
|
|
98
98
|
expect(out).toContain('| 🔴 |'); // the blocked account's State cell
|
|
99
99
|
expect(out).toContain('| 🟢 |'); // a healthy account's State cell
|
|
100
100
|
expect(out).not.toContain('**BLOCKED**');
|
|
@@ -262,11 +262,13 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
262
262
|
return rows;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
-
it('renders a GFM table with the State/Account/5h/7d/
|
|
265
|
+
it('renders a GFM table with the State/Account/5h/5h resets/7d/7d resets header', () => {
|
|
266
266
|
const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
|
|
267
267
|
expect(out).toContain('🔋 **Auth — fleet status**');
|
|
268
|
-
expect(out).toContain('| State | Account | 5h | 7d |
|
|
269
|
-
expect(out).toContain('| --- | --- | --- | --- | --- |');
|
|
268
|
+
expect(out).toContain('| State | Account | 5h | 5h resets | 7d | 7d resets |');
|
|
269
|
+
expect(out).toContain('| --- | --- | --- | --- | --- | --- |');
|
|
270
|
+
// The single collapsed Status column is gone.
|
|
271
|
+
expect(out).not.toContain('| 7d | Status |');
|
|
270
272
|
// No legacy group headers / health-section titles remain.
|
|
271
273
|
expect(out).not.toContain('**BLOCKED**');
|
|
272
274
|
expect(out).not.toContain('**HEALTHY**');
|
|
@@ -307,16 +309,22 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
307
309
|
expect(bob).toBeLessThan(alice);
|
|
308
310
|
});
|
|
309
311
|
|
|
310
|
-
it('
|
|
312
|
+
it('reset columns show a "<time> (in ...)" cell for a blocked account (7d binding window)', () => {
|
|
311
313
|
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
312
314
|
const bob = rows.find((r) => r[1].includes('bob@example.com'))!;
|
|
313
|
-
|
|
315
|
+
// bob is 7d-maxed; its 7d reset (2026-05-17T10:00Z) is ~2 days out and still
|
|
316
|
+
// renders in its own reset cell — no "back" prefix in the new per-window shape.
|
|
317
|
+
expect(bob[5]).toMatch(/^.* \(in .+\)$/);
|
|
318
|
+
expect(bob[5]).not.toContain('back');
|
|
314
319
|
});
|
|
315
320
|
|
|
316
|
-
it('
|
|
321
|
+
it('reset columns show a "<time> (in ...)" cell for each window of a healthy account', () => {
|
|
317
322
|
const rows = tableRows(renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' }));
|
|
318
323
|
const you = rows.find((r) => r[1].includes('you@example.com'))!;
|
|
319
|
-
|
|
324
|
+
// 5h resets cell [3] and 7d resets cell [5] both carry a relative hint.
|
|
325
|
+
expect(you[3]).toMatch(/^.* \(in .+\)$/);
|
|
326
|
+
expect(you[5]).toMatch(/^.* \(in .+\)$/);
|
|
327
|
+
expect(you[3]).not.toContain('refills');
|
|
320
328
|
});
|
|
321
329
|
|
|
322
330
|
it('NEVER displays a percentage over 100% even on an over-cap account', () => {
|
|
@@ -330,13 +338,15 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
330
338
|
];
|
|
331
339
|
const rows = tableRows(renderAuthSnapshotFormat2(overSnaps, { now: NOW, tz: 'UTC' }));
|
|
332
340
|
expect(rows[0][2]).toBe('100%'); // 5h
|
|
333
|
-
expect(rows[0][
|
|
334
|
-
// And the blocked state still surfaces via the emoji (🔴)
|
|
341
|
+
expect(rows[0][4]).toBe('100%'); // 7d
|
|
342
|
+
// And the blocked state still surfaces via the emoji (🔴). No resets known
|
|
343
|
+
// (fixture has no reset timestamps) → both reset cells degrade to "—".
|
|
335
344
|
expect(rows[0][0]).toBe('🔴');
|
|
336
|
-
expect(rows[0][
|
|
345
|
+
expect(rows[0][3]).toBe('—'); // 5h resets
|
|
346
|
+
expect(rows[0][5]).toBe('—'); // 7d resets
|
|
337
347
|
});
|
|
338
348
|
|
|
339
|
-
it('renders dates in the
|
|
349
|
+
it('renders dates in the reset columns only when NOT today (user tz)', () => {
|
|
340
350
|
// now = Fri 3:00 PM Melbourne. A reset later the SAME Melbourne day shows
|
|
341
351
|
// time-only; a reset on the next day shows the weekday.
|
|
342
352
|
const MEL = 'Australia/Melbourne';
|
|
@@ -349,8 +359,9 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
349
359
|
}) }),
|
|
350
360
|
];
|
|
351
361
|
const todayRows = tableRows(renderAuthSnapshotFormat2(sameDay, { now: NOW_MEL, tz: MEL }));
|
|
352
|
-
|
|
353
|
-
expect(todayRows[0][
|
|
362
|
+
// 5h resets cell [3] — same Melbourne day → time only.
|
|
363
|
+
expect(todayRows[0][3]).toContain('9:00 PM');
|
|
364
|
+
expect(todayRows[0][3]).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/);
|
|
354
365
|
|
|
355
366
|
const nextDay = [
|
|
356
367
|
snap({ label: 'tomorrow@example.com', isActive: true, quota: quota({
|
|
@@ -360,7 +371,8 @@ describe('renderAuthSnapshotFormat2', () => {
|
|
|
360
371
|
}) }),
|
|
361
372
|
];
|
|
362
373
|
const tomorrowRows = tableRows(renderAuthSnapshotFormat2(nextDay, { now: NOW_MEL, tz: MEL }));
|
|
363
|
-
|
|
374
|
+
// 5h resets cell [3] — next Melbourne day → weekday prefix.
|
|
375
|
+
expect(tomorrowRows[0][3]).toContain('Sat 1:00 AM');
|
|
364
376
|
});
|
|
365
377
|
|
|
366
378
|
it('emits a recommendation footer that names a healthy alternative when active is throttling', () => {
|
|
@@ -1120,14 +1132,16 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
|
|
|
1120
1132
|
expect(allText).toContain('Switch fleet → carol@example.com');
|
|
1121
1133
|
});
|
|
1122
1134
|
|
|
1123
|
-
it('quota-exhausted row shows a 🔴 +
|
|
1135
|
+
it('quota-exhausted row shows a 🔴 + its 5h reset time in the reset cell, not "billing disabled"', () => {
|
|
1124
1136
|
const futureReset = new Date(NOW.getTime() + 45 * 60_000);
|
|
1125
1137
|
const out = renderAuthSnapshotFormat2(
|
|
1126
1138
|
[snap({ label: 'ex@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 100, fiveHourResetAt: futureReset }) })],
|
|
1127
1139
|
{ now: NOW },
|
|
1128
1140
|
);
|
|
1129
1141
|
expect(out).toContain('| 🔴 |');
|
|
1130
|
-
|
|
1142
|
+
// The 5h reset (45m out) renders in its own reset cell — no "back" prefix.
|
|
1143
|
+
expect(out).toMatch(/\(in 45m\)/);
|
|
1144
|
+
expect(out).not.toContain('back ');
|
|
1131
1145
|
expect(out).not.toContain('billing disabled');
|
|
1132
1146
|
expect(out).not.toContain('overage off');
|
|
1133
1147
|
});
|
|
@@ -137,6 +137,54 @@ describe('Claude Code event-stream contract (canary)', () => {
|
|
|
137
137
|
).toBe(false)
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
+
it('CANARY (Fix 3 precondition): Task tool_use still projects {id, name, input} — the shape the foreground-sub-agent-tracking / post-answer-liveness-freeze fix keys "still dispatched" off of', () => {
|
|
141
|
+
// The Fix 3 gap-closer (turn-liveness-floor.ts `stillDispatched`) is only
|
|
142
|
+
// as good as `turn.foregroundSubAgents` staying populated for the
|
|
143
|
+
// lifetime of a real Task dispatch. That population is driven by THIS
|
|
144
|
+
// exact tool_use projection (toolName === 'Task') plus the matching
|
|
145
|
+
// tool_result closing it out — if a future Claude Code release renames
|
|
146
|
+
// `name`/`input`/`id` or nests them differently, the fix silently goes
|
|
147
|
+
// inert (stillDispatched always false, freeze regresses) rather than
|
|
148
|
+
// failing loudly. Pin the shape here so drift fails CI instead.
|
|
149
|
+
const line = JSON.stringify({
|
|
150
|
+
type: 'assistant',
|
|
151
|
+
message: {
|
|
152
|
+
id: 'msg_task',
|
|
153
|
+
stop_reason: 'tool_use',
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: 'tool_use',
|
|
157
|
+
id: 'toolu_task_1',
|
|
158
|
+
name: 'Task',
|
|
159
|
+
input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
const evs = projectTranscriptLine(line)
|
|
165
|
+
expect(evs).toEqual([
|
|
166
|
+
{
|
|
167
|
+
kind: 'tool_use',
|
|
168
|
+
toolName: 'Task',
|
|
169
|
+
toolUseId: 'toolu_task_1',
|
|
170
|
+
input: { description: 'Investigate the freeze bug', subagent_type: 'worker' },
|
|
171
|
+
},
|
|
172
|
+
])
|
|
173
|
+
// The matching tool_result (Task returning) must still carry tool_use_id
|
|
174
|
+
// so the gateway can close out the foreground-sub-agent tracking entry.
|
|
175
|
+
const resultLine = JSON.stringify({
|
|
176
|
+
type: 'user',
|
|
177
|
+
message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_task_1', is_error: false, content: 'done' }] },
|
|
178
|
+
})
|
|
179
|
+
const resultEvs = projectTranscriptLine(resultLine)
|
|
180
|
+
expect(resultEvs).toHaveLength(1)
|
|
181
|
+
// `is_error: false` projects to `isError: undefined` (only `true` is
|
|
182
|
+
// ever stamped — see session-tail.ts's `c.is_error === true ? true :
|
|
183
|
+
// undefined`), so assert the falsy/absent form, not a literal `false`.
|
|
184
|
+
expect(resultEvs[0]).toMatchObject({ kind: 'tool_result', toolUseId: 'toolu_task_1' })
|
|
185
|
+
expect((resultEvs[0] as { isError?: boolean }).isError).toBeFalsy()
|
|
186
|
+
})
|
|
187
|
+
|
|
140
188
|
it('sub-agent kickoff: first user message string prompt fires sub_agent_started', () => {
|
|
141
189
|
const st = { hasEmittedStart: false }
|
|
142
190
|
const line = JSON.stringify({
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
isConsolidationLegibilityEnabled,
|
|
4
|
+
detectConsolidationEvent,
|
|
5
|
+
renderConsolidationLine,
|
|
6
|
+
consolidationSignature,
|
|
7
|
+
ConsolidationRateLimiter,
|
|
8
|
+
HINDSIGHT_WEBHOOK_SOURCE,
|
|
9
|
+
CONSOLIDATION_COMPLETED_EVENT,
|
|
10
|
+
} from '../consolidation-legibility.js'
|
|
11
|
+
|
|
12
|
+
describe('isConsolidationLegibilityEnabled — OPT-IN (default OFF)', () => {
|
|
13
|
+
it('is OFF when unset (opposite of the default-ON tool-observation path)', () => {
|
|
14
|
+
expect(isConsolidationLegibilityEnabled(undefined)).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
it('is OFF for the empty string and for "0"/"false"/"off"', () => {
|
|
17
|
+
expect(isConsolidationLegibilityEnabled('')).toBe(false)
|
|
18
|
+
expect(isConsolidationLegibilityEnabled('0')).toBe(false)
|
|
19
|
+
expect(isConsolidationLegibilityEnabled('false')).toBe(false)
|
|
20
|
+
expect(isConsolidationLegibilityEnabled('off')).toBe(false)
|
|
21
|
+
expect(isConsolidationLegibilityEnabled('no')).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
it('is ON only for an explicit truthy opt-in', () => {
|
|
24
|
+
expect(isConsolidationLegibilityEnabled('1')).toBe(true)
|
|
25
|
+
expect(isConsolidationLegibilityEnabled('true')).toBe(true)
|
|
26
|
+
expect(isConsolidationLegibilityEnabled('on')).toBe(true)
|
|
27
|
+
expect(isConsolidationLegibilityEnabled('yes')).toBe(true)
|
|
28
|
+
expect(isConsolidationLegibilityEnabled(' TRUE ')).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('wire constants', () => {
|
|
33
|
+
it('names the hindsight consolidation.completed source/event', () => {
|
|
34
|
+
expect(HINDSIGHT_WEBHOOK_SOURCE).toBe('hindsight')
|
|
35
|
+
expect(CONSOLIDATION_COMPLETED_EVENT).toBe('consolidation.completed')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('detectConsolidationEvent — MATERIAL-only (sparse)', () => {
|
|
40
|
+
it('returns null on a routine no-op consolidation (nothing stored/corrected)', () => {
|
|
41
|
+
expect(detectConsolidationEvent({ stored: 0, corrected: 0 })).toBeNull()
|
|
42
|
+
expect(detectConsolidationEvent({})).toBeNull()
|
|
43
|
+
expect(detectConsolidationEvent(undefined)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('classifies a pure store as "updated"', () => {
|
|
47
|
+
const ev = detectConsolidationEvent({ stored: 2, subject: 'your deploy preferences' })
|
|
48
|
+
expect(ev).toEqual({ kind: 'updated', topic: 'your deploy preferences' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('classifies any correction as "revised" (higher signal), even alongside stores', () => {
|
|
52
|
+
expect(detectConsolidationEvent({ corrected: 1, subject: 'old runbook' })).toEqual({
|
|
53
|
+
kind: 'revised',
|
|
54
|
+
topic: 'old runbook',
|
|
55
|
+
})
|
|
56
|
+
expect(detectConsolidationEvent({ stored: 3, corrected: 1, subject: 'X' })).toEqual({
|
|
57
|
+
kind: 'revised',
|
|
58
|
+
topic: 'X',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('tolerates field aliases (created/added/new_observations; invalidated/superseded)', () => {
|
|
63
|
+
expect(detectConsolidationEvent({ created: 1, topic: 'a' })?.kind).toBe('updated')
|
|
64
|
+
expect(detectConsolidationEvent({ new_observations: 5, topic: 'a' })?.kind).toBe('updated')
|
|
65
|
+
expect(detectConsolidationEvent({ invalidated: 2, topic: 'a' })?.kind).toBe('revised')
|
|
66
|
+
expect(detectConsolidationEvent({ superseded: 1, topic: 'a' })?.kind).toBe('revised')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('counts an observations array by length as a store', () => {
|
|
70
|
+
const ev = detectConsolidationEvent({
|
|
71
|
+
observations: [{ statement: 'User prefers TS', subject: 'coding style' }],
|
|
72
|
+
})
|
|
73
|
+
expect(ev).toEqual({ kind: 'updated', topic: 'coding style' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('accepts numeric-string counts', () => {
|
|
77
|
+
expect(detectConsolidationEvent({ stored: '2', subject: 'x' })?.kind).toBe('updated')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('derives topic from subjects[]/entities[] arrays when no direct subject', () => {
|
|
81
|
+
expect(detectConsolidationEvent({ stored: 1, subjects: ['travel plans'] })?.topic).toBe(
|
|
82
|
+
'travel plans',
|
|
83
|
+
)
|
|
84
|
+
expect(
|
|
85
|
+
detectConsolidationEvent({ stored: 1, entities: [{ name: 'Acme Corp' }] })?.topic,
|
|
86
|
+
).toBe('Acme Corp')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('falls back to the first observation statement when no subject', () => {
|
|
90
|
+
const ev = detectConsolidationEvent({
|
|
91
|
+
observations: [{ statement: 'The user lives in Sydney' }],
|
|
92
|
+
})
|
|
93
|
+
expect(ev?.topic).toBe('The user lives in Sydney')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('leaves topic empty when the payload carries no legible subject', () => {
|
|
97
|
+
expect(detectConsolidationEvent({ stored: 1, bank_id: 'agent-x' })?.topic).toBe('')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('renderConsolidationLine — terse, HTML, notification-free surface', () => {
|
|
102
|
+
it('renders the updated line with a subject', () => {
|
|
103
|
+
expect(renderConsolidationLine({ kind: 'updated', topic: 'your deploy preferences' })).toBe(
|
|
104
|
+
'🧠 <i>updated what I know</i> about "your deploy preferences"',
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
it('renders the revised line with a subject', () => {
|
|
108
|
+
expect(renderConsolidationLine({ kind: 'revised', topic: 'the old runbook' })).toBe(
|
|
109
|
+
'🧠 <i>revised what I know</i> about "the old runbook"',
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
it('falls back to a bare line when there is no legible subject', () => {
|
|
113
|
+
expect(renderConsolidationLine({ kind: 'updated', topic: '' })).toBe(
|
|
114
|
+
'🧠 <i>updated what I know about you.</i>',
|
|
115
|
+
)
|
|
116
|
+
expect(renderConsolidationLine({ kind: 'revised', topic: ' ' })).toBe(
|
|
117
|
+
'🧠 <i>revised what I know about you.</i>',
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
it('HTML-escapes the subject (no entity injection)', () => {
|
|
121
|
+
expect(renderConsolidationLine({ kind: 'updated', topic: 'a <b> & c' })).toBe(
|
|
122
|
+
'🧠 <i>updated what I know</i> about "a <b> & c"',
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
it('truncates a very long subject', () => {
|
|
126
|
+
const long = 'x'.repeat(500)
|
|
127
|
+
const out = renderConsolidationLine({ kind: 'updated', topic: long })
|
|
128
|
+
expect(out.length).toBeLessThan(200)
|
|
129
|
+
expect(out).toContain('…')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('ConsolidationRateLimiter — sparse / non-spammy', () => {
|
|
134
|
+
it('allows the first line, then blocks a second within the min-interval', () => {
|
|
135
|
+
let t = 1_000_000
|
|
136
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
|
|
137
|
+
expect(rl.allow('klanker', 'updated:a')).toBe(true)
|
|
138
|
+
t += 60_000 // 1 min later
|
|
139
|
+
expect(rl.allow('klanker', 'updated:b')).toBe(false) // different topic, still capped
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('allows again once the min-interval has elapsed', () => {
|
|
143
|
+
let t = 1_000_000
|
|
144
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
|
|
145
|
+
expect(rl.allow('klanker', 'updated:a')).toBe(true)
|
|
146
|
+
t += 600_001
|
|
147
|
+
expect(rl.allow('klanker', 'updated:b')).toBe(true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('suppresses an IDENTICAL signature across the dedup window even after the interval', () => {
|
|
151
|
+
let t = 1_000_000
|
|
152
|
+
const rl = new ConsolidationRateLimiter({
|
|
153
|
+
minIntervalMs: 600_000,
|
|
154
|
+
dedupWindowMs: 3_600_000,
|
|
155
|
+
now: () => t,
|
|
156
|
+
})
|
|
157
|
+
expect(rl.allow('klanker', 'updated:deploy')).toBe(true)
|
|
158
|
+
t += 700_000 // past min-interval…
|
|
159
|
+
expect(rl.allow('klanker', 'updated:deploy')).toBe(false) // …but same topic, still deduped
|
|
160
|
+
t += 3_600_000 // past dedup window
|
|
161
|
+
expect(rl.allow('klanker', 'updated:deploy')).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('rate-limits per agent independently', () => {
|
|
165
|
+
let t = 1_000_000
|
|
166
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, now: () => t })
|
|
167
|
+
expect(rl.allow('klanker', 'updated:a')).toBe(true)
|
|
168
|
+
expect(rl.allow('reggie', 'updated:a')).toBe(true) // different agent, own budget
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('accepts an explicit now override per call', () => {
|
|
172
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000 })
|
|
173
|
+
expect(rl.allow('klanker', 'updated:a', 0)).toBe(true)
|
|
174
|
+
expect(rl.allow('klanker', 'updated:a', 100)).toBe(false)
|
|
175
|
+
expect(rl.allow('klanker', 'updated:a', 700_000)).toBe(false) // dedup window
|
|
176
|
+
expect(rl.allow('klanker', 'updated:b', 700_000)).toBe(true) // new topic past interval
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Regression: the gateway caller (surfaceConsolidationLegibility) MUST pass
|
|
180
|
+
// Date.now() as the clock, never a payload-derived rec.ts. This test proves
|
|
181
|
+
// WHY: a spoofed/stale timestamp from the webhook payload poisons the
|
|
182
|
+
// per-agent min-interval gate. A far-FUTURE payload ts leaves the stored
|
|
183
|
+
// last-surface time in the future, so a genuine later wall-clock surface
|
|
184
|
+
// reads as "in the past" and the gate stays jammed shut; a far-PAST ts makes
|
|
185
|
+
// every subsequent surface look older than the window and the gate never
|
|
186
|
+
// engages (spam). Feeding wall-clock avoids both.
|
|
187
|
+
it('a payload-derived far-future timestamp would jam the interval gate shut', () => {
|
|
188
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
|
|
189
|
+
const wallNow = 1_000_000
|
|
190
|
+
const spoofedFuture = wallNow + 10 * 365 * 24 * 3_600_000 // ~10y ahead
|
|
191
|
+
// First surface stamped with the attacker's future ts.
|
|
192
|
+
expect(rl.allow('klanker', 'updated:a', spoofedFuture)).toBe(true)
|
|
193
|
+
// A genuine later surface (real wall-clock) is now "before" the stored
|
|
194
|
+
// last time → interval never elapses → legitimate surface suppressed.
|
|
195
|
+
expect(rl.allow('klanker', 'updated:b', wallNow + 700_000)).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('a payload-derived timestamp lets a spoofer defeat the interval gate (spam)', () => {
|
|
199
|
+
// Two events arrive ~1ms apart in REALITY, but each carries a payload ts
|
|
200
|
+
// inflated to appear far apart. Keyed on payload ts, the gate sees them as
|
|
201
|
+
// past the interval and lets BOTH through — spam.
|
|
202
|
+
const rl = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
|
|
203
|
+
expect(rl.allow('klanker', 'updated:a', 0)).toBe(true)
|
|
204
|
+
expect(rl.allow('klanker', 'updated:b', 700_000)).toBe(true) // spoofed far-apart ts → not blocked
|
|
205
|
+
// Wall-clock (what the fix passes) blocks the same two events 1ms apart.
|
|
206
|
+
const rl2 = new ConsolidationRateLimiter({ minIntervalMs: 600_000, dedupWindowMs: 0 })
|
|
207
|
+
const wallNow = 1_000_000_000
|
|
208
|
+
expect(rl2.allow('klanker', 'updated:a', wallNow)).toBe(true)
|
|
209
|
+
expect(rl2.allow('klanker', 'updated:b', wallNow + 1)).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('consolidationSignature — stable dedup key', () => {
|
|
214
|
+
it('normalises whitespace + case so trivially-different topics collapse', () => {
|
|
215
|
+
expect(consolidationSignature({ kind: 'updated', topic: ' Deploy Prefs ' })).toBe(
|
|
216
|
+
'updated:deploy prefs',
|
|
217
|
+
)
|
|
218
|
+
})
|
|
219
|
+
it('separates kind so a store and a correction of the same topic differ', () => {
|
|
220
|
+
expect(consolidationSignature({ kind: 'updated', topic: 'x' })).not.toBe(
|
|
221
|
+
consolidationSignature({ kind: 'revised', topic: 'x' }),
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -315,7 +315,7 @@ describe('façade delegates to the existing emission primitives (call-site liter
|
|
|
315
315
|
})
|
|
316
316
|
})
|
|
317
317
|
|
|
318
|
-
describe('the
|
|
318
|
+
describe('the drain sites route through the façade with producers preserved verbatim', () => {
|
|
319
319
|
it('the narrative SHOW site routes via openOrEditCard("narrative") + the producer-"narrative" drain', () => {
|
|
320
320
|
const body = fnSrc('showNarrativeStep')
|
|
321
321
|
expect(body).toMatch(/openOrEditCard\('narrative'/)
|
|
@@ -325,26 +325,30 @@ describe('the 7 drain sites route through the façade with producers preserved v
|
|
|
325
325
|
expect(body).toMatch(/ea\.mayDrain\(turn\)/)
|
|
326
326
|
})
|
|
327
327
|
|
|
328
|
-
it('
|
|
328
|
+
it('all liveness sites route via openOrEditCard("liveness") + producer-"liveness" drains (one in openLivenessFeedIfDue, two in feedHeartbeatTick)', () => {
|
|
329
329
|
// The early-card-open refactor extracted the 0-tool liveness OPEN out of
|
|
330
330
|
// feedHeartbeatTick into the shared `openLivenessFeedIfDue` helper (so the
|
|
331
331
|
// 6 s heartbeat and the enqueue-time early-open timer reach ONE open path
|
|
332
|
-
// and never double-open).
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
332
|
+
// and never double-open). deterministic-turn-liveness.md Phase 1 then added
|
|
333
|
+
// the 0-label CLIMB of an already-open card back into feedHeartbeatTick (an
|
|
334
|
+
// EDIT, never an OPEN — so it does not double-open). So the liveness sites now
|
|
335
|
+
// live in:
|
|
336
|
+
// - openLivenessFeedIfDue: the 0-tool early-OPEN;
|
|
337
|
+
// - feedHeartbeatTick: the 0-label CLIMB of an already-open card
|
|
338
|
+
// (Phase 1) + the labelled-feed stale-step maintain.
|
|
339
|
+
// All must still route via the façade with the producer arg verbatim.
|
|
336
340
|
const earlyOpen = fnSrc('openLivenessFeedIfDue')
|
|
337
341
|
const heartbeat = fnSrc('feedHeartbeatTick')
|
|
338
342
|
const opens = [
|
|
339
343
|
...earlyOpen.matchAll(/openOrEditCard\('liveness'/g),
|
|
340
344
|
...heartbeat.matchAll(/openOrEditCard\('liveness'/g),
|
|
341
345
|
]
|
|
342
|
-
expect(opens).toHaveLength(
|
|
346
|
+
expect(opens).toHaveLength(3)
|
|
343
347
|
const drains = [
|
|
344
348
|
...earlyOpen.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
|
|
345
349
|
...heartbeat.matchAll(/drainActivitySummary\(turn,\s*'liveness'\)/g),
|
|
346
350
|
]
|
|
347
|
-
expect(drains).toHaveLength(
|
|
351
|
+
expect(drains).toHaveLength(3)
|
|
348
352
|
// The literal the feed-heartbeat oracle greps must still be present in both.
|
|
349
353
|
expect(earlyOpen).toMatch(/turn\.activityInFlight = drainActivitySummary/)
|
|
350
354
|
expect(heartbeat).toMatch(/turn\.activityInFlight = drainActivitySummary/)
|
|
@@ -366,8 +370,14 @@ describe('the 7 drain sites route through the façade with producers preserved v
|
|
|
366
370
|
|
|
367
371
|
it('every routed drain site guards the single-flight via ea.mayDrain(turn), not a bare activityInFlight read', () => {
|
|
368
372
|
const mayDrainGuards = [...gatewaySrc.matchAll(/if \(ea\.mayDrain\(turn\)\)/g)]
|
|
369
|
-
// narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness
|
|
373
|
+
// narrative + 2 liveness + tool + 2 sub-agent + 1 post-answer bg-liveness
|
|
374
|
+
// (Fix 2) = 7. The Phase-1 0-label climb (deterministic-turn-liveness.md)
|
|
375
|
+
// consults the SAME guard, but its `if (deps.mayDrain())` lives in the
|
|
376
|
+
// extracted tick body (feed-heartbeat-climb.ts) with `() => ea.mayDrain(turn)`
|
|
377
|
+
// injected at the gateway call site — pinned by
|
|
378
|
+
// feed-heartbeat-liveness-open.test.ts + silent-turn-climb-transport.test.ts.
|
|
370
379
|
expect(mayDrainGuards).toHaveLength(7)
|
|
380
|
+
expect(gatewaySrc).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
|
|
371
381
|
})
|
|
372
382
|
})
|
|
373
383
|
|
|
@@ -487,11 +497,16 @@ describe('mayDrainCardNow — PR-4d card-drain gate (pure read; gateway holds th
|
|
|
487
497
|
expect(ctxFn).toMatch(/turnInFlight:\s*turnInFlightForGate\(\)/)
|
|
488
498
|
})
|
|
489
499
|
|
|
490
|
-
it('the
|
|
500
|
+
it('the card-drain sites each route their guarded block through cardDrainGate (single-flight gate stays byte-identical)', () => {
|
|
491
501
|
// Option A: the 7 `if (ea.mayDrain(turn))` guards + drainActivitySummary
|
|
492
502
|
// thunks stay byte-identical, wrapped by the centralized helper.
|
|
493
503
|
// 6 original + 1 new post-answer background-agent liveness drain (Fix 2).
|
|
494
504
|
const wraps = [...gatewaySrc.matchAll(/cardDrainGate\(turn, ea, \(\) => \{/g)]
|
|
495
505
|
expect(wraps).toHaveLength(7)
|
|
506
|
+
// The Phase-1 0-label climb (deterministic-turn-liveness.md) routes through
|
|
507
|
+
// the SAME helper via an injected thunk — its call lives in the extracted
|
|
508
|
+
// tick body (feed-heartbeat-climb.ts), wired at the gateway call site as
|
|
509
|
+
// `cardDrainGate: (run) => cardDrainGate(turn, ea, run)`.
|
|
510
|
+
expect(gatewaySrc).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
|
|
496
511
|
})
|
|
497
512
|
})
|
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
* `openLivenessFeedIfDue` BEFORE the drain — a turn that hasn't passed the
|
|
26
26
|
* threshold, or one whose card is already open, returns early (no
|
|
27
27
|
* double-open).
|
|
28
|
-
* 4. The 0-tool branch of `feedHeartbeatTick`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* 4. The 0-tool branch of `feedHeartbeatTick` OPENS via that one shared helper
|
|
29
|
+
* (so the heartbeat and the enqueue-time timer can never double-open) AND,
|
|
30
|
+
* once a card is open, CLIMBS it every tick via `silentTurnClimbRender`
|
|
31
|
+
* (deterministic-turn-liveness.md Phase 1) — the freeze this test used to
|
|
32
|
+
* enshrine is inverted. The outcome-based proof is the transport test
|
|
33
|
+
* `silent-turn-climb-transport.test.ts`.
|
|
31
34
|
*
|
|
32
35
|
* These are STRUCTURAL (source-read) assertions; the gateway IIFE can't be
|
|
33
36
|
* instantiated in-process. Pattern matches silence-liveness-wiring.test.ts.
|
|
@@ -71,17 +74,38 @@ describe('H-1: feedHeartbeatTick liveness-open threshold', () => {
|
|
|
71
74
|
expect(initBlock).toMatch(/1[_]?200/)
|
|
72
75
|
})
|
|
73
76
|
|
|
74
|
-
it('the 0-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
77
|
+
it('the 0-label tick routes through the REAL extracted tick body with the gateway deps wired verbatim', () => {
|
|
78
|
+
// deterministic-turn-liveness.md Phase 1 + Phase 4(d): the prior version of
|
|
79
|
+
// this test enshrined the freeze — it asserted only that the branch delegates
|
|
80
|
+
// to `openLivenessFeedIfDue` (which no-ops once a card is open), which is
|
|
81
|
+
// exactly why a busy-but-silent 0-label card used to freeze. The property is
|
|
82
|
+
// now INVERTED: the 0-label tick must route through the extracted
|
|
83
|
+
// `runSilentTurnHeartbeatTick` (feed-heartbeat-climb.ts) — the shipped tick
|
|
84
|
+
// body that CLIMBS an already-open card — with the gateway's REAL deps
|
|
85
|
+
// (the shared OPEN helper, cardDrainGate, ea.mayDrain,
|
|
86
|
+
// ea.openOrEditCard('liveness'), the 'liveness' drain) wired at the call
|
|
87
|
+
// site. Removing or mis-wiring this call site fails HERE; the tick body's
|
|
88
|
+
// behaviour is proven outcome-based in
|
|
89
|
+
// tests/silent-turn-climb-transport.test.ts (>=4 climbing edits across a
|
|
90
|
+
// 40s silent tool, edit-only, gate sequence in order).
|
|
78
91
|
const body = feedHeartbeatTickSrc()
|
|
79
|
-
const start = body.indexOf('
|
|
92
|
+
const start = body.indexOf('runSilentTurnHeartbeatTick(')
|
|
80
93
|
expect(start).toBeGreaterThan(-1)
|
|
81
94
|
const branch = body.slice(start)
|
|
82
95
|
const end = branch.indexOf('// Labelled-feed heartbeat')
|
|
83
96
|
const scoped = end === -1 ? branch : branch.slice(0, end)
|
|
84
|
-
|
|
97
|
+
// The real deps, wired verbatim at the call site:
|
|
98
|
+
expect(scoped).toMatch(/openLivenessFeedIfDue:\s*\(\)\s*=>\s*openLivenessFeedIfDue\(turn\)/)
|
|
99
|
+
expect(scoped).toMatch(/setPendingRender:\s*\(rendered\)\s*=>\s*\{\s*turn\.activityPendingRender = rendered\s*\}/)
|
|
100
|
+
expect(scoped).toMatch(/cardDrainGate:\s*\(run\)\s*=>\s*cardDrainGate\(turn, ea, run\)/)
|
|
101
|
+
expect(scoped).toMatch(/mayDrain:\s*\(\)\s*=>\s*ea\.mayDrain\(turn\)/)
|
|
102
|
+
expect(scoped).toMatch(/openOrEditCard:\s*\(apply\)\s*=>\s*ea\.openOrEditCard\('liveness', apply\)/)
|
|
103
|
+
expect(scoped).toMatch(/drain:\s*\(\)\s*=>\s*\{\s*turn\.activityInFlight = drainActivitySummary\(turn, 'liveness'\)\s*\}/)
|
|
104
|
+
// The view carries the wall-clock elapsed + the labelled branch's stale floor.
|
|
105
|
+
expect(scoped).toMatch(/ageMs:\s*Date\.now\(\)\s*-\s*turn\.startedAt/)
|
|
106
|
+
expect(scoped).toMatch(/minStaleMs:\s*FEED_HEARTBEAT_MIN_STALE_MS/)
|
|
107
|
+
// A handled tick (0-label branch) stops before the labelled heartbeat.
|
|
108
|
+
expect(scoped).toMatch(/if \(handled\) return/)
|
|
85
109
|
})
|
|
86
110
|
|
|
87
111
|
it('the WHEN-gate (shouldEarlyOpenLiveness) precedes the drain call inside openLivenessFeedIfDue', () => {
|
|
@@ -129,6 +153,17 @@ describe('H-2: feedHeartbeatTick post-answer background-agent liveness (Fix 2 /
|
|
|
129
153
|
expect(postAnswerBlock).toMatch(/livenessVerdict\s*!==\s*'emit'/)
|
|
130
154
|
})
|
|
131
155
|
|
|
156
|
+
it('Fix 3: post-answer branch feeds stillDispatched from turn.foregroundSubAgents so a tracked foreground sub-agent bypasses the staleness cap', () => {
|
|
157
|
+
const body = feedHeartbeatTickSrc()
|
|
158
|
+
const afterPostAnswer = body.split('if (turn.finalAnswerDelivered)')[1] ?? ''
|
|
159
|
+
const postAnswerBlock = afterPostAnswer.split('\n }\n')[0] ?? ''
|
|
160
|
+
// A positive foreground-tracking signal must be computed and threaded
|
|
161
|
+
// into the pure decision — this is what stops the card freezing mid-
|
|
162
|
+
// delegation while a foreground Task/Agent is still outstanding.
|
|
163
|
+
expect(postAnswerBlock).toMatch(/stillDispatched\s*=\s*turn\.foregroundSubAgents\.size\s*>\s*0/)
|
|
164
|
+
expect(postAnswerBlock).toMatch(/stillDispatched,/)
|
|
165
|
+
})
|
|
166
|
+
|
|
132
167
|
it('staleness cap (concern 3) is parsed default-ON (30s) from SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS', () => {
|
|
133
168
|
// The cap const must default to 30_000 when the env is unset (the `|| 30_000`
|
|
134
169
|
// fallback over the positive-or-0 parse) so the post-answer card stops
|