Files
ligent/tests/test_tools.c
T
2026-10-03 14:43:43 +08:00

598 lines
27 KiB
C
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.
/* test_tools.c - 工具层测试 */
#include "tools.h"
#include "image.h"
#include "json.h"
#include "util.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
static int failures = 0;
static int checks = 0;
#define CHECK(cond, ...) \
do { \
checks++; \
if (!(cond)) { \
failures++; \
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
static char *call(const char *name, const char *args_json, int *is_error)
{
char err[256];
JsonValue *args = json_parse(args_json, err, sizeof err);
if (!args) {
printf(" 测试用例 args JSON 非法: %s\n", err);
exit(2);
}
char *r = tool_execute(name, args, is_error);
json_free(args);
return r;
}
int main(void)
{
char tmpl[] = "/tmp/ligent_test_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { perror("mkdtemp"); return 2; }
if (chdir(dir) != 0) { perror("chdir"); return 2; }
/* ---- 工具 schema 合法性 ---- */
puts("tools_schema");
JsonValue *schema = tools_schema();
CHECK(json_array_size(schema) == 6, "应有 6 个工具, 实际 %zu",
json_array_size(schema));
for (size_t i = 0; i < json_array_size(schema); i++) {
JsonValue *t = json_array_get(schema, i);
CHECK(strcmp(json_get_string(t, "type"), "function") == 0, "type");
JsonValue *fn = json_object_get(t, "function");
CHECK(json_get_string(fn, "name") != NULL, "缺 name");
JsonValue *params = json_object_get(fn, "parameters");
CHECK(json_get_string(params, "type") != NULL, "缺 parameters.type");
CHECK(json_object_get(params, "properties") != NULL, "缺 properties");
CHECK(json_object_get(params, "required") != NULL, "缺 required");
}
{
int has_image = 0;
for (size_t i = 0; i < json_array_size(schema); i++) {
JsonValue *fn = json_object_get(json_array_get(schema, i), "function");
const char *nm = json_get_string(fn, "name");
if (nm && strcmp(nm, "read_image") == 0) has_image = 1;
}
CHECK(has_image, "schema 里应有 read_image");
}
{
/* line 是集合型参数:schema 里必须是 array (元素 integer),
* 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法
* 只有 schema 展开对了才作数 */
JsonValue *props = NULL, *reqd = NULL;
for (size_t i = 0; i < json_array_size(schema); i++) {
JsonValue *fn = json_object_get(json_array_get(schema, i), "function");
const char *nm = json_get_string(fn, "name");
if (nm && strcmp(nm, "edit_file") == 0) {
JsonValue *par = json_object_get(fn, "parameters");
props = json_object_get(par, "properties");
reqd = json_object_get(par, "required");
}
}
/* line 是可选的 (省略 = 全文件替换), 不该出现在 required 里 */
int line_required = 0;
for (size_t i = 0; i < json_array_size(reqd); i++) {
const char *nm = json_string_value(json_array_get(reqd, i));
if (nm && strcmp(nm, "line") == 0) line_required = 1;
}
CHECK(!line_required, "line 可以省略 (= 全文件替换), 不该是 required");
JsonValue *line = props ? json_object_get(props, "line") : NULL;
const char *lt = line ? json_get_string(line, "type") : NULL;
CHECK(lt && strcmp(lt, "array") == 0, "edit_file 的 line 应是 array, 实际 %s",
lt ? lt : "(缺)");
JsonValue *items = line ? json_object_get(line, "items") : NULL;
const char *it = items ? json_get_string(items, "type") : NULL;
CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer, 实际 %s",
it ? it : "(缺)");
}
/* 序列化一遍确保可发给服务端 */
char *schema_json = json_dump(schema);
JsonValue *re = json_parse(schema_json, NULL, 0);
CHECK(re != NULL, "schema 无法被自身解析器往返");
free(schema_json);
json_free(re);
json_free(schema);
/* ---- run_command ---- */
puts("run_command");
int err = 0;
char *r = call("run_command", "{\"command\":\"echo hello\"}", &err);
CHECK(err == 0, "exit 0 不应算错误: %s", r);
CHECK(strstr(r, "hello") != NULL, "应含 hello: %s", r);
CHECK(strstr(r, "[exit code: 0]") != NULL, "应含退出码: %s", r);
free(r);
r = call("run_command", "{\"command\":\"exit 3\"}", &err);
CHECK(err == 1, "非零退出应标记为错误");
CHECK(strstr(r, "[exit code: 3]") != NULL, "应报告退出码 3: %s", r);
free(r);
r = call("run_command", "{\"command\":\"echo err >&2; exit 1\"}", &err);
CHECK(strstr(r, "err") != NULL, "stderr 应被合并捕获: %s", r);
free(r);
/* 超时 */
r = call("run_command", "{\"command\":\"sleep 5\",\"timeout\":1}", &err);
CHECK(err == 1 && strstr(r, "超时") != NULL, "应超时: %s", r);
free(r);
r = call("run_command", "{}", &err);
CHECK(err == 1 && strstr(r, "command") != NULL, "缺参数应报错: %s", r);
free(r);
/* ---- write_file / read_file ---- */
puts("write_file / read_file");
r = call("write_file",
"{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}", &err);
CHECK(err == 0, "写入应成功: %s", r);
CHECK(access("sub/dir/a.txt", R_OK) == 0, "父目录应被自动创建");
free(r);
r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err);
CHECK(err == 0, "读取应成功: %s", r);
CHECK(strstr(r, "line1") && strstr(r, "line3"), "内容应完整: %s", r);
CHECK(strstr(r, "1\t") != NULL, "应带行号: %s", r);
free(r);
r = call("read_file", "{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err);
CHECK(strstr(r, "line2") && !strstr(r, "line1") && !strstr(r, "line3"),
"offset/limit 应生效: %s", r);
free(r);
r = call("read_file", "{\"path\":\"does_not_exist\"}", &err);
CHECK(err == 1, "读不存在的文件应报错: %s", r);
free(r);
/* ---- edit_file:替换由行号钉住 ---- */
puts("edit_file");
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\","
"\"new_string\":\"LINE-TWO\"}", &err);
CHECK(err == 0, "按行号替换应成功: %s", r);
CHECK(strstr(r, "第 2 行") != NULL, "回执里应带上行号: %s", r);
free(r);
r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err);
CHECK(strstr(r, "LINE-TWO") != NULL, "替换结果应落盘: %s", r);
free(r);
/* 省略 line 不再是错误:那是"全文件替换"这条逃生门 (见下面那组用例) */
/* 行号对不上:必须报出它**真正在哪一行**, 否则模型只能反复猜 */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\","
"\"new_string\":\"x\"}", &err);
CHECK(err == 1, "行号不对应报错: %s", r);
CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r);
free(r);
/* 越界行号:要连文件总行数一起说清楚 */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
CHECK(err == 1 && strstr(r, "只有 3 行") != NULL,
"越界行号应报错并给出总行数: %s", r);
free(r);
/* 唯一性只要求**在这一行里**唯一:同一个词出现在别的行不该拦住替换
* (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\","
"\"new_string\":\"LINE\"}", &err);
CHECK(err == 0, "同一行里唯一即可, 别的行的重复不该拦: %s", r);
free(r);
/* 同一行里出现两处 → 仍然拒绝 (行内也要定得下来) */
r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", &err);
CHECK(err == 0, "写入 dup.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"}",
&err);
CHECK(err == 1 && strstr(r, "第 1 行里有 2 处") != NULL,
"同一行里多匹配应拒绝: %s", r);
free(r);
/* replace_all 仍然换全文件, 但 line 必须指向其中真实的一处 */
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\","
"\"replace_all\":true}", &err);
CHECK(err == 0, "replace_all 应成功: %s", r);
free(r);
r = call("read_file", "{\"path\":\"dup.txt\"}", &err);
CHECK(strstr(r, "bar bar") != NULL && !strstr(r, "foo"),
"replace_all 应换掉文件里全部匹配: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_string\":\"x\","
"\"replace_all\":true}", &err);
CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r);
free(r);
/* ---- edit_file:line 是集合型 ----
* 集合是**搜索范围** (old_string 的起点落在其中任一行即可), 不是
* "每行都改一次":真改动的仍是唯一那一处. */
puts("edit_file:line 集合");
r = call("write_file",
"{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}", &err);
CHECK(err == 0, "写入 set.txt: %s", r);
free(r);
/* 数组写法:起点落在集合里的哪一行都算命中; 回执要带上整个集合 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,2],\"old_string\":\"aaa\","
"\"new_string\":\"AAA\"}", &err);
CHECK(err == 0, "集合圈住的替换应成功: %s", r);
CHECK(strstr(r, "第 1、2 行") != NULL, "回执里应带上整个集合: %s", r);
free(r);
r = call("read_file", "{\"path\":\"set.txt\"}", &err);
CHECK(strstr(r, "AAA") != NULL && !strstr(r, "aaa"), "替换应落盘: %s", r);
free(r);
/* old_string 跨行时, 把覆盖到的几行都填进集合——不必赌起点是第几行 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\","
"\"new_string\":\"B\\nC\"}", &err);
CHECK(err == 0, "跨行替换 (集合给两行)应成功: %s", r);
free(r);
r = call("read_file", "{\"path\":\"set.txt\"}", &err);
CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"), "跨行替换应落盘: %s", r);
free(r);
/* 集合里有多处匹配 → 仍然拒绝 (集合不是"每行改一次"的授权) */
r = call("write_file", "{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err);
CHECK(err == 0, "写入 two.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"two.txt\",\"line\":[1,3],\"old_string\":\"foo\","
"\"new_string\":\"bar\"}", &err);
CHECK(err == 1 && strstr(r, "第 1、3 行里有 2 处") != NULL,
"集合里多处匹配应拒绝: %s", r);
free(r);
/* 重复的行号要去重:不然同一处匹配会被数成两处 */
r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err);
CHECK(err == 0, "写入 dup3.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\","
"\"new_string\":\"bar\"}", &err);
CHECK(err == 0, "重复行号应去重, 而不是当成两处: %s", r);
free(r);
/* 集合里有一行越界 → 和单个行号时一样, 报出文件总行数 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
CHECK(err == 1 && strstr(r, "只有 4 行") != NULL,
"集合里的越界行号应报错并给出总行数: %s", r);
free(r);
/* 写法本身不对的三种:空数组、非整数元素、一次给太多 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
CHECK(err == 1 && strstr(r, "空数组") != NULL, "空数组应报错: %s", r);
CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,1.5],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
CHECK(err == 1 && strstr(r, "第 2 个") != NULL,
"非整数元素应报错并指出是第几个: %s", r);
free(r);
/* ---- 省略 line = 全文件替换所有匹配 ----
* 这是 range 那样的集合参数该有的默认值:不给范围就是不设范围. */
r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err);
CHECK(err == 0, "写入 all.txt: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}", &err);
CHECK(err == 0, "省略 line 应做全文件替换: %s", r);
CHECK(strstr(r, "全文件替换") != NULL && strstr(r, "2 处") != NULL,
"回执要说清是全文件替换、换了几处: %s", r);
free(r);
r = call("read_file", "{\"path\":\"all.txt\"}", &err);
CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r);
free(r);
/* 省略 line 时文件里没有 old_string:只说整个文件里没有 (没有"哪几行"可说) */
r = call("edit_file",
"{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}", &err);
CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL, "该报整个文件里没有: %s", r);
free(r);
/* line 给错了类型 (不是"想全局"):仍然当场报错, 并告诉它可以省略 */
r = call("edit_file",
"{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\","
"\"new_string\":\"z\"}", &err);
CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL, "line 类型错应报错: %s", r);
CHECK(strstr(r, "整个省略") != NULL, "报错要指出可以省略 line: %s", r);
free(r);
{
/* 65 个行号:超过一次能圈住的范围, 要明说 */
char big[600];
size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":[");
for (int i = 1; i <= 65; i++)
o += (size_t)snprintf(big + o, sizeof big - o, "%s%d", i > 1 ? "," : "", i);
snprintf(big + o, sizeof big - o, "],\"old_string\":\"x\",\"new_string\":\"y\"}");
r = call("edit_file", big, &err);
CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", r);
free(r);
}
/* ---- list_dir ---- */
puts("list_dir");
r = call("list_dir", "{\"path\":\".\"}", &err);
CHECK(err == 0 && strstr(r, "sub/") != NULL, "应列出子目录: %s", r);
free(r);
r = call("list_dir", "{\"path\":\"nope\"}", &err);
CHECK(err == 1, "不存在的目录应报错: %s", r);
free(r);
/* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep,
* 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住
* "不要再冒出一个半吊子的替代品". */
r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r);
free(r);
r = call("spawn_subagent", "{}", &err);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", r);
free(r);
/* ---- 超大文件不能把内存吃干 ---- */
puts("超大文件防护");
{
/* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大.
* 开头 16 KB 写成 'x':这样它是"文本" (探测的前 8 KB 里没有 NUL),
* 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到
* "分段读大文件"了. */
FILE *big = fopen("huge.txt", "wb");
CHECK(big != NULL, "创建测试文件失败");
if (big) {
char head[16 * 1024];
memset(head, 'x', sizeof head);
fwrite(head, 1, sizeof head, big);
fseek(big, 40L * 1024 * 1024, SEEK_SET);
fputc('x', big);
fclose(big);
}
r = call("edit_file",
"{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_string\":\"y\"}",
&err);
CHECK(err == 1, "超大文件的 edit_file 应被拒绝: %.120s", r);
CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r);
free(r);
/* read_file 是分段读的, 应当照常可用 */
r = call("read_file", "{\"path\":\"huge.txt\",\"limit\":1}", &err);
CHECK(err == 0, "分段读大文件应当可用: %.120s", r);
free(r);
unlink("huge.txt");
}
/* ---- 二进制文件:所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */
puts("二进制拒绝");
{
FILE *bf = fopen("bin.dat", "wb");
CHECK(bf != NULL, "创建二进制测试文件失败");
if (bf) { fwrite("PNG\0\1\2\3", 1, 8, bf); fclose(bf); }
}
r = call("read_file", "{\"path\":\"bin.dat\"}", &err);
CHECK(err == 1, "read_file 读二进制应被拒绝: %s", r);
CHECK(strstr(r, "二进制") != NULL && strstr(r, "第 4 字节") != NULL,
"拒绝理由要指出 NUL 在哪一字节: %s", r);
free(r);
r = call("edit_file",
"{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_string\":\"JPG\"}",
&err);
CHECK(err == 1 && strstr(r, "二进制") != NULL, "edit_file 改二进制应被拒绝: %s", r);
free(r);
r = call("write_file", "{\"path\":\"bin.dat\",\"content\":\"text\"}", &err);
CHECK(err == 1 && strstr(r, "二进制") != NULL,
"write_file 覆盖二进制应被拒绝: %s", r);
free(r);
/* 被拒绝的三次都不能动到文件本身 —— 拒绝的意义就在这里 */
{
FILE *bf = fopen("bin.dat", "rb");
char keep[16];
memset(keep, 0, sizeof keep);
size_t kn = bf ? fread(keep, 1, sizeof keep, bf) : 0;
if (bf) fclose(bf);
CHECK(kn == 8 && memcmp(keep, "PNG\0\1\2\3", 8) == 0,
"被拒绝的读/写不能改动二进制文件的字节");
}
/* 确认提示里要能看出改的是哪一行 */
r = call("write_file", "{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err);
free(r);
r = call("edit_file",
"{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\",\"new_string\":\"ccc\"}",
&err);
CHECK(err == 0 && strstr(r, "第 2 行") != NULL, "回执带行号: %s", r);
free(r);
/* ---- 哪些工具会改动磁盘 (决定是否弹确认) ---- */
puts("写操作分类");
CHECK(tool_mutating("run_command") == 1, "run_command 会执行命令, 必须确认");
CHECK(tool_mutating("write_file") == 1, "write_file 会写盘, 必须确认");
CHECK(tool_mutating("edit_file") == 1, "edit_file 会改盘, 必须确认");
CHECK(tool_mutating("read_file") == 0, "read_file 只读, 不该打扰");
CHECK(tool_mutating("list_dir") == 0, "list_dir 只读, 不该打扰");
/* read_image 会在 ligent 自己的目录里留备份 (副作用),
* 但不改用户的任何东西 —— 它是"读", 不该每次都拦一道 */
CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件, 不该打扰");
CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理");
CHECK(tool_mutating(NULL) == 0, "空名字不应崩");
/* ---- read_image:只收真的能内联进去的图 ----
*
* 两条拒绝路径是新的:格式不认识、体积超限. 它们都必须**不备份** ——
* 一张交不出去的图, 留备份只会让 ligent.archive 变成垃圾场. */
puts("read_image:本地就把不合格的图拦掉");
{
char btmp[] = "/tmp/ligent_tools_img_XXXXXX";
char *bdir = mkdtemp(btmp);
CHECK(bdir != NULL, "建备份临时目录失败");
if (bdir) {
image_set_bindir(bdir);
image_pending_take(); /* 清干净, 免得上一段残留 */
/* 1) 内容是文本、扩展名骗人 */
FILE *f = fopen("notanimage.png", "wb");
if (f) { fputs("这不是图片", f); fclose(f); }
r = call("read_image", "{\"path\":\"notanimage.png\"}", &err);
CHECK(err == 1 && strstr(r, "不认识") != NULL,
"非图片内容应被拒: %s", r);
CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的");
free(r);
/* 2) 体积超限:base64 后超过 IMAGE_MAX_B64.
* 4/3 * 原始大小 = 编码后大小, 所以原始要 > 3/4 * 上限. */
size_t raw_needed = (size_t)(IMAGE_MAX_B64 / 4 * 3) + 4096;
f = fopen("huge.png", "wb");
if (f) {
fwrite("\x89PNG\r\n\x1a\n", 1, 8, f);
char *pad = xcalloc(1, 65536);
size_t left = raw_needed - 8;
while (left > 0) {
size_t chunk = left > 65536 ? 65536 : left;
if (fwrite(pad, 1, chunk, f) != chunk) break;
left -= chunk;
}
free(pad);
fclose(f);
}
r = call("read_image", "{\"path\":\"huge.png\"}", &err);
CHECK(err == 1 && strstr(r, "太大") != NULL,
"超限的图应被当场拒掉: %.120s", r);
CHECK(strstr(r, "内联上限") != NULL, "应说清上限是多少");
free(r);
/* 3) 合法的小图:成功, 并且挂上待发附件 */
f = fopen("ok.png", "wb");
if (f) { fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); fwrite("payload", 1, 7, f); fclose(f); }
r = call("read_image", "{\"path\":\"ok.png\"}", &err);
CHECK(err == 0 && strstr(r, "已读入图片") != NULL,
"合法图片应成功: %s", r);
CHECK(strstr(r, "base64") != NULL, "应说明会以 base64 内联");
free(r);
JsonValue *att = image_pending_take();
CHECK(att && json_array_size(att) == 1, "应挂上 1 个待发附件");
if (att && json_array_size(att) == 1)
CHECK(strcmp(json_get_string(json_array_get(att, 0), "type"),
"ligent_image") == 0, "附件应是内部引用块");
json_free(att);
/* 只有成功那一张留了备份 —— 两条拒绝路径都不该留下东西 */
char *ls = xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir);
FILE *p = popen(ls, "r");
int cnt = -1;
if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); }
free(ls);
CHECK(cnt == 1, "只该留 1 份备份 (成功那张), 实际 %d", cnt);
image_set_bindir(".");
}
}
/* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ----
*
* read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、
* 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错
* 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README 都写着
* "read_file 打出来的行号可以直接喂给 edit_file 的 line", 这里把它钉住. */
puts("超长单行的行号口径");
{
FILE *lf = fopen("longline.txt", "wb");
for (int i = 0; i < 70000; i++) fputc('a', lf); /* 跨过 64 KB 那块缓冲 */
fputc('\n', lf);
fputs("second line\nthird line\n", lf);
fclose(lf);
r = call("read_file", "{\"path\":\"longline.txt\"}", &err);
CHECK(err == 0, "读超长行文件失败: %s", r);
CHECK(strstr(r, " 1\ta") != NULL, "第 1 行应以 a 开头:\n%.120s", r);
CHECK(strstr(r, " 2\tsecond line") != NULL,
"超长行之后的第 2 行应是 second line (它不能被算成好几行):\n%.200s", r);
CHECK(strstr(r, " 3\tthird line") != NULL, "第 3 行应是 third line");
CHECK(strstr(r, " 4\t") == NULL, "不应该出现第 4 行");
free(r);
/* 拿 read_file 报出的行号去 edit_file 改, 必须命中同一处 */
r = call("edit_file",
"{\"path\":\"longline.txt\",\"line\":3,"
"\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err);
CHECK(err == 0, "用 read_file 的行号改第 3 行失败: %s", r);
CHECK(strstr(r, "第 3 行替换 1 处") != NULL, "回执应说清改的是第 3 行: %s", r);
free(r);
char *after = read_entire_file("longline.txt", NULL);
CHECK(after && strstr(after, "THIRD LINE") != NULL, "改动应落到文件里");
free(after);
/* 单行本身就超过输出上限:必须说清"这一行只给了开头", 不能说成
* "已截断, 从下一行继续" —— 那会让调用方以为拿到的是完整的行. */
FILE *hf = fopen("hugeline.txt", "wb");
for (int i = 0; i < 200000; i++) fputc('b', hf);
fputc('\n', hf);
fputs("tail\n", hf);
fclose(hf);
r = call("read_file", "{\"path\":\"hugeline.txt\"}", &err);
CHECK(strstr(r, "第 1 行本身就超过") != NULL,
"应说清是这一行超过上限:%.160s", r);
CHECK(strstr(r, " 2\t") == NULL, "不能把剩下的半个行伪装成第 2 行");
free(r);
remove("hugeline.txt");
remove("longline.txt");
}
/* ---- 空目录:qsort 的第一个参数会拿到 NULL ---- */
puts("list_dir 空目录");
if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ }
r = call("list_dir", "{\"path\":\"emptydir\"}", &err);
CHECK(err == 0 && strstr(r, "0 个条目") != NULL,
"空目录应报 0 个条目, 实际 %s", r);
free(r);
rmdir("emptydir");
/* ---- 未知工具 ---- */
r = call("no_such_tool", "{}", &err);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "未知工具应报错: %s", r);
free(r);
/* ---- 输出截断 ---- */
puts("output truncation");
r = call("run_command",
"{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}", &err);
CHECK(strlen(r) <= LIGENT_TOOL_OUTPUT_LIMIT + 200,
"输出应被截断, 实际 %zu 字节", strlen(r));
CHECK(strstr(r, "截断") != NULL, "应提示已截断");
free(r);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}