foliko 2.0.22 → 2.0.23

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,129 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // 测试 _injectReasoningContent:DeepSeek thinking mode 的关键修复
4
+ // 关键:reasoning 必须作为 {type: 'reasoning', text} part 放入 content 数组
5
+ // 不能直接 msg.reasoning_content = '...'(AI SDK 不认识这个字段,丢弃后报错)
6
+
7
+ const path = require('path');
8
+ const src = require('fs').readFileSync(
9
+ path.join(__dirname, '../../src/agent/chat.js'),
10
+ 'utf-8'
11
+ );
12
+
13
+ // 静态检查:方法已定义
14
+ describe('★ _injectReasoningContent 静态检查(关键修复)', () => {
15
+ it('★ chat.js 包含 _injectReasoningContent 方法', () => {
16
+ expect(src).toMatch(/_injectReasoningContent\s*\(/);
17
+ });
18
+
19
+ it('★ 不应再使用 msg.reasoning_content = 这种直接赋值(排除注释行)', () => {
20
+ // 关键:原始 bug 代码是 msg.reasoning_content = reasoningContent
21
+ // 修复后应该是 _injectReasoningContent(msg, ...)
22
+ // 允许的例外:delete msg.reasoning_content(清理旧字段);typeof 检查;=== 比较
23
+ // 逐行检查,跳过 // 注释行
24
+ const lines = src.split('\n');
25
+ const directAssignments = lines.filter(line => {
26
+ const trimmed = line.trim();
27
+ if (trimmed.startsWith('//')) return false; // 跳过注释
28
+ if (trimmed.startsWith('*')) return false; // 跳过 JSDoc
29
+ if (trimmed.startsWith('delete ')) return false; // 允许 delete
30
+ if (trimmed.startsWith('typeof ')) return false; // 允许 typeof 检查
31
+ // 只匹配 "msg.reasoning_content =" 后面不是 =(即 === 排除)
32
+ return /msg\.reasoning_content\s*=[^=]/.test(line);
33
+ });
34
+ expect(directAssignments).toEqual([]);
35
+ });
36
+
37
+ it('★ 修复版使用 ToolLoop 处理 reasoning(不再需要 injectReasoningContent)', () => {
38
+ // ToolLoop 内部自动处理 reasoning_content,不需要在 chat.js 里 inject
39
+ // _injectReasoningContent 仍保留用于旧 session 兼容
40
+ expect(src).toMatch(/_injectReasoningContent\s*\(/);
41
+ });
42
+ });
43
+
44
+ // 动态测试:实际验证 _injectReasoningContent 的行为
45
+ // 通过创建最小化的 AgentChatHandler 实例调用方法
46
+ describe('_injectReasoningContent 行为测试', () => {
47
+ // 动态 require chat.js 并取 AgentChatHandler 类
48
+ const { AgentChatHandler } = require('../../src/agent/chat.js');
49
+
50
+ function makeHandler() {
51
+ // 创建不调用构造函数的实例(只取方法)
52
+ return Object.create(AgentChatHandler.prototype);
53
+ }
54
+
55
+ it('★ 关键:content 是 string 时,转为 array 并加 reasoning part 在前', () => {
56
+ const handler = makeHandler();
57
+ const msg = { role: 'assistant', content: 'hello' };
58
+ handler._injectReasoningContent(msg, 'thinking text');
59
+ expect(Array.isArray(msg.content)).toBe(true);
60
+ expect(msg.content[0]).toEqual({ type: 'reasoning', text: 'thinking text' });
61
+ expect(msg.content[1]).toEqual({ type: 'text', text: 'hello' });
62
+ });
63
+
64
+ it('★ 关键:content 是 array 时,unshift reasoning part', () => {
65
+ const handler = makeHandler();
66
+ const msg = {
67
+ role: 'assistant',
68
+ content: [
69
+ { type: 'text', text: 'first' },
70
+ { type: 'tool-call', toolCallId: 'tc1', toolName: 'foo', input: {} },
71
+ ],
72
+ };
73
+ handler._injectReasoningContent(msg, 'reasoning text');
74
+ expect(msg.content[0]).toEqual({ type: 'reasoning', text: 'reasoning text' });
75
+ expect(msg.content[1]).toEqual({ type: 'text', text: 'first' });
76
+ expect(msg.content[2].type).toBe('tool-call');
77
+ });
78
+
79
+ it('★ 关键:已有 reasoning part 时合并(不重复)', () => {
80
+ const handler = makeHandler();
81
+ const msg = {
82
+ role: 'assistant',
83
+ content: [
84
+ { type: 'reasoning', text: 'OLD' },
85
+ { type: 'text', text: 'hello' },
86
+ ],
87
+ };
88
+ handler._injectReasoningContent(msg, 'NEW');
89
+ expect(msg.content).toHaveLength(2);
90
+ expect(msg.content[0]).toEqual({ type: 'reasoning', text: 'NEW' });
91
+ expect(msg.content[1]).toEqual({ type: 'text', text: 'hello' });
92
+ });
93
+
94
+ it('★ 关键:空 reasoningText 不修改消息', () => {
95
+ const handler = makeHandler();
96
+ const msg = { role: 'assistant', content: 'hello' };
97
+ handler._injectReasoningContent(msg, '');
98
+ expect(msg.content).toBe('hello'); // 未修改
99
+
100
+ handler._injectReasoningContent(msg, null);
101
+ expect(msg.content).toBe('hello'); // 未修改
102
+ });
103
+
104
+ it('★ content 是 undefined 时新建 array', () => {
105
+ const handler = makeHandler();
106
+ const msg = { role: 'assistant' };
107
+ handler._injectReasoningContent(msg, 'reasoning');
108
+ expect(msg.content).toEqual([{ type: 'reasoning', text: 'reasoning' }]);
109
+ });
110
+
111
+ it('★ 验证:ToolLoop 在 _buildApiMessages 中保留 reasoning(不丢失 tool-call 的配对)', () => {
112
+ // ToolLoop 的 _buildApiMessages 应该把 assistant(tool-call+reasoning) 转为 OpenAI 格式
113
+ // 不再依赖 AI SDK 的 convertToOpenAICompatibleMessages
114
+ const { ToolLoop } = require('../../src/agent/tool-loop.js');
115
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
116
+
117
+ const messages = [
118
+ { role: 'user', content: 'hi' },
119
+ { role: 'assistant', content: [{ type: 'text', text: 'I think' }, { type: 'tool-call', toolCallId: 'call_1', toolName: 'read', input: {} }] },
120
+ ];
121
+ const api = loop._buildApiMessages(messages, 'you are a bot');
122
+ // 第二个消息应转为 OpenAI 格式:text content + tool_calls
123
+ expect(api[1].role).toBe('user');
124
+ expect(api[2].role).toBe('assistant');
125
+ expect(api[2].tool_calls).toBeDefined();
126
+ expect(api[2].tool_calls[0].id).toBe('call_1');
127
+ expect(api[2].content).toBe('I think');
128
+ });
129
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ // 测试 _stripStaleToolCalls:处理"过时"的 tool_call 消息
4
+ // 场景:旧 session 文件(修复前保存)的 assistant(tool_call) 消息没有 reasoning part
5
+ // 截断/压缩后这些消息会被发给 DeepSeek,触发 "reasoning_content must be passed back"
6
+
7
+ const { AgentChatHandler } = require('../../src/agent/chat.js');
8
+
9
+ function makeHandler({ thinkingMode = true } = {}) {
10
+ const h = Object.create(AgentChatHandler.prototype);
11
+ h._thinkingMode = thinkingMode;
12
+ return h;
13
+ }
14
+
15
+ describe('_stripStaleToolCalls 处理"过时"消息', () => {
16
+ it('★ 关键:thinking 模式下,缺 reasoning 的 assistant 消息移除 tool_calls', () => {
17
+ const h = makeHandler({ thinkingMode: true });
18
+ const messages = [{
19
+ role: 'assistant',
20
+ content: [
21
+ { type: 'text', text: '我先看看文件' },
22
+ { type: 'tool-call', toolCallId: 'call_abc', toolName: 'read', input: {} },
23
+ ],
24
+ }];
25
+ h._stripStaleToolCalls(messages);
26
+ expect(messages[0].content).toHaveLength(1);
27
+ expect(messages[0].content[0].type).toBe('text');
28
+ });
29
+
30
+ it('★ 关键:有 reasoning 的消息保留 tool_calls', () => {
31
+ const h = makeHandler({ thinkingMode: true });
32
+ const messages = [{
33
+ role: 'assistant',
34
+ content: [
35
+ { type: 'reasoning', text: 'thinking...' },
36
+ { type: 'tool-call', toolCallId: 'call_abc', toolName: 'read', input: {} },
37
+ ],
38
+ }];
39
+ h._stripStaleToolCalls(messages);
40
+ expect(messages[0].content).toHaveLength(2); // 保留
41
+ expect(messages[0].content[1].type).toBe('tool-call');
42
+ });
43
+
44
+ it('★ 非 thinking 模式:不处理', () => {
45
+ const h = makeHandler({ thinkingMode: false });
46
+ const messages = [{
47
+ role: 'assistant',
48
+ content: [
49
+ { type: 'tool-call', toolCallId: 'call_abc', toolName: 'read', input: {} },
50
+ ],
51
+ }];
52
+ h._stripStaleToolCalls(messages);
53
+ // 保留 tool_call
54
+ expect(messages[0].content).toHaveLength(1);
55
+ });
56
+
57
+ it('★ 多个 tool_call 部分都被清理', () => {
58
+ const h = makeHandler({ thinkingMode: true });
59
+ const messages = [{
60
+ role: 'assistant',
61
+ content: [
62
+ { type: 'tool-call', toolCallId: 'c1', toolName: 'a', input: {} },
63
+ { type: 'text', text: '中间文字' },
64
+ { type: 'tool-call', toolCallId: 'c2', toolName: 'b', input: {} },
65
+ { type: 'tool-call', toolCallId: 'c3', toolName: 'c', input: {} },
66
+ ],
67
+ }];
68
+ const n = h._stripStaleToolCalls(messages);
69
+ expect(n).toBe(3);
70
+ expect(messages[0].content).toEqual([{ type: 'text', text: '中间文字' }]);
71
+ });
72
+
73
+ it('★ 处理 OpenAI 兼容格式的 msg.tool_calls(顶层字段)', () => {
74
+ const h = makeHandler({ thinkingMode: true });
75
+ const messages = [{
76
+ role: 'assistant',
77
+ content: [{ type: 'text', text: '我需要先读文件' }],
78
+ tool_calls: [
79
+ { id: 'call_1', type: 'function', function: { name: 'read', arguments: '{}' } },
80
+ { id: 'call_2', type: 'function', function: { name: 'write', arguments: '{}' } },
81
+ ],
82
+ }];
83
+ const n = h._stripStaleToolCalls(messages);
84
+ expect(n).toBe(2);
85
+ expect(messages[0].tool_calls).toBeUndefined();
86
+ });
87
+
88
+ it('★ 不影响非 assistant 消息', () => {
89
+ const h = makeHandler({ thinkingMode: true });
90
+ const messages = [
91
+ { role: 'user', content: 'hi' },
92
+ {
93
+ role: 'tool',
94
+ content: [
95
+ { type: 'tool-result', toolCallId: 'abc', toolName: 'x', output: { type: 'text', value: 'ok' } },
96
+ ],
97
+ },
98
+ ];
99
+ const n = h._stripStaleToolCalls(messages);
100
+ expect(n).toBe(0);
101
+ expect(messages[1].content).toHaveLength(1);
102
+ });
103
+
104
+ it('★ 模拟 _createPrepareStep 截断后调用', () => {
105
+ // 模拟实际场景:12 条消息截断到 5 条,其中包含"旧"assistant(tool_call) 消息
106
+ const messages = [
107
+ { role: 'user', content: '问题1' },
108
+ // 截断时这部分会被丢掉
109
+ { role: 'assistant', content: '旧回复1' },
110
+ { role: 'user', content: '问题2' },
111
+ { role: 'assistant', content: '旧回复2' },
112
+ { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'old1', toolName: 'x', output: { type: 'text', value: 'ok' } }] },
113
+ { role: 'user', content: '问题3' },
114
+ { role: 'assistant', content: '旧回复3' },
115
+ { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'old2', toolName: 'x', output: { type: 'text', value: 'ok' } }] },
116
+ // 截断后保留的
117
+ { role: 'user', content: '问题4' },
118
+ { role: 'assistant', content: [
119
+ { type: 'text', text: '我先看看' },
120
+ { type: 'tool-call', toolCallId: 'recent1', toolName: 'read', input: {} },
121
+ ] },
122
+ { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'recent1', toolName: 'read', output: { type: 'text', value: 'data' } }] },
123
+ { role: 'user', content: '问题5' },
124
+ ];
125
+ // 截断:保留最后 5 条 = [7, 8, 9, 10, 11]
126
+ const trimmed = messages.slice(-5);
127
+ messages.length = 0;
128
+ messages.push(...trimmed);
129
+
130
+ // 跑 _stripStaleToolCalls
131
+ const h = makeHandler({ thinkingMode: true });
132
+ const n = h._stripStaleToolCalls(messages);
133
+ expect(n).toBe(1);
134
+ // messages[2] 是 assistant(缺 reasoning 的 tool_call 被移除)
135
+ expect(messages[2].content).toEqual([{ type: 'text', text: '我先看看' }]);
136
+ // 其他消息不动
137
+ expect(messages).toHaveLength(5);
138
+ });
139
+
140
+ it('★ 返回值:清理的工具调用数量', () => {
141
+ const h = makeHandler({ thinkingMode: true });
142
+ const messages = [
143
+ {
144
+ role: 'assistant',
145
+ content: [
146
+ { type: 'tool-call', toolCallId: 'c1', toolName: 'a', input: {} },
147
+ { type: 'tool-call', toolCallId: 'c2', toolName: 'b', input: {} },
148
+ ],
149
+ },
150
+ ];
151
+ const n = h._stripStaleToolCalls(messages);
152
+ expect(n).toBe(2);
153
+ });
154
+ });
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+
3
+ const { ToolLoop } = require('../../src/agent/tool-loop.js');
4
+
5
+ describe('ToolLoop._buildApiMessages 格式转换', () => {
6
+ let loop;
7
+ beforeEach(() => {
8
+ loop = new ToolLoop({ tools: {}, maxSteps: 1 });
9
+ });
10
+
11
+ it('user string 消息正确转换', () => {
12
+ const api = loop._buildApiMessages([
13
+ { role: 'user', content: 'hello' },
14
+ ], 'system prompt');
15
+ expect(api).toEqual([
16
+ { role: 'system', content: 'system prompt' },
17
+ { role: 'user', content: 'hello' },
18
+ ]);
19
+ });
20
+
21
+ it('assistant 消息带 tool_calls 的正确转换', () => {
22
+ const api = loop._buildApiMessages([
23
+ { role: 'user', content: 'hi' },
24
+ {
25
+ role: 'assistant',
26
+ content: [
27
+ { type: 'text', text: 'I\'ll read the file' },
28
+ { type: 'tool-call', toolCallId: 'call_xyz', toolName: 'read', input: { path: '/a' } },
29
+ ],
30
+ },
31
+ ], 'you are a bot');
32
+ expect(api[0].role).toBe('system');
33
+ expect(api[1].role).toBe('user');
34
+ expect(api[2].role).toBe('assistant');
35
+ expect(api[2].content).toBe("I'll read the file");
36
+ expect(api[2].tool_calls).toHaveLength(1);
37
+ expect(api[2].tool_calls[0].id).toBe('call_xyz');
38
+ expect(api[2].tool_calls[0].function.name).toBe('read');
39
+ });
40
+
41
+ it('tool 消息正确转换', () => {
42
+ const api = loop._buildApiMessages([
43
+ { role: 'user', content: 'hi' },
44
+ {
45
+ role: 'assistant',
46
+ content: 'looks ok',
47
+ tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
48
+ },
49
+ {
50
+ role: 'tool',
51
+ content: [
52
+ { type: 'tool-result', toolCallId: 'c1', toolName: 'read', output: { type: 'json', value: { data: 'ok' } } },
53
+ ],
54
+ },
55
+ ], 'sys prompt');
56
+ expect(api).toHaveLength(4);
57
+ expect(api[3].role).toBe('tool');
58
+ expect(api[3].tool_call_id).toBe('c1');
59
+ expect(api[3].content).toBe('{"data":"ok"}');
60
+ });
61
+
62
+ it('tool 消息已经用 OpenAI 格式时保持', () => {
63
+ const api = loop._buildApiMessages([
64
+ { role: 'tool', tool_call_id: 'c1', content: 'done' },
65
+ ], 'system prompt');
66
+ // 已经符合 OpenAI 格式的直接保留
67
+ expect(api[0].role).toBe('system');
68
+ expect(api[1].role).toBe('tool');
69
+ expect(api[1].tool_call_id).toBe('c1');
70
+ });
71
+
72
+ it('systemPrompt 为空时不加 system 消息', () => {
73
+ const api = loop._buildApiMessages([
74
+ { role: 'user', content: 'hello' },
75
+ ], '');
76
+ expect(api).toHaveLength(1);
77
+ expect(api[0].role).toBe('user');
78
+ });
79
+
80
+ it('assistant content 为 string 时原样保留', () => {
81
+ const api = loop._buildApiMessages([
82
+ { role: 'assistant', content: 'just text, no tool calls' },
83
+ ], '');
84
+ expect(api[0].role).toBe('assistant');
85
+ expect(api[0].content).toBe('just text, no tool calls');
86
+ expect(api[0].tool_calls).toBeUndefined();
87
+ });
88
+
89
+ it('★ thinking mode + 旧消息无 reasoning → 自动补充 placeholder reasoning_content', () => {
90
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: true });
91
+ // 模拟旧 session 消息:assistant 带 tool_calls 但没有 reasoning_content
92
+ const api = loop._buildApiMessages([
93
+ { role: 'user', content: 'hi' },
94
+ {
95
+ role: 'assistant',
96
+ content: '',
97
+ tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
98
+ },
99
+ { role: 'tool', tool_call_id: 'c1', content: 'done' },
100
+ ], 'sys');
101
+ const assistant = api[2];
102
+ expect(assistant.reasoning_content).toBe('...');
103
+ expect(assistant.tool_calls).toHaveLength(1);
104
+ // tool 消息紧随其后 → 不会 orphan
105
+ expect(api[3].role).toBe('tool');
106
+ });
107
+
108
+ it('★ thinking mode + 新消息有 reasoning → 保留原始 reasoning_content', () => {
109
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: true });
110
+ const api = loop._buildApiMessages([
111
+ { role: 'user', content: 'hi' },
112
+ {
113
+ role: 'assistant',
114
+ content: '',
115
+ reasoning_content: 'I need to read the file first',
116
+ tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
117
+ },
118
+ ], 'sys');
119
+ expect(api[2].reasoning_content).toBe('I need to read the file first');
120
+ });
121
+
122
+ it('★ 非 thinking mode → 正常发送 (不添加 reasoning_content)', () => {
123
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1, thinkingMode: false });
124
+ const api = loop._buildApiMessages([
125
+ { role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }] },
126
+ ], 'sys');
127
+ expect(api[0].reasoning_content).toBeUndefined();
128
+ });
129
+
130
+ it('★ 关键:assistant content 为 string 且含 tool_calls(OpenAI 格式)时保留 tool_calls', () => {
131
+ const api = loop._buildApiMessages([
132
+ { role: 'user', content: 'hi' },
133
+ {
134
+ role: 'assistant',
135
+ content: '',
136
+ tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read', arguments: '{}' } }],
137
+ },
138
+ { role: 'tool', tool_call_id: 'c1', content: 'done' },
139
+ ], 'sys');
140
+ // assistant 消息应该同时有 content + tool_calls
141
+ const assistant = api[2];
142
+ expect(assistant.role).toBe('assistant');
143
+ expect(assistant.tool_calls).toBeDefined();
144
+ expect(assistant.tool_calls).toHaveLength(1);
145
+ expect(assistant.tool_calls[0].id).toBe('c1');
146
+ // tool 消息跟在后面,API 不会报 "must be a response to tool_calls"
147
+ const tool = api[3];
148
+ expect(tool.role).toBe('tool');
149
+ expect(tool.tool_call_id).toBe('c1');
150
+ });
151
+ });
152
+
153
+ describe('ToolLoop._buildApiTools', () => {
154
+ it('空 tools map 返回 undefined', () => {
155
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
156
+ expect(loop._buildApiTools({})).toBeUndefined();
157
+ });
158
+
159
+ it('有工具时返回 OpenAI function calling 格式', () => {
160
+ const loop = new ToolLoop({ maxSteps: 1 });
161
+ const tools = {
162
+ read: { name: 'read', description: '读取文件', parameters: { type: 'object', properties: { path: { type: 'string' } } } },
163
+ };
164
+ const apiTools = loop._buildApiTools(tools);
165
+ expect(apiTools).toHaveLength(1);
166
+ expect(apiTools[0].type).toBe('function');
167
+ expect(apiTools[0].function.name).toBe('read');
168
+ });
169
+ });
170
+
171
+ describe('ToolLoop._repairArgs', () => {
172
+ let loop;
173
+ beforeEach(() => {
174
+ loop = new ToolLoop({ tools: {}, maxSteps: 1, repairToolCall: null });
175
+ });
176
+
177
+ it('空字符串修复为 {}', () => {
178
+ const r = loop._repairArgs('test', '', new Error('parse fail'));
179
+ expect(r).toEqual({});
180
+ });
181
+
182
+ it('缺开头的 { 尝试补全', () => {
183
+ expect(() => loop._repairArgs('test', '"path":"/a"}', new Error('parse fail'))).toThrow();
184
+ });
185
+
186
+ it('不完整的 JSON(缺末尾})尝试补全', () => {
187
+ const r = loop._repairArgs('test', '{"path":"/a"', new Error('parse fail'));
188
+ expect(r).toEqual({ path: '/a' });
189
+ });
190
+ });
191
+
192
+ describe('ToolLoop 完整流程模拟(不调真实 API)', () => {
193
+ it('构造函数不抛错', () => {
194
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
195
+ expect(loop.maxSteps).toBe(1);
196
+ expect(typeof loop.generate).toBe('function');
197
+ expect(typeof loop.stream).toBe('function');
198
+ });
199
+
200
+ it('buildApiMessages 注入 system prompt', () => {
201
+ const loop = new ToolLoop({ tools: {}, maxSteps: 1 });
202
+ const api = loop._buildApiMessages(
203
+ [{ role: 'user', content: 'hi' }],
204
+ '你是一个助手'
205
+ );
206
+ expect(api[0]).toEqual({ role: 'system', content: '你是一个助手' });
207
+ });
208
+ });