Files
metona-ai-desktop/electron/harness/adapters/__tests__/openai-format-orphan.test.ts
T
thzxx a7090214b1
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m20s
CI / 产物编译验证 (push) Successful in 10m5s
fix: v0.6.2 修复工具调用不稳定与会话停止 — 纯 tool_calls 轮丢失 assistant 消息导致 API 400
【根因(main.log 实证)】
19:04 / 19:05 / 19:06 三次会话终止均为同一报错:
  DeepSeek 400 "Messages with role 'tool' must be a response to a preceding
  message with 'tool_calls'"

缺陷链:engine 主循环仅在 step.thought 存在(该轮有文本或思考内容)时才
将 assistant 消息加入请求历史。当模型发起纯工具调用(零文本零思考 —
DeepSeek 高频行为)时:
  - assistant(tool_calls) 消息不进 messages
  - 但 tool 结果消息照常 push
  → 下一轮请求出现孤立 tool 消息 → 协议 400(不可重试)→ 会话 ERROR 终止
"不稳定" = 模型每轮是否附带文本是概率性行为:带文本正常,纯调用必崩。
DB 持久化侧同源缺陷(if (!step.thought) continue)导致这些步骤的
assistant 与 tool 结果全部不落库 — 重启后工具上下文丢失,模型重复调用。

【修复】
- engine.ts: 有 toolCalls 的轮次必 push assistant(content=null,C-6 规范)
- agent.ts: 持久化条件同步修复(无 thought 但有 toolCalls 的步骤落库)
- 回归测试: 纯 tool_calls 轮后第二次请求中 tool 消息前必须是带
  tool_calls 的 assistant(请求契约断言,engine-toolchain.test.ts)

【纵深防御 — 孤立 tool 消息过滤】
- openai-format.ts(DeepSeek/Agnes/MiMo/OpenAI 四家共享): 构建请求时
  按 tool_call_id 配对过滤孤立 tool 消息(任何来源的历史污染不再 400 死锁)
- anthropic.adapter.ts: tool_use/tool_result 同策略配对过滤
- 单测 ×6: 正常配对保留 / 孤立丢弃 / id 不匹配丢弃 / 多轮配对 /
  includeImages 原位转换 / 非 vision 静默丢弃

【多模态索引对齐收敛】
4 家 adapter 的 images 处理循环原按未过滤的 nonSystemMsgs[i-1] 对齐索引,
孤立 tool 过滤引入后会错位 — 统一收进 buildOpenAICompatibleMessages
(includeImages 参数,基于 sanitized 序列原位转换),4 家 adapter 删除
各自的索引对齐循环(DeepSeek vision 判断 / OpenAI 推理模型拒绝保留在 adapter)。

【终止原因可见化】
MAX_ITERATIONS / TIMEOUT 终止此前无任何提示(用户感知"会话直接停止")—
前端 DONE 事件非 completed 终止原因显示为 system 消息。

【v0.6.1 回归缓解】
web_fetch timeoutMs 120s → 240s:浏览器回退串行化后并发 3 个排队最坏
~127.5s,旧值让排队末位抓取被工具超时杀掉(表现为抓取不稳定)。

【验证】
lint 0/0;typecheck 双工程 0 错误;test:electron 259/259(+7);
electron-vite build 成功
2026-08-22 19:34:16 +08:00

226 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* openai-format 孤立 tool 消息过滤测试(v0.6.2 会话停止根因的纵深防御)
*
* 背景:OpenAI/DeepSeek 协议要求 role='tool' 消息必须紧跟带 tool_calls 的
* assistant 消息,违反直接 400 且不可重试。engine 侧已保证配对,此处验证
* 共享构建函数对任何来源历史污染的兜底过滤。
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { buildOpenAICompatibleMessages } from '../shared/openai-format';
import type { MetonaRequest } from '../../types';
const systemPrompt = { roleDefinition: 'r', outputConstraints: 'o', safetyGuidelines: 's' };
function makeRequest(messages: MetonaRequest['messages']): MetonaRequest {
return {
meta: {
sessionId: 's',
iteration: 1,
requestId: 'r',
timestamp: Date.now(),
agentVersion: 't',
},
systemPrompt,
messages,
params: { stream: false },
};
}
describe('buildOpenAICompatibleMessages — 孤立 tool 过滤', () => {
it('正常配对序列完整保留(assistant(tool_calls) → tool', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_1', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_1',
toolName: 'read_file',
result: 'data',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
// system + user + assistant + tool
expect(out).toHaveLength(4);
expect(out[2].role).toBe('assistant');
expect(out[2].tool_calls).toHaveLength(1);
expect(out[3].role).toBe('tool');
expect(out[3].tool_call_id).toBe('tc_1');
});
it('孤立 tool 消息(前面无 assistant tool_calls)被丢弃,不产生 400 序列', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_orphan',
toolName: 'read_file',
result: 'x',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
// system + user(孤立 tool 被过滤)
expect(out).toHaveLength(2);
expect(out.every((m) => m.role !== 'tool')).toBe(true);
});
it('tool_call_id 不匹配最近 assistant 的 tool 消息也被过滤', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_a', name: 'read_file', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
// id 不匹配 tc_a
toolResult: {
toolCallId: 'tc_other',
toolName: 'read_file',
result: 'x',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
expect(out).toHaveLength(3);
expect(out.every((m) => m.role !== 'tool')).toBe(true);
});
it('多轮工具配对全部保留', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{ role: 'user', content: 'hi', timestamp: Date.now() },
{
role: 'assistant',
content: null,
toolCalls: [
{ id: 'tc_1', name: 'a', args: {}, iteration: 1, timestamp: Date.now() },
{ id: 'tc_2', name: 'b', args: {}, iteration: 1, timestamp: Date.now() },
],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_2',
toolName: 'b',
result: 'r2',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_1',
toolName: 'a',
result: 'r1',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
{
role: 'assistant',
content: null,
toolCalls: [{ id: 'tc_3', name: 'c', args: {}, iteration: 2, timestamp: Date.now() }],
timestamp: Date.now(),
},
{
role: 'tool',
content: null,
toolResult: {
toolCallId: 'tc_3',
toolName: 'c',
result: 'r3',
success: true,
durationMs: 1,
timestamp: Date.now(),
},
timestamp: Date.now(),
},
]),
);
expect(out).toHaveLength(7);
expect(out.filter((m) => m.role === 'tool')).toHaveLength(3);
});
it('includeImages=true 时 images 转 content parts(原位转换,索引与过滤后序列对齐)', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{
role: 'user',
content: 'look',
images: [{ url: 'data:image/png;base64,xxx' }],
timestamp: Date.now(),
},
]),
true,
);
const content = out[1].content as Array<Record<string, unknown>>;
expect(Array.isArray(content)).toBe(true);
expect(content[0]).toEqual({ type: 'text', text: 'look' });
expect(content[1]).toEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,xxx' },
});
});
it('includeImages=falseDeepSeek 非 vision)时 images 静默丢弃', () => {
const out = buildOpenAICompatibleMessages(
makeRequest([
{
role: 'user',
content: 'look',
images: [{ url: 'data:image/png;base64,xxx' }],
timestamp: Date.now(),
},
]),
);
expect(out[1].content).toBe('look');
});
});