@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,429 @@
|
|
|
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 {
|
|
6
|
+
DefaultSessionStore,
|
|
7
|
+
generateSessionId,
|
|
8
|
+
projectHash,
|
|
9
|
+
flowcodexHome,
|
|
10
|
+
sessionsDir,
|
|
11
|
+
reconstructMessages,
|
|
12
|
+
} from '../src/session/session-store.js';
|
|
13
|
+
import type { SessionEvent } from '../src/session/types.js';
|
|
14
|
+
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
let sessionDir: string;
|
|
17
|
+
|
|
18
|
+
describe('DefaultSessionStore', () => {
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-session-'));
|
|
21
|
+
sessionDir = path.join(tmpDir, 'sessions');
|
|
22
|
+
await fsp.mkdir(sessionDir, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('generates a valid session ID', () => {
|
|
29
|
+
const id = generateSessionId(new Date('2026-06-20T12:30:45Z'), 'claude-sonnet-4-6');
|
|
30
|
+
expect(id).toContain('2026-06-20');
|
|
31
|
+
expect(id).toContain('12-30-45Z');
|
|
32
|
+
expect(id).toContain('claude-sonnet-4-6');
|
|
33
|
+
expect(id.split('/')).toHaveLength(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('generates session ID without model', () => {
|
|
37
|
+
const id = generateSessionId(new Date('2026-06-20T12:30:45Z'));
|
|
38
|
+
expect(id).toContain('2026-06-20');
|
|
39
|
+
expect(id).not.toContain('claude');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('appends and reads events', async () => {
|
|
43
|
+
const store = new DefaultSessionStore({ dir: sessionDir, model: 'test-model' });
|
|
44
|
+
const events: SessionEvent[] = [
|
|
45
|
+
{
|
|
46
|
+
type: 'session_start',
|
|
47
|
+
ts: new Date().toISOString(),
|
|
48
|
+
id: store.sessionId,
|
|
49
|
+
model: 'test-model',
|
|
50
|
+
cwd: tmpDir,
|
|
51
|
+
},
|
|
52
|
+
{ type: 'user_input', ts: new Date().toISOString(), content: 'hello world' },
|
|
53
|
+
];
|
|
54
|
+
for (const ev of events) {
|
|
55
|
+
await store.append(ev);
|
|
56
|
+
}
|
|
57
|
+
const read = await store.readAll();
|
|
58
|
+
expect(read).toHaveLength(2);
|
|
59
|
+
expect(read[0]?.type).toBe('session_start');
|
|
60
|
+
expect(read[1]?.type).toBe('user_input');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reads empty for missing file', async () => {
|
|
64
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
65
|
+
const read = await store.readAll();
|
|
66
|
+
expect(read).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('close writes session_end event', async () => {
|
|
70
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
71
|
+
await store.append({
|
|
72
|
+
type: 'session_start',
|
|
73
|
+
ts: new Date().toISOString(),
|
|
74
|
+
id: store.sessionId,
|
|
75
|
+
model: 'm',
|
|
76
|
+
cwd: tmpDir,
|
|
77
|
+
});
|
|
78
|
+
await store.close('normal');
|
|
79
|
+
const read = await store.readAll();
|
|
80
|
+
expect(read).toHaveLength(2);
|
|
81
|
+
expect(read[1]?.type).toBe('session_end');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('close is idempotent', async () => {
|
|
85
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
86
|
+
await store.close('normal');
|
|
87
|
+
await store.close('aborted');
|
|
88
|
+
const read = await store.readAll();
|
|
89
|
+
expect(read).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not append after close', async () => {
|
|
93
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
94
|
+
await store.close('normal');
|
|
95
|
+
await store.append({ type: 'user_input', ts: new Date().toISOString(), content: 'late' });
|
|
96
|
+
const read = await store.readAll();
|
|
97
|
+
expect(read).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('scrubs secrets in user_input', async () => {
|
|
101
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
102
|
+
const secret = 'sk-ant-api03-' + 'a'.repeat(95);
|
|
103
|
+
await store.append({
|
|
104
|
+
type: 'user_input',
|
|
105
|
+
ts: new Date().toISOString(),
|
|
106
|
+
content: `key=${secret}`,
|
|
107
|
+
});
|
|
108
|
+
const read = await store.readAll();
|
|
109
|
+
const content = read[0] as Extract<SessionEvent, { type: 'user_input' }>;
|
|
110
|
+
expect(typeof content.content === 'string' ? content.content : '').not.toContain(secret);
|
|
111
|
+
expect(typeof content.content === 'string' ? content.content : '').toContain('[REDACTED');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('handles in_flight markers', async () => {
|
|
115
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
116
|
+
await store.append({
|
|
117
|
+
type: 'in_flight_start',
|
|
118
|
+
ts: new Date().toISOString(),
|
|
119
|
+
kind: 'tool_batch',
|
|
120
|
+
});
|
|
121
|
+
await store.append({ type: 'in_flight_end', ts: new Date().toISOString(), kind: 'tool_batch' });
|
|
122
|
+
const read = await store.readAll();
|
|
123
|
+
expect(read).toHaveLength(2);
|
|
124
|
+
expect(read[0]?.type).toBe('in_flight_start');
|
|
125
|
+
expect(read[1]?.type).toBe('in_flight_end');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('handles all event types', async () => {
|
|
129
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
130
|
+
const events: SessionEvent[] = [
|
|
131
|
+
{ type: 'session_start', ts: '2026-01-01T00:00:00Z', id: 'test', model: 'm', cwd: '/tmp' },
|
|
132
|
+
{ type: 'user_input', ts: '2026-01-01T00:00:01Z', content: 'hi' },
|
|
133
|
+
{ type: 'llm_request', ts: '2026-01-01T00:00:02Z', model: 'm', messageCount: 1 },
|
|
134
|
+
{
|
|
135
|
+
type: 'llm_response',
|
|
136
|
+
ts: '2026-01-01T00:00:03Z',
|
|
137
|
+
content: [{ type: 'text', text: 'hello' }],
|
|
138
|
+
stopReason: 'end_turn',
|
|
139
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'tool_use',
|
|
143
|
+
ts: '2026-01-01T00:00:04Z',
|
|
144
|
+
name: 'read',
|
|
145
|
+
id: 'tu1',
|
|
146
|
+
input: { path: 'test.ts' },
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
type: 'tool_result',
|
|
150
|
+
ts: '2026-01-01T00:00:05Z',
|
|
151
|
+
id: 'tu1',
|
|
152
|
+
content: 'file contents',
|
|
153
|
+
isError: false,
|
|
154
|
+
},
|
|
155
|
+
{ type: 'ctx_pct', ts: '2026-01-01T00:00:06Z', pct: 0.5 },
|
|
156
|
+
{ type: 'checkpoint', ts: '2026-01-01T00:00:07Z' },
|
|
157
|
+
{ type: 'error', ts: '2026-01-01T00:00:08Z', error: 'something went wrong' },
|
|
158
|
+
{ type: 'session_end', ts: '2026-01-01T00:00:09Z', reason: 'normal' },
|
|
159
|
+
];
|
|
160
|
+
for (const ev of events) {
|
|
161
|
+
await store.append(ev);
|
|
162
|
+
}
|
|
163
|
+
const read = await store.readAll();
|
|
164
|
+
expect(read).toHaveLength(events.length);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('skips corrupt lines on read', async () => {
|
|
168
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
169
|
+
await store.append({ type: 'user_input', ts: new Date().toISOString(), content: 'good' });
|
|
170
|
+
await fsp.appendFile(store.filePath, 'this is not valid JSON\n', 'utf8');
|
|
171
|
+
await store.append({ type: 'user_input', ts: new Date().toISOString(), content: 'good2' });
|
|
172
|
+
const read = await store.readAll();
|
|
173
|
+
expect(read).toHaveLength(2);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('path helpers', () => {
|
|
178
|
+
it('projectHash is deterministic and 12 chars', () => {
|
|
179
|
+
const h1 = projectHash('/home/user/project');
|
|
180
|
+
const h2 = projectHash('/home/user/project');
|
|
181
|
+
expect(h1).toBe(h2);
|
|
182
|
+
expect(h1).toHaveLength(12);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('projectHash differs for different paths', () => {
|
|
186
|
+
const h1 = projectHash('/home/user/project-a');
|
|
187
|
+
const h2 = projectHash('/home/user/project-b');
|
|
188
|
+
expect(h1).not.toBe(h2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('flowcodexHome respects FLOWCODEX_HOME', () => {
|
|
192
|
+
const prev = process.env.FLOWCODEX_HOME;
|
|
193
|
+
process.env.FLOWCODEX_HOME = '/custom/home';
|
|
194
|
+
expect(flowcodexHome()).toBe('/custom/home');
|
|
195
|
+
process.env.FLOWCODEX_HOME = prev;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('sessionsDir includes project hash', () => {
|
|
199
|
+
const dir = sessionsDir('/my/project');
|
|
200
|
+
expect(dir).toContain('projects');
|
|
201
|
+
expect(dir).toContain('sessions');
|
|
202
|
+
expect(dir).toContain(projectHash('/my/project'));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('writeSummary on close', () => {
|
|
207
|
+
let tmpDir: string;
|
|
208
|
+
let sessionDir: string;
|
|
209
|
+
|
|
210
|
+
beforeEach(async () => {
|
|
211
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-summary-'));
|
|
212
|
+
sessionDir = path.join(tmpDir, 'sessions');
|
|
213
|
+
await fsp.mkdir(sessionDir, { recursive: true });
|
|
214
|
+
});
|
|
215
|
+
afterEach(async () => {
|
|
216
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('writes summary.json on close', async () => {
|
|
220
|
+
const store = new DefaultSessionStore({ dir: sessionDir, model: 'test-model' });
|
|
221
|
+
store.setSummaryStats({
|
|
222
|
+
provider: 'anthropic',
|
|
223
|
+
totalTokens: 100,
|
|
224
|
+
totalCost: 0.5,
|
|
225
|
+
iterations: 3,
|
|
226
|
+
});
|
|
227
|
+
await store.append({
|
|
228
|
+
type: 'session_start',
|
|
229
|
+
ts: '2026-06-20T10:00:00Z',
|
|
230
|
+
id: store.sessionId,
|
|
231
|
+
model: 'test-model',
|
|
232
|
+
cwd: tmpDir,
|
|
233
|
+
});
|
|
234
|
+
await store.append({
|
|
235
|
+
type: 'user_input',
|
|
236
|
+
ts: '2026-06-20T10:00:01Z',
|
|
237
|
+
content: 'Hello world this is a test prompt',
|
|
238
|
+
});
|
|
239
|
+
await store.append({
|
|
240
|
+
type: 'tool_use',
|
|
241
|
+
ts: '2026-06-20T10:00:02Z',
|
|
242
|
+
name: 'read',
|
|
243
|
+
id: 'tu1',
|
|
244
|
+
input: { path: 'a.ts' },
|
|
245
|
+
});
|
|
246
|
+
await store.append({
|
|
247
|
+
type: 'tool_result',
|
|
248
|
+
ts: '2026-06-20T10:00:03Z',
|
|
249
|
+
id: 'tu1',
|
|
250
|
+
content: 'ok',
|
|
251
|
+
isError: false,
|
|
252
|
+
});
|
|
253
|
+
await store.close('normal');
|
|
254
|
+
|
|
255
|
+
const summaryPath = store.filePath.replace(/\.jsonl$/, '.summary.json');
|
|
256
|
+
const raw = await fsp.readFile(summaryPath, 'utf8');
|
|
257
|
+
const summary = JSON.parse(raw);
|
|
258
|
+
expect(summary.id).toBe(store.sessionId);
|
|
259
|
+
expect(summary.model).toBe('test-model');
|
|
260
|
+
expect(summary.provider).toBe('anthropic');
|
|
261
|
+
expect(summary.title).toContain('Hello world');
|
|
262
|
+
expect(summary.messageCount).toBe(1);
|
|
263
|
+
expect(summary.toolCallCount).toBe(1);
|
|
264
|
+
expect(summary.toolErrorCount).toBe(0);
|
|
265
|
+
expect(summary.totalTokens).toBe(100);
|
|
266
|
+
expect(summary.totalCost).toBe(0.5);
|
|
267
|
+
expect(summary.iterationCount).toBe(3);
|
|
268
|
+
expect(summary.outcome).toBe('completed');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('close with error reason sets outcome to error', async () => {
|
|
272
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
273
|
+
await store.append({
|
|
274
|
+
type: 'session_start',
|
|
275
|
+
ts: '2026-06-20T10:00:00Z',
|
|
276
|
+
id: store.sessionId,
|
|
277
|
+
model: 'm',
|
|
278
|
+
cwd: tmpDir,
|
|
279
|
+
});
|
|
280
|
+
await store.close('error');
|
|
281
|
+
const summaryPath = store.filePath.replace(/\.jsonl$/, '.summary.json');
|
|
282
|
+
const summary = JSON.parse(await fsp.readFile(summaryPath, 'utf8'));
|
|
283
|
+
expect(summary.outcome).toBe('error');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('truncates title to 50 chars', async () => {
|
|
287
|
+
const store = new DefaultSessionStore({ dir: sessionDir });
|
|
288
|
+
await store.append({
|
|
289
|
+
type: 'session_start',
|
|
290
|
+
ts: '2026-06-20T10:00:00Z',
|
|
291
|
+
id: store.sessionId,
|
|
292
|
+
model: 'm',
|
|
293
|
+
cwd: tmpDir,
|
|
294
|
+
});
|
|
295
|
+
const longText = 'A'.repeat(100);
|
|
296
|
+
await store.append({ type: 'user_input', ts: '2026-06-20T10:00:01Z', content: longText });
|
|
297
|
+
await store.close('normal');
|
|
298
|
+
const summaryPath = store.filePath.replace(/\.jsonl$/, '.summary.json');
|
|
299
|
+
const summary = JSON.parse(await fsp.readFile(summaryPath, 'utf8'));
|
|
300
|
+
expect(summary.title.length).toBe(50);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('listSessions', () => {
|
|
305
|
+
let tmpDir: string;
|
|
306
|
+
|
|
307
|
+
beforeEach(async () => {
|
|
308
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-list-'));
|
|
309
|
+
});
|
|
310
|
+
afterEach(async () => {
|
|
311
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('returns empty array when no sessions', async () => {
|
|
315
|
+
process.env.FLOWCODEX_HOME = tmpDir;
|
|
316
|
+
const summaries = await DefaultSessionStore.listSessions('/fake/project');
|
|
317
|
+
expect(summaries).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('lists sessions from summary.json files', async () => {
|
|
321
|
+
process.env.FLOWCODEX_HOME = tmpDir;
|
|
322
|
+
const store = new DefaultSessionStore({ dir: sessionsDir('/p'), model: 'm' });
|
|
323
|
+
await fsp.mkdir(path.dirname(store.filePath), { recursive: true });
|
|
324
|
+
await store.append({
|
|
325
|
+
type: 'session_start',
|
|
326
|
+
ts: '2026-06-20T10:00:00Z',
|
|
327
|
+
id: store.sessionId,
|
|
328
|
+
model: 'm',
|
|
329
|
+
cwd: '/p',
|
|
330
|
+
});
|
|
331
|
+
await store.append({ type: 'user_input', ts: '2026-06-20T10:00:01Z', content: 'test' });
|
|
332
|
+
await store.close('normal');
|
|
333
|
+
|
|
334
|
+
const summaries = await DefaultSessionStore.listSessions('/p');
|
|
335
|
+
expect(summaries.length).toBeGreaterThanOrEqual(1);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('deleteSession', () => {
|
|
340
|
+
let tmpDir: string;
|
|
341
|
+
|
|
342
|
+
beforeEach(async () => {
|
|
343
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-del-'));
|
|
344
|
+
process.env.FLOWCODEX_HOME = tmpDir;
|
|
345
|
+
});
|
|
346
|
+
afterEach(async () => {
|
|
347
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('deletes jsonl and summary.json', async () => {
|
|
351
|
+
const store = await DefaultSessionStore.create('/p', 'm');
|
|
352
|
+
await store.append({
|
|
353
|
+
type: 'session_start',
|
|
354
|
+
ts: '2026-06-20T10:00:00Z',
|
|
355
|
+
id: store.sessionId,
|
|
356
|
+
model: 'm',
|
|
357
|
+
cwd: '/p',
|
|
358
|
+
});
|
|
359
|
+
await store.close('normal');
|
|
360
|
+
|
|
361
|
+
const suffix = store.sessionId.slice(-4);
|
|
362
|
+
await DefaultSessionStore.deleteSession('/p', suffix);
|
|
363
|
+
|
|
364
|
+
const after = await DefaultSessionStore.listSessions('/p');
|
|
365
|
+
expect(after).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
describe('reconstructMessages', () => {
|
|
370
|
+
it('reconstructs user and assistant messages', () => {
|
|
371
|
+
const events: SessionEvent[] = [
|
|
372
|
+
{ type: 'user_input', ts: '2026-01-01T00:00:00Z', content: 'hello' },
|
|
373
|
+
{
|
|
374
|
+
type: 'llm_response',
|
|
375
|
+
ts: '2026-01-01T00:00:01Z',
|
|
376
|
+
content: [{ type: 'text', text: 'hi there' }],
|
|
377
|
+
stopReason: 'end_turn',
|
|
378
|
+
usage: { input: 1, output: 1 },
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
const msgs = reconstructMessages(events);
|
|
382
|
+
expect(msgs).toHaveLength(2);
|
|
383
|
+
expect(msgs[0]?.role).toBe('user');
|
|
384
|
+
expect(msgs[1]?.role).toBe('assistant');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('reconstructs tool_use and tool_result pairs', () => {
|
|
388
|
+
const events: SessionEvent[] = [
|
|
389
|
+
{ type: 'user_input', ts: '2026-01-01T00:00:00Z', content: 'run tool' },
|
|
390
|
+
{
|
|
391
|
+
type: 'tool_use',
|
|
392
|
+
ts: '2026-01-01T00:00:01Z',
|
|
393
|
+
name: 'read',
|
|
394
|
+
id: 'tu1',
|
|
395
|
+
input: { path: 'a.ts' },
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
type: 'tool_result',
|
|
399
|
+
ts: '2026-01-01T00:00:02Z',
|
|
400
|
+
id: 'tu1',
|
|
401
|
+
content: 'file content',
|
|
402
|
+
isError: false,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
type: 'llm_response',
|
|
406
|
+
ts: '2026-01-01T00:00:03Z',
|
|
407
|
+
content: [{ type: 'text', text: 'done' }],
|
|
408
|
+
stopReason: 'end_turn',
|
|
409
|
+
usage: { input: 1, output: 1 },
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
const msgs = reconstructMessages(events);
|
|
413
|
+
expect(msgs).toHaveLength(4);
|
|
414
|
+
expect(msgs[0]?.role).toBe('user');
|
|
415
|
+
expect(msgs[1]?.role).toBe('assistant');
|
|
416
|
+
expect(msgs[2]?.role).toBe('user');
|
|
417
|
+
expect(msgs[3]?.role).toBe('assistant');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('skips non-message events', () => {
|
|
421
|
+
const events: SessionEvent[] = [
|
|
422
|
+
{ type: 'session_start', ts: '2026-01-01T00:00:00Z', id: 's1', model: 'm', cwd: '/p' },
|
|
423
|
+
{ type: 'in_flight_start', ts: '2026-01-01T00:00:01Z', kind: 'tool_batch' },
|
|
424
|
+
{ type: 'session_end', ts: '2026-01-01T00:00:02Z', reason: 'normal' },
|
|
425
|
+
];
|
|
426
|
+
const msgs = reconstructMessages(events);
|
|
427
|
+
expect(msgs).toHaveLength(0);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { isPrivateIp } from '../src/utils/ssrf-guard.js';
|
|
3
|
+
|
|
4
|
+
describe('isPrivateIp', () => {
|
|
5
|
+
it('blocks 10.x.x.x', () => {
|
|
6
|
+
expect(isPrivateIp('10.0.0.1')).toBe(true);
|
|
7
|
+
expect(isPrivateIp('10.255.255.255')).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
it('blocks 172.16-31.x.x', () => {
|
|
10
|
+
expect(isPrivateIp('172.16.0.1')).toBe(true);
|
|
11
|
+
expect(isPrivateIp('172.31.255.255')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it('does not block 172.15 or 172.32', () => {
|
|
14
|
+
expect(isPrivateIp('172.15.0.1')).toBe(false);
|
|
15
|
+
expect(isPrivateIp('172.32.0.1')).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it('blocks 192.168.x.x', () => {
|
|
18
|
+
expect(isPrivateIp('192.168.1.1')).toBe(true);
|
|
19
|
+
expect(isPrivateIp('192.168.0.0')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('blocks 127.x.x.x loopback', () => {
|
|
22
|
+
expect(isPrivateIp('127.0.0.1')).toBe(true);
|
|
23
|
+
expect(isPrivateIp('127.1.2.3')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
it('blocks 169.254.x.x link-local/IMDS', () => {
|
|
26
|
+
expect(isPrivateIp('169.254.169.254')).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it('blocks 0.x.x.x unspecified', () => {
|
|
29
|
+
expect(isPrivateIp('0.0.0.0')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('allows public IPv4', () => {
|
|
32
|
+
expect(isPrivateIp('8.8.8.8')).toBe(false);
|
|
33
|
+
expect(isPrivateIp('1.1.1.1')).toBe(false);
|
|
34
|
+
expect(isPrivateIp('93.184.216.34')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
it('blocks IPv6 loopback ::1', () => {
|
|
37
|
+
expect(isPrivateIp('::1')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it('blocks IPv6 ULA fc/fd', () => {
|
|
40
|
+
expect(isPrivateIp('fc00::1')).toBe(true);
|
|
41
|
+
expect(isPrivateIp('fd00::1')).toBe(true);
|
|
42
|
+
expect(isPrivateIp('fd12:3456:789a::1')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('blocks IPv6 link-local fe80::/10', () => {
|
|
45
|
+
expect(isPrivateIp('fe80::1')).toBe(true);
|
|
46
|
+
expect(isPrivateIp('fe90::1')).toBe(true);
|
|
47
|
+
expect(isPrivateIp('febf::1')).toBe(true);
|
|
48
|
+
expect(isPrivateIp('fec0::1')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('allows public IPv6', () => {
|
|
51
|
+
expect(isPrivateIp('2001:4860:4860::8888')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('handles invalid IP strings', () => {
|
|
54
|
+
expect(isPrivateIp('not-an-ip')).toBe(false);
|
|
55
|
+
expect(isPrivateIp('')).toBe(false);
|
|
56
|
+
expect(isPrivateIp('999.999.999.999')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('safeFetch with allow-private', () => {
|
|
61
|
+
const prevAllow = process.env.FLOWCODEX_FETCH_ALLOW_PRIVATE;
|
|
62
|
+
const originalFetch = globalThis.fetch;
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
globalThis.fetch = originalFetch;
|
|
66
|
+
if (prevAllow !== undefined) {
|
|
67
|
+
process.env.FLOWCODEX_FETCH_ALLOW_PRIVATE = prevAllow;
|
|
68
|
+
} else {
|
|
69
|
+
delete process.env.FLOWCODEX_FETCH_ALLOW_PRIVATE;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('fetches when allow-private bypass is set', async () => {
|
|
74
|
+
process.env.FLOWCODEX_FETCH_ALLOW_PRIVATE = '1';
|
|
75
|
+
globalThis.fetch = (() =>
|
|
76
|
+
Promise.resolve(new Response('hello', { status: 200 }))) as typeof fetch;
|
|
77
|
+
const { safeFetch } = await import('../src/utils/ssrf-guard.js');
|
|
78
|
+
const res = await safeFetch('http://example.com/test');
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(await res.text()).toBe('hello');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('follows redirects under allow-private', async () => {
|
|
84
|
+
process.env.FLOWCODEX_FETCH_ALLOW_PRIVATE = '1';
|
|
85
|
+
let calls = 0;
|
|
86
|
+
globalThis.fetch = ((input: RequestInfo | URL) => {
|
|
87
|
+
calls++;
|
|
88
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
89
|
+
if (url.includes('/redirect')) {
|
|
90
|
+
return Promise.resolve(
|
|
91
|
+
new Response(null, { status: 302, headers: { location: 'http://example.com/final' } }),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return Promise.resolve(new Response('final', { status: 200 }));
|
|
95
|
+
}) as typeof fetch;
|
|
96
|
+
const { safeFetch } = await import('../src/utils/ssrf-guard.js');
|
|
97
|
+
const res = await safeFetch('http://example.com/redirect');
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(await res.text()).toBe('final');
|
|
100
|
+
expect(calls).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('throws WEBFETCH_FAILED on too many redirects', async () => {
|
|
104
|
+
process.env.FLOWCODEX_FETCH_ALLOW_PRIVATE = '1';
|
|
105
|
+
globalThis.fetch = (() =>
|
|
106
|
+
Promise.resolve(
|
|
107
|
+
new Response(null, { status: 302, headers: { location: 'http://example.com/loop' } }),
|
|
108
|
+
)) as typeof fetch;
|
|
109
|
+
const { safeFetch } = await import('../src/utils/ssrf-guard.js');
|
|
110
|
+
await expect(safeFetch('http://example.com/loop', { maxRedirects: 2 })).rejects.toThrow();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DefaultSystemPromptBuilder, IDENTITY_PROMPT } from '../src/agent/system-prompt-builder.js';
|
|
3
|
+
import type { Tool } from '../src/types/tool.js';
|
|
4
|
+
import type { TextBlock } from '../src/types/blocks.js';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
|
|
8
|
+
function makeTool(name: string, usageHint?: string): Tool {
|
|
9
|
+
return {
|
|
10
|
+
name,
|
|
11
|
+
description: usageHint ? '' : `description for ${name}`,
|
|
12
|
+
usageHint,
|
|
13
|
+
inputSchema: { type: 'object' },
|
|
14
|
+
permission: 'auto',
|
|
15
|
+
mutating: false,
|
|
16
|
+
async execute() {
|
|
17
|
+
return 'ok';
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tmpRoot = os.tmpdir();
|
|
23
|
+
|
|
24
|
+
function ctx(overrides: Partial<Parameters<DefaultSystemPromptBuilder['build']>[0]> = {}) {
|
|
25
|
+
return {
|
|
26
|
+
tools: [makeTool('read', 'Read a file.'), makeTool('bash', 'Run a shell command.')] as readonly Tool[],
|
|
27
|
+
model: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
28
|
+
projectRoot: tmpRoot,
|
|
29
|
+
cwd: tmpRoot,
|
|
30
|
+
tokenSavingMode: false,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('DefaultSystemPromptBuilder', () => {
|
|
36
|
+
it('builds exactly 3 layers when no AGENTS.md (identity, tools, env)', async () => {
|
|
37
|
+
const b = new DefaultSystemPromptBuilder();
|
|
38
|
+
const blocks = await b.build(ctx({ projectRoot: path.join(tmpRoot, 'no-such-agents-md-dir') }));
|
|
39
|
+
expect(blocks).toHaveLength(3);
|
|
40
|
+
expect(blocks.every((bl) => bl.type === 'text')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('layer 1 is the identity prompt', async () => {
|
|
44
|
+
const b = new DefaultSystemPromptBuilder();
|
|
45
|
+
const blocks = await b.build(ctx());
|
|
46
|
+
expect((blocks[0] as TextBlock).text).toBe(IDENTITY_PROMPT);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('layer 2 lists tools with usageHint ?? description', async () => {
|
|
50
|
+
const b = new DefaultSystemPromptBuilder();
|
|
51
|
+
const blocks = await b.build(ctx());
|
|
52
|
+
const toolsText = (blocks[1] as TextBlock).text;
|
|
53
|
+
expect(toolsText).toContain('- read:');
|
|
54
|
+
expect(toolsText).toContain('Read a file.');
|
|
55
|
+
expect(toolsText).toContain('- bash:');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('trims usageHint to 80 chars in normal mode', async () => {
|
|
59
|
+
const long = 'x'.repeat(120);
|
|
60
|
+
const b = new DefaultSystemPromptBuilder();
|
|
61
|
+
const blocks = await b.build(ctx({ tools: [makeTool('bigtool', long)] }));
|
|
62
|
+
const toolsText = (blocks[1] as TextBlock).text;
|
|
63
|
+
expect(toolsText).toContain('x'.repeat(80));
|
|
64
|
+
expect(toolsText).not.toContain('x'.repeat(81));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('trims usageHint to 60 chars in token-saving mode', async () => {
|
|
68
|
+
const long = 'y'.repeat(120);
|
|
69
|
+
const b = new DefaultSystemPromptBuilder();
|
|
70
|
+
const blocks = await b.build(ctx({ tools: [makeTool('bigtool', long)], tokenSavingMode: true }));
|
|
71
|
+
const toolsText = (blocks[1] as TextBlock).text;
|
|
72
|
+
expect(toolsText).toContain('y'.repeat(60));
|
|
73
|
+
expect(toolsText).not.toContain('y'.repeat(61));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('places cache_control (ephemeral, 5m) at end of stable prefix (layer 2)', async () => {
|
|
77
|
+
const b = new DefaultSystemPromptBuilder();
|
|
78
|
+
const blocks = await b.build(ctx());
|
|
79
|
+
expect((blocks[1] as TextBlock).cache_control).toEqual({ type: 'ephemeral', ttl: '5m' });
|
|
80
|
+
expect((blocks[0] as TextBlock).cache_control).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('env layer includes platform, node, model, cwd', async () => {
|
|
84
|
+
const b = new DefaultSystemPromptBuilder();
|
|
85
|
+
const blocks = await b.build(ctx());
|
|
86
|
+
const env = (blocks[2] as TextBlock).text;
|
|
87
|
+
expect(env).toContain('platform:');
|
|
88
|
+
expect(env).toContain('node:');
|
|
89
|
+
expect(env).toContain('model: anthropic/claude-sonnet-4-6');
|
|
90
|
+
expect(env).toContain('cwd:');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns cached blocks on identical signature (no rebuild)', async () => {
|
|
94
|
+
const b = new DefaultSystemPromptBuilder();
|
|
95
|
+
const c = ctx();
|
|
96
|
+
const first = await b.build(c);
|
|
97
|
+
const second = await b.build(c);
|
|
98
|
+
expect(second).toBe(first);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rebuilds when tool set changes', async () => {
|
|
102
|
+
const b = new DefaultSystemPromptBuilder();
|
|
103
|
+
const first = await b.build(ctx({ tools: [makeTool('a')] }));
|
|
104
|
+
const second = await b.build(ctx({ tools: [makeTool('b')] }));
|
|
105
|
+
expect(second).not.toBe(first);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws PROMPT_BUDGET_EXCEEDED when layers 1+2+3 exceed budget', async () => {
|
|
109
|
+
const b = new DefaultSystemPromptBuilder();
|
|
110
|
+
const huge: Tool[] = [];
|
|
111
|
+
for (let i = 0; i < 500; i++) {
|
|
112
|
+
huge.push(makeTool(`t${i}`, 'z'.repeat(200)));
|
|
113
|
+
}
|
|
114
|
+
await expect(b.build(ctx({ tools: huge }))).rejects.toThrow(/exceed budget/);
|
|
115
|
+
});
|
|
116
|
+
});
|