@flowcodex/core 0.3.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/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fsp } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { DefaultAuthService } from '../src/session/auth-service.js';
|
|
6
|
+
import type { AuthService } from '../src/session/auth-service.js';
|
|
7
|
+
import { authFilePath, keyFilePath } from '../src/session/session-store.js';
|
|
8
|
+
|
|
9
|
+
let tmpHome: string;
|
|
10
|
+
let prevHome: string | undefined;
|
|
11
|
+
|
|
12
|
+
async function setTmpHome(): Promise<void> {
|
|
13
|
+
tmpHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-auth-'));
|
|
14
|
+
prevHome = process.env.FLOWCODEX_HOME;
|
|
15
|
+
process.env.FLOWCODEX_HOME = tmpHome;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function restoreHome(): Promise<void> {
|
|
19
|
+
if (prevHome !== undefined) process.env.FLOWCODEX_HOME = prevHome;
|
|
20
|
+
else delete process.env.FLOWCODEX_HOME;
|
|
21
|
+
await fsp.rm(tmpHome, { recursive: true, force: true }).catch(() => {});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeAuth(): AuthService {
|
|
25
|
+
return new DefaultAuthService({ authFile: authFilePath(), keyFile: keyFilePath() });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('DefaultAuthService', () => {
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
await setTmpHome();
|
|
31
|
+
});
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await restoreHome();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns undefined for unknown provider when auth.json missing', async () => {
|
|
37
|
+
const auth = makeAuth();
|
|
38
|
+
await auth.load();
|
|
39
|
+
expect(auth.get('anthropic')).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('set + get round-trip for single key', async () => {
|
|
43
|
+
const auth = makeAuth();
|
|
44
|
+
await auth.load();
|
|
45
|
+
await auth.set('anthropic', 'sk-test-123');
|
|
46
|
+
const entry = auth.get('anthropic');
|
|
47
|
+
expect(entry).toBeDefined();
|
|
48
|
+
expect(entry!.key).toBe('sk-test-123');
|
|
49
|
+
expect(entry!.label).toBe('default');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('first key becomes active automatically', async () => {
|
|
53
|
+
const auth = makeAuth();
|
|
54
|
+
await auth.load();
|
|
55
|
+
await auth.set('anthropic', 'sk-first');
|
|
56
|
+
expect(auth.get('anthropic')!.key).toBe('sk-first');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('set with custom label', async () => {
|
|
60
|
+
const auth = makeAuth();
|
|
61
|
+
await auth.load();
|
|
62
|
+
await auth.set('anthropic', 'sk-personal', 'personal');
|
|
63
|
+
const entry = auth.get('anthropic');
|
|
64
|
+
expect(entry!.label).toBe('personal');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('multiple keys per provider, first stays active', async () => {
|
|
68
|
+
const auth = makeAuth();
|
|
69
|
+
await auth.load();
|
|
70
|
+
await auth.set('anthropic', 'sk-first');
|
|
71
|
+
await auth.set('anthropic', 'sk-second', 'work');
|
|
72
|
+
expect(auth.get('anthropic')!.key).toBe('sk-first');
|
|
73
|
+
expect(auth.all()['anthropic']).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('setActive changes active key', async () => {
|
|
77
|
+
const auth = makeAuth();
|
|
78
|
+
await auth.load();
|
|
79
|
+
await auth.set('anthropic', 'sk-first');
|
|
80
|
+
await auth.set('anthropic', 'sk-second', 'work');
|
|
81
|
+
await auth.setActive('anthropic', 'work');
|
|
82
|
+
expect(auth.get('anthropic')!.key).toBe('sk-second');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('setActive with invalid label throws', async () => {
|
|
86
|
+
const auth = makeAuth();
|
|
87
|
+
await auth.load();
|
|
88
|
+
await auth.set('anthropic', 'sk-first');
|
|
89
|
+
await expect(auth.setActive('anthropic', 'nonexistent')).rejects.toThrow();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('remove by label removes one key', async () => {
|
|
93
|
+
const auth = makeAuth();
|
|
94
|
+
await auth.load();
|
|
95
|
+
await auth.set('anthropic', 'sk-first');
|
|
96
|
+
await auth.set('anthropic', 'sk-second', 'work');
|
|
97
|
+
await auth.remove('anthropic', 'default');
|
|
98
|
+
expect(auth.all()['anthropic']).toHaveLength(1);
|
|
99
|
+
expect(auth.all()['anthropic']![0]!.label).toBe('work');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('remove without label removes all keys', async () => {
|
|
103
|
+
const auth = makeAuth();
|
|
104
|
+
await auth.load();
|
|
105
|
+
await auth.set('anthropic', 'sk-first');
|
|
106
|
+
await auth.set('anthropic', 'sk-second', 'work');
|
|
107
|
+
await auth.remove('anthropic');
|
|
108
|
+
expect(auth.get('anthropic')).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('removing active key makes next key active', async () => {
|
|
112
|
+
const auth = makeAuth();
|
|
113
|
+
await auth.load();
|
|
114
|
+
await auth.set('anthropic', 'sk-first');
|
|
115
|
+
await auth.set('anthropic', 'sk-second', 'work');
|
|
116
|
+
await auth.remove('anthropic', 'default');
|
|
117
|
+
expect(auth.get('anthropic')!.key).toBe('sk-second');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('list returns all providers with keys', async () => {
|
|
121
|
+
const auth = makeAuth();
|
|
122
|
+
await auth.load();
|
|
123
|
+
await auth.set('anthropic', 'sk-a');
|
|
124
|
+
await auth.set('openai', 'sk-b');
|
|
125
|
+
const entries = await auth.list();
|
|
126
|
+
expect(entries).toHaveLength(2);
|
|
127
|
+
const ids = entries.map((e) => e.providerId).sort();
|
|
128
|
+
expect(ids).toEqual(['anthropic', 'openai']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('status returns key count and active label', async () => {
|
|
132
|
+
const auth = makeAuth();
|
|
133
|
+
await auth.load();
|
|
134
|
+
await auth.set('anthropic', 'sk-a');
|
|
135
|
+
await auth.set('anthropic', 'sk-b', 'work');
|
|
136
|
+
const status = await auth.status('anthropic');
|
|
137
|
+
expect(status.keyCount).toBe(2);
|
|
138
|
+
expect(status.activeLabel).toBe('default');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('status detects env var', async () => {
|
|
142
|
+
const auth = makeAuth();
|
|
143
|
+
await auth.load();
|
|
144
|
+
process.env.ANTHROPIC_API_KEY = 'sk-env';
|
|
145
|
+
try {
|
|
146
|
+
const status = await auth.status('anthropic');
|
|
147
|
+
expect(status.envVarDetected).toBe(true);
|
|
148
|
+
} finally {
|
|
149
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('persists to encrypted auth.json on disk', async () => {
|
|
154
|
+
const auth = makeAuth();
|
|
155
|
+
await auth.load();
|
|
156
|
+
await auth.set('anthropic', 'sk-secret');
|
|
157
|
+
const raw = await fsp.readFile(authFilePath(), 'utf-8');
|
|
158
|
+
expect(raw).not.toContain('sk-secret');
|
|
159
|
+
expect(raw.startsWith('enc:v1:')).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('loads from encrypted auth.json on restart', async () => {
|
|
163
|
+
const auth1 = makeAuth();
|
|
164
|
+
await auth1.load();
|
|
165
|
+
await auth1.set('anthropic', 'sk-secret');
|
|
166
|
+
const auth2 = makeAuth();
|
|
167
|
+
await auth2.load();
|
|
168
|
+
expect(auth2.get('anthropic')!.key).toBe('sk-secret');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isImageBlock,
|
|
4
|
+
isPdfBlock,
|
|
5
|
+
isToolUseBlock,
|
|
6
|
+
type ContentBlock,
|
|
7
|
+
} from '../src/types/blocks.js';
|
|
8
|
+
import type { LLMRequest, ToolChoice, StructuredOutputConfig } from '../src/types/provider.js';
|
|
9
|
+
|
|
10
|
+
describe('ContentBlock type guards', () => {
|
|
11
|
+
it('isImageBlock matches base64 image', () => {
|
|
12
|
+
const b: ContentBlock = {
|
|
13
|
+
type: 'image',
|
|
14
|
+
source: { type: 'base64', media_type: 'image/png', data: 'abc' },
|
|
15
|
+
};
|
|
16
|
+
expect(isImageBlock(b)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('isImageBlock matches url image', () => {
|
|
19
|
+
const b: ContentBlock = {
|
|
20
|
+
type: 'image',
|
|
21
|
+
source: { type: 'url', url: 'https://example.com/x.png' },
|
|
22
|
+
};
|
|
23
|
+
expect(isImageBlock(b)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it('isImageBlock rejects text', () => {
|
|
26
|
+
const b: ContentBlock = { type: 'text', text: 'hi' };
|
|
27
|
+
expect(isImageBlock(b)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('isPdfBlock matches pdf block', () => {
|
|
30
|
+
const b: ContentBlock = {
|
|
31
|
+
type: 'pdf',
|
|
32
|
+
source: { type: 'base64', media_type: 'application/pdf', data: 'abc' },
|
|
33
|
+
};
|
|
34
|
+
expect(isPdfBlock(b)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
it('isPdfBlock rejects image', () => {
|
|
37
|
+
const b: ContentBlock = {
|
|
38
|
+
type: 'image',
|
|
39
|
+
source: { type: 'base64', media_type: 'image/png', data: 'abc' },
|
|
40
|
+
};
|
|
41
|
+
expect(isPdfBlock(b)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
it('isToolUseBlock still works', () => {
|
|
44
|
+
const b: ContentBlock = { type: 'tool_use', id: '1', name: 'read', input: {} };
|
|
45
|
+
expect(isToolUseBlock(b)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('LLMRequest v0.2.2 fields', () => {
|
|
50
|
+
it('accepts tool_choice with type tool', () => {
|
|
51
|
+
const tc: ToolChoice = { type: 'tool', name: 'structured_output' };
|
|
52
|
+
expect(tc.type).toBe('tool');
|
|
53
|
+
expect(tc.name).toBe('structured_output');
|
|
54
|
+
});
|
|
55
|
+
it('accepts tool_choice with type auto', () => {
|
|
56
|
+
const tc: ToolChoice = { type: 'auto' };
|
|
57
|
+
expect(tc.type).toBe('auto');
|
|
58
|
+
expect(tc.name).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it('accepts StructuredOutputConfig', () => {
|
|
61
|
+
const cfg: StructuredOutputConfig = {
|
|
62
|
+
name: 'structured_output',
|
|
63
|
+
schema: { type: 'object', properties: { x: { type: 'string' } } },
|
|
64
|
+
};
|
|
65
|
+
expect(cfg.name).toBe('structured_output');
|
|
66
|
+
expect(cfg.schema).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
it('LLMRequest allows tool_choice and typed output_config', () => {
|
|
69
|
+
const req: LLMRequest = {
|
|
70
|
+
model: 'm',
|
|
71
|
+
system: [],
|
|
72
|
+
messages: [],
|
|
73
|
+
tool_choice: { type: 'tool', name: 's' },
|
|
74
|
+
output_config: { name: 's', schema: {} },
|
|
75
|
+
};
|
|
76
|
+
expect(req.tool_choice?.type).toBe('tool');
|
|
77
|
+
expect(req.output_config?.name).toBe('s');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DefaultCatalogParser } from '../src/infrastructure/catalog-parser.js';
|
|
3
|
+
import type { ModelInfo } from '../src/infrastructure/catalog-parser.js';
|
|
4
|
+
|
|
5
|
+
const SNAPSHOT = {
|
|
6
|
+
anthropic: {
|
|
7
|
+
npm: '@anthropic-ai/sdk',
|
|
8
|
+
models: {
|
|
9
|
+
'claude-sonnet-4-6': {
|
|
10
|
+
name: 'Claude Sonnet 4.6',
|
|
11
|
+
limit: { context: 200_000, output: 8192 },
|
|
12
|
+
cost: { input: 3, output: 15 },
|
|
13
|
+
tool_call: true,
|
|
14
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
15
|
+
},
|
|
16
|
+
'claude-haiku-4-5': {
|
|
17
|
+
name: 'Claude Haiku 4.5',
|
|
18
|
+
limit: { context: 200_000, output: 8192 },
|
|
19
|
+
cost: { input: 0.8, output: 4 },
|
|
20
|
+
tool_call: true,
|
|
21
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
22
|
+
},
|
|
23
|
+
'claude-opus-4-1': {
|
|
24
|
+
name: 'Claude Opus 4.1',
|
|
25
|
+
limit: { context: 200_000, output: 8192 },
|
|
26
|
+
cost: { input: 15, output: 75 },
|
|
27
|
+
tool_call: true,
|
|
28
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
29
|
+
},
|
|
30
|
+
'claude-no-tools': {
|
|
31
|
+
name: 'Claude No Tools',
|
|
32
|
+
limit: { context: 200_000, output: 8192 },
|
|
33
|
+
cost: { input: 0.1, output: 0.5 },
|
|
34
|
+
tool_call: false,
|
|
35
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
36
|
+
},
|
|
37
|
+
'claude-tiny-context': {
|
|
38
|
+
name: 'Claude Tiny Context',
|
|
39
|
+
limit: { context: 8_000, output: 8192 },
|
|
40
|
+
cost: { input: 0.1, output: 0.5 },
|
|
41
|
+
tool_call: true,
|
|
42
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function parseRawForTest(raw: unknown): ModelInfo[] {
|
|
49
|
+
const data = raw as Record<string, unknown>;
|
|
50
|
+
const models: ModelInfo[] = [];
|
|
51
|
+
for (const [providerId, provider] of Object.entries(data)) {
|
|
52
|
+
const p = provider as Record<string, unknown>;
|
|
53
|
+
const rawModels = p.models as Record<string, unknown> | undefined;
|
|
54
|
+
if (!rawModels) continue;
|
|
55
|
+
for (const [modelId, model] of Object.entries(rawModels)) {
|
|
56
|
+
const m = model as Record<string, unknown>;
|
|
57
|
+
const limit = m.limit as Record<string, number>;
|
|
58
|
+
const cost = m.cost as Record<string, number>;
|
|
59
|
+
const modalities = m.modalities as Record<string, string[]>;
|
|
60
|
+
models.push({
|
|
61
|
+
id: modelId,
|
|
62
|
+
name: (m.name as string) ?? modelId,
|
|
63
|
+
provider: providerId,
|
|
64
|
+
npm: p.npm as string | undefined,
|
|
65
|
+
reasoning: (m.reasoning as boolean) ?? false,
|
|
66
|
+
limit: { context: limit.context, output: limit.output },
|
|
67
|
+
cost: cost ? { input: cost.input, output: cost.output } : undefined,
|
|
68
|
+
tool_call: (m.tool_call as boolean) ?? false,
|
|
69
|
+
modalities: { input: modalities?.input ?? ['text'], output: modalities?.output ?? ['text'] },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return models;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeParserWithSnapshot(snapshot: unknown): DefaultCatalogParser {
|
|
77
|
+
const parser = new DefaultCatalogParser({ disableNetwork: true });
|
|
78
|
+
(parser as unknown as { cached: { models: ModelInfo[]; fetchedAt: number; source: string } }).cached = {
|
|
79
|
+
models: parseRawForTest(snapshot),
|
|
80
|
+
fetchedAt: Date.now(),
|
|
81
|
+
source: 'snapshot',
|
|
82
|
+
};
|
|
83
|
+
return parser;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('DefaultCatalogParser.getCheapestModel', () => {
|
|
87
|
+
it('returns the cheapest tool-capable model excluding the current one', () => {
|
|
88
|
+
const parser = makeParserWithSnapshot(SNAPSHOT);
|
|
89
|
+
const cheapest = parser.getCheapestModel('anthropic', 'claude-sonnet-4-6');
|
|
90
|
+
expect(cheapest).toBeDefined();
|
|
91
|
+
expect(cheapest!.id).toBe('claude-haiku-4-5');
|
|
92
|
+
expect(cheapest!.cost!.output).toBe(4);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns the cheapest remaining model when current is haiku', () => {
|
|
96
|
+
const parser = makeParserWithSnapshot(SNAPSHOT);
|
|
97
|
+
const cheapest = parser.getCheapestModel('anthropic', 'claude-haiku-4-5');
|
|
98
|
+
expect(cheapest).toBeDefined();
|
|
99
|
+
expect(cheapest!.id).toBe('claude-sonnet-4-6');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('excludes models without tool_call support', () => {
|
|
103
|
+
const parser = makeParserWithSnapshot(SNAPSHOT);
|
|
104
|
+
const cheapest = parser.getCheapestModel('anthropic', 'claude-opus-4-1');
|
|
105
|
+
expect(cheapest).toBeDefined();
|
|
106
|
+
expect(cheapest!.id).not.toBe('claude-no-tools');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('excludes models with context limit below 16000', () => {
|
|
110
|
+
const parser = makeParserWithSnapshot(SNAPSHOT);
|
|
111
|
+
const cheapest = parser.getCheapestModel('anthropic', 'claude-opus-4-1');
|
|
112
|
+
expect(cheapest).toBeDefined();
|
|
113
|
+
expect(cheapest!.id).not.toBe('claude-tiny-context');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns undefined when no candidates remain', () => {
|
|
117
|
+
const parser = makeParserWithSnapshot({
|
|
118
|
+
anthropic: {
|
|
119
|
+
npm: '@anthropic-ai/sdk',
|
|
120
|
+
models: {
|
|
121
|
+
'only-model': {
|
|
122
|
+
name: 'Only',
|
|
123
|
+
limit: { context: 200_000, output: 8192 },
|
|
124
|
+
cost: { input: 1, output: 1 },
|
|
125
|
+
tool_call: true,
|
|
126
|
+
modalities: { input: ['text'], output: ['text'] },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const cheapest = parser.getCheapestModel('anthropic', 'only-model');
|
|
132
|
+
expect(cheapest).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns undefined when provider has no models', () => {
|
|
136
|
+
const parser = makeParserWithSnapshot({ other: { models: {} } });
|
|
137
|
+
const cheapest = parser.getCheapestModel('anthropic', 'anything');
|
|
138
|
+
expect(cheapest).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns undefined when cached catalog is empty (not loaded)', () => {
|
|
142
|
+
const parser = new DefaultCatalogParser({ disableNetwork: true });
|
|
143
|
+
const cheapest = parser.getCheapestModel('anthropic', 'anything');
|
|
144
|
+
expect(cheapest).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
import { classifyFamily } from '../src/infrastructure/catalog-parser.js';
|
|
149
|
+
|
|
150
|
+
describe('classifyFamily', () => {
|
|
151
|
+
it('classifies @anthropic-ai/sdk as anthropic', () => {
|
|
152
|
+
expect(classifyFamily('@anthropic-ai/sdk')).toBe('anthropic');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('classifies openai as openai', () => {
|
|
156
|
+
expect(classifyFamily('openai')).toBe('openai');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('classifies @google/genai as google', () => {
|
|
160
|
+
expect(classifyFamily('@google/genai')).toBe('google');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('classifies openai-compatible packages as openai-compatible', () => {
|
|
164
|
+
expect(classifyFamily('some-openai-compatible')).toBe('openai-compatible');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns unsupported for undefined', () => {
|
|
168
|
+
expect(classifyFamily(undefined)).toBe('unsupported');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('returns unsupported for unknown packages', () => {
|
|
172
|
+
expect(classifyFamily('unknown-pkg')).toBe('unsupported');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
HybridCompactor,
|
|
4
|
+
MODE_CONFIGS,
|
|
5
|
+
ACTIVE_MODE,
|
|
6
|
+
FLOOR_PRESERVE_K,
|
|
7
|
+
repairToolUseAdjacency,
|
|
8
|
+
} from '../src/execution/compactor.js';
|
|
9
|
+
import type { ContentBlock, Message } from '../src/types/blocks.js';
|
|
10
|
+
|
|
11
|
+
function text(t: string): ContentBlock {
|
|
12
|
+
return { type: 'text', text: t };
|
|
13
|
+
}
|
|
14
|
+
function toolUse(id: string, name = 'read', input: unknown = {}): ContentBlock {
|
|
15
|
+
return { type: 'tool_use', id, name, input };
|
|
16
|
+
}
|
|
17
|
+
function toolResult(id: string, content: string): ContentBlock {
|
|
18
|
+
return { type: 'tool_result', tool_use_id: id, content };
|
|
19
|
+
}
|
|
20
|
+
function user(content: ContentBlock[] | string): Message {
|
|
21
|
+
return { role: 'user', content };
|
|
22
|
+
}
|
|
23
|
+
function assistant(content: ContentBlock[]): Message {
|
|
24
|
+
return { role: 'assistant', content };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function bigResult(id: string, tokens: number): ContentBlock {
|
|
28
|
+
return toolResult(id, 'x'.repeat(tokens * 4));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('MODE_CONFIGS', () => {
|
|
32
|
+
it('defines all four modes', () => {
|
|
33
|
+
expect(Object.keys(MODE_CONFIGS).sort()).toEqual(['archival', 'balanced', 'deep', 'frugal']);
|
|
34
|
+
});
|
|
35
|
+
it('ACTIVE_MODE is balanced', () => {
|
|
36
|
+
expect(ACTIVE_MODE).toBe('balanced');
|
|
37
|
+
});
|
|
38
|
+
it('every mode preserves at least FLOOR_PRESERVE_K', () => {
|
|
39
|
+
for (const cfg of Object.values(MODE_CONFIGS)) {
|
|
40
|
+
expect(cfg.preserveK).toBeGreaterThanOrEqual(FLOOR_PRESERVE_K);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('HybridCompactor', () => {
|
|
46
|
+
const compactor = new HybridCompactor();
|
|
47
|
+
|
|
48
|
+
it('returns messages unchanged when already small', async () => {
|
|
49
|
+
const msgs = [user('hi'), assistant([text('hello')])];
|
|
50
|
+
const r = await compactor.compact({ messages: msgs, mode: 'balanced', aggressive: false });
|
|
51
|
+
expect(r.messages).toHaveLength(2);
|
|
52
|
+
expect(r.after).toBeLessThanOrEqual(r.before);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('Phase 1 elides oversized tool_results in compactable region', async () => {
|
|
56
|
+
const big = bigResult('1', 3000); // > balanced.elideThreshold (2000)
|
|
57
|
+
const msgs: Message[] = [
|
|
58
|
+
user('do task'),
|
|
59
|
+
assistant([text('thinking'), toolUse('1')]),
|
|
60
|
+
user([big]),
|
|
61
|
+
assistant([text('done')]),
|
|
62
|
+
];
|
|
63
|
+
// pad enough recent turns to push the ancient pair into the compactable region
|
|
64
|
+
for (let i = 0; i < 11; i++) {
|
|
65
|
+
msgs.push(user([text(`q${i}`)]));
|
|
66
|
+
msgs.push(assistant([text(`a${i}`)]));
|
|
67
|
+
}
|
|
68
|
+
const r = await compactor.compact({ messages: msgs, mode: 'balanced', aggressive: false });
|
|
69
|
+
const elidedBlock = r.messages
|
|
70
|
+
.flatMap((m) => (typeof m.content === 'string' ? [] : m.content))
|
|
71
|
+
.find((b) => b.type === 'tool_result') as Extract<ContentBlock, { type: 'tool_result' }> | undefined;
|
|
72
|
+
expect(elidedBlock).toBeDefined();
|
|
73
|
+
expect((elidedBlock as { content: string }).content).toContain('[elided');
|
|
74
|
+
expect(r.after).toBeLessThan(r.before);
|
|
75
|
+
expect(r.digest).toContain('tool_results elided');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('preserves recent assistant turns verbatim (preserveK)', async () => {
|
|
79
|
+
const msgs: Message[] = [];
|
|
80
|
+
for (let i = 0; i < 12; i++) {
|
|
81
|
+
msgs.push(user(`q${i}`));
|
|
82
|
+
msgs.push(assistant([text(`a${i}`)]));
|
|
83
|
+
}
|
|
84
|
+
const r = await compactor.compact({ messages: msgs, mode: 'balanced', aggressive: false });
|
|
85
|
+
// last balanced.preserveK (10) assistant turns preserved verbatim
|
|
86
|
+
const lastBlocks = r.messages[r.messages.length - 1]!.content as ContentBlock[];
|
|
87
|
+
expect((lastBlocks[0] as { text: string }).text).toBe('a11');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('Phase 2 (aggressive) collapses ancient tool I/O but keeps text', async () => {
|
|
91
|
+
const msgs: Message[] = [user('do it')];
|
|
92
|
+
// ancient turn with tool I/O (before the preserveK boundary)
|
|
93
|
+
msgs.push(assistant([text('ancient decision'), toolUse('1'), toolUse('2')]));
|
|
94
|
+
msgs.push(user([toolResult('1', 'r1'), toolResult('2', 'r2')]));
|
|
95
|
+
// pad with enough recent assistant turns to exceed preserveK (balanced=10)
|
|
96
|
+
for (let i = 0; i < 11; i++) {
|
|
97
|
+
msgs.push(assistant([text(`recent ${i}`)]));
|
|
98
|
+
msgs.push(user([text(`ack ${i}`)]));
|
|
99
|
+
}
|
|
100
|
+
const r = await compactor.compact({ messages: msgs, mode: 'balanced', aggressive: true });
|
|
101
|
+
const ancient = r.messages.find((m) => m.role === 'assistant' && Array.isArray(m.content) && m.content.some((b) => b.type === 'text' && b.text === 'ancient decision'));
|
|
102
|
+
expect(ancient).toBeDefined();
|
|
103
|
+
const ancientContent = ancient!.content as ContentBlock[];
|
|
104
|
+
expect(ancientContent.some((b) => b.type === 'tool_use')).toBe(false);
|
|
105
|
+
expect(r.digest).toContain('ancient turns digested');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('preserves tool_use/tool_result pairing (repairToolUseAdjacency runs)', async () => {
|
|
109
|
+
const msgs: Message[] = [
|
|
110
|
+
user('go'),
|
|
111
|
+
assistant([text('ok'), toolUse('1')]),
|
|
112
|
+
user([toolResult('1', 'result data')]),
|
|
113
|
+
assistant([text('done')]),
|
|
114
|
+
];
|
|
115
|
+
const r = await compactor.compact({ messages: msgs, mode: 'balanced', aggressive: false });
|
|
116
|
+
// the tool_use '1' must still have its tool_result, or both dropped together
|
|
117
|
+
const uses = r.messages.flatMap((m) => (typeof m.content === 'string' ? [] : m.content)).filter((b) => b.type === 'tool_use');
|
|
118
|
+
const results = r.messages.flatMap((m) => (typeof m.content === 'string' ? [] : m.content)).filter((b) => b.type === 'tool_result');
|
|
119
|
+
const useIds = new Set(uses.map((b) => (b as { id: string }).id));
|
|
120
|
+
const resultIds = new Set(results.map((b) => (b as { tool_use_id: string }).tool_use_id));
|
|
121
|
+
for (const id of useIds) expect(resultIds.has(id)).toBe(true);
|
|
122
|
+
for (const id of resultIds) expect(useIds.has(id)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('never drops below FLOOR_PRESERVE_K assistant turns even when aggressive', async () => {
|
|
126
|
+
const msgs: Message[] = [];
|
|
127
|
+
for (let i = 0; i < 8; i++) {
|
|
128
|
+
msgs.push(user(`q${i}`));
|
|
129
|
+
msgs.push(assistant([text(`a${i}`)]));
|
|
130
|
+
}
|
|
131
|
+
const r = await compactor.compact({ messages: msgs, mode: 'frugal', aggressive: true });
|
|
132
|
+
const assistants = r.messages.filter((m) => m.role === 'assistant');
|
|
133
|
+
expect(assistants.length).toBeGreaterThanOrEqual(FLOOR_PRESERVE_K);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('repairToolUseAdjacency', () => {
|
|
138
|
+
it('drops orphan tool_use with no matching tool_result', () => {
|
|
139
|
+
const msgs: Message[] = [
|
|
140
|
+
assistant([text('x'), toolUse('orphan'), toolUse('paired')]),
|
|
141
|
+
user([toolResult('paired', 'r')]),
|
|
142
|
+
];
|
|
143
|
+
const r = repairToolUseAdjacency(msgs);
|
|
144
|
+
expect(r.removedToolUses).toBe(1);
|
|
145
|
+
const uses = r.changed.flatMap((m) => (typeof m.content === 'string' ? [] : m.content)).filter((b) => b.type === 'tool_use');
|
|
146
|
+
expect(uses).toHaveLength(1);
|
|
147
|
+
expect((uses[0] as { id: string }).id).toBe('paired');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('drops orphan tool_result with no matching tool_use', () => {
|
|
151
|
+
const msgs: Message[] = [
|
|
152
|
+
assistant([toolUse('1')]),
|
|
153
|
+
user([toolResult('1', 'r'), toolResult('orphan', 'x')]),
|
|
154
|
+
];
|
|
155
|
+
const r = repairToolUseAdjacency(msgs);
|
|
156
|
+
expect(r.removedToolResults).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('drops messages that become empty', () => {
|
|
160
|
+
const msgs: Message[] = [
|
|
161
|
+
assistant([toolUse('orphan')]), // will be dropped (no result), message becomes empty
|
|
162
|
+
user([text('keep')]),
|
|
163
|
+
];
|
|
164
|
+
const r = repairToolUseAdjacency(msgs);
|
|
165
|
+
expect(r.removedMessages).toBeGreaterThanOrEqual(1);
|
|
166
|
+
expect(r.changed).toHaveLength(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('preserves text/thinking/image blocks untouched', () => {
|
|
170
|
+
const msgs: Message[] = [
|
|
171
|
+
assistant([text('hello'), toolUse('1')]),
|
|
172
|
+
user([toolResult('1', 'r')]),
|
|
173
|
+
];
|
|
174
|
+
const r = repairToolUseAdjacency(msgs);
|
|
175
|
+
expect(r.removedToolUses).toBe(0);
|
|
176
|
+
expect(r.removedToolResults).toBe(0);
|
|
177
|
+
const first = r.changed[0]!.content as ContentBlock[];
|
|
178
|
+
expect((first[0] as { text: string }).text).toBe('hello');
|
|
179
|
+
});
|
|
180
|
+
});
|