Files
Haocode/tests/test_tool_params.py
T
sorrow404null a7412824e0 chore: import original project baseline
Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
2026-09-17 16:40:01 +08:00

256 lines
13 KiB
Python
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.
# -*- coding: utf-8 -*-
"""
四大工具参数层单测(纯函数级,无 DB/无 UI 依赖)
覆盖本轮修复:
T1-T9 参数归一化 + JSON Schema 校验(全错误上报 / null 归一化 / 轻量强制转换 / bool 漏洞)
T10-T13 edit 参数预处理(legacy 单条 / edits 为 JSON 字符串)
T14-T19 edit 区间规划(唯一性 / 重叠检测 / 空 oldText
T20-T25 read 参数钳制(limit 负数漏洞 / 越界 / 空文件)
T26-T29 write + edit 原子写(无临时文件残留)
T30-T31 before 钩子改参后重新校验
运行: PYTHONIOENCODING=utf-8 python tests/test_tool_params.py
"""
import os
import sys
import json
import glob
import tempfile
import shutil
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.agent.tools import (validate_json_schema, normalize_and_coerce, # noqa: E402
_prepare_edit_args, _plan_edits,
tool_read, tool_write, tool_edit,
execute_tool_call, PreparedToolCall)
from core.agent.types import (AgentTool, AgentToolResult, AbortSignal, # noqa: E402
ToolCall, AgentMessage)
RESULTS = []
def check(name, cond, extra=""):
RESULTS.append((name, bool(cond)))
print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True)
# ======================================================================
# 测试用 schema(与 default_tools() 一致)
# ======================================================================
S_READ = {"type": "object",
"properties": {"path": {"type": "string"},
"offset": {"type": "integer"},
"limit": {"type": "integer"}},
"required": ["path"]}
S_BASH = {"type": "object",
"properties": {"command": {"type": "string"},
"timeout": {"type": "number"}},
"required": ["command"]}
S_EDIT = {"type": "object",
"properties": {
"path": {"type": "string"},
"edits": {"type": "array",
"items": {"type": "object",
"properties": {"oldText": {"type": "string"},
"newText": {"type": "string"}},
"required": ["oldText"]}}},
"required": ["path", "edits"]}
SG = AbortSignal()
# ======================================================================
# T1-T9 校验层
# ======================================================================
def err_of(args, schema):
return validate_json_schema(normalize_and_coerce(args, schema), schema)
check("T1.缺必填参数", err_of({"offset": 1}, S_READ) == "缺少必填参数: path",
err_of({"offset": 1}, S_READ))
check("T2.string 类型错误", "应为 string" in (err_of({"path": 123}, S_READ) or ""),
err_of({"path": 123}, S_READ))
check("T3.bool 不得冒充 integer(旧版漏洞)",
"应为 integer" in (err_of({"path": "a", "offset": True}, S_READ) or ""),
err_of({"path": "a", "offset": True}, S_READ))
check("T4.bool 不得冒充 number(旧版漏洞)",
"应为 number" in (err_of({"command": "x", "timeout": True}, S_BASH) or ""),
err_of({"command": "x", "timeout": True}, S_BASH))
check("T5.整数 1.5 拒绝", "期望 integer" in (err_of({"path": "a", "offset": 1.5}, S_READ) or ""),
err_of({"path": "a", "offset": 1.5}, S_READ))
check("T6.多余参数宽容(与 pi 一致)", err_of({"path": "a", "lines": 10}, S_READ) is None,
err_of({"path": "a", "lines": 10}, S_READ))
_err2 = err_of({"path": 123, "offset": "bad"}, S_READ) or ""
check("T7.多错全量上报(不再只报第 1 个)",
_err2.count(";") >= 1 and "path" in _err2 and "offset" in _err2, _err2)
check("T8.嵌套必填字段",
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT)
== "参数 edits[0] 缺少必填字段 oldText",
err_of({"path": "a", "edits": [{"newText": "y"}]}, S_EDIT))
check("T9.数组元素类型错误定位到下标",
"edits[1]" in (err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT) or ""),
err_of({"path": "a", "edits": [{"oldText": "x"}, {"oldText": 5}]}, S_EDIT))
# ---- 归一化 / 强制转换 ----
check("T10.可选字段 null 被删除(不再误报类型错)",
normalize_and_coerce({"path": "a", "offset": None}, S_READ) == {"path": "a"})
check("T11.数字字符串被转换 \"30\"→30",
normalize_and_coerce({"command": "x", "timeout": "30"}, S_BASH)["timeout"] == 30)
check("T12.integer 字段收 \"1.5\" 保持原值(交由校验报错)",
normalize_and_coerce({"path": "a", "offset": "1.5"}, S_READ)["offset"] == "1.5")
_orig = {"path": "a", "offset": None}
normalize_and_coerce(_orig, S_READ)
check("T13.归一化不修改入参", _orig == {"path": "a", "offset": None}, _orig)
# ======================================================================
# T14-T15 edit 参数预处理
# ======================================================================
check("T14.legacy 单条形式 {oldText,newText} → edits[]",
_prepare_edit_args({"path": "a", "oldText": "x", "newText": "y"})
== {"path": "a", "edits": [{"oldText": "x", "newText": "y"}]})
check("T15.edits 为 JSON 字符串 → 解析为数组",
_prepare_edit_args({"path": "a", "edits": '[{"oldText":"x"}]'})
== {"path": "a", "edits": [{"oldText": "x"}]})
# ======================================================================
# T16-T19 edit 区间规划
# ======================================================================
_e, _sp = _plan_edits("hello world", [{"oldText": "world", "newText": "there"}])
check("T16.唯一命中 → 规划成功", _e is None and _sp[0][0] == 6, (_e, _sp))
check("T17.未命中 → 报错", "未找到匹配文本" in (_plan_edits("abc", [{"oldText": "zz"}])[0] or ""),
_plan_edits("abc", [{"oldText": "zz"}])[0])
check("T18.重复命中 → 报错", "匹配到 2 处" in (_plan_edits("foo foo", [{"oldText": "foo"}])[0] or ""),
_plan_edits("foo foo", [{"oldText": "foo"}])[0])
# 旧版会:两条对原始内容各自唯一 → 校验通过 → 应用时第 2 条已找不到(静默 no-op,仍报“已应用 2 处”)
# 现在必须判定为重叠并整批拒绝
_e2 = _plan_edits("foo bar", [{"oldText": "foo bar", "newText": "foo BAR"},
{"oldText": "bar", "newText": "baz"}])[0]
check("T19.区间重叠被检出(旧版静默 no-op 第 2 条)", "重叠" in (_e2 or ""), _e2)
check("T19b.嵌套重叠被检出",
"重叠" in (_plan_edits("abc", [{"oldText": "abc", "newText": "Z"},
{"oldText": "ab", "newText": "Q"}])[0] or ""))
check("T19d.原文件不存在的 oldText 被拒(对齐 pi:对原始快照匹配)",
"未找到匹配文本" in (_plan_edits("abc", [{"oldText": "ab", "newText": "aX"},
{"oldText": "aXb", "newText": "ZZ"}])[0] or ""))
check("T19c.空 oldText 被拒(旧版空文件会静默插入)",
"不能为空" in (_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0] or ""),
_plan_edits("", [{"oldText": "", "newText": "INJ"}])[0])
# ======================================================================
# T20-T25 read 参数钳制(临时文件,不碰项目数据)
# ======================================================================
_TMP = tempfile.mkdtemp(prefix="haocode_tp_")
CTX = {"cwd": _TMP}
def _mk(name, text):
p = os.path.join(_TMP, name)
with open(p, "w", encoding="utf-8") as f:
f.write(text)
return p
try:
_mk("lines.txt", "".join(f"L{i}\n" for i in range(1, 11))) # 10 行
_mk("empty.txt", "")
r = tool_read("t", {"path": "lines.txt", "limit": -5}, SG, None, CTX)
_t20 = r.as_text()
check("T20.limit 负数不再读全文件(旧版返回 10 行减 5)",
"L1" in _t20 and "L2" not in _t20, _t20.replace("\n", " | ")[:120])
r = tool_read("t", {"path": "lines.txt", "limit": 0}, SG, None, CTX)
check("T21.limit=0 钳到 1(旧版返回空块 + 无意义脚注)",
"L1" in r.as_text() and "L2" not in r.as_text(), r.as_text().replace("\n", " | ")[:120])
r = tool_read("t", {"path": "lines.txt", "offset": 999}, SG, None, CTX)
check("T22.offset 越界 → 明确提示(旧版'已显示 999998 行'",
"超出文件范围" in r.as_text() and "共 10 行" in r.as_text(), r.as_text())
r = tool_read("t", {"path": "empty.txt"}, SG, None, CTX)
check("T23.空文件 → 明确提示", "文件为空" in r.as_text(), r.as_text())
r = tool_read("t", {"path": ""}, SG, None, CTX)
check("T24.path 为空 → 'path 不能为空'(旧版报'文件不存在: <cwd>'",
r.is_error and "不能为空" in r.as_text(), r.as_text())
r = tool_read("t", {"path": "lines.txt", "offset": 3, "limit": 2}, SG, None, CTX)
check("T25.正常行窗口不变(offset=3,limit=2 → L3,L4",
"L3" in r.as_text() and "L4" in r.as_text() and "L5" not in r.as_text()
and "offset=5" in r.as_text(), r.as_text().replace("\n", " | ")[:140])
# ==================================================================
# T26-T29 原子写 / edit 端到端
# ==================================================================
r = tool_write("t", {"path": "w.txt", "content": "a\nb\n"}, SG, None, CTX)
left = glob.glob(os.path.join(_TMP, ".hocode_w_*"))
check("T26.write 成功且无临时文件残留", not r.is_error and not left, (r.as_text(), left))
tool_write("t", {"path": "w2.txt", "content": "foo bar"}, SG, None, CTX)
r = tool_edit("t", {"path": "w2.txt",
"edits": [{"oldText": "foo bar", "newText": "foo BAR"},
{"oldText": "bar", "newText": "baz"}]}, SG, None, CTX)
check("T27.重叠 edit 整批拒绝且文件未被改动",
r.is_error and "重叠" in r.as_text(), r.as_text())
r = tool_edit("t", {"path": "w.txt",
"edits": [{"oldText": "a", "newText": "A"},
{"oldText": "b", "newText": "B"}]}, SG, None, CTX)
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
after = f.read()
check("T28.多条不重叠 edit 一次性应用(对原始快照)",
not r.is_error and after == "A\nB\n", (r.as_text(), repr(after)))
r = tool_edit("t", {"path": "w.txt", "edits": [{"oldText": "", "newText": "X"}]}, SG, None, CTX)
with open(os.path.join(_TMP, "w.txt"), encoding="utf-8") as f:
after = f.read()
check("T29.空 oldText 被拒且文件未变", r.is_error and after == "A\nB\n",
(r.as_text(), repr(after)))
# ==================================================================
# T30-T31 before 钩子改参后重新校验
# ==================================================================
def _echo(args):
return AgentToolResult.text(f"echo:{json.dumps(args, sort_keys=True)}")
def _echo5(tool_call_id, args, signal, on_update, ctx):
return _echo(args)
class _Cfg:
tool_context = {"cwd": _TMP}
before_tool_call = None
after_tool_call = None
etool = AgentTool(name="echo", description="d", parameters=S_READ, execute=_echo5)
tc = ToolCall(id="c1", name="echo", arguments={"path": "a"})
cfg_bad = _Cfg()
cfg_bad.before_tool_call = lambda payload, sig: {"args": {"path": 123}}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_bad, SG, None)
check("T30.before 钩子返回非法参数 → 拒绝执行(pi 同款重校验)",
res.is_error and "校验失败" in res.as_text(), res.as_text())
cfg_ok = _Cfg()
cfg_ok.before_tool_call = lambda payload, sig: {"args": {"path": "b", "offset": "7"}}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_ok, SG, None)
check("T31.before 钩子改参后归一化生效(\"7\"→7",
not res.is_error and '"offset": 7' in res.as_text(), res.as_text())
cfg_blk = _Cfg()
cfg_blk.before_tool_call = lambda payload, sig: {"block": True, "reason": "nope"}
res = execute_tool_call(PreparedToolCall(tool_call=tc, tool=etool, args={"path": "a"}),
AgentMessage(role="assistant", content=""), cfg_blk, SG, None)
check("T32.before 钩子 block 语义不变", res.is_error and "nope" in res.as_text(), res.as_text())
finally:
shutil.rmtree(_TMP, ignore_errors=True)
failed = [n for n, ok in RESULTS if not ok]
print(f"\n===== {len(RESULTS) - len(failed)}/{len(RESULTS)} PASS =====", flush=True)
print("ALL PASS" if not failed else f"FAILED: {failed}", flush=True)
sys.exit(0 if not failed else 1)