@diyor28/context 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +270 -0
- package/dist/__tests__/attachment-selector.test.d.ts +11 -0
- package/dist/__tests__/attachment-selector.test.d.ts.map +1 -0
- package/dist/__tests__/attachment-selector.test.js +449 -0
- package/dist/__tests__/attachment-selector.test.js.map +1 -0
- package/dist/__tests__/cache-breakpoints.test.d.ts +11 -0
- package/dist/__tests__/cache-breakpoints.test.d.ts.map +1 -0
- package/dist/__tests__/cache-breakpoints.test.js +398 -0
- package/dist/__tests__/cache-breakpoints.test.js.map +1 -0
- package/dist/__tests__/codecs.test.d.ts +7 -0
- package/dist/__tests__/codecs.test.d.ts.map +1 -0
- package/dist/__tests__/codecs.test.js +331 -0
- package/dist/__tests__/codecs.test.js.map +1 -0
- package/dist/__tests__/compactor.test.d.ts +11 -0
- package/dist/__tests__/compactor.test.d.ts.map +1 -0
- package/dist/__tests__/compactor.test.js +519 -0
- package/dist/__tests__/compactor.test.js.map +1 -0
- package/dist/__tests__/context-graph.test.d.ts +7 -0
- package/dist/__tests__/context-graph.test.d.ts.map +1 -0
- package/dist/__tests__/context-graph.test.js +262 -0
- package/dist/__tests__/context-graph.test.js.map +1 -0
- package/dist/__tests__/hash.test.d.ts +7 -0
- package/dist/__tests__/hash.test.d.ts.map +1 -0
- package/dist/__tests__/hash.test.js +228 -0
- package/dist/__tests__/hash.test.js.map +1 -0
- package/dist/__tests__/integration.test.d.ts +15 -0
- package/dist/__tests__/integration.test.d.ts.map +1 -0
- package/dist/__tests__/integration.test.js +728 -0
- package/dist/__tests__/integration.test.js.map +1 -0
- package/dist/__tests__/kind-order.test.d.ts +7 -0
- package/dist/__tests__/kind-order.test.d.ts.map +1 -0
- package/dist/__tests__/kind-order.test.js +243 -0
- package/dist/__tests__/kind-order.test.js.map +1 -0
- package/dist/__tests__/phase2-integration.test.d.ts +5 -0
- package/dist/__tests__/phase2-integration.test.d.ts.map +1 -0
- package/dist/__tests__/phase2-integration.test.js +222 -0
- package/dist/__tests__/phase2-integration.test.js.map +1 -0
- package/dist/__tests__/queries.test.d.ts +7 -0
- package/dist/__tests__/queries.test.d.ts.map +1 -0
- package/dist/__tests__/queries.test.js +254 -0
- package/dist/__tests__/queries.test.js.map +1 -0
- package/dist/__tests__/token-estimator.test.d.ts +7 -0
- package/dist/__tests__/token-estimator.test.d.ts.map +1 -0
- package/dist/__tests__/token-estimator.test.js +267 -0
- package/dist/__tests__/token-estimator.test.js.map +1 -0
- package/dist/adapters/anthropic-estimator.d.ts +38 -0
- package/dist/adapters/anthropic-estimator.d.ts.map +1 -0
- package/dist/adapters/anthropic-estimator.js +108 -0
- package/dist/adapters/anthropic-estimator.js.map +1 -0
- package/dist/adapters/attachment-resolver.d.ts +96 -0
- package/dist/adapters/attachment-resolver.d.ts.map +1 -0
- package/dist/adapters/attachment-resolver.js +176 -0
- package/dist/adapters/attachment-resolver.js.map +1 -0
- package/dist/adapters/attachment-selector.d.ts +59 -0
- package/dist/adapters/attachment-selector.d.ts.map +1 -0
- package/dist/adapters/attachment-selector.js +163 -0
- package/dist/adapters/attachment-selector.js.map +1 -0
- package/dist/adapters/gemini-estimator.d.ts +27 -0
- package/dist/adapters/gemini-estimator.d.ts.map +1 -0
- package/dist/adapters/gemini-estimator.js +80 -0
- package/dist/adapters/gemini-estimator.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/memory-store.d.ts +139 -0
- package/dist/adapters/memory-store.d.ts.map +1 -0
- package/dist/adapters/memory-store.js +187 -0
- package/dist/adapters/memory-store.js.map +1 -0
- package/dist/adapters/openai-estimator.d.ts +35 -0
- package/dist/adapters/openai-estimator.d.ts.map +1 -0
- package/dist/adapters/openai-estimator.js +89 -0
- package/dist/adapters/openai-estimator.js.map +1 -0
- package/dist/adapters/summarizer.d.ts +121 -0
- package/dist/adapters/summarizer.d.ts.map +1 -0
- package/dist/adapters/summarizer.js +121 -0
- package/dist/adapters/summarizer.js.map +1 -0
- package/dist/adapters/token-estimator.d.ts +63 -0
- package/dist/adapters/token-estimator.d.ts.map +1 -0
- package/dist/adapters/token-estimator.js +37 -0
- package/dist/adapters/token-estimator.js.map +1 -0
- package/dist/builder/context-builder.d.ts +186 -0
- package/dist/builder/context-builder.d.ts.map +1 -0
- package/dist/builder/context-builder.js +305 -0
- package/dist/builder/context-builder.js.map +1 -0
- package/dist/builder/context-fork.d.ts +166 -0
- package/dist/builder/context-fork.d.ts.map +1 -0
- package/dist/builder/context-fork.js +282 -0
- package/dist/builder/context-fork.js.map +1 -0
- package/dist/builder/index.d.ts +6 -0
- package/dist/builder/index.d.ts.map +1 -0
- package/dist/builder/index.js +22 -0
- package/dist/builder/index.js.map +1 -0
- package/dist/codecs/base.d.ts +18 -0
- package/dist/codecs/base.d.ts.map +1 -0
- package/dist/codecs/base.js +39 -0
- package/dist/codecs/base.js.map +1 -0
- package/dist/codecs/conversation-history.codec.d.ts +81 -0
- package/dist/codecs/conversation-history.codec.d.ts.map +1 -0
- package/dist/codecs/conversation-history.codec.js +89 -0
- package/dist/codecs/conversation-history.codec.js.map +1 -0
- package/dist/codecs/index.d.ts +31 -0
- package/dist/codecs/index.d.ts.map +1 -0
- package/dist/codecs/index.js +71 -0
- package/dist/codecs/index.js.map +1 -0
- package/dist/codecs/redacted-stub.codec.d.ts +32 -0
- package/dist/codecs/redacted-stub.codec.d.ts.map +1 -0
- package/dist/codecs/redacted-stub.codec.js +64 -0
- package/dist/codecs/redacted-stub.codec.js.map +1 -0
- package/dist/codecs/structured-reference.codec.d.ts +40 -0
- package/dist/codecs/structured-reference.codec.d.ts.map +1 -0
- package/dist/codecs/structured-reference.codec.js +81 -0
- package/dist/codecs/structured-reference.codec.js.map +1 -0
- package/dist/codecs/system-rules.codec.d.ts +32 -0
- package/dist/codecs/system-rules.codec.d.ts.map +1 -0
- package/dist/codecs/system-rules.codec.js +62 -0
- package/dist/codecs/system-rules.codec.js.map +1 -0
- package/dist/codecs/tool-output.codec.d.ts +66 -0
- package/dist/codecs/tool-output.codec.d.ts.map +1 -0
- package/dist/codecs/tool-output.codec.js +95 -0
- package/dist/codecs/tool-output.codec.js.map +1 -0
- package/dist/codecs/tool-schema.codec.d.ts +36 -0
- package/dist/codecs/tool-schema.codec.d.ts.map +1 -0
- package/dist/codecs/tool-schema.codec.js +74 -0
- package/dist/codecs/tool-schema.codec.js.map +1 -0
- package/dist/codecs/unsafe-text.codec.d.ts +28 -0
- package/dist/codecs/unsafe-text.codec.d.ts.map +1 -0
- package/dist/codecs/unsafe-text.codec.js +63 -0
- package/dist/codecs/unsafe-text.codec.js.map +1 -0
- package/dist/graph/context-graph.d.ts +121 -0
- package/dist/graph/context-graph.d.ts.map +1 -0
- package/dist/graph/context-graph.js +166 -0
- package/dist/graph/context-graph.js.map +1 -0
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +24 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/kind-order.d.ts +60 -0
- package/dist/graph/kind-order.d.ts.map +1 -0
- package/dist/graph/kind-order.js +113 -0
- package/dist/graph/kind-order.js.map +1 -0
- package/dist/graph/queries.d.ts +68 -0
- package/dist/graph/queries.d.ts.map +1 -0
- package/dist/graph/queries.js +240 -0
- package/dist/graph/queries.js.map +1 -0
- package/dist/graph/views.d.ts +90 -0
- package/dist/graph/views.d.ts.map +1 -0
- package/dist/graph/views.js +173 -0
- package/dist/graph/views.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/pipeline/compactor.d.ts +128 -0
- package/dist/pipeline/compactor.d.ts.map +1 -0
- package/dist/pipeline/compactor.js +346 -0
- package/dist/pipeline/compactor.js.map +1 -0
- package/dist/pipeline/index.d.ts +6 -0
- package/dist/pipeline/index.d.ts.map +1 -0
- package/dist/pipeline/index.js +22 -0
- package/dist/pipeline/index.js.map +1 -0
- package/dist/pipeline/summarizer.d.ts +18 -0
- package/dist/pipeline/summarizer.d.ts.map +1 -0
- package/dist/pipeline/summarizer.js +68 -0
- package/dist/pipeline/summarizer.js.map +1 -0
- package/dist/policies/default-policy.d.ts +29 -0
- package/dist/policies/default-policy.d.ts.map +1 -0
- package/dist/policies/default-policy.js +58 -0
- package/dist/policies/default-policy.js.map +1 -0
- package/dist/policies/index.d.ts +5 -0
- package/dist/policies/index.d.ts.map +1 -0
- package/dist/policies/index.js +21 -0
- package/dist/policies/index.js.map +1 -0
- package/dist/providers/anthropic-compiler.d.ts +58 -0
- package/dist/providers/anthropic-compiler.d.ts.map +1 -0
- package/dist/providers/anthropic-compiler.js +182 -0
- package/dist/providers/anthropic-compiler.js.map +1 -0
- package/dist/providers/capabilities.d.ts +54 -0
- package/dist/providers/capabilities.d.ts.map +1 -0
- package/dist/providers/capabilities.js +87 -0
- package/dist/providers/capabilities.js.map +1 -0
- package/dist/providers/gemini-compiler.d.ts +51 -0
- package/dist/providers/gemini-compiler.d.ts.map +1 -0
- package/dist/providers/gemini-compiler.js +206 -0
- package/dist/providers/gemini-compiler.js.map +1 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +24 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai-compiler.d.ts +46 -0
- package/dist/providers/openai-compiler.d.ts.map +1 -0
- package/dist/providers/openai-compiler.js +149 -0
- package/dist/providers/openai-compiler.js.map +1 -0
- package/dist/types/attachment.d.ts +62 -0
- package/dist/types/attachment.d.ts.map +1 -0
- package/dist/types/attachment.js +6 -0
- package/dist/types/attachment.js.map +1 -0
- package/dist/types/block.d.ts +61 -0
- package/dist/types/block.d.ts.map +1 -0
- package/dist/types/block.js +8 -0
- package/dist/types/block.js.map +1 -0
- package/dist/types/codec.d.ts +58 -0
- package/dist/types/codec.d.ts.map +1 -0
- package/dist/types/codec.js +6 -0
- package/dist/types/codec.js.map +1 -0
- package/dist/types/compiled.d.ts +91 -0
- package/dist/types/compiled.d.ts.map +1 -0
- package/dist/types/compiled.js +6 -0
- package/dist/types/compiled.js.map +1 -0
- package/dist/types/hash.d.ts +24 -0
- package/dist/types/hash.d.ts.map +1 -0
- package/dist/types/hash.js +49 -0
- package/dist/types/hash.js.map +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +26 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/policy.d.ts +128 -0
- package/dist/types/policy.d.ts.map +1 -0
- package/dist/types/policy.js +55 -0
- package/dist/types/policy.js.map +1 -0
- package/package.json +55 -0
- package/postcss.config.js +4 -0
- package/src/__tests__/attachment-selector.test.ts +559 -0
- package/src/__tests__/cache-breakpoints.test.ts +566 -0
- package/src/__tests__/codecs.test.ts +417 -0
- package/src/__tests__/compactor.test.ts +608 -0
- package/src/__tests__/context-graph.test.ts +383 -0
- package/src/__tests__/hash.test.ts +274 -0
- package/src/__tests__/integration.test.ts +866 -0
- package/src/__tests__/kind-order.test.ts +312 -0
- package/src/__tests__/phase2-integration.test.ts +253 -0
- package/src/__tests__/queries.test.ts +387 -0
- package/src/__tests__/token-estimator.test.ts +326 -0
- package/src/adapters/anthropic-estimator.ts +125 -0
- package/src/adapters/attachment-resolver.ts +295 -0
- package/src/adapters/attachment-selector.ts +218 -0
- package/src/adapters/gemini-estimator.ts +93 -0
- package/src/adapters/index.ts +12 -0
- package/src/adapters/memory-store.ts +299 -0
- package/src/adapters/openai-estimator.ts +105 -0
- package/src/adapters/summarizer.ts +250 -0
- package/src/adapters/token-estimator.ts +74 -0
- package/src/builder/context-builder.ts +467 -0
- package/src/builder/context-fork.ts +471 -0
- package/src/builder/index.ts +6 -0
- package/src/codecs/base.ts +36 -0
- package/src/codecs/conversation-history.codec.ts +108 -0
- package/src/codecs/index.ts +57 -0
- package/src/codecs/redacted-stub.codec.ts +76 -0
- package/src/codecs/structured-reference.codec.ts +96 -0
- package/src/codecs/system-rules.codec.ts +74 -0
- package/src/codecs/tool-output.codec.ts +109 -0
- package/src/codecs/tool-schema.codec.ts +87 -0
- package/src/codecs/unsafe-text.codec.ts +74 -0
- package/src/graph/context-graph.ts +205 -0
- package/src/graph/index.ts +8 -0
- package/src/graph/kind-order.ts +125 -0
- package/src/graph/queries.ts +306 -0
- package/src/graph/views.ts +255 -0
- package/src/index.ts +31 -0
- package/src/pipeline/compactor.ts +563 -0
- package/src/pipeline/index.ts +6 -0
- package/src/pipeline/summarizer.ts +76 -0
- package/src/policies/default-policy.ts +69 -0
- package/src/policies/index.ts +5 -0
- package/src/providers/anthropic-compiler.ts +294 -0
- package/src/providers/capabilities.ts +144 -0
- package/src/providers/gemini-compiler.ts +272 -0
- package/src/providers/index.ts +8 -0
- package/src/providers/openai-compiler.ts +191 -0
- package/src/types/attachment.ts +86 -0
- package/src/types/block.ts +84 -0
- package/src/types/codec.ts +68 -0
- package/src/types/compiled.ts +109 -0
- package/src/types/hash.ts +58 -0
- package/src/types/index.ts +10 -0
- package/src/types/policy.ts +194 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +21 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for @foundry/context library.
|
|
3
|
+
*
|
|
4
|
+
* Tests end-to-end behavior including:
|
|
5
|
+
* - Pipeline purity (compilation determinism)
|
|
6
|
+
* - Compaction provenance
|
|
7
|
+
* - Fork sensitivity redaction
|
|
8
|
+
* - Execution hash reproducibility
|
|
9
|
+
* - Token estimation confidence
|
|
10
|
+
* - Cache breakpoint resolution
|
|
11
|
+
* - Tool output pruning
|
|
12
|
+
* - Attachment budget enforcement
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
16
|
+
import { ContextBuilder } from '../builder/context-builder.js';
|
|
17
|
+
import { ContextFork, computeExecutionHash, computeSchemaHash, filterBySensitivity } from '../builder/context-fork.js';
|
|
18
|
+
import { compactView, type PipelineCompactionConfig } from '../pipeline/compactor.js';
|
|
19
|
+
import { compileAnthropicContext, type CacheBreakpointSelector } from '../providers/anthropic-compiler.js';
|
|
20
|
+
import { OpenAITokenEstimator } from '../adapters/openai-estimator.js';
|
|
21
|
+
import { heuristicTokenCount, LOW_CONFIDENCE_MULTIPLIER, serializeBlockForEstimation, type TokenEstimator, type TokenEstimate } from '../adapters/token-estimator.js';
|
|
22
|
+
import { SystemRulesCodec } from '../codecs/system-rules.codec.js';
|
|
23
|
+
import { ToolOutputCodec } from '../codecs/tool-output.codec.js';
|
|
24
|
+
import { RedactedStubCodec } from '../codecs/redacted-stub.codec.js';
|
|
25
|
+
import type { ContextPolicy } from '../types/policy.js';
|
|
26
|
+
import type { ContextBlock } from '../types/block.js';
|
|
27
|
+
import type { BlockCodec } from '../types/codec.js';
|
|
28
|
+
import type { ResolvedAttachment, AttachmentRef } from '../types/attachment.js';
|
|
29
|
+
import { AttachmentSelector, type RankedAttachment } from '../adapters/attachment-selector.js';
|
|
30
|
+
import type { AttachmentPolicy } from '../types/policy.js';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
|
|
33
|
+
// Mock token estimator for tests
|
|
34
|
+
class MockTokenEstimator implements TokenEstimator {
|
|
35
|
+
async estimate(blocks: ContextBlock<unknown>[]): Promise<TokenEstimate> {
|
|
36
|
+
const totalTokens = blocks.reduce((sum, block) => {
|
|
37
|
+
const text = serializeBlockForEstimation(block);
|
|
38
|
+
return sum + heuristicTokenCount(text);
|
|
39
|
+
}, 0);
|
|
40
|
+
return { tokens: totalTokens, confidence: 'low' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async estimateBlock(block: ContextBlock<unknown>): Promise<TokenEstimate> {
|
|
44
|
+
const text = serializeBlockForEstimation(block);
|
|
45
|
+
return { tokens: heuristicTokenCount(text), confidence: 'low' };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('Integration Tests', () => {
|
|
50
|
+
describe('Pipeline purity', () => {
|
|
51
|
+
it('should produce identical output for repeated compilation with same policy/provider', async () => {
|
|
52
|
+
// Build context with multiple blocks
|
|
53
|
+
const builder = new ContextBuilder();
|
|
54
|
+
builder
|
|
55
|
+
.system({ text: 'You are a helpful assistant.' })
|
|
56
|
+
.history([
|
|
57
|
+
{ role: 'user', content: 'Hello' },
|
|
58
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
59
|
+
])
|
|
60
|
+
.turn('What is the capital of France?');
|
|
61
|
+
|
|
62
|
+
const policy: ContextPolicy = {
|
|
63
|
+
provider: 'anthropic',
|
|
64
|
+
modelId: 'claude-sonnet-4-5',
|
|
65
|
+
contextWindow: 200000,
|
|
66
|
+
completionReserve: 8000,
|
|
67
|
+
overflowStrategy: 'truncate',
|
|
68
|
+
kindPriorities: [],
|
|
69
|
+
sensitivity: {
|
|
70
|
+
maxSensitivity: 'public',
|
|
71
|
+
redactRestricted: true,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Create codec registry
|
|
76
|
+
const codecRegistry = new Map();
|
|
77
|
+
codecRegistry.set('system-rules', SystemRulesCodec);
|
|
78
|
+
codecRegistry.set('conversation-history', {
|
|
79
|
+
codecId: 'conversation-history',
|
|
80
|
+
version: '1.0.0',
|
|
81
|
+
payloadSchema: {},
|
|
82
|
+
canonicalize: (p: any) => p,
|
|
83
|
+
hash: () => 'history-hash',
|
|
84
|
+
render: (block: any) => ({
|
|
85
|
+
anthropic: block.payload.messages.map((m: any) => ({
|
|
86
|
+
role: m.role,
|
|
87
|
+
content: m.content,
|
|
88
|
+
})),
|
|
89
|
+
openai: {},
|
|
90
|
+
gemini: {},
|
|
91
|
+
}),
|
|
92
|
+
validate: (p: any) => p,
|
|
93
|
+
});
|
|
94
|
+
codecRegistry.set('user-turn', {
|
|
95
|
+
codecId: 'user-turn',
|
|
96
|
+
version: '1.0.0',
|
|
97
|
+
payloadSchema: {},
|
|
98
|
+
canonicalize: (p: any) => p,
|
|
99
|
+
hash: () => 'turn-hash',
|
|
100
|
+
render: (block: any) => ({
|
|
101
|
+
anthropic: { role: 'user', content: block.payload.text },
|
|
102
|
+
openai: {},
|
|
103
|
+
gemini: {},
|
|
104
|
+
}),
|
|
105
|
+
validate: (p: any) => p,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Compile twice
|
|
109
|
+
const graph = builder.getGraph();
|
|
110
|
+
const view1 = await graph.createView({});
|
|
111
|
+
const view2 = await graph.createView({});
|
|
112
|
+
|
|
113
|
+
const compiled1 = compileAnthropicContext([...view1.blocks], policy, { codecRegistry });
|
|
114
|
+
const compiled2 = compileAnthropicContext([...view2.blocks], policy, { codecRegistry });
|
|
115
|
+
|
|
116
|
+
// Verify identical outputs
|
|
117
|
+
expect(compiled1.messages).toEqual(compiled2.messages);
|
|
118
|
+
expect(compiled1.system).toEqual(compiled2.system);
|
|
119
|
+
expect(compiled1.modelId).toBe(compiled2.modelId);
|
|
120
|
+
expect(compiled1.provider).toBe(compiled2.provider);
|
|
121
|
+
|
|
122
|
+
// Verify stable prefix hash
|
|
123
|
+
expect(view1.stablePrefixHash).toBe(view2.stablePrefixHash);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should produce different output when blocks change', async () => {
|
|
127
|
+
const builder1 = new ContextBuilder();
|
|
128
|
+
builder1.system({ text: 'You are a helpful assistant.' });
|
|
129
|
+
|
|
130
|
+
const builder2 = new ContextBuilder();
|
|
131
|
+
builder2.system({ text: 'You are a coding expert.' });
|
|
132
|
+
|
|
133
|
+
const view1 = await builder1.getGraph().createView({});
|
|
134
|
+
const view2 = await builder2.getGraph().createView({});
|
|
135
|
+
|
|
136
|
+
// Verify different hashes
|
|
137
|
+
expect(view1.stablePrefixHash).not.toBe(view2.stablePrefixHash);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Compaction provenance', () => {
|
|
142
|
+
it('should produce valid provenance for compacted blocks', async () => {
|
|
143
|
+
const builder = new ContextBuilder();
|
|
144
|
+
|
|
145
|
+
// Add tool output blocks with large content
|
|
146
|
+
for (let i = 0; i < 5; i++) {
|
|
147
|
+
const toolBlock: ContextBlock<any> = {
|
|
148
|
+
blockHash: `tool-hash-${i}`,
|
|
149
|
+
meta: {
|
|
150
|
+
kind: 'tool_output',
|
|
151
|
+
sensitivity: 'public',
|
|
152
|
+
codecId: 'tool-output',
|
|
153
|
+
codecVersion: '1.0.0',
|
|
154
|
+
createdAt: Math.floor(Date.now() / 1000) + i,
|
|
155
|
+
},
|
|
156
|
+
payload: {
|
|
157
|
+
tool_name: 'bash',
|
|
158
|
+
output: 'x'.repeat(1000), // Large output
|
|
159
|
+
status: 'success',
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
builder.getGraph().addBlock(toolBlock);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const view = await builder.getGraph().createView({});
|
|
167
|
+
|
|
168
|
+
// Compact with tool output pruning
|
|
169
|
+
const compactionConfig: PipelineCompactionConfig = {
|
|
170
|
+
steps: ['tool_output_prune'],
|
|
171
|
+
toolOutputPruning: {
|
|
172
|
+
maxRawTailChars: 100,
|
|
173
|
+
preserveErrorTail: true,
|
|
174
|
+
maxOutputsPerTool: 3,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const estimator = new MockTokenEstimator();
|
|
179
|
+
const result = await compactView(view, compactionConfig, estimator);
|
|
180
|
+
|
|
181
|
+
// Verify compaction happened
|
|
182
|
+
expect(result.blocks.length).toBeLessThanOrEqual(3);
|
|
183
|
+
expect(result.removedBlocks.length).toBeGreaterThan(0);
|
|
184
|
+
|
|
185
|
+
// Verify replaced blocks have provenance tags
|
|
186
|
+
const compactedBlocks = result.blocks.filter((b) =>
|
|
187
|
+
b.meta.tags?.some((tag) => tag.startsWith('compacted:'))
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(compactedBlocks.length).toBeGreaterThan(0);
|
|
191
|
+
|
|
192
|
+
for (const block of compactedBlocks) {
|
|
193
|
+
// Verify compaction tag
|
|
194
|
+
expect(block.meta.tags).toContain('compacted:tool_output_prune');
|
|
195
|
+
|
|
196
|
+
// Verify source indicates compaction
|
|
197
|
+
expect(block.meta.source).toContain(':compacted');
|
|
198
|
+
|
|
199
|
+
// Verify truncated payload
|
|
200
|
+
if (block.payload && typeof block.payload === 'object' && 'output' in block.payload) {
|
|
201
|
+
const output = (block.payload as any).output;
|
|
202
|
+
expect(output.length).toBeLessThanOrEqual(100 + 50); // Max tail + truncation marker
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should track deduplication correctly', async () => {
|
|
208
|
+
// NOTE: ContextGraph.addBlock is idempotent, so we need to create
|
|
209
|
+
// duplicates in the view directly to test deduplication
|
|
210
|
+
const duplicateBlock: ContextBlock<string> = {
|
|
211
|
+
blockHash: 'duplicate-hash',
|
|
212
|
+
meta: {
|
|
213
|
+
kind: 'memory',
|
|
214
|
+
sensitivity: 'public',
|
|
215
|
+
codecId: 'test',
|
|
216
|
+
codecVersion: '1.0.0',
|
|
217
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
218
|
+
},
|
|
219
|
+
payload: 'duplicate content',
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Create view with duplicates manually (since graph is idempotent)
|
|
223
|
+
const view: any = {
|
|
224
|
+
blocks: [duplicateBlock, { ...duplicateBlock }, { ...duplicateBlock }],
|
|
225
|
+
stablePrefixHash: 'test-hash',
|
|
226
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Compact with deduplication
|
|
230
|
+
const compactionConfig: PipelineCompactionConfig = {
|
|
231
|
+
steps: ['dedupe'],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const estimator = new MockTokenEstimator();
|
|
235
|
+
const result = await compactView(view, compactionConfig, estimator);
|
|
236
|
+
|
|
237
|
+
// Verify only one instance remains
|
|
238
|
+
const duplicates = result.blocks.filter((b) => b.blockHash === 'duplicate-hash');
|
|
239
|
+
expect(duplicates.length).toBe(1);
|
|
240
|
+
|
|
241
|
+
// Verify 2 blocks were removed
|
|
242
|
+
expect(result.removedBlocks.length).toBe(2);
|
|
243
|
+
|
|
244
|
+
// Verify report
|
|
245
|
+
expect(result.report.stepsApplied).toContain('dedupe');
|
|
246
|
+
const dedupeReport = result.report.stepReports.find((r) => r.step === 'dedupe');
|
|
247
|
+
expect(dedupeReport?.blocksRemoved).toBe(2);
|
|
248
|
+
expect(dedupeReport?.lossy).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('Fork sensitivity redaction', () => {
|
|
253
|
+
it('should replace sensitive blocks with RedactedStubs', async () => {
|
|
254
|
+
const builder = new ContextBuilder();
|
|
255
|
+
|
|
256
|
+
// Add public block
|
|
257
|
+
builder.system({ text: 'Public system prompt' }, { sensitivity: 'public' });
|
|
258
|
+
|
|
259
|
+
// Add internal blocks
|
|
260
|
+
const internalBlock: ContextBlock<string> = {
|
|
261
|
+
blockHash: 'internal-hash',
|
|
262
|
+
meta: {
|
|
263
|
+
kind: 'memory',
|
|
264
|
+
sensitivity: 'internal',
|
|
265
|
+
codecId: 'test',
|
|
266
|
+
codecVersion: '1.0.0',
|
|
267
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
268
|
+
tags: ['secret'],
|
|
269
|
+
},
|
|
270
|
+
payload: 'Internal company information',
|
|
271
|
+
};
|
|
272
|
+
builder.getGraph().addBlock(internalBlock);
|
|
273
|
+
|
|
274
|
+
// Add restricted block
|
|
275
|
+
const restrictedBlock: ContextBlock<string> = {
|
|
276
|
+
blockHash: 'restricted-hash',
|
|
277
|
+
meta: {
|
|
278
|
+
kind: 'state',
|
|
279
|
+
sensitivity: 'restricted',
|
|
280
|
+
codecId: 'test',
|
|
281
|
+
codecVersion: '1.0.0',
|
|
282
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
283
|
+
},
|
|
284
|
+
payload: 'Highly sensitive data',
|
|
285
|
+
};
|
|
286
|
+
builder.getGraph().addBlock(restrictedBlock);
|
|
287
|
+
|
|
288
|
+
const view = await builder.getGraph().createView({});
|
|
289
|
+
|
|
290
|
+
// Filter by 'public' sensitivity
|
|
291
|
+
const filteredBlocks = filterBySensitivity(view, 'public');
|
|
292
|
+
|
|
293
|
+
// Verify public block is unchanged
|
|
294
|
+
const publicBlocks = filteredBlocks.filter(
|
|
295
|
+
(b) => b.meta.codecId === 'system-rules'
|
|
296
|
+
);
|
|
297
|
+
expect(publicBlocks.length).toBe(1);
|
|
298
|
+
expect(publicBlocks[0].meta.sensitivity).toBe('public');
|
|
299
|
+
|
|
300
|
+
// Verify internal/restricted blocks are redacted
|
|
301
|
+
const redactedBlocks = filteredBlocks.filter(
|
|
302
|
+
(b) => b.meta.codecId === RedactedStubCodec.codecId
|
|
303
|
+
);
|
|
304
|
+
expect(redactedBlocks.length).toBe(2);
|
|
305
|
+
|
|
306
|
+
for (const block of redactedBlocks) {
|
|
307
|
+
expect(block.meta.sensitivity).toBe('public');
|
|
308
|
+
expect(block.payload).toHaveProperty('originalBlockHash');
|
|
309
|
+
expect(block.payload).toHaveProperty('reason');
|
|
310
|
+
expect((block.payload as any).reason).toContain('exceeds maximum');
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should allow internal blocks when maxSensitivity is internal', async () => {
|
|
315
|
+
const builder = new ContextBuilder();
|
|
316
|
+
|
|
317
|
+
builder.system({ text: 'Public' }, { sensitivity: 'public' });
|
|
318
|
+
|
|
319
|
+
const internalBlock: ContextBlock<string> = {
|
|
320
|
+
blockHash: 'internal-hash',
|
|
321
|
+
meta: {
|
|
322
|
+
kind: 'memory',
|
|
323
|
+
sensitivity: 'internal',
|
|
324
|
+
codecId: 'test',
|
|
325
|
+
codecVersion: '1.0.0',
|
|
326
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
327
|
+
},
|
|
328
|
+
payload: 'Internal',
|
|
329
|
+
};
|
|
330
|
+
builder.getGraph().addBlock(internalBlock);
|
|
331
|
+
|
|
332
|
+
const view = await builder.getGraph().createView({});
|
|
333
|
+
const filteredBlocks = filterBySensitivity(view, 'internal');
|
|
334
|
+
|
|
335
|
+
// Verify both blocks are kept
|
|
336
|
+
const redactedBlocks = filteredBlocks.filter(
|
|
337
|
+
(b) => b.meta.codecId === RedactedStubCodec.codecId
|
|
338
|
+
);
|
|
339
|
+
expect(redactedBlocks.length).toBe(0);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('Execution hash reproducibility', () => {
|
|
344
|
+
it('should produce identical executionHash for same inputs', () => {
|
|
345
|
+
const model = { provider: 'anthropic' as const, model: 'claude-sonnet-4-5' };
|
|
346
|
+
const viewHash = 'view-hash-123';
|
|
347
|
+
const instruction = 'Analyze this code';
|
|
348
|
+
const schema = z.object({ result: z.string() });
|
|
349
|
+
const schemaHash = computeSchemaHash(schema);
|
|
350
|
+
|
|
351
|
+
const hash1 = computeExecutionHash(model, viewHash, instruction, schemaHash);
|
|
352
|
+
const hash2 = computeExecutionHash(model, viewHash, instruction, schemaHash);
|
|
353
|
+
|
|
354
|
+
expect(hash1).toBe(hash2);
|
|
355
|
+
expect(hash1).toMatch(/^[a-f0-9]{64}$/); // Valid SHA-256 hex
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should produce different executionHash when inputs change', () => {
|
|
359
|
+
const model1 = { provider: 'anthropic' as const, model: 'claude-sonnet-4-5' };
|
|
360
|
+
const model2 = { provider: 'anthropic' as const, model: 'claude-opus-4-5' };
|
|
361
|
+
const viewHash = 'view-hash-123';
|
|
362
|
+
const instruction = 'Analyze this code';
|
|
363
|
+
const schema = z.object({ result: z.string() });
|
|
364
|
+
const schemaHash = computeSchemaHash(schema);
|
|
365
|
+
|
|
366
|
+
const hash1 = computeExecutionHash(model1, viewHash, instruction, schemaHash);
|
|
367
|
+
const hash2 = computeExecutionHash(model2, viewHash, instruction, schemaHash);
|
|
368
|
+
|
|
369
|
+
expect(hash1).not.toBe(hash2);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should include toolset version in hash computation', () => {
|
|
373
|
+
const model = { provider: 'anthropic' as const, model: 'claude-sonnet-4-5' };
|
|
374
|
+
const viewHash = 'view-hash-123';
|
|
375
|
+
const instruction = 'Analyze this code';
|
|
376
|
+
const schema = z.object({ result: z.string() });
|
|
377
|
+
const schemaHash = computeSchemaHash(schema);
|
|
378
|
+
|
|
379
|
+
const hashWithoutToolset = computeExecutionHash(model, viewHash, instruction, schemaHash);
|
|
380
|
+
const hashWithToolset = computeExecutionHash(
|
|
381
|
+
model,
|
|
382
|
+
viewHash,
|
|
383
|
+
instruction,
|
|
384
|
+
schemaHash,
|
|
385
|
+
'v1.2.3'
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
expect(hashWithoutToolset).not.toBe(hashWithToolset);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe('Token estimation confidence', () => {
|
|
393
|
+
it('should apply safety multiplier for low-confidence estimates', () => {
|
|
394
|
+
const text = 'a'.repeat(100); // 100 chars
|
|
395
|
+
|
|
396
|
+
const heuristicTokens = heuristicTokenCount(text);
|
|
397
|
+
|
|
398
|
+
// Verify multiplier is applied
|
|
399
|
+
const baseEstimate = Math.ceil(100 / 4); // 25 tokens
|
|
400
|
+
const expectedWithMultiplier = Math.ceil(baseEstimate * LOW_CONFIDENCE_MULTIPLIER);
|
|
401
|
+
|
|
402
|
+
expect(heuristicTokens).toBe(expectedWithMultiplier);
|
|
403
|
+
expect(heuristicTokens).toBeGreaterThan(baseEstimate);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should use 1.2x safety multiplier', () => {
|
|
407
|
+
expect(LOW_CONFIDENCE_MULTIPLIER).toBe(1.2);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('should estimate tokens consistently for same input', () => {
|
|
411
|
+
const text = 'Test input for token estimation';
|
|
412
|
+
|
|
413
|
+
const estimate1 = heuristicTokenCount(text);
|
|
414
|
+
const estimate2 = heuristicTokenCount(text);
|
|
415
|
+
|
|
416
|
+
expect(estimate1).toBe(estimate2);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe('Cache breakpoint resolution', () => {
|
|
421
|
+
it('should resolve cache breakpoint to "after last match"', async () => {
|
|
422
|
+
const builder = new ContextBuilder();
|
|
423
|
+
|
|
424
|
+
// Add multiple system blocks
|
|
425
|
+
builder.system({ text: 'System 1' }, { tags: ['cacheable'] });
|
|
426
|
+
builder.system({ text: 'System 2' }, { tags: ['cacheable'] });
|
|
427
|
+
builder.system({ text: 'System 3' }, { tags: ['other'] });
|
|
428
|
+
builder.system({ text: 'System 4' }, { tags: ['cacheable'] });
|
|
429
|
+
|
|
430
|
+
const view = await builder.getGraph().createView({});
|
|
431
|
+
const policy: ContextPolicy = {
|
|
432
|
+
provider: 'anthropic',
|
|
433
|
+
modelId: 'claude-sonnet-4-5',
|
|
434
|
+
contextWindow: 200000,
|
|
435
|
+
completionReserve: 8000,
|
|
436
|
+
overflowStrategy: 'truncate',
|
|
437
|
+
kindPriorities: [],
|
|
438
|
+
sensitivity: {
|
|
439
|
+
maxSensitivity: 'public',
|
|
440
|
+
redactRestricted: true,
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Create codec registry
|
|
445
|
+
const codecRegistry = new Map();
|
|
446
|
+
codecRegistry.set('system-rules', SystemRulesCodec);
|
|
447
|
+
|
|
448
|
+
const cacheBreakpoint: CacheBreakpointSelector = {
|
|
449
|
+
tag: 'cacheable',
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const compiled = compileAnthropicContext([...view.blocks], policy, {
|
|
453
|
+
codecRegistry,
|
|
454
|
+
cacheBreakpoint,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Verify cache_control is on the last cacheable block (index 3)
|
|
458
|
+
expect(compiled.system).toBeDefined();
|
|
459
|
+
if (compiled.system) {
|
|
460
|
+
const cacheControlBlocks = compiled.system.filter((msg) => msg.cache_control);
|
|
461
|
+
expect(cacheControlBlocks.length).toBe(1);
|
|
462
|
+
|
|
463
|
+
// Last cacheable block should have cache control
|
|
464
|
+
expect(compiled.system[3]).toHaveProperty('cache_control');
|
|
465
|
+
expect(compiled.system[3].cache_control).toEqual({ type: 'ephemeral' });
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should handle no matching blocks gracefully', async () => {
|
|
470
|
+
const builder = new ContextBuilder();
|
|
471
|
+
builder.system({ text: 'System 1' });
|
|
472
|
+
|
|
473
|
+
const view = await builder.getGraph().createView({});
|
|
474
|
+
const policy: ContextPolicy = {
|
|
475
|
+
provider: 'anthropic',
|
|
476
|
+
modelId: 'claude-sonnet-4-5',
|
|
477
|
+
contextWindow: 200000,
|
|
478
|
+
completionReserve: 8000,
|
|
479
|
+
overflowStrategy: 'truncate',
|
|
480
|
+
kindPriorities: [],
|
|
481
|
+
sensitivity: {
|
|
482
|
+
maxSensitivity: 'public',
|
|
483
|
+
redactRestricted: true,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const codecRegistry = new Map();
|
|
488
|
+
codecRegistry.set('system-rules', SystemRulesCodec);
|
|
489
|
+
|
|
490
|
+
const cacheBreakpoint: CacheBreakpointSelector = {
|
|
491
|
+
tag: 'nonexistent',
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const compiled = compileAnthropicContext([...view.blocks], policy, {
|
|
495
|
+
codecRegistry,
|
|
496
|
+
cacheBreakpoint,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Verify no cache control when no blocks match
|
|
500
|
+
if (compiled.system) {
|
|
501
|
+
const cacheControlBlocks = compiled.system.filter((msg) => msg.cache_control);
|
|
502
|
+
expect(cacheControlBlocks.length).toBe(0);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe('Tool output pruning', () => {
|
|
508
|
+
it('should enforce maxOutputsPerTool', async () => {
|
|
509
|
+
const builder = new ContextBuilder();
|
|
510
|
+
|
|
511
|
+
// Add 10 tool outputs from same tool
|
|
512
|
+
for (let i = 0; i < 10; i++) {
|
|
513
|
+
const toolBlock: ContextBlock<any> = {
|
|
514
|
+
blockHash: `tool-hash-${i}`,
|
|
515
|
+
meta: {
|
|
516
|
+
kind: 'tool_output',
|
|
517
|
+
sensitivity: 'public',
|
|
518
|
+
codecId: 'tool-bash',
|
|
519
|
+
codecVersion: '1.0.0',
|
|
520
|
+
createdAt: Math.floor(Date.now() / 1000) + i,
|
|
521
|
+
},
|
|
522
|
+
payload: {
|
|
523
|
+
tool_name: 'bash',
|
|
524
|
+
output: `output ${i}`,
|
|
525
|
+
status: 'success',
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
builder.getGraph().addBlock(toolBlock);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const view = await builder.getGraph().createView({});
|
|
533
|
+
|
|
534
|
+
const compactionConfig: PipelineCompactionConfig = {
|
|
535
|
+
steps: ['tool_output_prune'],
|
|
536
|
+
toolOutputPruning: {
|
|
537
|
+
maxRawTailChars: 500,
|
|
538
|
+
preserveErrorTail: true,
|
|
539
|
+
maxOutputsPerTool: 3,
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const estimator = new MockTokenEstimator();
|
|
544
|
+
const result = await compactView(view, compactionConfig, estimator);
|
|
545
|
+
|
|
546
|
+
// Verify only 3 tool outputs remain
|
|
547
|
+
const toolOutputs = result.blocks.filter((b) => b.meta.kind === 'tool_output');
|
|
548
|
+
expect(toolOutputs.length).toBe(3);
|
|
549
|
+
|
|
550
|
+
// Verify 7 were removed
|
|
551
|
+
expect(result.removedBlocks.length).toBe(7);
|
|
552
|
+
|
|
553
|
+
// Verify kept blocks are the most recent
|
|
554
|
+
const keptHashes = toolOutputs.map((b) => b.blockHash).sort();
|
|
555
|
+
expect(keptHashes).toContain('tool-hash-7');
|
|
556
|
+
expect(keptHashes).toContain('tool-hash-8');
|
|
557
|
+
expect(keptHashes).toContain('tool-hash-9');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('should preserve error outputs even if over maxOutputsPerTool', async () => {
|
|
561
|
+
// NOTE: The current implementation of pruneToolOutputs keeps the most
|
|
562
|
+
// recent N outputs per tool, but doesn't preserve errors separately.
|
|
563
|
+
// This test verifies that large error outputs preserve the tail.
|
|
564
|
+
const view: any = {
|
|
565
|
+
blocks: [
|
|
566
|
+
{
|
|
567
|
+
blockHash: 'error-hash',
|
|
568
|
+
meta: {
|
|
569
|
+
kind: 'tool_output',
|
|
570
|
+
sensitivity: 'public',
|
|
571
|
+
codecId: 'tool-bash',
|
|
572
|
+
codecVersion: '1.0.0',
|
|
573
|
+
createdAt: Math.floor(Date.now() / 1000) + 3, // Most recent
|
|
574
|
+
},
|
|
575
|
+
payload: {
|
|
576
|
+
tool_name: 'bash',
|
|
577
|
+
output: 'x'.repeat(1000), // Large output
|
|
578
|
+
status: 'error',
|
|
579
|
+
error: true,
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
],
|
|
583
|
+
stablePrefixHash: 'test-hash',
|
|
584
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const compactionConfig: PipelineCompactionConfig = {
|
|
588
|
+
steps: ['tool_output_prune'],
|
|
589
|
+
toolOutputPruning: {
|
|
590
|
+
maxRawTailChars: 100,
|
|
591
|
+
preserveErrorTail: true,
|
|
592
|
+
maxOutputsPerTool: 3,
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const estimator = new MockTokenEstimator();
|
|
597
|
+
const result = await compactView(view, compactionConfig, estimator);
|
|
598
|
+
|
|
599
|
+
// Error block should be kept (recent enough)
|
|
600
|
+
const errorBlocks = result.blocks.filter((b) => b.blockHash === 'error-hash');
|
|
601
|
+
expect(errorBlocks.length).toBe(1);
|
|
602
|
+
|
|
603
|
+
// Verify error tail is NOT truncated (preserveErrorTail = true)
|
|
604
|
+
const errorBlock = errorBlocks[0];
|
|
605
|
+
if (errorBlock.payload && typeof errorBlock.payload === 'object' && 'output' in errorBlock.payload) {
|
|
606
|
+
const output = (errorBlock.payload as any).output;
|
|
607
|
+
expect(output.length).toBe(1000); // Not truncated
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe('Attachment budget enforcement', () => {
|
|
613
|
+
it('should select attachments within budget', async () => {
|
|
614
|
+
const attachments: RankedAttachment[] = [
|
|
615
|
+
{
|
|
616
|
+
attachmentId: 'att1',
|
|
617
|
+
filename: 'file1.txt',
|
|
618
|
+
mimeType: 'text/plain',
|
|
619
|
+
sizeBytes: 200,
|
|
620
|
+
storage: 'local',
|
|
621
|
+
storagePath: '/tmp/file1.txt',
|
|
622
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
623
|
+
text: 'a'.repeat(200), // ~50 tokens
|
|
624
|
+
purpose: 'evidence',
|
|
625
|
+
userMention: true,
|
|
626
|
+
rankScore: 0,
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
attachmentId: 'att2',
|
|
630
|
+
filename: 'file2.txt',
|
|
631
|
+
mimeType: 'text/plain',
|
|
632
|
+
sizeBytes: 200,
|
|
633
|
+
storage: 'local',
|
|
634
|
+
storagePath: '/tmp/file2.txt',
|
|
635
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
636
|
+
text: 'b'.repeat(200), // ~50 tokens
|
|
637
|
+
purpose: 'context',
|
|
638
|
+
userMention: false,
|
|
639
|
+
rankScore: 0,
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
attachmentId: 'att3',
|
|
643
|
+
filename: 'file3.txt',
|
|
644
|
+
mimeType: 'text/plain',
|
|
645
|
+
sizeBytes: 200,
|
|
646
|
+
storage: 'local',
|
|
647
|
+
storagePath: '/tmp/file3.txt',
|
|
648
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
649
|
+
text: 'c'.repeat(200), // ~50 tokens
|
|
650
|
+
purpose: 'input',
|
|
651
|
+
userMention: false,
|
|
652
|
+
rankScore: 0,
|
|
653
|
+
},
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
const policy: AttachmentPolicy = {
|
|
657
|
+
maxTokensTotal: 100, // Only room for 2 attachments
|
|
658
|
+
selectionStrategy: {
|
|
659
|
+
rankBy: ['purpose', 'user_mention', 'recency'],
|
|
660
|
+
purposePriority: {
|
|
661
|
+
evidence: 1,
|
|
662
|
+
input: 2,
|
|
663
|
+
context: 3,
|
|
664
|
+
artifact: 4,
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const selector = new AttachmentSelector(policy, {} as any);
|
|
670
|
+
const result = await selector.selectAttachments(attachments);
|
|
671
|
+
|
|
672
|
+
// Verify only 2 attachments selected (within budget)
|
|
673
|
+
expect(result.selected.length).toBe(2);
|
|
674
|
+
expect(result.tokensUsed).toBeLessThanOrEqual(100);
|
|
675
|
+
|
|
676
|
+
// Verify highest priority attachments selected
|
|
677
|
+
// evidence + input should be selected (user_mention breaks tie)
|
|
678
|
+
const selectedFilenames = result.selected.map((a) => a.filename);
|
|
679
|
+
expect(selectedFilenames).toContain('file1.txt'); // evidence + user mention
|
|
680
|
+
expect(selectedFilenames).toContain('file3.txt'); // input
|
|
681
|
+
|
|
682
|
+
// Verify excluded
|
|
683
|
+
expect(result.excluded.length).toBe(1);
|
|
684
|
+
expect(result.excluded[0].attachmentId).toBe('att2');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('should respect maxTokensTotal strictly', async () => {
|
|
688
|
+
const largeAttachments: RankedAttachment[] = [
|
|
689
|
+
{
|
|
690
|
+
attachmentId: 'large1',
|
|
691
|
+
filename: 'large1.txt',
|
|
692
|
+
mimeType: 'text/plain',
|
|
693
|
+
sizeBytes: 1000,
|
|
694
|
+
storage: 'local',
|
|
695
|
+
storagePath: '/tmp/large1.txt',
|
|
696
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
697
|
+
text: 'x'.repeat(1000), // ~250 tokens (1000 chars / 4)
|
|
698
|
+
purpose: 'evidence',
|
|
699
|
+
userMention: true,
|
|
700
|
+
rankScore: 0,
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
attachmentId: 'large2',
|
|
704
|
+
filename: 'large2.txt',
|
|
705
|
+
mimeType: 'text/plain',
|
|
706
|
+
sizeBytes: 1000,
|
|
707
|
+
storage: 'local',
|
|
708
|
+
storagePath: '/tmp/large2.txt',
|
|
709
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
710
|
+
text: 'y'.repeat(1000), // ~250 tokens (1000 chars / 4)
|
|
711
|
+
purpose: 'evidence',
|
|
712
|
+
userMention: false,
|
|
713
|
+
rankScore: 0,
|
|
714
|
+
},
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
const policy: AttachmentPolicy = {
|
|
718
|
+
maxTokensTotal: 300, // Only room for 1 large attachment (250 tokens)
|
|
719
|
+
selectionStrategy: {
|
|
720
|
+
rankBy: ['user_mention'],
|
|
721
|
+
},
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const selector = new AttachmentSelector(policy, {} as any);
|
|
725
|
+
const result = await selector.selectAttachments(largeAttachments);
|
|
726
|
+
|
|
727
|
+
// Verify only 1 selected
|
|
728
|
+
expect(result.selected.length).toBe(1);
|
|
729
|
+
expect(result.tokensUsed).toBeLessThanOrEqual(300);
|
|
730
|
+
|
|
731
|
+
// Verify user-mentioned one is selected
|
|
732
|
+
expect(result.selected[0].filename).toBe('large1.txt');
|
|
733
|
+
expect(result.excluded.length).toBe(1);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
describe('End-to-end pipeline', () => {
|
|
738
|
+
it('should handle complete build → fork → compile workflow', async () => {
|
|
739
|
+
// Build context
|
|
740
|
+
const builder = new ContextBuilder();
|
|
741
|
+
|
|
742
|
+
// Create a test codec with validate method
|
|
743
|
+
const testCodec: BlockCodec<any> = {
|
|
744
|
+
codecId: 'test',
|
|
745
|
+
version: '1.0.0',
|
|
746
|
+
payloadSchema: z.object({ data: z.string() }),
|
|
747
|
+
canonicalize: (p: any) => p,
|
|
748
|
+
hash: () => 'test-hash',
|
|
749
|
+
render: (block: any) => ({
|
|
750
|
+
anthropic: { role: 'user', content: JSON.stringify(block.payload) },
|
|
751
|
+
openai: {},
|
|
752
|
+
gemini: {},
|
|
753
|
+
}),
|
|
754
|
+
validate: (p: any) => p,
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
builder
|
|
758
|
+
.system({ text: 'You are a helpful assistant.' })
|
|
759
|
+
.memory(
|
|
760
|
+
testCodec,
|
|
761
|
+
{ data: 'Internal knowledge' },
|
|
762
|
+
{ sensitivity: 'internal' }
|
|
763
|
+
)
|
|
764
|
+
.history([
|
|
765
|
+
{ role: 'user', content: 'Hello' },
|
|
766
|
+
{ role: 'assistant', content: 'Hi!' },
|
|
767
|
+
])
|
|
768
|
+
.turn('Summarize our conversation');
|
|
769
|
+
|
|
770
|
+
// Create view
|
|
771
|
+
const view = await builder.getGraph().createView({});
|
|
772
|
+
expect(view.blocks.length).toBeGreaterThan(0);
|
|
773
|
+
|
|
774
|
+
// Fork with public sensitivity
|
|
775
|
+
const fork = new ContextFork(builder.getGraph(), view);
|
|
776
|
+
const forkedView = await fork.createFork({
|
|
777
|
+
agentId: 'summarizer',
|
|
778
|
+
name: 'Conversation Summarizer',
|
|
779
|
+
model: { provider: 'anthropic', model: 'claude-haiku-4-5' },
|
|
780
|
+
maxSensitivity: 'public',
|
|
781
|
+
includeHistory: true,
|
|
782
|
+
includeState: false,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Verify internal blocks are redacted
|
|
786
|
+
const redactedBlocks = forkedView.blocks.filter(
|
|
787
|
+
(b) => b.meta.codecId === RedactedStubCodec.codecId
|
|
788
|
+
);
|
|
789
|
+
expect(redactedBlocks.length).toBeGreaterThan(0);
|
|
790
|
+
|
|
791
|
+
// Verify history is included
|
|
792
|
+
const historyBlocks = forkedView.blocks.filter((b) => b.meta.kind === 'history');
|
|
793
|
+
expect(historyBlocks.length).toBeGreaterThan(0);
|
|
794
|
+
|
|
795
|
+
// Compile for Anthropic
|
|
796
|
+
const policy: ContextPolicy = {
|
|
797
|
+
provider: 'anthropic',
|
|
798
|
+
modelId: 'claude-haiku-4-5',
|
|
799
|
+
contextWindow: 200000,
|
|
800
|
+
completionReserve: 4000,
|
|
801
|
+
overflowStrategy: 'truncate',
|
|
802
|
+
kindPriorities: [],
|
|
803
|
+
sensitivity: {
|
|
804
|
+
maxSensitivity: 'public',
|
|
805
|
+
redactRestricted: true,
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const codecRegistry = new Map();
|
|
810
|
+
codecRegistry.set('system-rules', SystemRulesCodec);
|
|
811
|
+
codecRegistry.set('redacted-stub', RedactedStubCodec);
|
|
812
|
+
codecRegistry.set('conversation-history', {
|
|
813
|
+
codecId: 'conversation-history',
|
|
814
|
+
version: '1.0.0',
|
|
815
|
+
payloadSchema: {},
|
|
816
|
+
canonicalize: (p: any) => p,
|
|
817
|
+
hash: () => 'history-hash',
|
|
818
|
+
render: (block: any) => ({
|
|
819
|
+
anthropic: block.payload.messages.map((m: any) => ({
|
|
820
|
+
role: m.role,
|
|
821
|
+
content: m.content,
|
|
822
|
+
})),
|
|
823
|
+
openai: {},
|
|
824
|
+
gemini: {},
|
|
825
|
+
}),
|
|
826
|
+
validate: (p: any) => p,
|
|
827
|
+
});
|
|
828
|
+
codecRegistry.set('user-turn', {
|
|
829
|
+
codecId: 'user-turn',
|
|
830
|
+
version: '1.0.0',
|
|
831
|
+
payloadSchema: {},
|
|
832
|
+
canonicalize: (p: any) => p,
|
|
833
|
+
hash: () => 'turn-hash',
|
|
834
|
+
render: (block: any) => ({
|
|
835
|
+
anthropic: { role: 'user', content: block.payload.text },
|
|
836
|
+
openai: {},
|
|
837
|
+
gemini: {},
|
|
838
|
+
}),
|
|
839
|
+
validate: (p: any) => p,
|
|
840
|
+
});
|
|
841
|
+
codecRegistry.set('test', {
|
|
842
|
+
codecId: 'test',
|
|
843
|
+
version: '1.0.0',
|
|
844
|
+
payloadSchema: {},
|
|
845
|
+
canonicalize: (p: any) => p,
|
|
846
|
+
hash: () => 'test-hash',
|
|
847
|
+
render: (block: any) => ({
|
|
848
|
+
anthropic: { role: 'user', content: JSON.stringify(block.payload) },
|
|
849
|
+
openai: {},
|
|
850
|
+
gemini: {},
|
|
851
|
+
}),
|
|
852
|
+
validate: (p: any) => p,
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
const compiled = compileAnthropicContext([...forkedView.blocks], policy, {
|
|
856
|
+
codecRegistry,
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// Verify compiled output
|
|
860
|
+
expect(compiled.provider).toBe('anthropic');
|
|
861
|
+
expect(compiled.modelId).toBe('claude-haiku-4-5');
|
|
862
|
+
expect(compiled.messages.length).toBeGreaterThan(0);
|
|
863
|
+
expect(compiled.system).toBeDefined();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
});
|