@animalabs/connectome-host 0.3.5 → 0.3.8

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.
Files changed (51) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/ci.yml +52 -0
  3. package/.github/workflows/publish.yml +2 -2
  4. package/README.md +5 -0
  5. package/bun.lock +14 -6
  6. package/docs/AGENT-ONBOARDING.md +6 -1
  7. package/package.json +6 -4
  8. package/scripts/import-codex-rollout.ts +288 -0
  9. package/src/call-ledger.ts +371 -0
  10. package/src/call-pricing.ts +119 -0
  11. package/src/framework-agent-config.ts +51 -0
  12. package/src/framework-strategy.ts +70 -0
  13. package/src/index.ts +130 -105
  14. package/src/logging-adapter.ts +105 -12
  15. package/src/mcpl-config.ts +1 -0
  16. package/src/modules/channel-mode-module.ts +14 -16
  17. package/src/modules/fleet-module.ts +17 -7
  18. package/src/modules/mcpl-admin-module.ts +6 -15
  19. package/src/modules/settings-module.ts +83 -59
  20. package/src/modules/subscription-gc-module.ts +17 -20
  21. package/src/modules/time-module.ts +15 -9
  22. package/src/modules/web-ui-module.ts +88 -10
  23. package/src/modules/web-ui-observers.ts +35 -9
  24. package/src/recipe.ts +133 -6
  25. package/src/state/agent-tree-reducer.ts +17 -3
  26. package/src/strategies/frontdesk-strategy.ts +15 -9
  27. package/src/web/protocol.ts +89 -0
  28. package/test/agent-tree-reducer.test.ts +35 -3
  29. package/test/autobio-progress-snapshot.test.ts +27 -12
  30. package/test/call-ledger.test.ts +91 -0
  31. package/test/call-pricing.test.ts +69 -0
  32. package/test/framework-fkm-composition.test.ts +160 -0
  33. package/test/frontdesk-strategy.test.ts +10 -0
  34. package/test/import-codex-rollout.test.ts +36 -0
  35. package/test/logging-adapter-refusal.test.ts +57 -0
  36. package/test/logging-adapter.test.ts +58 -1
  37. package/test/recipe-cache-ttl.test.ts +7 -3
  38. package/test/recipe-compression-fallback.test.ts +36 -0
  39. package/test/recipe-primary-summary-fallback.test.ts +40 -0
  40. package/test/recipe-provider.test.ts +36 -0
  41. package/test/recipe-think-policy.test.ts +39 -0
  42. package/test/recipe-timezone.test.ts +20 -0
  43. package/test/subscription-gc-module.test.ts +7 -5
  44. package/test/time-module.test.ts +13 -0
  45. package/test/web-ui-observers.test.ts +70 -2
  46. package/web/package-lock.json +425 -302
  47. package/web/src/App.tsx +9 -0
  48. package/web/src/ObserverGate.tsx +21 -11
  49. package/web/src/Usage.tsx +116 -2
  50. package/web/src/wire.ts +5 -2
  51. package/web/bun.lock +0 -357
@@ -86,9 +86,12 @@ describe('AgentTreeReducer', () => {
86
86
  });
87
87
 
88
88
  test('inference:exhausted from in-band stream abort also produces cancelled (P1 #3)', () => {
89
- // Postmortem 2026-05-28 F1: framework.ts:2108 emits inference:exhausted
90
- // for budget-restart / stream-side cancel paths. Same semantics as
91
- // aborted — benign termination, not a fault.
89
+ // Postmortem 2026-05-28 F1: AF used to emit inference:exhausted for
90
+ // budget-restart / stream-side cancel paths. Same semantics as aborted —
91
+ // benign termination, not a fault. AF PR #55 (≥ 0.6.5) suppresses the
92
+ // endTurn/budget-restart cases at the source, but genuine cancels and
93
+ // reboot-induced exhausted still arrive, and the mapping must keep
94
+ // handling streams from older AF.
92
95
  const r = new AgentTreeReducer();
93
96
  r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
94
97
  r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'budget', timestamp: ts(1) });
@@ -97,6 +100,35 @@ describe('AgentTreeReducer', () => {
97
100
  expect(node.status).toBe('cancelled');
98
101
  });
99
102
 
103
+ test('inference:turn_ended is terminal in its own right (AF PR #55: no trailing exhausted)', () => {
104
+ // endTurn = the agent deliberately ended its turn — a successful
105
+ // terminal. Before AF PR #55 the terminal state of endTurn'd agents came
106
+ // from the SPURIOUS inference:exhausted the framework emitted right
107
+ // after; ≥ 0.6.5 no longer sends it, so without this transition an
108
+ // endTurn'd agent would sit in the tree as 'running' forever.
109
+ const r = new AgentTreeReducer();
110
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
111
+ r.applyEvent({ type: 'inference:turn_ended', agentName: 'a', timestamp: ts(1) });
112
+ const node = r.getNode('a')!;
113
+ expect(node.phase).toBe('done');
114
+ expect(node.status).toBe('completed');
115
+ expect(node.completedAt).toBe(ts(1));
116
+ });
117
+
118
+ test('older AF: the exhausted trailing an endTurn re-terminates as cancelled (harmless)', () => {
119
+ // Pre-0.6.5 streams still deliver turn_ended followed by the spurious
120
+ // exhausted. The node flips completed → cancelled one event later; both
121
+ // are terminal-and-benign, so renderers stay correct either way. Pinned
122
+ // so the compat behavior is a documented choice, not an accident.
123
+ const r = new AgentTreeReducer();
124
+ r.applyEvent({ type: 'inference:started', agentName: 'a', timestamp: ts(0) });
125
+ r.applyEvent({ type: 'inference:turn_ended', agentName: 'a', timestamp: ts(1) });
126
+ r.applyEvent({ type: 'inference:exhausted', agentName: 'a', error: 'Stream aborted: user', timestamp: ts(2) });
127
+ const node = r.getNode('a')!;
128
+ expect(node.phase).toBe('cancelled');
129
+ expect(node.status).toBe('cancelled');
130
+ });
131
+
100
132
  // Postmortem 2026-05-28 F1: the reducer never transitions status to 'completed'.
101
133
  // inference:completed only sets phase='done', leaving status='running' for the
102
134
  // entire lifetime of the agent — which is fine until a later aborted/exhausted/
@@ -1,14 +1,32 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { AutobiographicalStrategy } from '@animalabs/agent-framework';
3
+ import { ContextManager } from '@animalabs/context-manager';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ async function initializedStrategy() {
9
+ const root = mkdtempSync(join(tmpdir(), 'autobio-progress-'));
10
+ const path = join(root, 'store');
11
+ const strategy = new AutobiographicalStrategy({
12
+ compressionModel: 'claude-sonnet-4-5-20250929',
13
+ autoTickOnNewMessage: false,
14
+ });
15
+ const cm = await ContextManager.open({ path, strategy });
16
+ return {
17
+ strategy,
18
+ close: () => {
19
+ cm.close();
20
+ rmSync(root, { recursive: true, force: true });
21
+ },
22
+ };
23
+ }
3
24
 
4
25
  // Guards against silent breakage if upstream renames the protected fields
5
26
  // that getProgressSnapshot reads. The shape is what warmup-session.ts relies on.
6
27
  describe('AutobiographicalStrategy.getProgressSnapshot', () => {
7
- test('returns the expected shape on a fresh strategy', () => {
8
- const strategy = new AutobiographicalStrategy({
9
- compressionModel: 'claude-sonnet-4-5-20250929',
10
- autoTickOnNewMessage: false,
11
- });
28
+ test('returns the expected shape after branch state is initialized', async () => {
29
+ const { strategy, close } = await initializedStrategy();
12
30
  const snapshot = strategy.getProgressSnapshot();
13
31
  expect(snapshot).toEqual({
14
32
  totalChunks: 0,
@@ -18,16 +36,12 @@ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
18
36
  summaryCounts: { l1: 0, l2: 0, l3: 0 },
19
37
  pending: false,
20
38
  });
39
+ close();
21
40
  });
22
41
 
23
- test('exposes the keys warmup-session.ts depends on', () => {
24
- const strategy = new AutobiographicalStrategy({
25
- compressionModel: 'claude-sonnet-4-5-20250929',
26
- autoTickOnNewMessage: false,
27
- });
42
+ test('exposes the keys warmup-session.ts depends on', async () => {
43
+ const { strategy, close } = await initializedStrategy();
28
44
  const s = strategy.getProgressSnapshot();
29
- // Property access — if any rename happens upstream these become undefined
30
- // and the test fails loudly.
31
45
  expect(typeof s.totalChunks).toBe('number');
32
46
  expect(typeof s.chunksCompressed).toBe('number');
33
47
  expect(typeof s.l1QueueLength).toBe('number');
@@ -36,5 +50,6 @@ describe('AutobiographicalStrategy.getProgressSnapshot', () => {
36
50
  expect(typeof s.summaryCounts.l1).toBe('number');
37
51
  expect(typeof s.summaryCounts.l2).toBe('number');
38
52
  expect(typeof s.summaryCounts.l3).toBe('number');
53
+ close();
39
54
  });
40
55
  });
@@ -0,0 +1,91 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { CallLedger, summarizeCacheControls, type ProviderCallRecord } from '../src/call-ledger.js';
3
+
4
+ const at = (seconds: number): string => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString();
5
+
6
+ function call(overrides: Partial<ProviderCallRecord> = {}): ProviderCallRecord {
7
+ return {
8
+ timestamp: at(0),
9
+ kind: 'stream',
10
+ durationMs: 1000,
11
+ model: 'claude-fable-5',
12
+ messages: 10,
13
+ inputTokens: 2,
14
+ outputTokens: 100,
15
+ cacheReadTokens: 0,
16
+ cacheWriteTokens: 0,
17
+ cacheBreakpoints: 4,
18
+ cacheTtls: ['1h', '1h', '1h', '1h'],
19
+ stopReason: 'end_turn',
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ describe('CallLedger cache verdicts', () => {
25
+ test('distinguishes first write, hit, and expired rewrite using the effective TTL', () => {
26
+ const ledger = new CallLedger({ hydrate: false, defaultTtl: '1h' });
27
+ ledger.record(call({ timestamp: at(0), cacheWriteTokens: 100_000 }));
28
+ ledger.record(call({ timestamp: at(1800), cacheReadTokens: 100_000 }));
29
+ ledger.record(call({ timestamp: at(5500), cacheWriteTokens: 101_000 }));
30
+
31
+ const rows = ledger.snapshot().rows;
32
+ expect(rows.map((r) => r.verdict)).toEqual(['first-write', 'HIT', 'rewrite:expired']);
33
+ expect(rows[2]!.cause).toContain('gap 3700s > ttl 3600s');
34
+ });
35
+
36
+ test('calls without cache flags are visibly uncached', () => {
37
+ const ledger = new CallLedger({ hydrate: false });
38
+ ledger.record(call({ kind: 'complete', cacheBreakpoints: 0, cacheTtls: [], inputTokens: 48_000 }));
39
+ expect(ledger.snapshot().rows[0]).toMatchObject({
40
+ verdict: 'uncached',
41
+ cause: 'no cache_control flags in request',
42
+ });
43
+ });
44
+
45
+ test('reports hit+extend and an aggregate cache ratio', () => {
46
+ const ledger = new CallLedger({ hydrate: false });
47
+ ledger.record(call({ inputTokens: 100, cacheReadTokens: 900, cacheWriteTokens: 100 }));
48
+ const snap = ledger.snapshot();
49
+ expect(snap.rows[0]!.verdict).toBe('hit+extend');
50
+ expect(snap.summary.cacheHitRatio).toBeCloseTo(900 / 1100);
51
+ });
52
+
53
+ test('allocates an auditable per-call cost and reconciles the retained total', () => {
54
+ const ledger = new CallLedger({ hydrate: false });
55
+ ledger.record(call({
56
+ cacheWriteTokens: 336_010,
57
+ cacheWrite5mTokens: 336_010,
58
+ cacheWrite1hTokens: 0,
59
+ cacheWriteBucketsAuthoritative: true,
60
+ cacheTtls: ['5m'],
61
+ outputTokens: 639,
62
+ serviceTier: 'standard',
63
+ inferenceGeo: 'global',
64
+ }));
65
+ ledger.record(call({ model: 'unknown-model' }));
66
+
67
+ const snap = ledger.snapshot();
68
+ expect(snap.rows[0]!.cost?.total).toBeCloseTo(4.232095, 9);
69
+ expect(snap.rows[1]!.cost).toBeUndefined();
70
+ expect(snap.summary.cost).toMatchObject({
71
+ total: 4.232095,
72
+ pricedCalls: 1,
73
+ unpricedCalls: 1,
74
+ currency: 'USD',
75
+ });
76
+ });
77
+
78
+ test('does not guess a cache-write TTL bucket from request flags', () => {
79
+ const ledger = new CallLedger({ hydrate: false });
80
+ ledger.record(call({ cacheWriteTokens: 100_000, cacheTtls: ['1h'] }));
81
+ expect(ledger.snapshot().rows[0]!.cost).toBeUndefined();
82
+ });
83
+ });
84
+
85
+ test('summarizeCacheControls inventories markers without retaining content', () => {
86
+ const raw = {
87
+ system: [{ type: 'text', text: 'secret', cache_control: { type: 'ephemeral', ttl: '1h' } }],
88
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'private', cache_control: { type: 'ephemeral' } }] }],
89
+ };
90
+ expect(summarizeCacheControls(raw)).toEqual({ count: 2, ttls: ['1h', 'default'] });
91
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { ANTHROPIC_PRICING_VERSION, priceAnthropicCall } from '../src/call-pricing.js';
3
+
4
+ const timestamp = '2026-07-13T12:00:00.000Z';
5
+
6
+ function usage(overrides: Partial<Parameters<typeof priceAnthropicCall>[2]> = {}) {
7
+ return {
8
+ inputTokens: 0,
9
+ outputTokens: 0,
10
+ cacheReadTokens: 0,
11
+ cacheWrite5mTokens: 0,
12
+ cacheWrite1hTokens: 0,
13
+ unclassifiedCacheWriteTokens: 0,
14
+ serviceTier: 'standard',
15
+ inferenceGeo: 'global',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('Anthropic per-call pricing', () => {
21
+ test('reproduces the ledger-dashboard Fable/Mythos 5m sample exactly', () => {
22
+ const cost = priceAnthropicCall('claude-fable-5', timestamp, usage({
23
+ inputTokens: 2,
24
+ cacheWrite5mTokens: 336_010,
25
+ outputTokens: 639,
26
+ }));
27
+
28
+ expect(cost?.total).toBeCloseTo(4.232095, 9);
29
+ expect(cost).toMatchObject({
30
+ cacheWrite5m: 4.200125,
31
+ cacheWrite1h: 0,
32
+ currency: 'USD',
33
+ grade: 'billing',
34
+ pricingVersion: ANTHROPIC_PRICING_VERSION,
35
+ });
36
+ });
37
+
38
+ test('prices 1h writes at 2x base input and cache reads at 0.1x', () => {
39
+ const write = priceAnthropicCall('claude-mythos-5', timestamp, usage({ cacheWrite1hTokens: 336_010 }));
40
+ const read = priceAnthropicCall('claude-mythos-5', timestamp, usage({ cacheReadTokens: 336_010 }));
41
+ expect(write?.cacheWrite1h).toBeCloseTo(6.7202, 9);
42
+ expect(read?.cacheRead).toBeCloseTo(0.33601, 9);
43
+ });
44
+
45
+ test('applies the US-only inference multiplier to every token category', () => {
46
+ const global = priceAnthropicCall('claude-fable-5', timestamp, usage({ inputTokens: 1_000_000 }));
47
+ const us = priceAnthropicCall('claude-fable-5', timestamp, usage({
48
+ inputTokens: 1_000_000,
49
+ inferenceGeo: 'us',
50
+ }));
51
+ expect(global?.total).toBe(10);
52
+ expect(us?.total).toBeCloseTo(11, 9);
53
+ });
54
+
55
+ test('leaves unknown rates, custom tiers, and unclassified writes unpriced', () => {
56
+ expect(priceAnthropicCall('unknown-model', timestamp, usage())).toBeUndefined();
57
+ expect(priceAnthropicCall('claude-fable-5', timestamp, usage({ serviceTier: 'priority' }))).toBeUndefined();
58
+ expect(priceAnthropicCall('claude-fable-5', timestamp, usage({
59
+ unclassifiedCacheWriteTokens: 1,
60
+ }))).toBeUndefined();
61
+ });
62
+
63
+ test('honors the published Sonnet 5 promotional cutoff', () => {
64
+ const promo = priceAnthropicCall('claude-sonnet-5', '2026-08-31T23:59:59Z', usage({ inputTokens: 1_000_000 }));
65
+ const standard = priceAnthropicCall('claude-sonnet-5', '2026-09-01T00:00:00Z', usage({ inputTokens: 1_000_000 }));
66
+ expect(promo?.total).toBe(2);
67
+ expect(standard?.total).toBe(3);
68
+ });
69
+ });
@@ -0,0 +1,160 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { buildFrameworkAgentConfig } from '../src/framework-agent-config.js';
6
+ import { buildFrameworkStrategy } from '../src/framework-strategy.js';
7
+ import { loadSavedRecipe, saveRecipe, validateRecipe } from '../src/recipe.js';
8
+
9
+ function recipe(agent: Record<string, unknown> = {}) {
10
+ return {
11
+ name: 'framework-fkm-composition',
12
+ agent: {
13
+ systemPrompt: 'sys',
14
+ ...agent,
15
+ },
16
+ };
17
+ }
18
+
19
+ function strategyConfigView(strategy: object): Record<string, unknown> {
20
+ return (strategy as { config?: Record<string, unknown> }).config ?? {};
21
+ }
22
+
23
+ const tempDirs: string[] = [];
24
+
25
+ afterEach(() => {
26
+ while (tempDirs.length > 0) {
27
+ rmSync(tempDirs.pop()!, { force: true, recursive: true });
28
+ }
29
+ });
30
+
31
+ describe('framework FKM composition', () => {
32
+ test('preserves the pre-merge AgentConfig shape field-for-field when FKM additions are omitted', () => {
33
+ const parsed = validateRecipe(recipe({
34
+ provider: 'openai-responses',
35
+ maxTokens: 4_096,
36
+ maxStreamTokens: 80_000,
37
+ contextBudgetTokens: 120_000,
38
+ cacheTtl: '5m',
39
+ responses: {
40
+ reasoningEffort: 'medium',
41
+ reasoningContext: 'current_turn',
42
+ serviceTier: 'priority',
43
+ compactThreshold: 90_000,
44
+ },
45
+ thinking: {
46
+ enabled: true,
47
+ budgetTokens: 1_024,
48
+ },
49
+ refusalHandling: {
50
+ autoRewind: true,
51
+ maxRewinds: 2,
52
+ announceHumanTurns: false,
53
+ },
54
+ }));
55
+ const strategy = { kind: 'baseline-strategy' } as never;
56
+
57
+ expect(buildFrameworkAgentConfig(parsed, 'agent', 'model', strategy)).toEqual({
58
+ name: 'agent',
59
+ model: 'model',
60
+ systemPrompt: 'sys',
61
+ maxTokens: 4_096,
62
+ maxStreamTokens: 80_000,
63
+ contextBudgetTokens: 120_000,
64
+ cacheTtl: '5m',
65
+ providerParams: {
66
+ reasoning: {
67
+ effort: 'medium',
68
+ context: 'current_turn',
69
+ },
70
+ service_tier: 'priority',
71
+ context_management: [{
72
+ type: 'compaction',
73
+ compact_threshold: 90_000,
74
+ }],
75
+ },
76
+ strategy,
77
+ thinking: {
78
+ enabled: true,
79
+ budgetTokens: 1_024,
80
+ },
81
+ refusalHandling: {
82
+ autoRewind: true,
83
+ maxRewinds: 2,
84
+ announceHumanTurns: false,
85
+ },
86
+ });
87
+ });
88
+
89
+ test('composes validation, serialization, and AF/CM wiring for the merged FKM settings without cross-agent bleed', () => {
90
+ const parsed = validateRecipe(recipe({
91
+ provider: 'openai-responses',
92
+ maxTokens: 4_096,
93
+ responses: {
94
+ reasoningEffort: 'high',
95
+ reasoningContext: 'all_turns',
96
+ serviceTier: 'priority',
97
+ },
98
+ sameRoundThinkTextPolicy: 'private',
99
+ refusalHandling: {
100
+ autoRewind: true,
101
+ primarySummaryFallback: {
102
+ enabled: true,
103
+ maxNewSummaries: 4,
104
+ requestBudgetTokens: 216_000,
105
+ },
106
+ },
107
+ strategy: {
108
+ type: 'autobiographical',
109
+ compressionRefusalCurveFallbacks: 2,
110
+ compressionContextBudgetTokens: 19_000,
111
+ },
112
+ }));
113
+
114
+ const dir = mkdtempSync(join(tmpdir(), 'connectome-fkm-'));
115
+ tempDirs.push(dir);
116
+ saveRecipe(dir, parsed);
117
+ const reloaded = loadSavedRecipe(dir);
118
+ expect(reloaded).not.toBeNull();
119
+
120
+ expect(reloaded!.agent.sameRoundThinkTextPolicy).toBe('private');
121
+ expect(reloaded!.agent.refusalHandling?.primarySummaryFallback).toEqual({
122
+ enabled: true,
123
+ maxNewSummaries: 4,
124
+ requestBudgetTokens: 216_000,
125
+ });
126
+ expect(reloaded!.agent.strategy?.compressionRefusalCurveFallbacks).toBe(2);
127
+ expect(reloaded!.agent.strategy?.compressionContextBudgetTokens).toBe(19_000);
128
+
129
+ const runtimeStrategy = buildFrameworkStrategy(reloaded!, 'model', 'America/Los_Angeles');
130
+ const runtimeConfig = strategyConfigView(runtimeStrategy);
131
+ expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
132
+ expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
133
+
134
+ const agentConfig = buildFrameworkAgentConfig(reloaded!, 'agent', 'model', runtimeStrategy);
135
+ expect(agentConfig.sameRoundThinkTextPolicy).toBe('private');
136
+ expect(agentConfig.refusalHandling?.primarySummaryFallback).toEqual({
137
+ enabled: true,
138
+ maxNewSummaries: 4,
139
+ requestBudgetTokens: 216_000,
140
+ });
141
+ expect((agentConfig.providerParams as Record<string, unknown>).service_tier).toBe('priority');
142
+ expect(Object.keys(agentConfig.providerParams as Record<string, unknown>)
143
+ .filter((key) => key === 'service_tier')).toHaveLength(1);
144
+
145
+ const otherRecipe = validateRecipe(recipe({
146
+ strategy: {
147
+ type: 'autobiographical',
148
+ compressionRefusalCurveFallbacks: 0,
149
+ compressionContextBudgetTokens: 50_000,
150
+ },
151
+ }));
152
+ const otherStrategy = buildFrameworkStrategy(otherRecipe, 'other-model', 'America/Los_Angeles');
153
+ const otherConfig = strategyConfigView(otherStrategy);
154
+ expect(otherConfig.compressionRefusalCurveFallbacks).toBe(0);
155
+ expect(otherConfig.compressionContextBudgetTokens).toBe(50_000);
156
+ expect(otherConfig).not.toBe(runtimeConfig);
157
+ expect(runtimeConfig.compressionRefusalCurveFallbacks).toBe(2);
158
+ expect(runtimeConfig.compressionContextBudgetTokens).toBe(19_000);
159
+ });
160
+ });
@@ -81,6 +81,7 @@ function makeStrategy(): TestFrontdesk {
81
81
  targetChunkTokens: 50,
82
82
  autoTickOnNewMessage: false,
83
83
  maxMessageTokens: 0,
84
+ timeZone: 'UTC',
84
85
  });
85
86
  }
86
87
 
@@ -110,6 +111,15 @@ describe('provenance wrapping', () => {
110
111
  expect(header!.endsWith('\n')).toBe(true);
111
112
  });
112
113
 
114
+ test('renders provenance time in the configured zone', () => {
115
+ const s = new TestFrontdesk({ timeZone: 'America/Los_Angeles' });
116
+ const m = msg('User', 'hello', {
117
+ serverId: 'zulip',
118
+ timestamp: '2026-07-17T14:32:00Z',
119
+ });
120
+ expect(s.pub_buildHeader(m)).toContain('07:32');
121
+ });
122
+
113
123
  test('wrapProvenance prepends header into the first text block', () => {
114
124
  const s = makeStrategy();
115
125
  const m = msg('User', 'hello', {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ projectItem,
4
+ reconstructEffectiveHistory,
5
+ } from '../scripts/import-codex-rollout.js';
6
+
7
+ describe('Codex rollout import', () => {
8
+ test('uses replacement_history as the authoritative compaction boundary', () => {
9
+ const history = reconstructEffectiveHistory([
10
+ { type: 'response_item', payload: { type: 'message', id: 'discarded' } },
11
+ {
12
+ type: 'compacted', timestamp: '2026-07-13T00:00:00Z',
13
+ payload: { replacement_history: [
14
+ { type: 'message', role: 'user', content: 'kept' },
15
+ { type: 'compaction', id: 'cmp_1', encrypted_content: 'opaque' },
16
+ ] },
17
+ },
18
+ { type: 'response_item', payload: { type: 'reasoning', id: 'rs_2', encrypted_content: 'tail' } },
19
+ ]);
20
+
21
+ expect(history.map(entry => entry.item.id ?? entry.item.type)).toEqual([
22
+ 'message', 'cmp_1', 'rs_2',
23
+ ]);
24
+ expect(history[0]!.restoredByCompaction).toBe(true);
25
+ expect(history[2]!.restoredByCompaction).toBe(false);
26
+ });
27
+
28
+ test('keeps the exact native item on the Chronicle projection block', () => {
29
+ const item = {
30
+ type: 'message', id: 'msg_1', role: 'assistant', phase: 'commentary',
31
+ content: [{ type: 'output_text', text: 'hello' }],
32
+ };
33
+ const [block] = projectItem(item);
34
+ expect(block).toMatchObject({ type: 'text', text: 'hello', rawItem: item });
35
+ });
36
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Off-path refusal dragnet (observability M3): the logging adapter fires
3
+ * onRefusal for refusals on complete() calls — the compression/summarizer
4
+ * path the framework's own noteRefusal never sees — and stays silent for
5
+ * streamed refusals (main driver's job) and non-refusal completions.
6
+ */
7
+ import { test, expect } from 'bun:test';
8
+ import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
9
+
10
+ function makeAdapter() {
11
+ const adapter = new LoggingAnthropicAdapter({ apiKey: 'test-key' }, '/tmp/llm-test.jsonl', () => ({ enabled: false, budgetTokens: 0 }));
12
+ const fired: unknown[] = [];
13
+ adapter.onRefusal = (info) => fired.push(info);
14
+ const observe = (kind: 'complete' | 'stream', stopReason?: string, category?: string) =>
15
+ (adapter as unknown as {
16
+ observeCall: (k: string, t: string, d: number, req: unknown, raw: unknown, res?: unknown) => void;
17
+ }).observeCall(kind, new Date().toISOString(), 100,
18
+ { model: 'claude-fable-5', messages: new Array(37).fill({}) },
19
+ undefined,
20
+ stopReason
21
+ ? {
22
+ usage: { inputTokens: 64_000, outputTokens: 3, cacheReadTokens: 0, cacheCreationTokens: 0 },
23
+ stopReason,
24
+ raw: { stop_reason: stopReason, ...(category ? { stop_details: { category } } : {}) },
25
+ }
26
+ : undefined,
27
+ );
28
+ return { adapter, fired, observe };
29
+ }
30
+
31
+ test('complete() refusal fires the dragnet with category + size', () => {
32
+ const { fired, observe } = makeAdapter();
33
+ observe('complete', 'refusal', 'reasoning_extraction');
34
+ expect(fired.length).toBe(1);
35
+ expect(fired[0]).toMatchObject({
36
+ kind: 'complete',
37
+ category: 'reasoning_extraction',
38
+ model: 'claude-fable-5',
39
+ messages: 37,
40
+ inputTokens: 64_000,
41
+ });
42
+ });
43
+
44
+ test('streamed refusal does NOT fire (main driver already escalates those)', () => {
45
+ const { fired, observe } = makeAdapter();
46
+ observe('stream', 'refusal', 'cyber');
47
+ expect(fired.length).toBe(0);
48
+ });
49
+
50
+ test('non-refusal completions and throwing callbacks are harmless', () => {
51
+ const { adapter, fired, observe } = makeAdapter();
52
+ observe('complete', 'end_turn');
53
+ observe('complete'); // error path: no response at all
54
+ expect(fired.length).toBe(0);
55
+ adapter.onRefusal = () => { throw new Error('observer bug'); };
56
+ expect(() => observe('complete', 'refusal', 'cyber')).not.toThrow();
57
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { LoggingAnthropicAdapter } from '../src/logging-adapter.js';
3
+ import type { ProviderCallRecord } from '../src/call-ledger.js';
3
4
  import type { ProviderRequest, ProviderResponse } from '@animalabs/membrane';
4
5
 
5
6
  // Regression guard for the reasoning passthrough. `withReasoning` injects
@@ -57,7 +58,7 @@ describe('LoggingAnthropicAdapter.withReasoning', () => {
57
58
  describe('LoggingAnthropicAdapter request logging', () => {
58
59
  const adapter = new LoggingAnthropicAdapter({ apiKey: 'test' }, '/dev/null');
59
60
  const internals = adapter as unknown as {
60
- requestSummary(r: ProviderRequest): Record<string, unknown>;
61
+ requestSummary(r: ProviderRequest, raw?: unknown): Record<string, unknown>;
61
62
  refusalRawRequest(r: ProviderResponse, raw: unknown): unknown;
62
63
  };
63
64
 
@@ -76,6 +77,20 @@ describe('LoggingAnthropicAdapter request logging', () => {
76
77
  });
77
78
  });
78
79
 
80
+ test('summarizes provider cache markers without retaining prompt content', () => {
81
+ const raw = {
82
+ messages: [{
83
+ role: 'user',
84
+ content: [{ type: 'text', text: 'do not log me', cache_control: { type: 'ephemeral', ttl: '1h' } }],
85
+ }],
86
+ };
87
+ expect(internals.requestSummary(baseRequest, raw)).toMatchObject({
88
+ cacheBreakpoints: 1,
89
+ cacheTtls: ['1h'],
90
+ });
91
+ expect(JSON.stringify(internals.requestSummary(baseRequest, raw))).not.toContain('do not log me');
92
+ });
93
+
79
94
  test('retains the raw request only for refusals', () => {
80
95
  const rawRequest = { messages: ['forensic context'] };
81
96
  const success = { raw: { stop_reason: 'end_turn' } } as unknown as ProviderResponse;
@@ -84,4 +99,46 @@ describe('LoggingAnthropicAdapter request logging', () => {
84
99
  expect(internals.refusalRawRequest(success, rawRequest)).toBeUndefined();
85
100
  expect(internals.refusalRawRequest(refusal, rawRequest)).toBe(rawRequest);
86
101
  });
102
+
103
+ test('forwards authoritative billing buckets from the provider response', () => {
104
+ const calls: ProviderCallRecord[] = [];
105
+ const observed = new LoggingAnthropicAdapter(
106
+ { apiKey: 'test' },
107
+ '/dev/null',
108
+ undefined,
109
+ (call) => calls.push(call),
110
+ ) as unknown as {
111
+ observeCall(
112
+ kind: 'complete' | 'stream',
113
+ timestamp: string,
114
+ durationMs: number,
115
+ request: ProviderRequest,
116
+ rawRequest: unknown,
117
+ response: ProviderResponse,
118
+ ): void;
119
+ };
120
+ const response = {
121
+ usage: { inputTokens: 2, outputTokens: 10, cacheCreationTokens: 100, cacheReadTokens: 50 },
122
+ raw: {
123
+ usage: {
124
+ cache_creation: {
125
+ ephemeral_5m_input_tokens: 25,
126
+ ephemeral_1h_input_tokens: 75,
127
+ },
128
+ service_tier: 'standard',
129
+ inference_geo: 'global',
130
+ },
131
+ },
132
+ } as unknown as ProviderResponse;
133
+
134
+ observed.observeCall('stream', '2026-07-13T00:00:00Z', 10, baseRequest, {}, response);
135
+ expect(calls[0]).toMatchObject({
136
+ cacheWriteTokens: 100,
137
+ cacheWrite5mTokens: 25,
138
+ cacheWrite1hTokens: 75,
139
+ cacheWriteBucketsAuthoritative: true,
140
+ serviceTier: 'standard',
141
+ inferenceGeo: 'global',
142
+ });
143
+ });
87
144
  });
@@ -4,7 +4,7 @@
4
4
  * inference).
5
5
  */
6
6
  import { describe, test, expect } from 'bun:test';
7
- import { validateRecipe } from '../src/recipe.js';
7
+ import { DEFAULT_RECIPE, validateRecipe } from '../src/recipe.js';
8
8
 
9
9
  function recipeWithCacheTtl(cacheTtl?: unknown) {
10
10
  return {
@@ -17,6 +17,10 @@ function recipeWithCacheTtl(cacheTtl?: unknown) {
17
17
  }
18
18
 
19
19
  describe('recipe agent.cacheTtl validation', () => {
20
+ test('the shipped default recipe explicitly uses "1h"', () => {
21
+ expect(DEFAULT_RECIPE.agent.cacheTtl).toBe('1h');
22
+ });
23
+
20
24
  test('accepts "5m"', () => {
21
25
  expect(validateRecipe(recipeWithCacheTtl('5m')).agent.cacheTtl).toBe('5m');
22
26
  });
@@ -25,8 +29,8 @@ describe('recipe agent.cacheTtl validation', () => {
25
29
  expect(validateRecipe(recipeWithCacheTtl('1h')).agent.cacheTtl).toBe('1h');
26
30
  });
27
31
 
28
- test('accepts unset (provider default applies)', () => {
29
- expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).toBeUndefined();
32
+ test('defaults unset cacheTtl to "1h"', () => {
33
+ expect(validateRecipe(recipeWithCacheTtl()).agent.cacheTtl).toBe('1h');
30
34
  });
31
35
 
32
36
  test('rejects a typo\'d TTL at load time', () => {