Files
2026-10-04 13:50:33 +08:00

683 lines
28 KiB
C

/* test_tools.c - 工具层测试 */
#include "image.h"
#include "json.h"
#include "tools.h"
#include "util.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.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;
}