"""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()