foliko 2.0.30 → 2.0.32

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.
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const { ToolLoop } = require('../../src/agent/tool-loop.js');
7
+ const { atomicWriteFileSync } = require('../../src/common/atomic-write.js');
8
+ const { SessionManager } = require('../../src/session/session.js');
9
+
10
+ function mockClient(responses) {
11
+ return { chat: { completions: { create: async () => { const r = responses.shift(); if (r instanceof Error) throw r; return r; } } } };
12
+ }
13
+ const toolCall = (name) => ({ choices: [{ message: { content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name, arguments: '{}' } }] }, finish_reason: 'tool_calls' }], usage: {} });
14
+ const answer = (t) => ({ choices: [{ message: { content: t }, finish_reason: 'stop' }], usage: {} });
15
+
16
+ describe('P1#1 — maxSteps 用尽返回 stopReason', () => {
17
+ it('工具轮次耗尽 → stopReason=max_steps(不是成功)', async () => {
18
+ // 工具一直被调用,maxSteps=2 会在两轮后耗尽
19
+ const loop = new ToolLoop({ tools: { loopTool: { name: 'loopTool', execute: async () => 'again' } }, maxSteps: 2 });
20
+ const client = mockClient([toolCall('loopTool'), toolCall('loopTool'), toolCall('loopTool')]);
21
+ const messages = [{ role: 'user', content: 'go' }];
22
+ const result = await loop.generate({ messages, systemPrompt: 's', model: 'm', client });
23
+ expect(result.stopReason).toBe('max_steps');
24
+ });
25
+
26
+ it('正常给出答复 → stopReason=stop', async () => {
27
+ const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
28
+ const client = mockClient([answer('done')]);
29
+ const messages = [{ role: 'user', content: 'go' }];
30
+ const result = await loop.generate({ messages, systemPrompt: 's', model: 'm', client });
31
+ expect(result.stopReason).toBe('stop');
32
+ expect(result.text).toBe('done');
33
+ });
34
+ });
35
+
36
+ describe('P1#6 — atomicWriteFileSync 原子写', () => {
37
+ it('写入内容可完整读回', () => {
38
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'foliko-atomic-'));
39
+ const fp = path.join(dir, 'x.jsonl');
40
+ atomicWriteFileSync(fp, 'line1\nline2\n');
41
+ expect(fs.readFileSync(fp, 'utf8')).toBe('line1\nline2\n');
42
+ // 覆盖写
43
+ atomicWriteFileSync(fp, 'new\n');
44
+ expect(fs.readFileSync(fp, 'utf8')).toBe('new\n');
45
+ // 不残留 tmp 文件
46
+ expect(fs.readdirSync(dir).filter(n => n.includes('.tmp-'))).toHaveLength(0);
47
+ fs.rmSync(dir, { recursive: true, force: true });
48
+ });
49
+ });
50
+
51
+ describe('P1#3 — buildSessionContext 走活跃分支', () => {
52
+ it('切换分支后,非活跃分支的消息不进入上下文', () => {
53
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'foliko-branch-'));
54
+ const sm = new SessionManager(dir, dir, path.join(dir, 's.jsonl'), true);
55
+ // 主线:user A -> assistant A
56
+ sm.appendMessage({ role: 'user', content: '问题A' });
57
+ const branchPointId = sm.appendMessage({ role: 'assistant', content: '回答A' }); // 从这里分叉
58
+
59
+ // 继续主线:user B -> assistant B
60
+ sm.appendMessage({ role: 'user', content: '问题B-主线' });
61
+ sm.appendMessage({ role: 'assistant', content: '回答B-主线' });
62
+
63
+ // 从 branchPoint 开新分支(leafId 回到分叉点,再追加)
64
+ sm.branch(branchPointId);
65
+ sm.appendMessage({ role: 'user', content: '问题B-新分支' });
66
+ sm.appendMessage({ role: 'assistant', content: '回答B-新分支' });
67
+
68
+ const texts = sm.getMessages().map(m => (typeof m.content === 'string' ? m.content : '')).join('|');
69
+ // 活跃分支应包含新分支内容,不含主线的 B
70
+ expect(texts).toContain('问题B-新分支');
71
+ expect(texts).not.toContain('问题B-主线');
72
+ // 共同前缀仍在
73
+ expect(texts).toContain('问题A');
74
+ fs.rmSync(dir, { recursive: true, force: true });
75
+ });
76
+ });