zcbot/tests/test_salvage.py

119 lines
6.7 KiB
Python

"""salvage_tool_arguments 守卫逻辑单测。
盲区:线上真垃圾前缀本机复现不出(定层为 provider-wire 抖动),/verify 端到端跑不了 ——
这份单测是该机制的主要正确性保障。夹具含 admin「工具失败聚集」面板里的真样本形态
(2026-07-15 scheduled 简报 write:char-0 中文正文前缀 + 尾部完好 {"path","content"})。
"""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.salvage import salvage_tool_arguments # noqa: E402
WRITE_KEYS = {"path", "content"}
# 与各工具 schema properties 一致(tools/fs.py / run_python.py / shell.py):salvage 线上
# 由 executor.schemas() 现取,这里手抄一份钉死"多工具 key 集各异但机制通用"。
EDIT_KEYS = {"path", "old_str", "new_str"}
RUN_PYTHON_KEYS = {"code", "script_path", "timeout", "background"}
SHELL_KEYS = {"command", "timeout", "background"}
class TestSalvage(unittest.TestCase):
def test_real_char0_prefix_sample(self):
"""线上真形态:一段中文正文垃圾前缀 + 尾部完好 write JSON → 抠出干净 dict。"""
good = {"path": "/workspace/scheduled-f57075f9/2026-07-15-AI重点新闻简报.txt",
"content": "═══ AI 重点新闻简报 ═══\n[1] OpenAI 发布 GPT-5。"}
raw = ("到轻量场景。\n\n[2] Claude Opus 4-7-thinking登顶代码榜 | IT之家 | 07/13\n"
" Arena平台" + json.dumps(good, ensure_ascii=False))
# 前置条件:整串确实解析不了(char-0 是中文,不是 '{')
with self.assertRaises(json.JSONDecodeError):
json.loads(raw)
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), good)
def test_edit_char0_prefix_sample(self):
"""同形态发生在 edit(2026-07-15 面板真前缀):key 集 {path,old_str,new_str} 也命中。"""
good = {"path": "/workspace/十五五KGJ项目/sections/03_关键技术研究内容及技术路线.md",
"old_str": "补充实验的迭代执行机制:分三批滚动执行。",
"new_str": "补充实验的迭代执行机制:序贯实验设计,分三批滚动执行。"}
raw = '描述符向量,为后续正向预测模型提供"宏观性能' + json.dumps(good, ensure_ascii=False)
self.assertEqual(salvage_tool_arguments(raw, EDIT_KEYS), good)
def test_run_python_char0_prefix_sample(self):
"""同形态发生在 run_python(真前缀是 python list repr 碎片):key 集 {code,...} 命中。
code 值里含 import/换行/引号,parse-to-end 照样抠回(全在 JSON 字符串转义内)。"""
good = {"code": "import olefile, struct\n\npath = '/workspace/GB16911/水泥.doc'\n"
"ole = olefile.OleFileIO(path)\nprint(ole.listdir())"}
raw = "性质', '选择除尘', '测试孔', 'HJ 1045', '合理选择']\n" + json.dumps(good, ensure_ascii=False)
self.assertEqual(salvage_tool_arguments(raw, RUN_PYTHON_KEYS), good)
def test_shell_char0_prefix_sample(self):
"""同形态发生在 shell(真前缀含上下标/全角括号):key 集 {command,...} 命中。
command 值里含 heredoc `<< 'PYEOF'` + 多行 python,全在 JSON 字符串内不破坏解析。"""
good = {"command": "cd /workspace/申报书 && python3 << 'PYEOF'\n"
"from docx import Document\ndoc = Document('x.docx')\nPYEOF"}
raw = "变参数——对于d⁹和d⁷构型的离子(如Cu²⁺、" + json.dumps(good, ensure_ascii=False)
self.assertEqual(salvage_tool_arguments(raw, SHELL_KEYS), good)
def test_wrong_tool_keys_dont_crosstalk(self):
"""key 白名单按工具隔离:write 的 {path,content} 拿 shell 白名单校验 → 拒(content 不在)。
证明 _try_salvage_response 必须用"该 tool_call 自己工具"的白名单,不能混用。"""
raw = '前缀{"path": "/a", "content": "x"}'
self.assertIsNone(salvage_tool_arguments(raw, SHELL_KEYS))
def test_truncated_tail_rejected(self):
"""尾部 JSON 被截断(content 未闭合)→ parse-to-end 失败 → None,回落重试不冒险写残缺。"""
raw = '垃圾前缀{"path": "/a.txt", "content": "只写了一半没有结束引号'
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
def test_prefix_has_selfconsistent_wrongkey_json(self):
"""垃圾前缀里恰好有一段自洽但 key 不对的 JSON → 跳过,继续找到后面真 JSON。"""
good = {"path": "/b.txt", "content": "正文"}
raw = ('模型正文里引用了 {"foo": 1, "bar": 2} 这段配置,然后'
+ json.dumps(good, ensure_ascii=False))
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), good)
def test_only_wrongkey_json_returns_none(self):
"""通篇只有 key 不在白名单的 JSON(无真 tool 参数)→ None,绝不误采。"""
raw = '前缀{"foo": 1, "bar": {"path": "x"}}'
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
def test_superset_keys_rejected(self):
"""顶层多出 schema 外的 key(非纯 tool 参数)→ 拒绝(key ⊆ allowed 不成立)。"""
raw = '前缀{"path": "/a", "content": "x", "unexpected": 1}'
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
def test_subset_keys_accepted(self):
"""只给了部分参数(path 无 content)但都在白名单 → 采纳;缺必填交 executor 报错。"""
raw = '前缀{"path": "/a"}'
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS), {"path": "/a"})
def test_trailing_garbage_after_json_rejected(self):
"""已知局限:JSON 后面还挂垃圾 → parse-to-end 失败 → None(观测形态为 JSON 在尾部,
本例是防御性记录:宁可回落重试也不用 raw_decode 容忍尾残渣误采半个对象)。"""
raw = '前缀{"path": "/a", "content": "x"}尾部残渣'
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
def test_clean_json_at_char0(self):
"""char-0 就是完好 JSON(理论上不会进 salvage,但函数应幂等返回它)。"""
raw = '{"path": "/a", "content": "x"}'
self.assertEqual(salvage_tool_arguments(raw, WRITE_KEYS),
{"path": "/a", "content": "x"})
def test_no_brace_returns_none(self):
"""整串无 '{' → None。"""
self.assertIsNone(salvage_tool_arguments("纯文本没有大括号", WRITE_KEYS))
def test_empty_dict_rejected(self):
"""抠出的是空 {} → 视为无内容,不采纳(非空 dict 才算)。"""
raw = '前缀{}'
self.assertIsNone(salvage_tool_arguments(raw, WRITE_KEYS))
if __name__ == "__main__":
unittest.main()