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.
- package/.editorconfig +56 -56
- package/.lintstagedrc +7 -7
- package/.prettierignore +29 -29
- package/.prettierrc +11 -11
- package/Dockerfile +63 -63
- package/docs/plugin-dev-guide.md +850 -0
- package/install.ps1 +129 -129
- package/install.sh +121 -121
- package/package.json +1 -1
- package/plugins/core/workflow/context.js +941 -941
- package/plugins/core/workflow/engine.js +66 -66
- package/plugins/core/workflow/js-runner.js +318 -318
- package/plugins/core/workflow/json-runner.js +323 -323
- package/plugins/core/workflow/stages/choice.js +74 -74
- package/plugins/core/workflow/stages/each.js +123 -123
- package/plugins/core/workflow/stages/parallel.js +69 -69
- package/skills/find-skills/SKILL.md +133 -133
- package/src/agent/chat.js +31 -9
- package/src/agent/sub.js +17 -3
- package/src/agent/tool-loop.js +132 -37
- package/src/common/atomic-write.js +33 -0
- package/src/context/compressor.js +22 -18
- package/src/framework/framework.js +19 -1
- package/src/plugin/manager.js +12 -2
- package/src/session/session.js +9 -4
- package/src/storage/manager.js +3 -1
- package/tests/core/chat-tool.test.js +187 -187
- package/tests/core/latest-message-not-filtered.test.js +143 -0
- package/tests/core/p0-fixes.test.js +129 -0
- package/tests/core/p1-fixes.test.js +76 -0
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
|
|
3
|
-
// 测试 file-system 插件的 chat 工具修复
|
|
4
|
-
// 通过读源码 + 模拟 execute 来验证行为
|
|
5
|
-
|
|
6
|
-
const fs = require('fs');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
|
|
9
|
-
const FileSystemPlugin = require('../../plugins/io/file-system/index.js');
|
|
10
|
-
|
|
11
|
-
function makeFakeFw(opts = {}) {
|
|
12
|
-
const llmConfig = opts.llmConfig || { model: 'test-model', provider: 'test-provider' };
|
|
13
|
-
const captured = { agentConfig: null, chatArgs: null, registeredTools: [] };
|
|
14
|
-
const fw = {
|
|
15
|
-
getCwd: () => process.cwd(),
|
|
16
|
-
pluginManager: {
|
|
17
|
-
get: (name) => {
|
|
18
|
-
if (name === 'ai') return { getConfig: () => llmConfig };
|
|
19
|
-
return null;
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
// ★ Plugin base class 的 tool.register 会先 push 到 _registeredTools,
|
|
23
|
-
// 然后查 registry.has(),has() 返回 false 才调 registry.register()。
|
|
24
|
-
// 所以 mock 必须实现 has() 否则注册流程被中断。
|
|
25
|
-
registerTool: (toolDef) => {
|
|
26
|
-
captured.registeredTools.push(toolDef);
|
|
27
|
-
},
|
|
28
|
-
toolRegistry: {
|
|
29
|
-
has: () => false,
|
|
30
|
-
register: (toolDef) => {
|
|
31
|
-
captured.registeredTools.push(toolDef);
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
createSubAgent: (config) => {
|
|
35
|
-
captured.agentConfig = config;
|
|
36
|
-
return {
|
|
37
|
-
chat: async (msg, opts) => {
|
|
38
|
-
captured.chatArgs = { msg, opts };
|
|
39
|
-
return { success: true, message: 'mock reply', steps: 1 };
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
return { fw, captured };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function getChatTool(captured) {
|
|
48
|
-
return captured.registeredTools.find(t => t.name === 'chat');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe('file-system 插件的 chat 工具', () => {
|
|
52
|
-
it('★ 关键:name 唯一(用 Date.now + random 避免冲突)', async () => {
|
|
53
|
-
const { fw, captured } = makeFakeFw();
|
|
54
|
-
const p = new FileSystemPlugin();
|
|
55
|
-
p.install(fw);
|
|
56
|
-
p.start(fw);
|
|
57
|
-
// 直接调工具的 execute
|
|
58
|
-
// find chat tool in _registeredTools
|
|
59
|
-
const chatTool = getChatTool(captured);
|
|
60
|
-
expect(chatTool).toBeTruthy();
|
|
61
|
-
if (!chatTool) return;
|
|
62
|
-
|
|
63
|
-
const r1 = await chatTool.execute({ message: 'hi' }, fw);
|
|
64
|
-
const r2 = await chatTool.execute({ message: 'hi2' }, fw);
|
|
65
|
-
|
|
66
|
-
expect(captured.agentConfig.name).toMatch(/^file-system-chat-\d+/);
|
|
67
|
-
// 两次调用应产生不同名字
|
|
68
|
-
const name1 = r1.metadata?.agent;
|
|
69
|
-
const name2 = r2.metadata?.agent;
|
|
70
|
-
expect(name1).not.toBe(name2);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('★ 关键:sessionId 隔离(避免污染主对话)', async () => {
|
|
74
|
-
const { fw, captured } = makeFakeFw();
|
|
75
|
-
const p = new FileSystemPlugin();
|
|
76
|
-
p.install(fw);
|
|
77
|
-
p.start(fw);
|
|
78
|
-
const chatTool = getChatTool(captured);
|
|
79
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
80
|
-
|
|
81
|
-
// 1. 不传 sessionId → 自动生成
|
|
82
|
-
await chatTool.execute({ message: 'm1' }, fw);
|
|
83
|
-
expect(captured.chatArgs.opts.sessionId).toMatch(/^chat-\d+$/);
|
|
84
|
-
|
|
85
|
-
// 2. 传 sessionId → 使用传入的
|
|
86
|
-
await chatTool.execute({ message: 'm2', sessionId: 'my-session' }, fw);
|
|
87
|
-
expect(captured.chatArgs.opts.sessionId).toBe('my-session');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('★ 关键:跟随主 agent 的 LLM 配置', async () => {
|
|
91
|
-
const llmConfig = {
|
|
92
|
-
model: 'deepseek-chat',
|
|
93
|
-
provider: 'deepseek',
|
|
94
|
-
apiKey: 'sk-test',
|
|
95
|
-
baseURL: 'https://api.deepseek.com/v1',
|
|
96
|
-
};
|
|
97
|
-
const { fw, captured } = makeFakeFw({ llmConfig });
|
|
98
|
-
const p = new FileSystemPlugin();
|
|
99
|
-
p.install(fw);
|
|
100
|
-
p.start(fw);
|
|
101
|
-
const chatTool = getChatTool(captured);
|
|
102
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
103
|
-
|
|
104
|
-
await chatTool.execute({ message: 'hi' }, fw);
|
|
105
|
-
expect(captured.agentConfig.model).toBe('deepseek-chat');
|
|
106
|
-
expect(captured.agentConfig.provider).toBe('deepseek');
|
|
107
|
-
expect(captured.agentConfig.apiKey).toBe('sk-test');
|
|
108
|
-
expect(captured.agentConfig.baseURL).toBe('https://api.deepseek.com/v1');
|
|
109
|
-
expect(captured.agentConfig.hidden).toBe(true);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('★ 关键:data 只返回 message 文本(不返回整个对象)', async () => {
|
|
113
|
-
const { fw, captured } = makeFakeFw();
|
|
114
|
-
const p = new FileSystemPlugin();
|
|
115
|
-
p.install(fw);
|
|
116
|
-
p.start(fw);
|
|
117
|
-
const chatTool = getChatTool(captured);
|
|
118
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
119
|
-
|
|
120
|
-
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
121
|
-
expect(r.success).toBe(true);
|
|
122
|
-
expect(r.data).toBe('mock reply'); // 只是字符串,不是整个对象
|
|
123
|
-
expect(typeof r.data).toBe('string');
|
|
124
|
-
// metadata 含元信息
|
|
125
|
-
expect(r.metadata).toBeTruthy();
|
|
126
|
-
expect(r.metadata.steps).toBe(1);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('★ 关键:systemPrompt 默认值合理', async () => {
|
|
130
|
-
const { fw, captured } = makeFakeFw();
|
|
131
|
-
const p = new FileSystemPlugin();
|
|
132
|
-
p.install(fw);
|
|
133
|
-
p.start(fw);
|
|
134
|
-
const chatTool = getChatTool(captured);
|
|
135
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
136
|
-
|
|
137
|
-
// 不传 systemPrompt → 用默认
|
|
138
|
-
await chatTool.execute({ message: 'hi' }, fw);
|
|
139
|
-
expect(captured.agentConfig.systemPrompt).toContain('文件系统');
|
|
140
|
-
|
|
141
|
-
// 传 systemPrompt → 覆盖
|
|
142
|
-
await chatTool.execute({
|
|
143
|
-
message: 'hi',
|
|
144
|
-
systemPrompt: '你是一个翻译助手',
|
|
145
|
-
}, fw);
|
|
146
|
-
expect(captured.agentConfig.systemPrompt).toBe('你是一个翻译助手');
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('★ 关键:错误捕获(AI 抛错时不崩溃)', async () => {
|
|
150
|
-
const registeredTools = [];
|
|
151
|
-
const fw = {
|
|
152
|
-
getCwd: () => process.cwd(),
|
|
153
|
-
pluginManager: { get: () => null },
|
|
154
|
-
registerTool: (t) => registeredTools.push(t),
|
|
155
|
-
toolRegistry: { has: () => false, register: (t) => registeredTools.push(t) },
|
|
156
|
-
createSubAgent: () => ({
|
|
157
|
-
chat: async () => { throw new Error('AI 服务不可用'); },
|
|
158
|
-
}),
|
|
159
|
-
};
|
|
160
|
-
const p = new FileSystemPlugin();
|
|
161
|
-
p.install(fw);
|
|
162
|
-
p.start(fw);
|
|
163
|
-
const chatTool = registeredTools.find(t => t.name === 'chat');
|
|
164
|
-
if (!chatTool) throw new Error('no chat tool');
|
|
165
|
-
|
|
166
|
-
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
167
|
-
expect(r.success).toBe(false);
|
|
168
|
-
expect(r.error).toBe('AI 服务不可用');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('★ inputSchema 含 sessionId 字段', () => {
|
|
172
|
-
// 直接读源码验证
|
|
173
|
-
const src = fs.readFileSync(
|
|
174
|
-
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
175
|
-
'utf-8'
|
|
176
|
-
);
|
|
177
|
-
expect(src).toMatch(/sessionId:\s*z\.string\(\)\.optional\(\)/);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('★ 删除死代码:const ai = framework.getAI()', () => {
|
|
181
|
-
const src = fs.readFileSync(
|
|
182
|
-
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
183
|
-
'utf-8'
|
|
184
|
-
);
|
|
185
|
-
expect(src).not.toMatch(/const\s+ai\s*=\s*framework\.getAI/);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// 测试 file-system 插件的 chat 工具修复
|
|
4
|
+
// 通过读源码 + 模拟 execute 来验证行为
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const FileSystemPlugin = require('../../plugins/io/file-system/index.js');
|
|
10
|
+
|
|
11
|
+
function makeFakeFw(opts = {}) {
|
|
12
|
+
const llmConfig = opts.llmConfig || { model: 'test-model', provider: 'test-provider' };
|
|
13
|
+
const captured = { agentConfig: null, chatArgs: null, registeredTools: [] };
|
|
14
|
+
const fw = {
|
|
15
|
+
getCwd: () => process.cwd(),
|
|
16
|
+
pluginManager: {
|
|
17
|
+
get: (name) => {
|
|
18
|
+
if (name === 'ai') return { getConfig: () => llmConfig };
|
|
19
|
+
return null;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// ★ Plugin base class 的 tool.register 会先 push 到 _registeredTools,
|
|
23
|
+
// 然后查 registry.has(),has() 返回 false 才调 registry.register()。
|
|
24
|
+
// 所以 mock 必须实现 has() 否则注册流程被中断。
|
|
25
|
+
registerTool: (toolDef) => {
|
|
26
|
+
captured.registeredTools.push(toolDef);
|
|
27
|
+
},
|
|
28
|
+
toolRegistry: {
|
|
29
|
+
has: () => false,
|
|
30
|
+
register: (toolDef) => {
|
|
31
|
+
captured.registeredTools.push(toolDef);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
createSubAgent: (config) => {
|
|
35
|
+
captured.agentConfig = config;
|
|
36
|
+
return {
|
|
37
|
+
chat: async (msg, opts) => {
|
|
38
|
+
captured.chatArgs = { msg, opts };
|
|
39
|
+
return { success: true, message: 'mock reply', steps: 1 };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { fw, captured };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getChatTool(captured) {
|
|
48
|
+
return captured.registeredTools.find(t => t.name === 'chat');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('file-system 插件的 chat 工具', () => {
|
|
52
|
+
it('★ 关键:name 唯一(用 Date.now + random 避免冲突)', async () => {
|
|
53
|
+
const { fw, captured } = makeFakeFw();
|
|
54
|
+
const p = new FileSystemPlugin();
|
|
55
|
+
p.install(fw);
|
|
56
|
+
p.start(fw);
|
|
57
|
+
// 直接调工具的 execute
|
|
58
|
+
// find chat tool in _registeredTools
|
|
59
|
+
const chatTool = getChatTool(captured);
|
|
60
|
+
expect(chatTool).toBeTruthy();
|
|
61
|
+
if (!chatTool) return;
|
|
62
|
+
|
|
63
|
+
const r1 = await chatTool.execute({ message: 'hi' }, fw);
|
|
64
|
+
const r2 = await chatTool.execute({ message: 'hi2' }, fw);
|
|
65
|
+
|
|
66
|
+
expect(captured.agentConfig.name).toMatch(/^file-system-chat-\d+/);
|
|
67
|
+
// 两次调用应产生不同名字
|
|
68
|
+
const name1 = r1.metadata?.agent;
|
|
69
|
+
const name2 = r2.metadata?.agent;
|
|
70
|
+
expect(name1).not.toBe(name2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('★ 关键:sessionId 隔离(避免污染主对话)', async () => {
|
|
74
|
+
const { fw, captured } = makeFakeFw();
|
|
75
|
+
const p = new FileSystemPlugin();
|
|
76
|
+
p.install(fw);
|
|
77
|
+
p.start(fw);
|
|
78
|
+
const chatTool = getChatTool(captured);
|
|
79
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
80
|
+
|
|
81
|
+
// 1. 不传 sessionId → 自动生成
|
|
82
|
+
await chatTool.execute({ message: 'm1' }, fw);
|
|
83
|
+
expect(captured.chatArgs.opts.sessionId).toMatch(/^chat-\d+$/);
|
|
84
|
+
|
|
85
|
+
// 2. 传 sessionId → 使用传入的
|
|
86
|
+
await chatTool.execute({ message: 'm2', sessionId: 'my-session' }, fw);
|
|
87
|
+
expect(captured.chatArgs.opts.sessionId).toBe('my-session');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('★ 关键:跟随主 agent 的 LLM 配置', async () => {
|
|
91
|
+
const llmConfig = {
|
|
92
|
+
model: 'deepseek-chat',
|
|
93
|
+
provider: 'deepseek',
|
|
94
|
+
apiKey: 'sk-test',
|
|
95
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
96
|
+
};
|
|
97
|
+
const { fw, captured } = makeFakeFw({ llmConfig });
|
|
98
|
+
const p = new FileSystemPlugin();
|
|
99
|
+
p.install(fw);
|
|
100
|
+
p.start(fw);
|
|
101
|
+
const chatTool = getChatTool(captured);
|
|
102
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
103
|
+
|
|
104
|
+
await chatTool.execute({ message: 'hi' }, fw);
|
|
105
|
+
expect(captured.agentConfig.model).toBe('deepseek-chat');
|
|
106
|
+
expect(captured.agentConfig.provider).toBe('deepseek');
|
|
107
|
+
expect(captured.agentConfig.apiKey).toBe('sk-test');
|
|
108
|
+
expect(captured.agentConfig.baseURL).toBe('https://api.deepseek.com/v1');
|
|
109
|
+
expect(captured.agentConfig.hidden).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('★ 关键:data 只返回 message 文本(不返回整个对象)', async () => {
|
|
113
|
+
const { fw, captured } = makeFakeFw();
|
|
114
|
+
const p = new FileSystemPlugin();
|
|
115
|
+
p.install(fw);
|
|
116
|
+
p.start(fw);
|
|
117
|
+
const chatTool = getChatTool(captured);
|
|
118
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
119
|
+
|
|
120
|
+
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
121
|
+
expect(r.success).toBe(true);
|
|
122
|
+
expect(r.data).toBe('mock reply'); // 只是字符串,不是整个对象
|
|
123
|
+
expect(typeof r.data).toBe('string');
|
|
124
|
+
// metadata 含元信息
|
|
125
|
+
expect(r.metadata).toBeTruthy();
|
|
126
|
+
expect(r.metadata.steps).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('★ 关键:systemPrompt 默认值合理', async () => {
|
|
130
|
+
const { fw, captured } = makeFakeFw();
|
|
131
|
+
const p = new FileSystemPlugin();
|
|
132
|
+
p.install(fw);
|
|
133
|
+
p.start(fw);
|
|
134
|
+
const chatTool = getChatTool(captured);
|
|
135
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
136
|
+
|
|
137
|
+
// 不传 systemPrompt → 用默认
|
|
138
|
+
await chatTool.execute({ message: 'hi' }, fw);
|
|
139
|
+
expect(captured.agentConfig.systemPrompt).toContain('文件系统');
|
|
140
|
+
|
|
141
|
+
// 传 systemPrompt → 覆盖
|
|
142
|
+
await chatTool.execute({
|
|
143
|
+
message: 'hi',
|
|
144
|
+
systemPrompt: '你是一个翻译助手',
|
|
145
|
+
}, fw);
|
|
146
|
+
expect(captured.agentConfig.systemPrompt).toBe('你是一个翻译助手');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('★ 关键:错误捕获(AI 抛错时不崩溃)', async () => {
|
|
150
|
+
const registeredTools = [];
|
|
151
|
+
const fw = {
|
|
152
|
+
getCwd: () => process.cwd(),
|
|
153
|
+
pluginManager: { get: () => null },
|
|
154
|
+
registerTool: (t) => registeredTools.push(t),
|
|
155
|
+
toolRegistry: { has: () => false, register: (t) => registeredTools.push(t) },
|
|
156
|
+
createSubAgent: () => ({
|
|
157
|
+
chat: async () => { throw new Error('AI 服务不可用'); },
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
const p = new FileSystemPlugin();
|
|
161
|
+
p.install(fw);
|
|
162
|
+
p.start(fw);
|
|
163
|
+
const chatTool = registeredTools.find(t => t.name === 'chat');
|
|
164
|
+
if (!chatTool) throw new Error('no chat tool');
|
|
165
|
+
|
|
166
|
+
const r = await chatTool.execute({ message: 'hi' }, fw);
|
|
167
|
+
expect(r.success).toBe(false);
|
|
168
|
+
expect(r.error).toBe('AI 服务不可用');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('★ inputSchema 含 sessionId 字段', () => {
|
|
172
|
+
// 直接读源码验证
|
|
173
|
+
const src = fs.readFileSync(
|
|
174
|
+
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
175
|
+
'utf-8'
|
|
176
|
+
);
|
|
177
|
+
expect(src).toMatch(/sessionId:\s*z\.string\(\)\.optional\(\)/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('★ 删除死代码:const ai = framework.getAI()', () => {
|
|
181
|
+
const src = fs.readFileSync(
|
|
182
|
+
path.join(__dirname, '../../plugins/io/file-system/index.js'),
|
|
183
|
+
'utf-8'
|
|
184
|
+
);
|
|
185
|
+
expect(src).not.toMatch(/const\s+ai\s*=\s*framework\.getAI/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { ToolLoop } = require('../../src/agent/tool-loop.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 验证:"每次发送消息都是最新的,不会被过滤掉"
|
|
7
|
+
* 即:无论历史消息如何被 sanitize / 补 reasoning / 去重,
|
|
8
|
+
* 用户最新的一条消息永远作为最后一条被发送给 API,绝不丢失。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// 一个最小可捕获的 OpenAI 兼容客户端 mock
|
|
12
|
+
function mockClient(responses) {
|
|
13
|
+
const calls = [];
|
|
14
|
+
return {
|
|
15
|
+
calls,
|
|
16
|
+
chat: {
|
|
17
|
+
completions: {
|
|
18
|
+
create: async (params) => {
|
|
19
|
+
// 深拷贝快照,避免后续 loop 复用引用导致断言失真
|
|
20
|
+
calls.push(JSON.parse(JSON.stringify(params)));
|
|
21
|
+
const r = responses.shift();
|
|
22
|
+
if (r instanceof Error) throw r;
|
|
23
|
+
return r;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 非流式最终答复(无 tool_calls)
|
|
31
|
+
function finalAnswer(text) {
|
|
32
|
+
return {
|
|
33
|
+
choices: [{ message: { content: text }, finish_reason: 'stop' }],
|
|
34
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lastOf = (arr) => arr[arr.length - 1];
|
|
39
|
+
|
|
40
|
+
describe('最新消息一定被发送(_buildApiMessages 层)', () => {
|
|
41
|
+
it('纯文本历史 + 最新 user → 最新 user 永远在最后', () => {
|
|
42
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
43
|
+
const api = loop._buildApiMessages([
|
|
44
|
+
{ role: 'user', content: '第一条' },
|
|
45
|
+
{ role: 'assistant', content: '回复第一条' },
|
|
46
|
+
{ role: 'user', content: '最新消息-abc' },
|
|
47
|
+
], 'sys');
|
|
48
|
+
expect(lastOf(api)).toEqual({ role: 'user', content: '最新消息-abc' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('历史含 tool_calls / tool 结果 / reasoning 时,最新 user 仍在最后且内容不变', () => {
|
|
52
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: true });
|
|
53
|
+
const api = loop._buildApiMessages([
|
|
54
|
+
{ role: 'user', content: '早前的问题' },
|
|
55
|
+
{
|
|
56
|
+
role: 'assistant',
|
|
57
|
+
content: '',
|
|
58
|
+
reasoning_content: '先读文件',
|
|
59
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
60
|
+
},
|
|
61
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'file data' },
|
|
62
|
+
{ role: 'assistant', content: '读完了' },
|
|
63
|
+
{ role: 'user', content: '这是我最新发的-xyz' },
|
|
64
|
+
], 'sys');
|
|
65
|
+
const last = lastOf(api);
|
|
66
|
+
expect(last.role).toBe('user');
|
|
67
|
+
expect(last.content).toBe('这是我最新发的-xyz');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('最新 user 内容为空字符串也不会被丢弃', () => {
|
|
71
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
72
|
+
const api = loop._buildApiMessages([
|
|
73
|
+
{ role: 'assistant', content: '上一轮' },
|
|
74
|
+
{ role: 'user', content: '' },
|
|
75
|
+
], 'sys');
|
|
76
|
+
expect(lastOf(api)).toEqual({ role: 'user', content: '' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('最新 user 是数组内容(含 text part)时,文本被保留为最后一条', () => {
|
|
80
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
|
|
81
|
+
const api = loop._buildApiMessages([
|
|
82
|
+
{ role: 'user', content: '历史' },
|
|
83
|
+
{ role: 'assistant', content: 'ok' },
|
|
84
|
+
{ role: 'user', content: [{ type: 'text', text: '多模态最新-777' }] },
|
|
85
|
+
], 'sys');
|
|
86
|
+
const last = lastOf(api);
|
|
87
|
+
expect(last.role).toBe('user');
|
|
88
|
+
expect(last.content).toBe('多模态最新-777');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('最新消息一定被发送(generate 端到端,mock client)', () => {
|
|
93
|
+
it('实际发给 API 的 messages 末尾就是最新 user 输入', async () => {
|
|
94
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
95
|
+
const client = mockClient([finalAnswer('这是回复')]);
|
|
96
|
+
|
|
97
|
+
const messages = [
|
|
98
|
+
{ role: 'user', content: '旧消息1' },
|
|
99
|
+
{ role: 'assistant', content: '旧回复1' },
|
|
100
|
+
{ role: 'user', content: '★最新用户输入★' },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const result = await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
104
|
+
|
|
105
|
+
expect(result.text).toBe('这是回复');
|
|
106
|
+
expect(client.calls).toHaveLength(1);
|
|
107
|
+
const sent = lastOf(client.calls[0].messages);
|
|
108
|
+
expect(sent.role).toBe('user');
|
|
109
|
+
expect(sent.content).toBe('★最新用户输入★');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('触发 reasoning_content 自愈重试后,最新 user 依旧在最后(两次请求都保住)', async () => {
|
|
113
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
114
|
+
const err = new Error('400 The reasoning_content in the thinking mode must be passed back to the API.');
|
|
115
|
+
const client = mockClient([err, finalAnswer('重试成功的回复')]);
|
|
116
|
+
|
|
117
|
+
const messages = [
|
|
118
|
+
{ role: 'user', content: '历史问' },
|
|
119
|
+
{
|
|
120
|
+
role: 'assistant',
|
|
121
|
+
content: '',
|
|
122
|
+
tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
|
|
123
|
+
},
|
|
124
|
+
{ role: 'tool', tool_call_id: 'c1', content: 'done' },
|
|
125
|
+
{ role: 'user', content: '重试也要带上的最新消息-999' },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const result = await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
129
|
+
|
|
130
|
+
expect(result.text).toBe('重试成功的回复');
|
|
131
|
+
// 一次失败 + 一次自愈重试 = 2 次调用
|
|
132
|
+
expect(client.calls).toHaveLength(2);
|
|
133
|
+
for (const call of client.calls) {
|
|
134
|
+
const last = lastOf(call.messages);
|
|
135
|
+
expect(last.role).toBe('user');
|
|
136
|
+
expect(last.content).toBe('重试也要带上的最新消息-999');
|
|
137
|
+
}
|
|
138
|
+
// 自愈生效:重试请求里 assistant 已补上 reasoning_content
|
|
139
|
+
const retried = client.calls[1].messages;
|
|
140
|
+
const assistantMsg = retried.find((m) => m.role === 'assistant' && m.tool_calls);
|
|
141
|
+
expect(assistantMsg.reasoning_content).toBeTruthy();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { ToolLoop } = require('../../src/agent/tool-loop.js');
|
|
4
|
+
const { ContextCompressor } = require('../../src/context/compressor.js');
|
|
5
|
+
|
|
6
|
+
// 最小可捕获的 OpenAI 兼容客户端 mock
|
|
7
|
+
function mockClient(responses) {
|
|
8
|
+
const calls = [];
|
|
9
|
+
return {
|
|
10
|
+
calls,
|
|
11
|
+
chat: { completions: { create: async (p) => { calls.push(p); const r = responses.shift(); if (r instanceof Error) throw r; return r; } } },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const finalAnswer = (text, reasoning) => ({
|
|
15
|
+
choices: [{ message: { content: text, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: 'stop' }],
|
|
16
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
17
|
+
});
|
|
18
|
+
const toolThenAnswer = (toolName) => ({
|
|
19
|
+
choices: [{ message: { content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: toolName, arguments: '{}' } }] }, finish_reason: 'tool_calls' }],
|
|
20
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('P0#2 — 最终 assistant 回复被写进 messages(generate)', () => {
|
|
24
|
+
it('无工具时,最终回复作为 assistant 消息追加到 messages', async () => {
|
|
25
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 3 });
|
|
26
|
+
const client = mockClient([finalAnswer('这是最终答复', '我的思考')]);
|
|
27
|
+
const messages = [{ role: 'user', content: '问题' }];
|
|
28
|
+
|
|
29
|
+
const result = await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
30
|
+
|
|
31
|
+
expect(result.text).toBe('这是最终答复');
|
|
32
|
+
const last = messages[messages.length - 1];
|
|
33
|
+
expect(last.role).toBe('assistant');
|
|
34
|
+
expect(last.content).toBe('这是最终答复');
|
|
35
|
+
expect(last.reasoning_content).toBe('我的思考');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('工具轮 + 最终回复:messages 末尾是 assistant 答复(历史不丢答复)', async () => {
|
|
39
|
+
const loop = new ToolLoop({ tools: { read: { name: 'read', execute: async () => 'file data' } }, maxSteps: 3 });
|
|
40
|
+
const client = mockClient([toolThenAnswer('read'), finalAnswer('读完了,结果是X')]);
|
|
41
|
+
const messages = [{ role: 'user', content: '读文件' }];
|
|
42
|
+
|
|
43
|
+
await loop.generate({ messages, systemPrompt: 'sys', model: 'm', client });
|
|
44
|
+
|
|
45
|
+
const roles = messages.map(m => m.role);
|
|
46
|
+
// user -> assistant(tool_calls) -> tool -> assistant(final)
|
|
47
|
+
expect(roles[0]).toBe('user');
|
|
48
|
+
expect(roles[roles.length - 1]).toBe('assistant');
|
|
49
|
+
expect(messages[messages.length - 1].content).toBe('读完了,结果是X');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('P0#2/#3 — stream 追加最终回复 + thinking 只发增量', () => {
|
|
54
|
+
async function drain(gen) {
|
|
55
|
+
const events = [];
|
|
56
|
+
let ret;
|
|
57
|
+
while (true) {
|
|
58
|
+
const { value, done } = await gen.next();
|
|
59
|
+
if (done) { ret = value; break; }
|
|
60
|
+
events.push(value);
|
|
61
|
+
}
|
|
62
|
+
return { events, ret };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 流式 chunk 构造
|
|
66
|
+
const chunk = (delta, finish = null) => ({ choices: [{ delta, finish_reason: finish }] });
|
|
67
|
+
|
|
68
|
+
it('thinking 事件是增量而非累积(不重复)', async () => {
|
|
69
|
+
const loop = new ToolLoop({ tools: {}, maxSteps: 2, thinkingMode: true });
|
|
70
|
+
async function* streamGen() {
|
|
71
|
+
yield chunk({ reasoning_content: '我先' });
|
|
72
|
+
yield chunk({ reasoning_content: '想一下' });
|
|
73
|
+
yield chunk({ content: '答复' });
|
|
74
|
+
yield chunk({}, 'stop');
|
|
75
|
+
yield { choices: [], usage: { prompt_tokens: 1, completion_tokens: 1 } };
|
|
76
|
+
}
|
|
77
|
+
const client = { chat: { completions: { create: async () => streamGen() } } };
|
|
78
|
+
const messages = [{ role: 'user', content: 'hi' }];
|
|
79
|
+
|
|
80
|
+
const { events } = await drain(loop.stream({ messages, systemPrompt: 'sys', model: 'm', client }));
|
|
81
|
+
const thinking = events.filter(e => e.type === 'thinking').map(e => e.text);
|
|
82
|
+
// 增量应为 ['我先','想一下'],而不是 ['我先','我先想一下']
|
|
83
|
+
expect(thinking).toEqual(['我先', '想一下']);
|
|
84
|
+
// 且最终 assistant 回复被追加,reasoning 完整保存
|
|
85
|
+
const last = messages[messages.length - 1];
|
|
86
|
+
expect(last.role).toBe('assistant');
|
|
87
|
+
expect(last.content).toBe('答复');
|
|
88
|
+
expect(last.reasoning_content).toBe('我先想一下');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('P0#4 — 压缩器按 sessionId 隔离在途 promise', () => {
|
|
93
|
+
it('不同 session 并发压缩各自独立执行(不共享 in-flight)', async () => {
|
|
94
|
+
const comp = new ContextCompressor({ agent: null, framework: null, model: 'deepseek-chat', keepRecentMessages: 1, enableSmartCompress: false });
|
|
95
|
+
let calls = 0;
|
|
96
|
+
const orig = comp._doCompress.bind(comp);
|
|
97
|
+
comp._doCompress = async (...a) => { calls++; return orig(...a); };
|
|
98
|
+
const mkMsgs = (tag) => [
|
|
99
|
+
{ role: 'user', content: `${tag}-1` },
|
|
100
|
+
{ role: 'assistant', content: `${tag}-2` },
|
|
101
|
+
{ role: 'user', content: `${tag}-3` },
|
|
102
|
+
];
|
|
103
|
+
const storeA = { messages: mkMsgs('A'), save: async () => {} };
|
|
104
|
+
const storeB = { messages: mkMsgs('B'), save: async () => {} };
|
|
105
|
+
|
|
106
|
+
await Promise.all([
|
|
107
|
+
comp.compress('sessA', storeA.messages, storeA),
|
|
108
|
+
comp.compress('sessB', storeB.messages, storeB),
|
|
109
|
+
]);
|
|
110
|
+
// 两个 session 各自跑一次压缩(旧实现里 B 会拿到 A 的 promise、根本不压缩自己)
|
|
111
|
+
expect(calls).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('同一 session 并发调用只执行一次压缩(复用 in-flight)', async () => {
|
|
115
|
+
const comp = new ContextCompressor({ agent: null, framework: null, model: 'deepseek-chat', keepRecentMessages: 1, enableSmartCompress: false });
|
|
116
|
+
let calls = 0;
|
|
117
|
+
const orig = comp._doCompress.bind(comp);
|
|
118
|
+
comp._doCompress = async (...a) => { calls++; return orig(...a); };
|
|
119
|
+
const msgs = [
|
|
120
|
+
{ role: 'user', content: 'x1' }, { role: 'assistant', content: 'x2' }, { role: 'user', content: 'x3' },
|
|
121
|
+
];
|
|
122
|
+
const store = { messages: msgs, save: async () => {} };
|
|
123
|
+
await Promise.all([
|
|
124
|
+
comp.compress('same', msgs, store),
|
|
125
|
+
comp.compress('same', msgs, store),
|
|
126
|
+
]);
|
|
127
|
+
expect(calls).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
});
|