chore: 规范标点

This commit is contained in:
pluv committed 2026-10-03 14:43:43 +08:00
1 parent e54390a9f8
commit 15e79887fa
84 files changed
+5439 -5398

No files matched your search

+25 -25
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env python3
"""confirm_check.py - 确认提示上的每一种回答都必须被正确处理
重点是 Ctrl-C。
重点是 Ctrl-C.
`confirm_tool` 用 fgets 读输入,而 SIGINT 会让 fgets 以 EINTR 失败、返回
NULL;代码把 NULL 当成"没人能回答,按默认值放行"。于是**在确认提示
上按 Ctrl-C 等于批准这次操作**。run_command 侥幸自己查了中断标志才没执行,
write_file / edit_file 没查,文件照写。
`confirm_tool` 用 fgets 读输入, 而 SIGINT 会让 fgets 以 EINTR 失败、返回
NULL; 代码把 NULL 当成"没人能回答, 按默认值放行". 于是**在确认提示
上按 Ctrl-C 等于批准这次操作**. run_command 侥幸自己查了中断标志才没执行,
write_file / edit_file 没查, 文件照写.
这个脚本把三种回答都跑一遍,直接看磁盘上有没有留下东西:
这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西:
Ctrl-C -> 必须拒绝
n -> 必须拒绝
y -> 必须执行(否则就是"修过头"了)
y -> 必须执行 (否则就是"修过头"了)
用法:
confirm_check.py <ligent 二进制> <mock base-url>
@@ -22,19 +22,19 @@ import select
import sys
import time
# 探针文件放在 build/ 下,别弄脏仓库根目录
# WRITE_TEST 写这一个;WRITE2_TEST 写 PROBE1 / PROBE2(一轮里两次)
# 探针文件放在 build/ 下, 别弄脏仓库根目录
# WRITE_TEST 写这一个; WRITE2_TEST 写 PROBE1 / PROBE2 (一轮里两次)
PROBE = "build/tmp/confirm_probe.txt"
PROBE1 = "build/tmp/confirm_probe1.txt"
PROBE2 = "build/tmp/confirm_probe2.txt"
# 只认稳定前缀:选项列表是会变的(现在是 Y/N/Always/neVer),
# 写死整串会在改提示文案时把测试弄坏。
# 只认稳定前缀:选项列表是会变的 (现在是 Y/N/Always/neVer),
# 写死整串会在改提示文案时把测试弄坏.
MARK = "继续?"
PROMPT_READY = b"\x1b[?2004h"
def run_case(binary, base, answer, wait_confirm=6.0):
"""跑一轮:触发 write_file → 等确认提示出现 → 回答 → 看文件在不在。"""
"""跑一轮:触发 write_file → 等确认提示出现 → 回答 → 看文件在不在. """
try:
os.unlink(PROBE)
except OSError:
@@ -69,7 +69,7 @@ def run_case(binary, base, answer, wait_confirm=6.0):
return True
def wait_for(text, seconds):
"""一直读到 text 出现(或超时)。按标记等,不靠 sleep 猜。"""
"""一直读到 text 出现 (或超时). 按标记等, 不靠 sleep 猜. """
end = time.time() + seconds
while time.time() < end:
if text.encode("utf-8") in bytes(out):
@@ -105,13 +105,13 @@ def run_case(binary, base, answer, wait_confirm=6.0):
def run_turn_scope(binary, base):
"""Always / neVer 的作用域必须是"这一轮",不是整个会话。
"""Always / neVer 的作用域必须是"这一轮", 不是整个会话.
一轮里让模型连写两个文件:第一次问、答 Always,第二次不该再问。
然后开新的一轮(下一条用户消息),必须**重新问** —— 会话级的开关是
/yolo,不是这个。
一轮里让模型连写两个文件:第一次问、答 Always, 第二次不该再问.
然后开新的一轮 (下一条用户消息), 必须**重新问** —— 会话级的开关是
/yolo, 不是这个.
全程按标记同步("继续?"出现 / 回到提示符),不靠 sleep 猜时间。
全程按标记同步 ("继续?"出现 / 回到提示符), 不靠 sleep 猜时间.
"""
for p in (PROBE1, PROBE2):
try:
@@ -183,14 +183,14 @@ def run_turn_scope(binary, base):
os.write(fd, "WRITE2_TEST 写两个文件\n".encode())
wait_for(has_prompt, 8.0)
os.write(fd, b"Always\n")
# 等这一轮彻底跑完(回到提示符),别用固定时长猜
# 等这一轮彻底跑完 (回到提示符), 别用固定时长猜
wait_for(lambda: state["ready"] > base_ready, 10.0)
pump(0.3)
first_turn_prompts = prompt_count()
both_written = os.path.exists(PROBE1) and os.path.exists(PROBE2)
# ---- 第二轮:换一条用户消息,必须重新问 ----
# ---- 第二轮:换一条用户消息, 必须重新问 ----
base_ready = state["ready"]
before = prompt_count()
os.write(fd, "WRITE_LAST_TEST 再来一轮\n".encode())
@@ -255,7 +255,7 @@ def main():
n1, both, again, n2 = run_turn_scope(binary, base)
check("Always:同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1)
check("Always:同轮的第二次改动被放行", both, "第二个文件没写")
check("新一轮会重新询问(作用域仅限本轮)", again and n2 >= 1,
check("新一轮会重新询问 (作用域仅限本轮)", again and n2 >= 1,
"新一轮没有询问")
for p in (PROBE, PROBE1, PROBE2):
@@ -263,11 +263,11 @@ def main():
os.unlink(p)
except OSError:
pass
# 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录,删掉它
# 会连带清掉别人放进去的东西(我就因此丢过两次日志),而它自己的三个文件
# 上面已经删干净了。测试辅助脚本只该收拾自己造的东西。
# 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录, 删掉它
# 会连带清掉别人放进去的东西 (我就因此丢过两次日志), 而它自己的三个文件
# 上面已经删干净了. 测试辅助脚本只该收拾自己造的东西.
print("\n%d 项检查,%d 项失败" % (checks, fails))
print("\n%d 项检查, %d 项失败" % (checks, fails))
return 1 if fails else 0
+8 -8
View File
@@ -1,16 +1,16 @@
# ligent 收发记录 —— 导出 2026-09-21 17:09:35,共 5981 字节
# ligent 收发记录 —— 导出 2026-09-21 17:09:35, 共 5981 字节
===== 2026-09-21 17:09:35 =====
请求: POST http://127.0.0.1:8882/v1/chat/completions
----- 请求体 (5533 字节) -----
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容,含文件类型与大小。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径,默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看(截图、设计稿、图表、Line truncated
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的Line truncated
----- 响应头 -----
HTTP/1.0 400 Bad Request
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:35 GMT
Content-Type: application/json
Content-Length: 112
HTTP/1.0 400 Bad Request
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:35 GMT
Content-Type: application/json
Content-Length: 112
{"error": {"message": "mock \u6a21\u62df\u7684\u670d\u52a1\u7aef\u9519\u8bef", "type": "invalid_request_error"}}
----- 结束: HTTP 400 -----
+22 -22
View File
@@ -1,16 +1,16 @@
# ligent 收发记录 —— 导出 2026-09-21 17:09:24,共 19586 字节
# ligent 收发记录 —— 导出 2026-09-21 17:09:24, 共 19586 字节
===== 2026-09-21 17:09:24 =====
请求: POST http://127.0.0.1:8881/v1/chat/completions
----- 请求体 (5508 字节) -----
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容,含文件类型与大小。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径,默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看(截图、设计稿、图表、报错弹窗都行)。图Line truncated
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片Line truncated
----- 响应头 -----
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:24 GMT
Content-Type: text/event-stream
Cache-Control: no-cache
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:24 GMT
Content-Type: text/event-stream
Cache-Control: no-cache
data: {"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]}
data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": {"name": "run_command", "arguments": ""}}]}, "finish_reason": null}]}
@@ -29,14 +29,14 @@ data: [DONE]
===== 2026-09-21 17:09:24 =====
请求: POST http://127.0.0.1:8881/v1/chat/completions
----- 请求体 (5765 字节) -----
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容,含文件类型与大小。","parameters":{"type":"object","properties":{"path":Line truncated
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","Line truncated
----- 响应头 -----
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:24 GMT
Content-Type: text/event-stream
Cache-Control: no-cache
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:24 GMT
Content-Type: text/event-stream
Cache-Control: no-cache
data: {"choices": [{"index": 0, "delta": {"content": "FINAL: hello-from-t"}, "finish_reason": null}]}
data: {"choices": [{"index": 0, "delta": {"content": "ool / [exit code: 0]"}, "finish_reason": null}]}
@@ -51,14 +51,14 @@ data: [DONE]
===== 2026-09-21 17:09:24 =====
请求: POST http://127.0.0.1:8881/v1/chat/completions
----- 请求体 (5905 字节) -----
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"},{"role":"assistant","content":"FINAL: hello-from-tool / [exit code: 0]"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","functLine truncated
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令. \n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时, 先用 list_dir / read_file 查看, 不要凭猜测写代码. 搜内容用 run_command (如 grep -rn 模式 目录). \n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line, 替换只发生在那一行里. 只有创建新文件或需要整体重写时才用 write_file. \n3. 工具报错时, 先读错误信息定位原因, 再决定换一种方式; 不要重复执行同一个失败的命令. \n4. 回答用中文, 简洁直接, 不要把大段代码重复贴回来. 完成任务后用一两句话说明改了什么、验证结果如何. \n5. 不要执行破坏性的操作 (例如删除用户数据、强制推送), 除非用户明确要求. \n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝. 看图片用 read_image, 看其它二进制用 run_command (file / xxd / strings). \n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"},{"role":"assistant","content":"FINAL: hello-from-tool / [exit code: 0]"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令, 返回合并后的 stdout/stderr 与退出码. 适合编译、运行测试、git 操作等. ","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令 (由 /bin/sh -c 执行, 支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令. **只在真的撞到权限错误之后才用**, 不要预防性地加. 加了之后:你会先被要求确认 (看清要跑什么), 再由 sudo 向用户要密码 —— 密码是用户敲的, 你敲不出来. 所以被拒绝时不要反复重试, 说明你需要用户亲自做什么即可. "},"timeout":{"type":"integer","description":"超时秒数, 默认 60, 最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容, 返回带行号的文本; 行号可以直接喂给 edit_file 的 line. 大文件请用 offset/limit 分段读取. **二进制文件会被拒绝** (它只处理文本). ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径, 相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号 (从 1 开始), 默认 1"},"limit":{"type":"integer","description":"最多读取的行数, 默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件 (会覆盖原有内容). 修改已有文件优先用 edit_file. 目标已经是二进制文件时会被拒绝覆盖. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径; 父目录不存在会自动创建. **已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换. **必须给 line** (read_file 打出来的行号):替换只发生在那一行里, old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长. 行号对不上会报出 old_string 真正在哪几行, 照着改 line 即可. ","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号, **必填**. 就是 read_file 打出来的那个行号 (从 1 开始)—— 没见过这个文件就先 read_file 看一眼. 替换只会发生在这一行里. "},"old_string":{"type":"string","description":"被替换的原文, 必须与文件内容逐字符一致 (含缩进), 且**在 line 那一行里**只有一处匹配 (匹配可以跨行, 起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配 (line 仍必须指向其中真实的一处), 默认 false (只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"tLine truncated
----- 响应头 -----
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:24 GMT
Content-Type: text/event-stream
Cache-Control: no-cache
HTTP/1.0 200 OK
Server: BaseHTTP/0.6 Python/3.14.7
Date: Mon, 21 Sep 2026 09:09:24 GMT
Content-Type: text/event-stream
Cache-Control: no-cache
data: {"choices": [{"index": 0, "delta": {"content": "FINAL: hello-from-t"}, "finish_reason": null}]}
data: {"choices": [{"index": 0, "delta": {"content": "ool / [exit code: 0]"}, "finish_reason": null}]}
+20 -20
View File
@@ -1,12 +1,12 @@
/* fuzz.c - 对抗性模糊测试:JSON 解析器 + Markdown 渲染器
*
* 这两个模块是"解析不可信输入"的地方(服务端响应、模型输出),
* 也是最可能出崩溃/越界/泄漏的地方。所以用随机输入去砸它们。
* 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出),
* 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们.
*
* 检查的不变量:
* JSON
* 1. 任何输入都不能崩溃、不能泄漏(靠 ASan/UBSan/LeakSanitizer)
* 2. 解析成功 => dump 再 parse 再 dump,两次 dump 必须一致(幂等)
* 1. 任何输入都不能崩溃、不能泄漏 (靠 ASan/UBSan/LeakSanitizer)
* 2. 解析成功 => dump 再 parse 再 dump, 两次 dump 必须一致 (幂等)
* 3. 随机生成的合法 JSON 必须解析成功并完整往返
* Markdown
* 4. 任何输入都不能崩溃、不能泄漏
@@ -104,17 +104,17 @@ static int fuzz_json_once(char *err, size_t errlen)
int mutate = (int)rnd_below(2);
if (mutate) {
/* 变异模式:先造合法 JSON,再随机破坏 */
/* 变异模式:先造合法 JSON, 再随机破坏 */
gen_json(&sb, 0);
size_t n = sb.len;
if (n == 0) { sb_free(&sb); return 0; }
/* 每次突变都必须基于"当前"长度。早期版本用了突变前记下的旧长度,
* 截断之后再按下标写就会覆盖掉结尾的 '\0',后续 strlen 冲出分配区,
* 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟。 */
/* 每次突变都必须基于"当前"长度. 早期版本用了突变前记下的旧长度,
* 截断之后再按下标写就会覆盖掉结尾的 '\0', 后续 strlen 冲出分配区,
* 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟. */
uint32_t muts = 1 + rnd_below(4);
for (uint32_t i = 0; i < muts && sb.len > 0; i++) {
uint32_t op = rnd_below(3);
if (op == 0) { /* 翻一个字节(下标恒 < len) */
if (op == 0) { /* 翻一个字节 (下标恒 < len) */
sb.data[rnd_below((uint32_t)sb.len)] = (char)(rnd() & 0xFF);
} else if (op == 1) { /* 截断 */
sb.len = rnd_below((uint32_t)sb.len);
@@ -128,14 +128,14 @@ static int fuzz_json_once(char *err, size_t errlen)
}
}
}
/* 无论怎么突变,结尾符必须是好的 */
/* 无论怎么突变, 结尾符必须是好的 */
if (sb.data) sb.data[sb.len] = '\0';
n = sb.len;
} else {
/* 纯随机字节 */
uint32_t n = rnd_below(256);
for (uint32_t i = 0; i < n; i++) {
/* 偏向 JSON 元字符,提高触发率 */
/* 偏向 JSON 元字符, 提高触发率 */
static const char meta[] = "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u";
char c = (rnd() & 3) ? meta[rnd_below(sizeof meta - 1)]
: (char)(rnd() & 0xFF);
@@ -146,7 +146,7 @@ static int fuzz_json_once(char *err, size_t errlen)
const char *input = sb.data ? sb.data : "";
size_t inlen = sb.len;
/* 输入里不能有 NUL,否则不是合法的 C 字符串测试对象 */
/* 输入里不能有 NUL, 否则不是合法的 C 字符串测试对象 */
for (size_t i = 0; i < inlen; i++) {
if (input[i] == '\0') {
sb_free(&sb);
@@ -196,7 +196,7 @@ static int fuzz_json_once(char *err, size_t errlen)
#define MD_RENDER_LIMIT (1u << 20)
/* 渲染到内存;chunk==0 表示一次性 */
/* 渲染到内存; chunk==0 表示一次性 */
static char *md_render(const char *src, size_t len, size_t chunk)
{
char *buf = NULL;
@@ -224,7 +224,7 @@ static char *md_render(const char *src, size_t len, size_t chunk)
static int markdown_metachar(int c)
{
/* 这些字符会触发渲染器改写输出;其余字符必须原样通过 */
/* 这些字符会触发渲染器改写输出; 其余字符必须原样通过 */
return c == '*' || c == '_' || c == '`' || c == '[' || c == ']' ||
c == '#' || c == '>' || c == '-' || c == '+' || c == '|' ||
c == '\n' || c == '\r';
@@ -278,9 +278,9 @@ static int fuzz_md_once(char *err, size_t errlen)
if (!part) continue;
if (strcmp(whole, part) != 0) {
snprintf(err, errlen,
"分片不变性被破坏(%zu 字节/片)",
"分片不变性被破坏 (%zu 字节/片)",
chunks[k]);
/* 落盘,方便用 build/reduce 做最小化 */
/* 落盘, 方便用 build/reduce 做最小化 */
FILE *fp = fopen("build/md_fail.bin", "wb");
if (fp) { fwrite(src, 1, len, fp); fclose(fp); }
free(part); free(whole); sb_free(&sb);
@@ -315,7 +315,7 @@ int main(int argc, char **argv)
char err[512];
long json_fail = 0, md_fail = 0;
printf("模糊测试:JSON %ld 轮 + Markdown %ld 轮(种子 %llu)\n",
printf("模糊测试:JSON %ld 轮 + Markdown %ld 轮 (种子 %llu)\n",
iters, iters, (unsigned long long)RNG);
for (long i = 0; i < iters; i++) {
@@ -325,7 +325,7 @@ int main(int argc, char **argv)
if (++json_fail >= 3) break;
}
}
printf(" JSON %ld 轮,%ld 次失败\n", iters, json_fail);
printf(" JSON %ld 轮, %ld 次失败\n", iters, json_fail);
for (long i = 0; i < iters; i++) {
err[0] = '\0';
@@ -334,12 +334,12 @@ int main(int argc, char **argv)
if (++md_fail >= 3) break;
}
}
printf(" Markdown %ld 轮,%ld 次失败\n", iters, md_fail);
printf(" Markdown %ld 轮, %ld 次失败\n", iters, md_fail);
if (json_fail || md_fail) {
printf("\n模糊测试发现 %ld 个问题\n", json_fail + md_fail);
return 1;
}
printf("\n模糊测试通过(无崩溃、无泄漏、不变量全部成立)\n");
printf("\n模糊测试通过 (无崩溃、无泄漏、不变量全部成立)\n");
return 0;
}
+110 -110
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""mock_server.py - 最小的 OpenAI 兼容服务端,用于端到端测试 ligent。
"""mock_server.py - 最小的 OpenAI 兼容服务端, 用于端到端测试 ligent.
不依赖任何第三方包。行为完全由请求内容决定,保持确定性:
不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性:
- 用户消息含 ERROR_TEST -> 返回 HTTP 400 + error 对象
- 尚未有 tool 结果 -> 流式返回一个 tool_call(run_command echo ...)
- 尚未有 tool 结果 -> 流式返回一个 tool_call (run_command echo ...)
- 用户消息含 twice 且只执行过一次工具 -> 再返回第二个 tool_call
- 用户消息含 MANYSTEPS_TEST -> 连要 4 次工具往返才收工
- 其余 -> 流式/非流式返回最终文本,内容里回显最近一次工具输出
- 其余 -> 流式/非流式返回最终文本, 内容里回显最近一次工具输出
"""
import json
import os
@@ -26,12 +26,12 @@ def sse(wfile, obj):
def pairing_error(msgs):
"""服务端的硬要求:带 tool_calls 的 assistant 后面必须**紧跟**与每个
tool_call_id 一一对应的 tool 消息,中间不能夹别的东西。
tool_call_id 一一对应的 tool 消息, 中间不能夹别的东西.
真实服务端就是这么回 400 的,mock 也照做 —— 这样任何会写坏历史的回归
(比如把图片附件插在两条 tool 消息中间、或者中断时丢下没回应的调用)
都会在 e2e 里当场炸出来,而不是等到用户真拿它去连服务端才发现。
返回错误文案,没问题返回 None。"""
真实服务端就是这么回 400 的, mock 也照做 —— 这样任何会写坏历史的回归
(比如把图片附件插在两条 tool 消息中间、或者中断时丢下没回应的调用)
都会在 e2e 里当场炸出来, 而不是等到用户真拿它去连服务端才发现.
返回错误文案, 没问题返回 None. """
i, n = 0, len(msgs)
while i < n:
m = msgs[i]
@@ -57,9 +57,9 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
# -- 工具调用(流式增量) --
# -- 工具调用 (流式增量) --
def emit_tool_call(self, index, call_id, name, arguments):
# 故意把 arguments 切成多片,检验客户端的增量拼接
# 故意把 arguments 切成多片, 检验客户端的增量拼接
half = len(arguments) // 2
sse(self.wfile, {"choices": [{"index": 0, "delta": {
"role": "assistant", "content": ""}, "finish_reason": None}]})
@@ -87,7 +87,7 @@ class Handler(BaseHTTPRequestHandler):
"total_tokens": 2}})
def reject(self, status, message):
"""回一个错误响应(探测/校验用)"""
"""回一个错误响应 (探测/校验用)"""
payload = json.dumps({"error": {"message": message}}).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
@@ -96,7 +96,7 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(payload)
def send_text(self, text, stream, model):
"""按流式/非流式把一段最终文本发回去。"""
"""按流式/非流式把一段最终文本发回去. """
if not stream:
payload = json.dumps({"choices": [{"index": 0,
"message": {"role": "assistant",
@@ -118,13 +118,13 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.flush()
def do_GET(self):
# doctor 的连通性/重定向检查走 GET。
# /redirect 开头故意返回 307,用来验证客户端能把重定向指出来。
# doctor 的认证探测:端点要求认证,且只认 "Bearer good-key"。
# 带对 key -> 200(认证过了)
# 带错 key -> 401(密钥被拒)
# 不带 key -> 401(端点要求认证,而你没配)
# 客户端要靠"有没有配 key"把后两种 401 区分开。
# doctor 的连通性/重定向检查走 GET.
# /redirect 开头故意返回 307, 用来验证客户端能把重定向指出来.
# doctor 的认证探测:端点要求认证, 且只认 "Bearer good-key".
# 带对 key -> 200 (认证过了)
# 带错 key -> 401 (密钥被拒)
# 不带 key -> 401 (端点要求认证, 而你没配)
# 客户端要靠"有没有配 key"把后两种 401 区分开.
if self.path.startswith("/authed"):
auth = self.headers.get("Authorization") or ""
ok = (auth == "Bearer good-key")
@@ -135,13 +135,13 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(b"{}")
return
# /model 不带参数时拉模型列表。
# 故意混进一个重复项和一个缺 id 的条目 —— 客户端要能去重、跳过坏条目。
# MODEL_LIST=bare 时改回裸数组,MODEL_LIST=empty 时不返回任何模型,
# 两种兼容/异常形状都由 e2e 覆盖。
# /model 不带参数时拉模型列表.
# 故意混进一个重复项和一个缺 id 的条目 —— 客户端要能去重、跳过坏条目.
# MODEL_LIST=bare 时改回裸数组, MODEL_LIST=empty 时不返回任何模型,
# 两种兼容/异常形状都由 e2e 覆盖.
if "/models" in self.path:
# 模式从**文件**读,不从环境变量读:mock 是脚本开头起好的,
# 给客户端进程设的 env 它根本看不见(第一版就是这么假通过的)。
# 模式从**文件**读, 不从环境变量读:mock 是脚本开头起好的,
# 给客户端进程设的 env 它根本看不见 (第一版就是这么假通过的).
mode = ""
mode_file = os.environ.get("MODELS_MODE_FILE", "")
if mode_file:
@@ -157,9 +157,9 @@ class Handler(BaseHTTPRequestHandler):
elif mode == "garbage":
payload = b"<html>not json at all</html>"
else:
# 默认列表里**故意包含当前模型**(deepseek-flash):
# 这样才验得了"光标初始落在当前项上"。
# 另外塞一个重复项和一个缺 id 的坏条目,验去重与跳过。
# 默认列表里**故意包含当前模型** (deepseek-flash):
# 这样才验得了"光标初始落在当前项上".
# 另外塞一个重复项和一个缺 id 的坏条目, 验去重与跳过.
payload = json.dumps({"object": "list", "data": [
{"id": "deepseek-flash", "object": "model", "owned_by": "mock"},
{"id": "alpha-model", "object": "model", "owned_by": "mock"},
@@ -195,11 +195,11 @@ class Handler(BaseHTTPRequestHandler):
# DeepSeek 对请求体里非法的 UTF-8 回的是
# 400 Failed to parse the request body as JSON:
# messages[43].content: invalid unicode code point
# 而坏数据一旦进了对话历史,之后**每一次**请求都会失败,除非丢掉整轮
# 对话。所以 mock 必须一样严格,否则"序列化器吐出了非法 UTF-8"这类 bug
# 在 e2e 里永远看不见。
# (json.loads(bytes) 其实也会因此抛异常、被下面那个 except 兜成 400,
# 但那条笼统的 400 什么都没说明,而且换个实现就可能漏掉,所以显式判。)
# 而坏数据一旦进了对话历史, 之后**每一次**请求都会失败, 除非丢掉整轮
# 对话. 所以 mock 必须一样严格, 否则"序列化器吐出了非法 UTF-8"这类 bug
# 在 e2e 里永远看不见.
# (json.loads(bytes) 其实也会因此抛异常、被下面那个 except 兜成 400,
# 但那条笼统的 400 什么都没说明, 而且换个实现就可能漏掉, 所以显式判. )
try:
raw.decode("utf-8")
except UnicodeDecodeError as exc:
@@ -208,7 +208,7 @@ class Handler(BaseHTTPRequestHandler):
return
# ---- 假的 Files API ----
# 只做够用的部分:认 multipart 里有没有图片内容,发一个递增的 file_id。
# 只做够用的部分:认 multipart 里有没有图片内容, 发一个递增的 file_id.
try:
body = json.loads(raw or b"{}")
except Exception:
@@ -219,9 +219,9 @@ class Handler(BaseHTTPRequestHandler):
model = body.get("model", "?")
stream = bool(body.get("stream"))
# 把 user 消息拍平成纯文本再匹配。带附件的消息 content 是**块数组**
# (/include、read_image 的附件都是),不拍平的话 first_user 会是个
# list,`"标记" in list` 永远不成立 —— 分支会莫名其妙地匹配不上。
# 把 user 消息拍平成纯文本再匹配. 带附件的消息 content 是**块数组**
# (/include、read_image 的附件都是), 不拍平的话 first_user 会是个
# list, `"标记" in list` 永远不成立 —— 分支会莫名其妙地匹配不上.
def user_text(m):
c = m.get("content")
if isinstance(c, str):
@@ -244,9 +244,9 @@ class Handler(BaseHTTPRequestHandler):
return
# ---- 能力探测序列 ----
# ligent 会在第一次真实请求之前发几个试探请求(基线 / 图片 / tools),
# 它们会被真实处理,所以这里必须排在所有标记分支之前。
# 辨认靠形状:刚好一条 user 消息,内容是 "1",或者带一张 1x1 的图。
# ligent 会在第一次真实请求之前发几个试探请求 (基线 / 图片 / tools),
# 它们会被真实处理, 所以这里必须排在所有标记分支之前.
# 辨认靠形状:刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图.
def looks_like_probe(msgs):
if len(msgs) != 1 or msgs[0].get("role") != "user":
return False
@@ -293,15 +293,15 @@ class Handler(BaseHTTPRequestHandler):
self.send_text("ELEV_SAW[%s]" % got.replace("\n", " | "), stream, model)
return
# /include:把收到的 user 内容块报回来。
# 用 first_user 判断——附带之后最新那条 user 是带块的消息,last_user 会漂。
# /include:把收到的 user 内容块报回来.
# 用 first_user 判断——附带之后最新那条 user 是带块的消息, last_user 会漂.
if "INCLUDE_TEST" in first_user:
texts, imgs, names = 0, 0, []
for m in messages:
if m.get("role") != "user":
continue
c = m.get("content")
# 没有附件时 content 是纯字符串,那也算"一个文本块"
# 没有附件时 content 是纯字符串, 那也算"一个文本块"
if isinstance(c, str):
texts += 1
continue
@@ -315,14 +315,14 @@ class Handler(BaseHTTPRequestHandler):
t = p.get("text", "")
for line in t.split("\n"):
if line.startswith("===== "):
names.append(line[6:].split("(")[0])
names.append(line[6:].split(" (")[0])
elif p.get("type") == "image_url":
imgs += 1
self.send_text("INC[text=%d img=%d files=%s]"
% (texts, imgs, ",".join(names)), stream, model)
return
# 回显收到的工具名,用来验证"不该暴露的工具确实没发出去"
# 回显收到的工具名, 用来验证"不该暴露的工具确实没发出去"
if "TOOLS_ECHO_TEST" in first_user:
names = [((t or {}).get("function") or {}).get("name")
for t in (tools_sent or [])]
@@ -343,8 +343,8 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(payload)
return
# 消息条数超过阈值就报"上下文超限",用于验证客户端的自动裁剪。
# 客户端丢掉最老的一轮后条数会降下来,于是下一轮就能成功。
# 消息条数超过阈值就报"上下文超限", 用于验证客户端的自动裁剪.
# 客户端丢掉最老的一轮后条数会降下来, 于是下一轮就能成功.
if "TOOLONG_TEST" in first_user:
if len(messages) > 3:
payload = json.dumps({"error": {
@@ -358,7 +358,7 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(payload)
return
text = "当前历史 %d 条消息,正常回复" % len(messages)
text = "当前历史 %d 条消息, 正常回复" % len(messages)
payload = json.dumps({"choices": [{"index": 0,
"message": {"role": "assistant",
"content": text},
@@ -370,7 +370,7 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(payload)
return
# 把收到的用户消息原样回显,用于验证"一次请求里到底装了什么"
# 把收到的用户消息原样回显, 用于验证"一次请求里到底装了什么"
if "ECHO_TEST" in first_user:
text = "ECHO[" + first_user.replace("\n", "\\n") + "]"
payload = json.dumps({"choices": [{"index": 0,
@@ -384,8 +384,8 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(payload)
return
# 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了,
# 但文案说的是 "Missing Authentication header",容易把人引偏
# 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了,
# 但文案说的是 "Missing Authentication header", 容易把人引偏
if "AUTHFAIL_TEST" in first_user:
payload = json.dumps({"error": {"message": "Missing Authentication header",
"code": 401}}).encode()
@@ -396,8 +396,8 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(payload)
return
# 重定向:用于验证客户端不静默跟随(跨源跟随会丢掉 Authorization,
# 服务端只会返回 401 "Missing Authentication header",误导用户去查 key)
# 重定向:用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization,
# 服务端只会返回 401 "Missing Authentication header", 误导用户去查 key)
if "REDIRECT_TEST" in first_user:
self.send_response(307)
self.send_header("Location", "https://example.invalid/v1/chat/completions")
@@ -405,14 +405,14 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
return
# 前几次返回 503,用于验证重试与 Retry-After
# 前几次返回 503, 用于验证重试与 Retry-After
if "RETRY_TEST" in first_user:
key = "retry:" + first_user
STATE[key] = STATE.get(key, 0) + 1
STATE["retry"] = STATE[key]
if STATE["retry"] < 3:
payload = json.dumps({"error": {
"message": "mock 临时不可用(第 %d 次)" % STATE["retry"]}}).encode()
"message": "mock 临时不可用 (第 %d 次)" % STATE["retry"]}}).encode()
self.send_response(503)
self.send_header("Content-Type", "application/json")
self.send_header("Retry-After", "1")
@@ -420,7 +420,7 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(payload)
return
text = "重试后成功(共尝试 %d 次)" % STATE["retry"]
text = "重试后成功 (共尝试 %d 次)" % STATE["retry"]
payload = json.dumps({"choices": [{"index": 0,
"message": {"role": "assistant",
"content": text},
@@ -432,7 +432,7 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(payload)
return
# 永远 503,用于验证重试次数耗尽后正确报错
# 永远 503, 用于验证重试次数耗尽后正确报错
if "ALWAYS503_TEST" in first_user:
payload = json.dumps({"error": {"message": "mock 持续不可用"}}).encode()
self.send_response(503)
@@ -459,7 +459,7 @@ class Handler(BaseHTTPRequestHandler):
pass # 客户端主动断开是预期行为
return
# 畸形 SSE:各种不合法的片段,客户端必须不崩溃且给出合理结果
# 畸形 SSE:各种不合法的片段, 客户端必须不崩溃且给出合理结果
if "GARBAGE_TEST" in first_user:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
@@ -490,14 +490,14 @@ class Handler(BaseHTTPRequestHandler):
self.close_connection = True
return
# 固定的 Markdown 回复,用于验证流式渲染
# 固定的 Markdown 回复, 用于验证流式渲染
if "MARKDOWN_TEST" in first_user:
md = (
"# 一级标题\n"
"## 二级标题\n"
"\n"
"这是 **粗体** 和 *斜体* 和 _也是斜体_ 和 `code` 混排。\n"
"参考 [官方文档](https://example.com/doc) 与 arr[i]。\n"
"这是 **粗体** 和 *斜体* 和 _也是斜体_ 和 `code` 混排. \n"
"参考 [官方文档](https://example.com/doc) 与 arr[i]. \n"
"\n"
"- 列表甲\n- 列表乙\n"
"\n"
@@ -526,7 +526,7 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
# 故意按 1 字符一片下发,最大化"标记被切断"的概率
# 故意按 1 字符一片下发, 最大化"标记被切断"的概率
for ch in md:
sse(self.wfile, {"choices": [{"index": 0,
"delta": {"content": ch},
@@ -537,14 +537,14 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.flush()
return
# 思考模式:验证 reasoning_content 既能被累积,也能随 assistant 消息
# 回传。DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上,
# 漏了下一轮就 400,所以这里第二轮专门检查它有没有回来。
# 思考模式:验证 reasoning_content 既能被累积, 也能随 assistant 消息
# 回传. DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上,
# 漏了下一轮就 400, 所以这里第二轮专门检查它有没有回来.
if "THINK_TEST" in first_user:
if not tool_msgs:
if not stream:
msg = {"role": "assistant", "content": None,
"reasoning_content": "先想一下要不要调用工具。",
"reasoning_content": "先想一下要不要调用工具. ",
"tool_calls": [{"id": "call_1", "type": "function",
"function": {
"name": "run_command",
@@ -562,8 +562,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
# 思考过程分片下发:客户端必须累积,而不是只留最后一片
for part in ("先想一下", "要不要调用工具。"):
# 思考过程分片下发:客户端必须累积, 而不是只留最后一片
for part in ("先想一下", "要不要调用工具. "):
sse(self.wfile, {"choices": [{"index": 0,
"delta": {"reasoning_content": part},
"finish_reason": None}]})
@@ -587,10 +587,10 @@ class Handler(BaseHTTPRequestHandler):
body.get("reasoning_effort") or "none"), stream, model)
return
# 慢速响应:先静默一段(网络/排队),再吐思维链,再静默一段,
# 最后给正文。用来验证等待动画的两段标签:"等待响应" → "思考中"。
# 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的,
# 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡。
# 慢速响应:先静默一段 (网络/排队), 再吐思维链, 再静默一段,
# 最后给正文. 用来验证等待动画的两段标签:"等待响应" → "思考中".
# 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的,
# 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡.
if "SLOWRESP_TEST" in first_user:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
@@ -598,14 +598,14 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
time.sleep(0.8) # 这期间一个字节都没发
sse(self.wfile, {"choices": [{"index": 0,
"delta": {"reasoning_content": "先想一下。"},
"delta": {"reasoning_content": "先想一下. "},
"finish_reason": None}]})
time.sleep(0.8) # 这期间模型在推理
self.send_text("答案", stream=True, model=model)
return
# meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮,
# 第二轮收工。用来端到端验证 /meta 的完整链路。
# meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮,
# 第二轮收工. 用来端到端验证 /meta 的完整链路.
if "META_TEST" in first_user:
if not tool_msgs:
self.send_response(200)
@@ -623,11 +623,11 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
return
self.send_text("已删除第 2 轮。", stream, model)
self.send_text("已删除第 2 轮. ", stream, model)
return
# 按"最后一条用户消息"触发:上面那些模式都看 first_user,
# 同一个进程里的第二轮就再也触发不了了,没法验证"作用域仅限本轮"。
# 按"最后一条用户消息"触发:上面那些模式都看 first_user,
# 同一个进程里的第二轮就再也触发不了了, 没法验证"作用域仅限本轮".
if "WRITE_LAST_TEST" in last_user:
if messages[-1].get("role") == "user": # 新一轮的开头
args = json.dumps({"path": "build/tmp/confirm_probe_turn2.txt",
@@ -686,11 +686,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_text("写完了", stream, model)
return
# 读一个**含非法 UTF-8 字节**的文件。工具输出里出现坏字节的来源很日常:
# 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常:
# 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多字节字符、二进制
# 文件里混的一小段文本。这段字节会进对话历史,然后被序列化进请求体 ——
# 如果序列化器原样吐出,服务端就会 400,而且之后每次请求都 400。
# 这个分支的作用就是让那条路径在 e2e 里真的被走一遍。
# 文件里混的一小段文本. 这段字节会进对话历史, 然后被序列化进请求体 ——
# 如果序列化器原样吐出, 服务端就会 400, 而且之后每次请求都 400.
# 这个分支的作用就是让那条路径在 e2e 里真的被走一遍.
if "UTF8_TEST" in first_user:
if not tool_msgs:
args = json.dumps({"path": "build/tmp/bad_utf8.txt"})
@@ -715,18 +715,18 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
return
# 第二轮:能走到这里就说明请求体过了上面的 UTF-8 校验。
# 把工具消息里有没有替换字符也顺带报出来,便于断言。
# 第二轮:能走到这里就说明请求体过了上面的 UTF-8 校验.
# 把工具消息里有没有替换字符也顺带报出来, 便于断言.
got = ""
for m in tool_msgs:
c = m.get("content")
if isinstance(c, str):
got += c
self.send_text("读完了,替换字符=%d" % got.count("\ufffd"), stream, model)
self.send_text("读完了, 替换字符=%d" % got.count("\ufffd"), stream, model)
return
# 一轮里连写两个文件:用来验证 Always/neVer 的作用域就是"这一轮"
# (名字刻意不含 "WRITE_TEST",免得被上面那个分支先截胡)
# (名字刻意不含 "WRITE_TEST", 免得被上面那个分支先截胡)
if "WRITE2_TEST" in first_user:
n = len(tool_msgs)
if n < 2:
@@ -757,12 +757,12 @@ class Handler(BaseHTTPRequestHandler):
self.send_text("两个都写完了", stream, model)
return
# 让模型去读一个"扩展名是 .png、内容不是图片"的文件,
# 让模型去读一个"扩展名是 .png、内容不是图片"的文件,
# 验证 read_image 按内容判断、并明确拒绝
# 两种"读不了":内容不是图片、以及大得内联不进去。
# 注意:mock 的标记靠子串匹配,是平铺的命名空间 —— 上面第 249 行的
# HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记,所以这里叫 OVERSIZE。
# 两者都该在**本地**被拒 —— 工具返回错误,之后就没有图片块可发。
# 两种"读不了":内容不是图片、以及大得内联不进去.
# 注意:mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的
# HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记, 所以这里叫 OVERSIZE.
# 两者都该在**本地**被拒 —— 工具返回错误, 之后就没有图片块可发.
if "IMAGE_BAD_TEST" in last_user or "IMAGE_OVERSIZE_TEST" in last_user:
if not tool_msgs:
path = ("build/tmp/huge.png" if "IMAGE_OVERSIZE_TEST" in last_user
@@ -779,11 +779,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_text("读不了就算了", stream, model)
return
# 一条 assistant 里发**两个**工具调用,第一个是 read_image。
# 一条 assistant 里发**两个**工具调用, 第一个是 read_image.
# 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状:
# 只要附件没被攒到最后一起推,上面的结构校验就会回 400。
# 注意用 first_user:图片附件本身也是一条 user 消息,而且排在最后,
# 所以第二轮请求的 last_user 是那个附件(一个 list),不是这条标记。
# 只要附件没被攒到最后一起推, 上面的结构校验就会回 400.
# 注意用 first_user:图片附件本身也是一条 user 消息, 而且排在最后,
# 所以第二轮请求的 last_user 是那个附件 (一个 list), 不是这条标记.
if "IMAGE_PAIR_TEST" in first_user:
if not tool_msgs:
self.send_response(200)
@@ -808,8 +808,8 @@ class Handler(BaseHTTPRequestHandler):
% (len(found), len(tool_msgs)), stream, model)
return
# 图片:先让模型调一次 read_image,下一轮把收到的图片内联块报回来。
# Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64。
# 图片:先让模型调一次 read_image, 下一轮把收到的图片内联块报回来.
# Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64.
if "IMAGE_TEST" in first_user:
if not tool_msgs:
args = json.dumps({"path": "build/tmp/dot.png"})
@@ -835,7 +835,7 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.flush()
return
# 第二轮:请求里应该已经带上内联图片了。
# 第二轮:请求里应该已经带上内联图片了.
# 报文形状:{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}
found, notes = [], []
for m in messages:
@@ -850,14 +850,14 @@ class Handler(BaseHTTPRequestHandler):
head = url.split(";", 1)[0] # data:image/png
b64 = url.split(",", 1)[1] if "," in url else ""
found.append("%s:%d" % (head, len(b64)))
# 图片被拒之后,附件消息里应该换成一句给模型看的说明。
# 把它回显出来,端到端才能证明**模型真的收到了**这句话,
# 而不只是我们自己的历史被改了。
# 图片被拒之后, 附件消息里应该换成一句给模型看的说明.
# 把它回显出来, 端到端才能证明**模型真的收到了**这句话,
# 而不只是我们自己的历史被改了.
elif part.get("type") == "text" and m.get("role") == "user":
notes.append(part.get("text", ""))
# IMAGE_REJECT_TEST:第一次故意说服务端不收这张图,
# 客户端应该把图片从历史里摘掉再试(而不是每轮都失败)
# IMAGE_REJECT_TEST:第一次故意说服务端不收这张图,
# 客户端应该把图片从历史里摘掉再试 (而不是每轮都失败)
if "IMAGE_REJECT_TEST" in last_user and not STATE.get("told_once"):
STATE["told_once"] = 1
payload = json.dumps({"error": {
@@ -874,16 +874,16 @@ class Handler(BaseHTTPRequestHandler):
self.send_text(reply, stream, model)
return
# edit_file 的**调用展示**:成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照,
# 而不是一整段参数 JSON(old_string/new_string 原样打出来会刷屏)。
# EDITTEST_OK 成功剧本,EDITTEST_FAIL 行号越界剧本。
# edit_file 的**调用展示**:成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照,
# 而不是一整段参数 JSON (old_string/new_string 原样打出来会刷屏).
# EDITTEST_OK 成功剧本, EDITTEST_FAIL 行号越界剧本.
if "EDITTEST_OK" in first_user or "EDITTEST_FAIL" in first_user:
if tool_msgs:
self.send_text("DONE[%s]" % tool_msgs[-1].get("content", "")[:60],
stream, model)
return
line = 999 if "EDITTEST_FAIL" in first_user else 2
# 12 行的 old_string:既覆盖"多行改动"的显示,也覆盖"超过 8 行要折叠"
# 12 行的 old_string:既覆盖"多行改动"的显示, 也覆盖"超过 8 行要折叠"
old = "\n".join("OLD%d" % i for i in range(1, 13)) if line == 2 else "OLD"
args = json.dumps({"path": "build/tmp/edit_probe.txt", "line": line,
"old_string": old, "new_string": "NEW"})
@@ -909,8 +909,8 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.flush()
return
# 多步(4 次工具往返才收工):用来钉住"往返次数默认不限"。
# 默认配置下必须跑得完;显式设了正数上限时要停在那个上限上并如实报出。
# 多步 (4 次工具往返才收工):用来钉住"往返次数默认不限".
# 默认配置下必须跑得完; 显式设了正数上限时要停在那个上限上并如实报出.
if "MANYSTEPS_TEST" in first_user:
done = len(tool_msgs)
if done < 4:
+4 -4
View File
@@ -1,6 +1,6 @@
/* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例
*
* 判定(oracle):同一段输入,"一次性喂" 与 "逐字节喂" 的渲染结果不一致。
* 判定 (oracle):同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致.
* 用法: build/reduce build/md_fail.bin
*/
#include "markdown.h"
@@ -9,8 +9,8 @@
#include <stdlib.h>
#include <string.h>
/* 注意:md_feed 只接受 NUL 结尾的字符串,而最小化过程中的缓冲区
* 是"长度受限"的,所以必须先做一份带结尾符的副本。 */
/* 注意:md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区
* 是"长度受限"的, 所以必须先做一份带结尾符的副本. */
static char *render(const char *src, size_t len, size_t chunk)
{
char *text = xmalloc(len + 1);
@@ -44,7 +44,7 @@ static char *render(const char *src, size_t len, size_t chunk)
static int fails(const char *src, size_t len)
{
if (len == 0) return 0;
/* md_feed 以 NUL 结尾,输入里不能有 NUL */
/* md_feed 以 NUL 结尾, 输入里不能有 NUL */
for (size_t i = 0; i < len; i++) if (src[i] == '\0') return 0;
char *a = render(src, len, 0);
char *b = render(src, len, 1);
+14 -14
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""repl_drive.py - 用 pty 驱动 ligent 的 REPL,跑多轮对话并打印全部输出。
"""repl_drive.py - 用 pty 驱动 ligent 的 REPL, 跑多轮对话并打印全部输出.
交互式行为(多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把
整段 stdin 当成一个任务。所以这里开一个伪终端,逐条发送提示词。
交互式行为 (多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把
整段 stdin 当成一个任务. 所以这里开一个伪终端, 逐条发送提示词.
用法:
repl_drive.py --bin ./ligent --base-url URL --prompt "第一轮" --prompt "第二轮"
@@ -24,25 +24,25 @@ def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5,
os._exit(127)
out = bytearray()
# 行编辑器每次回到提示符都会打开括号粘贴,所以这个序列就是"轮到用户输入了"
# 的信号。用它做同步点,比"等输出安静下来"可靠也快得多。
# 行编辑器每次回到提示符都会打开括号粘贴, 所以这个序列就是"轮到用户输入了"
# 的信号. 用它做同步点, 比"等输出安静下来"可靠也快得多.
PROMPT_READY = b"\x1b[?2004h"
ready_seen = 0
tail = b""
def note(chunk):
# 标记可能被 read 切断,所以要连着上一块的尾巴一起找
# 标记可能被 read 切断, 所以要连着上一块的尾巴一起找
nonlocal tail, ready_seen
buf = tail + chunk
ready_seen += buf.count(PROMPT_READY)
tail = buf[-(len(PROMPT_READY) - 1):]
def drain(timeout, idle=0.5, until_ready=False):
"""等到"回到提示符"(until_ready),或者输出彻底安静下来。
"""等到"回到提示符" (until_ready), 或者输出彻底安静下来.
必须按标记等,不能只按安静等:等待动画每 250ms 画一帧,pty 永远
不会安静超过 idle,于是每次都要白等满 timeout —— 而 mock 其实几
毫秒就答复了。这是整个 e2e 从 126 秒掉下来的关键。
必须按标记等, 不能只按安静等:等待动画每 250ms 画一帧, pty 永远
不会安静超过 idle, 于是每次都要白等满 timeout —— 而 mock 其实几
毫秒就答复了. 这是整个 e2e 从 126 秒掉下来的关键.
"""
want = ready_seen + 1 if until_ready else None
deadline = time.time() + timeout
@@ -67,7 +67,7 @@ def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5,
drain(quiet_after, until_ready=True) # 等横幅画完、第一次回到提示符
if raw_keys is not None:
# 逐字节发送原始按键序列(用于测方向键等编辑行为)
# 逐字节发送原始按键序列 (用于测方向键等编辑行为)
for chunk, pause in raw_keys:
os.write(fd, chunk if isinstance(chunk, bytes) else chunk.encode("utf-8"))
drain(pause)
@@ -112,11 +112,11 @@ def main():
ap.add_argument("--extra", action="append", default=[])
ap.add_argument("--wait", type=float, default=2.0)
ap.add_argument("--keys", action="append", default=[],
help="原始按键序列(支持 \\x1b 转义),按顺序发送")
help="原始按键序列 (支持 \\x1b 转义), 按顺序发送")
a = ap.parse_args()
# --no-history:被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾。
# 需要历史的调用方用 --extra=--history PATH(排在后面,会覆盖这一条)。
# --no-history:被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾.
# 需要历史的调用方用 --extra=--history PATH (排在后面, 会覆盖这一条).
args = ["--api-key", a.api_key, "--base-url", a.base_url, "--no-color",
"--no-confirm", "--no-auto-save", "--no-history"] + a.extra
if a.keys:
+47 -47
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""repl_edit_check.py - 验证 REPL 的行编辑行为(方向键、Home/End、退格、历史)
"""repl_edit_check.py - 验证 REPL 的行编辑行为 (方向键、Home/End、退格、历史)
只抓字节流是不够的:光标移动是靠转义序列实现的,必须把输出回放到一个
终端模型里才能知道"屏幕上最终长什么样"。所以这里带一个极简终端模拟器,
只实现本编辑器用到的那几条控制序列。
只抓字节流是不够的:光标移动是靠转义序列实现的, 必须把输出回放到一个
终端模型里才能知道"屏幕上最终长什么样". 所以这里带一个极简终端模拟器,
只实现本编辑器用到的那几条控制序列.
"""
import os
import pty
@@ -41,7 +41,7 @@ class Term:
if j < n and data[j] in '[O':
j += 1
start = j
# CSI 的参数可以包含 ? < > ! 等私有标记,一律扫到终结符为止
# CSI 的参数可以包含 ? < > ! 等私有标记, 一律扫到终结符为止
while j < n and not ('@' <= data[j] <= '~'):
j += 1
params = data[start:j]
@@ -99,8 +99,8 @@ class Term:
def to_bytes(s):
# 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节。
# 不能直接用 unicode_escape:一条串里既有转义又有中文时,latin-1 那步会报错。
# 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节.
# 不能直接用 unicode_escape:一条串里既有转义又有中文时, latin-1 那步会报错.
if isinstance(s, bytes):
return s
out = bytearray()
@@ -145,12 +145,12 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03):
tail = buf[-(len(PROMPT_READY) - 1):]
def drain(timeout, idle=idle, until_ready=False):
"""安静下来就返回,不把整个 timeout 睡满。
"""安静下来就返回, 不把整个 timeout 睡满.
idle 取得很小(0.03s)是有依据的:行编辑器每次按键都在一个 fflush
里把整屏重绘写完,所以一次 read 之后基本就没了。而提交输入触发请求
时,等待动画每 250ms 一帧,永远不会安静,于是照样等到 timeout ——
那条路径的行为和以前一致,不会因为 idle 调小而抢跑。
idle 取得很小 (0.03s)是有依据的:行编辑器每次按键都在一个 fflush
里把整屏重绘写完, 所以一次 read 之后基本就没了. 而提交输入触发请求
时, 等待动画每 250ms 一帧, 永远不会安静, 于是照样等到 timeout ——
那条路径的行为和以前一致, 不会因为 idle 调小而抢跑.
"""
want = ready_seen + 1 if until_ready else None
end = time.time() + timeout
@@ -178,7 +178,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03):
drain(keyset_settle)
drain(settle)
# 刻意不发 Ctrl-C(它会在光标处打出 ^C 标记并覆盖两个字),
# 刻意不发 Ctrl-C (它会在光标处打出 ^C 标记并覆盖两个字),
# 直接关掉 pty:最后那次重绘就是缓冲区的真值
try:
os.close(fd)
@@ -192,10 +192,10 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03):
def wait_ready(fd, timeout=3.0):
"""等到行编辑器回到提示符(它会打开括号粘贴)。
"""等到行编辑器回到提示符 (它会打开括号粘贴).
裸 pty 会话里不要用 time.sleep 猜时间:猜短了会 flaky,猜长了每处都在
白等。进程退出(EOF)也算等到。"""
裸 pty 会话里不要用 time.sleep 猜时间:猜短了会 flaky, 猜长了每处都在
白等. 进程退出 (EOF)也算等到. """
READY = b"\x1b[?2004h"
end = time.time() + timeout
tail = b""
@@ -225,15 +225,15 @@ BS, CTRLC = '\\x7f', '\\x03'
def main():
binary = sys.argv[1] if len(sys.argv) > 1 else './ligent'
base = sys.argv[2] if len(sys.argv) > 2 else None
# 编辑测试不会真的发请求,但程序在缺 key 时会直接退出,所以必须给一个
# 默认关掉确认与自动存档:它们会打断被测行为。
# 专门测确认的用例用 --extra 显式打开。
# 编辑测试不会真的发请求, 但程序在缺 key 时会直接退出, 所以必须给一个
# 默认关掉确认与自动存档:它们会打断被测行为.
# 专门测确认的用例用 --extra 显式打开.
args = ['--no-color', '--no-confirm', '--no-auto-save', '--api-key', 'test',
'--base-url', base or 'http://127.0.0.1:1/v1']
# 被驱动的实例会读写输入历史。整轮都指到临时文件:既不往用户真实的
# ligent.history 里写测试垃圾,方向键用例的结果也不会取决于开发者机器
# 上碰巧有什么历史。
# 被驱动的实例会读写输入历史. 整轮都指到临时文件:既不往用户真实的
# ligent.history 里写测试垃圾, 方向键用例的结果也不会取决于开发者机器
# 上碰巧有什么历史.
scratch = os.path.join(tempfile.gettempdir(), "ligent_edit_hist_%d" % os.getpid())
os.environ["LIGENT_HISTORY"] = scratch
@@ -248,10 +248,10 @@ def main():
cases.append(("Home 回到行首后插入",
['world', '\\x01', 'hello '],
'ligent> hello world'))
cases.append(("CSI Home 序列(\\x1b[H)",
cases.append(("CSI Home 序列 (\\x1b[H)",
['world', HOME_END[0], 'hello '],
'ligent> hello world'))
cases.append(("End 序列(\\x1b[F)回到末尾",
cases.append(("End 序列 (\\x1b[F)回到末尾",
['world', HOME_END[0], 'hello ', HOME_END[1], '!'],
'ligent> hello world!'))
cases.append(("行首再左移不越界",
@@ -274,7 +274,7 @@ def main():
'ligent> cd'))
PASTE = '\\x1b[200~'
PASTE_END = '\\x1b[201~'
cases.append(("粘贴多行:整段插入,不逐行提交",
cases.append(("粘贴多行:整段插入, 不逐行提交",
[PASTE + 'line1\nline2\nline3' + PASTE_END],
['ligent> line1', 'line2', 'line3']))
cases.append(("粘贴后可继续编辑",
@@ -293,16 +293,16 @@ def main():
['中文abc'] + [LEFT] * 3 + ['X'],
'ligent> 中文Xabc'))
# 粘贴进来的原始字节里可能夹着控制字符。read_paste 是原样收下的(不走
# classify),所以必须清洗:终端按制表位展开 \t,编辑器只当 1 列;
# ESC 会被终端真的执行掉;\r 把光标拉回行首。
# 粘贴进来的原始字节里可能夹着控制字符. read_paste 是原样收下的 (不走
# classify), 所以必须清洗:终端按制表位展开 \t, 编辑器只当 1 列;
# ESC 会被终端真的执行掉; \r 把光标拉回行首.
cases.append(("粘贴里的制表符按制表位展开",
[PASTE + 'a\\tb' + PASTE_END, 'X'],
'ligent> ' + 'a' + ' ' * 7 + 'bX'))
cases.append(("制表符展开后光标位置正确(左移再插入)",
cases.append(("制表符展开后光标位置正确 (左移再插入)",
[PASTE + 'a\\tb' + PASTE_END, LEFT, 'X'],
'ligent> ' + 'a' + ' ' * 7 + 'Xb'))
cases.append(("粘贴里的 ESC 序列被丢弃,不让它执行",
cases.append(("粘贴里的 ESC 序列被丢弃, 不让它执行",
[PASTE + 'before\\x1b[2Jafter' + PASTE_END],
'ligent> beforeafter'))
cases.append(("粘贴里的 CRLF 归一成 LF",
@@ -330,12 +330,12 @@ def main():
print(" 期望: %r" % want)
print(" 实际: %r" % got)
if literal_escapes:
print(" 输出里出现了字面量转义文本 ^[[(方向键没被识别)")
print(" 输出里出现了字面量转义文本 ^[[ (方向键没被识别)")
else:
print(" ✓ %s" % name)
# 提示符带颜色时,那串转义序列不能占列宽 —— 光标定位必须和不着色时一致。
# 上面的用例都带 --no-color,所以这里单独补一组彩色的。
# 提示符带颜色时, 那串转义序列不能占列宽 —— 光标定位必须和不着色时一致.
# 上面的用例都带 --no-color, 所以这里单独补一组彩色的.
color_args = [a for a in args if a != '--no-color'] + ['--color']
color_cases = [
("彩色提示符:左移后插入",
@@ -362,9 +362,9 @@ def main():
print(" 期望: %r" % want)
print(" 实际: %r" % got)
# 上面那些用例只比屏幕内容,而颜色在终端模型里是被剥掉的 —— 包没包住
# 输入的字看不出来。所以这里直接查字节:绿色区间必须一直延续到用户敲的
# 内容之后。只包提示符的话,提示符自带的 reset 会让输入掉回默认色。
# 上面那些用例只比屏幕内容, 而颜色在终端模型里是被剥掉的 —— 包没包住
# 输入的字看不出来. 所以这里直接查字节:绿色区间必须一直延续到用户敲的
# 内容之后. 只包提示符的话, 提示符自带的 reset 会让输入掉回默认色.
raw = run(binary, color_args, ['greenme'])
txt = raw.decode('utf-8', 'replace')
start = txt.rfind('\x1b[32m')
@@ -378,7 +378,7 @@ def main():
print(" ✗ 输入的字也被包在绿色里")
print(" 绿色区间: %r" % inside)
# 粘贴多行后提交:必须作为"一条"消息发出去(回显里能看到内嵌换行)
# 粘贴多行后提交:必须作为"一条"消息发出去 (回显里能看到内嵌换行)
if base:
raw = run(binary, args,
[PASTE + 'ECHO_TEST 第一行\n第二行\n第三行' + PASTE_END, '\\r'],
@@ -393,7 +393,7 @@ def main():
print(" ✗ 粘贴的多行作为一条消息提交")
print(" 屏幕尾部: %r" % term.nonempty()[-6:])
# 上下方向键取历史:需要真的提交一行,所以只有在给了服务端时才测
# 上下方向键取历史:需要真的提交一行, 所以只有在给了服务端时才测
if base:
raw = run(binary, args, ['recalldemo', '\\r'], keyset_settle=0.8,
settle=0.6, idle=0.5)
@@ -412,15 +412,15 @@ def main():
print(" 提示符行: %r" % lines[-3:])
cases.append(("history", [], "ligent> firstline"))
# 历史要跨进程:上一个实例敲过的,新实例按上方向键就能翻到。
# 用独立的 LIGENT_HISTORY,免得受其他用例和真实历史文件影响。
# 历史要跨进程:上一个实例敲过的, 新实例按上方向键就能翻到.
# 用独立的 LIGENT_HISTORY, 免得受其他用例和真实历史文件影响.
hist = os.path.join(tempfile.gettempdir(), "ligent_hist_check_%d" % os.getpid())
for p in (hist, hist + ".tmp"):
if os.path.exists(p):
os.unlink(p)
os.environ["LIGENT_HISTORY"] = hist
try:
# 实时性:进程还活着、还没退出,文件里就该已经有这条了
# 实时性:进程还活着、还没退出, 文件里就该已经有这条了
pid, fd = pty.fork()
if pid == 0:
os.environ['TERM'] = 'xterm-256color'
@@ -434,12 +434,12 @@ def main():
with open(hist, encoding='utf-8') as fh:
content = fh.read()
if 'live_append' in content:
print(" ✓ 提交后立刻落盘(进程未退出)")
print(" ✓ 提交后立刻落盘 (进程未退出)")
else:
fail += 1
print(" ✗ 提交后立刻落盘(进程未退出)")
print(" ✗ 提交后立刻落盘 (进程未退出)")
print(" 文件内容: %r" % content)
# 继续敲两条再正常退出,供下一个实例翻
# 继续敲两条再正常退出, 供下一个实例翻
for k in ('persist_one\r', '/think high\r'):
os.write(fd, k.encode())
wait_ready(fd)
@@ -454,7 +454,7 @@ def main():
except OSError:
pass
# 文件里依次是 live_append / persist_one / /think high / /exit,
# 文件里依次是 live_append / persist_one / /think high / /exit,
# 所以按三次上方向键才轮到 persist_one
raw3 = run(binary, args, [UP, UP, UP], keyset_settle=0.3, settle=0.6, idle=0.03)
term3 = Term()
@@ -478,7 +478,7 @@ def main():
term = Term()
term.feed(raw.decode('utf-8', 'replace'))
prompts = [l for l in term.nonempty() if l.startswith('ligent> ')]
# 期望:Ctrl-C 丢掉了 "garbage",之后那一行只含 "typed"
# 期望:Ctrl-C 丢掉了 "garbage", 之后那一行只含 "typed"
last = prompts[-1] if prompts else ''
ok = len(prompts) >= 2 and 'garbage' not in last and 'typed' in last
if not ok:
@@ -490,7 +490,7 @@ def main():
total = len(cases) + len(color_cases) + 2
print()
print("通过 %d 项,失败 %d 项" % (total - fail, fail))
print("通过 %d 项, 失败 %d 项" % (total - fail, fail))
for p in (scratch, scratch + ".tmp"):
if os.path.exists(p):
os.unlink(p)
+32 -32
View File
@@ -1,26 +1,26 @@
#!/usr/bin/env python3
"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放。
"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放.
为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过,而 mock
是我们自己写的 —— 它接受什么,完全取决于**我们以为**服务端会接受什么。真实
服务端的差异是最大的未知数。
为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过, 而 mock
是我们自己写的 —— 它接受什么, 完全取决于**我们以为**服务端会接受什么. 真实
服务端的差异是最大的未知数.
/log 导出的是**原始字节**(请求体、响应头、未经解析的响应原文,流式就是原始
SSE),正好够原样伺候回去。所以这里不重写一个"模拟客户端",而是当**离线
服务端**:把录下来的响应按顺序发给一个真的 ligent 实例,于是 http.c 的分块
读取、llm.c 的 SSE 行切分与 tool_calls 增量重组,全都在真实字节上跑。
/log 导出的是**原始字节** (请求体、响应头、未经解析的响应原文, 流式就是原始
SSE), 正好够原样伺候回去. 所以这里不重写一个"模拟客户端", 而是当**离线
服务端**:把录下来的响应按顺序发给一个真的 ligent 实例, 于是 http.c 的分块
读取、llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑.
用法:
replay.py --summary FILE 这份录制里有什么
replay.py --serve PORT FILE [--strict] 当离线服务端
replay.py --check FILE 自检:能不能解析出结构完整的往返
--strict 会把每个进来的请求体与录制时那一份做**结构比对**(顶层键、消息角色
序列、工具名集合),不一致就回 400 并在 stderr 打印差异。这是这个工具最有用
的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言。
--strict 会把每个进来的请求体与录制时那一份做**结构比对** (顶层键、消息角色
序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 这是这个工具最有用
的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言.
注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求,而那几轮
不在录制里。录制时也建议关掉(LIGENT_PROBE=0),两边一致。
注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求, 而那几轮
不在录制里. 录制时也建议关掉 (LIGENT_PROBE=0), 两边一致.
"""
import argparse
import json
@@ -32,9 +32,9 @@ import threading
def parse_log(text):
"""把一份 /log 导出切成若干次 HTTP 往返。
"""把一份 /log 导出切成若干次 HTTP 往返.
格式(见 http.c 的记录点):
格式 (见 http.c 的记录点):
===== <stamp> =====
请求: POST <url>
----- 请求体 (N 字节) -----
@@ -45,8 +45,8 @@ def parse_log(text):
<响应原文>
----- 结束: HTTP <status> -----
日志是**滚动缓冲**,开头那一段可能是被截断的,所以要容忍缺标记 —— 跳过
解析不出来的块,而不是整个失败。
日志是**滚动缓冲**, 开头那一段可能是被截断的, 所以要容忍缺标记 —— 跳过
解析不出来的块, 而不是整个失败.
"""
lines = text.split("\n")
out = []
@@ -67,20 +67,20 @@ def parse_log(text):
ex["url"] = parts[1] if len(parts) > 1 else ""
i += 1
# 请求体:按标记里给出的字节数精确取,比按行取稳
# 请求体:按标记里给出的字节数精确取, 比按行取稳
if i < len(lines) and lines[i].startswith("----- 请求体 ("):
n = int(lines[i].split("(")[1].split(" ")[0])
i += 1
# 从这一行起累加字节,直到凑够 n
# 从这一行起累加字节, 直到凑够 n
blob = ""
while i < len(lines) and len(blob.encode()) < n:
blob += (lines[i] + "\n")
i += 1
ex["req"] = blob.encode()[:n]
# 上面多读了一行就把多余的还回去(blob 里包含了尾部换行)
# 上面多读了一行就把多余的还回去 (blob 里包含了尾部换行)
consumed = len(blob.encode())
if consumed > n:
# 把这一行重新交回处理(只可能多出换行)
# 把这一行重新交回处理 (只可能多出换行)
pass
# 响应头
@@ -105,17 +105,17 @@ def parse_log(text):
ex["status"] = 200
i += 1
# 结构不完整(多半是滚动缓冲把开头截了)就丢掉
# 结构不完整 (多半是滚动缓冲把开头截了)就丢掉
if ex["url"] and ex["status"] is not None:
out.append(ex)
return out
def shape_of(req_bytes):
"""一个请求体的**结构指纹**:只看形状,不看内容。
"""一个请求体的**结构指纹**:只看形状, 不看内容.
内容每轮都不一样(历史在长),但形状必须稳定 —— 顶层键、消息角色序列、
工具名集合。客户端一旦改了报文形状,这里就会显示出来。
内容每轮都不一样 (历史在长), 但形状必须稳定 —— 顶层键、消息角色序列、
工具名集合. 客户端一旦改了报文形状, 这里就会显示出来.
"""
try:
body = json.loads(req_bytes.decode("utf-8", "replace"))
@@ -162,9 +162,9 @@ class ReplayServer:
e = self.next_exchange()
if e is None:
sys.stderr.write(
"replay: 录制里没有更多响应了(已经回了 %d 个)。\n"
" 客户端比录制时多发了请求 —— 多半是多了能力探测(回放时请加 "
"--no-probe)或者客户端行为变了。\n" % len(self.ex))
"replay: 录制里没有更多响应了 (已经回了 %d 个). \n"
" 客户端比录制时多发了请求 —— 多半是多了能力探测 (回放时请加 "
"--no-probe)或者客户端行为变了. \n" % len(self.ex))
return 500, {}, b'{"error":{"message":"replay: out of recorded responses"}}'
if self.strict:
@@ -197,8 +197,8 @@ def serve(exchanges, port, strict, loop):
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(8)
print("replay server on %d(%d 个录制往返%s)"
% (port, len(exchanges), ",strict" if strict else ""), flush=True)
print("replay server on %d (%d 个录制往返%s)"
% (port, len(exchanges), ", strict" if strict else ""), flush=True)
while True:
conn, _ = s.accept()
@@ -250,7 +250,7 @@ def main():
ap.add_argument("--check", action="store_true", help="自检能否解析成完整往返")
ap.add_argument("--serve", type=int, metavar="PORT", help="当离线服务端")
ap.add_argument("--strict", action="store_true",
help="比对每个请求的结构,不一致就回 400")
help="比对每个请求的结构, 不一致就回 400")
ap.add_argument("--loop", action="store_true", help="响应用完后从头再来")
a = ap.parse_args()
@@ -282,7 +282,7 @@ def main():
serve(ex, a.serve, a.strict, a.loop)
return 0
ap.error("要么 --summary/--check,要么 --serve PORT")
ap.error("要么 --summary/--check, 要么 --serve PORT")
if __name__ == "__main__":
+267 -267
View File
File diff suppressed because it is too large. Load diff
+28 -28
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""shell_screen_check.py - 验证 /shell 记录里对全屏程序的处理
交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面。
不清洗的话模型看到的就是几百个空格加一串光标定位;清过头又会把命令和
状态行一起吃掉。这里用 printf 造出确定的输入来钉住行为:
交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面.
不清洗的话模型看到的就是几百个空格加一串光标定位; 清过头又会把命令和
状态行一起吃掉. 这里用 printf 造出确定的输入来钉住行为:
1. 备用屏幕(vim/less/top 走这条路)——画面必须省略,只留首尾标记;
2. 成片空格(nano/top 靠空格重绘)——必须压掉,但短缩进要原样保留;
3. 大输出里夹着的备用屏幕——跨 read 也要能认出来。
1. 备用屏幕 (vim/less/top 走这条路)——画面必须省略, 只留首尾标记;
2. 成片空格 (nano/top 靠空格重绘)——必须压掉, 但短缩进要原样保留;
3. 大输出里夹着的备用屏幕——跨 read 也要能认出来.
用法:
shell_screen_check.py <ligent 二进制> <mock base-url>
@@ -27,18 +27,18 @@ NORMAL_TEXT = "正常输出"
def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
"""在 pty 里跑 ligent,进 /shell 执行 commands,导出后返回记录文本。
"""在 pty 里跑 ligent, 进 /shell 执行 commands, 导出后返回记录文本.
term=None 表示不设 TERM,用来观察退化行为。"""
term=None 表示不设 TERM, 用来观察退化行为. """
saved = os.environ.copy()
os.environ["LIGENT_API_KEY"] = "sk-test"
# 没有 TERM 的话 less/vim 会退回非全屏模式,测不到备用屏幕那条路。
# 没有 TERM 的话 less/vim 会退回非全屏模式, 测不到备用屏幕那条路.
if term is None:
os.environ.pop("TERM", None)
else:
os.environ["TERM"] = term
# 每轮都清空归档:归档名是随机的,按文件名排序取"最后一个"会拿到上一轮。
# 每轮都清空归档:归档名是随机的, 按文件名排序取"最后一个"会拿到上一轮.
bindir = os.path.dirname(os.path.abspath(binary))
shutil.rmtree(os.path.join(bindir, "ligent.archive"), ignore_errors=True)
@@ -47,9 +47,9 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
os.execv(binary, [binary, "--base-url", base, "--no-confirm", "--auto-save"])
os._exit(127)
# 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号。
# 只按"输出安静"等是不行的:等待动画每 250ms 画一帧,pty 永远不会安静
# 超过 idle,于是每次都要白等满整个额度。
# 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号.
# 只按"输出安静"等是不行的:等待动画每 250ms 画一帧, pty 永远不会安静
# 超过 idle, 于是每次都要白等满整个额度.
PROMPT_READY = b"\x1b[?2004h"
ready_seen = 0
tail = b""
@@ -104,11 +104,11 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
return users[-1] if users else ""
def between_markers(rec, start="[全屏程序运行中,画面内容已省略]", end="[全屏程序结束]"):
"""取出"全屏程序"首尾标记之间的内容。
def between_markers(rec, start="[全屏程序运行中, 画面内容已省略]", end="[全屏程序结束]"):
"""取出"全屏程序"首尾标记之间的内容.
命令回显必然把输入里的字面文本也录进去,所以不能直接断言"机密文本不在
记录里"——只能断言它没出现在真正被省略的那段区间内。"""
命令回显必然把输入里的字面文本也录进去, 所以不能直接断言"机密文本不在
记录里"——只能断言它没出现在真正被省略的那段区间内. """
parts = rec.split(start)
if len(parts) < 2:
return None
@@ -135,8 +135,8 @@ def main():
fails += 1
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
# ---- 1. 备用屏幕:画面省略,只留首尾标记 ----
print("备用屏幕(vim/less 路径)")
# ---- 1. 备用屏幕:画面省略, 只留首尾标记 ----
print("备用屏幕 (vim/less 路径)")
rec = drive(binary, base, [
r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'",
"echo " + NORMAL_TEXT,
@@ -154,8 +154,8 @@ def main():
check("标记之外的输出还在", NORMAL_TEXT in rec)
check("记录里没有裸转义符", "\x1b" not in rec)
# ---- 2. 成片空格:压掉,但缩进保留 ----
print("成片空格(nano/top 重绘路径)")
# ---- 2. 成片空格:压掉, 但缩进保留 ----
print("成片空格 (nano/top 重绘路径)")
rec = drive(binary, base, [
r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'",
r"printf ' 缩进四个空格\n'",
@@ -171,7 +171,7 @@ def main():
check("空白占比低于 40%", blank < 0.40, "实际 %.0f%%" % (100 * blank))
# ---- 3. 大输出里夹着的备用屏幕 ----
print("大输出(跨 read)里的备用屏幕")
print("大输出 (跨 read)里的备用屏幕")
rec = drive(binary, base, [
"head -c 8000 /dev/zero | tr '\\0' 'x'; printf '\\033[?1049h"
+ ALT_TEXT + "\\033[?1049l'",
@@ -185,7 +185,7 @@ def main():
"切断后漏了画面")
check("跨块标记成对出现", body is not None)
# ---- 4. 同一个会话里进两次全屏,标记必须成对 ----
# ---- 4. 同一个会话里进两次全屏, 标记必须成对 ----
print("连续两次进入全屏")
rec = drive(binary, base, [
r"printf '\033[?1049hAAA\033[?1049l'; printf '\033[?1049hBBB\033[?1049l'",
@@ -194,14 +194,14 @@ def main():
check("拿到记录", False, "没有生成归档")
return 1
check("拿到记录", True)
check("两次开始标记", rec.count("[全屏程序运行中,画面内容已省略]") == 2,
"实际 %d 个" % rec.count("[全屏程序运行中,画面内容已省略]"))
check("两次开始标记", rec.count("[全屏程序运行中, 画面内容已省略]") == 2,
"实际 %d 个" % rec.count("[全屏程序运行中, 画面内容已省略]"))
check("两次结束标记", rec.count("[全屏程序结束]") == 2,
"实际 %d 个" % rec.count("[全屏程序结束]"))
# ---- 5. TERM 退化时补一个像样的值 ----
# TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘,整段忽略就失效了。
# 而 ligent 自己的 REPL 就在用光标定位,说明终端并非真的 dumb。
# TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘, 整段忽略就失效了.
# 而 ligent 自己的 REPL 就在用光标定位, 说明终端并非真的 dumb.
for bad in ("dumb", None):
label = "TERM=%s" % (bad if bad else "(未设置)")
print("退化的 %s" % label)
@@ -213,7 +213,7 @@ def main():
"实际记录:%s" % rec.strip().replace("\n", " | ")[-80:])
shutil.rmtree(archive, ignore_errors=True)
print("\n%d 项检查,%d 项失败" % (total, fails))
print("\n%d 项检查, %d 项失败" % (total, fails))
return 1 if fails else 0
+44 -44
View File
@@ -1,4 +1,4 @@
/* test_agent.c - agent 上下文管理(不涉及网络) */
/* test_agent.c - agent 上下文管理 (不涉及网络) */
#include "agent.h"
#include "json.h"
#include "util.h"
@@ -71,14 +71,14 @@ static void add_tool_result(Agent *a, const char *id, const char *content)
json_array_push(a->messages, m);
}
/* 序列化历史,便于断言 */
/* 序列化历史, 便于断言 */
static char *dump_history(const Agent *a)
{
return json_dump(a->messages);
}
/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息。
* 服务端就是这么要求的,违反了下一轮直接 400 —— 所以每次动过历史之后都要查。 */
/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息.
* 服务端就是这么要求的, 违反了下一轮直接 400 —— 所以每次动过历史之后都要查. */
static void check_pairing(Agent *a)
{
size_t n = json_array_size(a->messages);
@@ -92,18 +92,18 @@ static void check_pairing(Agent *a)
JsonValue *nxt = json_array_get(a->messages, i + 1);
const char *nid = json_get_string(nxt, "tool_call_id");
CHECK(nid && id && strcmp(nid, id) == 0,
"tool 结果必须紧跟其 tool_call(第 %zu 条)", i);
"tool 结果必须紧跟其 tool_call (第 %zu 条)", i);
}
}
}
/* 一轮一轮摘下去(/back 走的就是这条路),历史在每一步都必须仍然自洽。
/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽.
*
* 这里原来测的是"上下文超限自动裁剪"(agent_drop_oldest_round)—— 那套机制
* 已经**整个删掉**:它的判据只是错误文案里的关键词,却会不可逆地删掉最老的
* 几轮对话。现在超限就是如实报错、历史一个字节都不动,怎么处理由用户决定
* (/back 摘一轮、/reset 重来)。但"摘掉一整轮之后 tool 配对不能散"这条
* 不变量与由谁触发无关,所以留着继续测。 */
* 这里原来测的是"上下文超限自动裁剪" (agent_drop_oldest_round)—— 那套机制
* 已经**整个删掉**:它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的
* 几轮对话. 现在超限就是如实报错、历史一个字节都不动, 怎么处理由用户决定
* (/back 摘一轮、/reset 重来). 但"摘掉一整轮之后 tool 配对不能散"这条
* 不变量与由谁触发无关, 所以留着继续测. */
static void test_take_rounds_keeps_pairing(void)
{
puts("逐轮摘掉之后历史仍然自洽");
@@ -127,15 +127,15 @@ static void test_take_rounds_keeps_pairing(void)
rounds++;
check_pairing(&a); /* 每摘一轮都查一遍配对 */
}
CHECK(rounds == 4, "共应摘掉 4 轮,实际 %d", rounds);
CHECK(agent_history_len(&a) == 1, "最后应只剩 system,实际 %zu",
CHECK(rounds == 4, "共应摘掉 4 轮, 实际 %d", rounds);
CHECK(agent_history_len(&a) == 1, "最后应只剩 system, 实际 %zu",
agent_history_len(&a));
agent_free(&a);
}
static void test_take_last_round(void)
{
puts("摘掉最近一轮(/back 用)");
puts("摘掉最近一轮 (/back 用)");
Agent a;
agent_init(&a);
agent_add_system(&a, "SYS");
@@ -146,13 +146,13 @@ static void test_take_last_round(void)
add_msg(&a, "user", "U2");
add_msg(&a, "assistant", "A2");
CHECK(agent_history_len(&a) == 7, "初始 7 条,实际 %zu", agent_history_len(&a));
CHECK(agent_history_len(&a) == 7, "初始 7 条, 实际 %zu", agent_history_len(&a));
JsonValue *r = agent_take_last_round(&a);
CHECK(r != NULL, "应摘到一轮");
CHECK(r && json_array_size(r) == 2, "最后一轮应 2 条,实际 %zu",
CHECK(r && json_array_size(r) == 2, "最后一轮应 2 条, 实际 %zu",
r ? json_array_size(r) : 0);
CHECK(agent_history_len(&a) == 5, "摘完应剩 5 条,实际 %zu",
CHECK(agent_history_len(&a) == 5, "摘完应剩 5 条, 实际 %zu",
agent_history_len(&a));
char *h = dump_history(&a);
CHECK(strstr(h, "U2") == NULL, "U2 应被摘掉");
@@ -162,11 +162,11 @@ static void test_take_last_round(void)
/* 关键:工具调用与结果必须整组一起走 */
r = agent_take_last_round(&a);
CHECK(r && json_array_size(r) == 4, "第一轮应 4 条,实际 %zu",
CHECK(r && json_array_size(r) == 4, "第一轮应 4 条, 实际 %zu",
r ? json_array_size(r) : 0);
h = dump_history(&a);
CHECK(strstr(h, "call_1") == NULL && strstr(h, "R1") == NULL,
"工具调用与结果应一并摘掉,不能只删一半");
"工具调用与结果应一并摘掉, 不能只删一半");
CHECK(strstr(h, "SYS") != NULL, "system 必须保留");
free(h);
json_free(r);
@@ -191,7 +191,7 @@ static void test_take_last_round(void)
/* 结构体检:assistant(tool_calls) 与 tool 消息的配对 */
/* ------------------------------------------------------------------ */
/* 造一条 assistant,带 count 个工具调用(id 依次是 c0, c1, ...) */
/* 造一条 assistant, 带 count 个工具调用 (id 依次是 c0, c1, ...) */
static JsonValue *mk_assistant(size_t count)
{
JsonValue *m = json_new_object();
@@ -271,13 +271,13 @@ static void test_repair_pairing(void)
json_array_push(ok, mk_plain("assistant", "做完了"));
CHECK(pairing_ok(ok), "构造出来的历史本身应是合法的");
CHECK(agent_repair_tool_pairing(ok, &fixed) == NULL,
"合法历史不该被重建(NULL = 什么都没改)");
"合法历史不该被重建 (NULL = 什么都没改)");
CHECK(fixed == 0, "合法历史的修复处数应为 0");
json_free(ok);
puts("缺 tool 消息:补齐,且补在原位");
/* 复现真实报错:assistant 带两个调用,中间夹了一条 user(图省事直接
* 模仿当年那个 bug 的形状),第二个调用没人回应 */
puts("缺 tool 消息:补齐, 且补在原位");
/* 复现真实报错:assistant 带两个调用, 中间夹了一条 user (图省事直接
* 模仿当年那个 bug 的形状), 第二个调用没人回应 */
JsonValue *bad = json_new_array();
json_array_push(bad, mk_plain("user", "看看图"));
json_array_push(bad, mk_assistant(2));
@@ -289,20 +289,20 @@ static void test_repair_pairing(void)
CHECK(r != NULL, "对不上时应该返回重建好的数组");
if (r) {
CHECK(pairing_ok(r), "修完之后配对必须成立");
/* 两处:给 c1 补一条占位(它没被回应),再把错位的那条 c1 结果丢掉
* (它被 user 消息隔开,已经不在 tool 段里了 —— 留着就是孤儿)。
* 丢掉真实结果听着可惜,但结构坏了只能二选一:要么丢它,要么留一段
* 服务端必然拒绝的历史。模型会从占位消息里知道该重跑。 */
CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿,实际 %d", fixed);
/* 两处:给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉
* (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿).
* 丢掉真实结果听着可惜, 但结构坏了只能二选一:要么丢它, 要么留一段
* 服务端必然拒绝的历史. 模型会从占位消息里知道该重跑. */
CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿, 实际 %d", fixed);
CHECK(json_array_size(r) == 5,
"补一条、丢一条之后仍是 5 条,实际 %zu", json_array_size(r));
/* 占位消息必须紧跟在 assistant 的 tool 段里,而不是被塞到最后 */
"补一条、丢一条之后仍是 5 条, 实际 %zu", json_array_size(r));
/* 占位消息必须紧跟在 assistant 的 tool 段里, 而不是被塞到最后 */
CHECK(strcmp(nth_role(r, 2), "tool") == 0 &&
strcmp(nth_role(r, 3), "tool") == 0,
"两条 tool 消息应紧跟在 assistant 之后");
const char *c = json_get_string(json_array_get(r, 3), "content");
CHECK(c && strstr(c, "没有执行") == NULL && strstr(c, "没有得到结果") != NULL,
"占位消息要说明它没拿到结果,实际 %s", c ? c : "(null)");
"占位消息要说明它没拿到结果, 实际 %s", c ? c : "(null)");
json_free(r);
}
json_free(bad);
@@ -313,17 +313,17 @@ static void test_repair_pairing(void)
json_array_push(tail, mk_assistant(1));
fixed = 0;
r = agent_repair_tool_pairing(tail, &fixed);
CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上(fixed=%d)", fixed);
CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上 (fixed=%d)", fixed);
json_free(r);
json_free(tail);
puts("孤儿 tool 消息:没有对应的 tool_calls,要丢掉");
puts("孤儿 tool 消息:没有对应的 tool_calls, 要丢掉");
JsonValue *orphan = json_new_array();
json_array_push(orphan, mk_plain("user", "你好"));
json_array_push(orphan, mk_tool("谁也不是"));
JsonValue *r2 = agent_repair_tool_pairing(orphan, &fixed);
CHECK(fixed == 1, "孤儿 tool 消息应被计为 1 处,实际 %d", fixed);
CHECK(r2 != NULL && json_array_size(r2) == 1, "孤儿应被丢掉,只剩 user");
CHECK(fixed == 1, "孤儿 tool 消息应被计为 1 处, 实际 %d", fixed);
CHECK(r2 != NULL && json_array_size(r2) == 1, "孤儿应被丢掉, 只剩 user");
json_free(r2);
json_free(orphan);
@@ -348,25 +348,25 @@ static void test_repair_pairing(void)
static void test_conversation_len(void)
{
puts("历史条数:system 是配置,不该算进对话");
puts("历史条数:system 是配置, 不该算进对话");
Agent a;
agent_init(&a);
CHECK(agent_history_len(&a) == 0, "空 agent 的数组长度是 0");
CHECK(agent_conversation_len(&a) == 0, "空 agent 的对话长度也是 0");
/* 只有 system:数组里 1 条,但对话是空的 */
/* 只有 system:数组里 1 条, 但对话是空的 */
agent_add_system(&a, "你是助手");
CHECK(agent_history_len(&a) == 1, "只有 system 时数组长度 1");
CHECK(agent_conversation_len(&a) == 0,
"只有 system 时**对话**长度必须是 0(用户报的就是这里)");
"只有 system 时**对话**长度必须是 0 (用户报的就是这里)");
add_msg(&a, "user", "hi");
add_msg(&a, "assistant", "你好");
CHECK(agent_history_len(&a) == 3, "system + 一问一答 = 3");
CHECK(agent_conversation_len(&a) == 2, "对话长度 2");
/* 没有 system 的历史(比如裸构造的)不该被误减 */
/* 没有 system 的历史 (比如裸构造的)不该被误减 */
Agent b;
agent_init(&b);
add_msg(&b, "user", "hi");
@@ -397,7 +397,7 @@ int main(void)
/* 关键不变量:重复设置 system 应替换而不是追加 */
agent_add_system(&a, "S2");
CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加,实际 %zu 条",
CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加, 实际 %zu 条",
count_role(&a, "system"));
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0,
"新 system 应在第 0 条");
@@ -408,7 +408,7 @@ int main(void)
CHECK(agent_history_len(&a) == 3, "应 3 条");
agent_reset(&a);
CHECK(agent_history_len(&a) == 1, "reset 后只保留 system,实际 %zu",
CHECK(agent_history_len(&a) == 1, "reset 后只保留 system, 实际 %zu",
agent_history_len(&a));
CHECK(count_role(&a, "system") == 1, "reset 后 system 仍在");
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0,
@@ -419,7 +419,7 @@ int main(void)
agent_init(&b);
agent_add_user(&b, "only user");
agent_reset(&b);
CHECK(agent_history_len(&b) == 0, "无 system 时 reset 应为空,实际 %zu",
CHECK(agent_history_len(&b) == 0, "无 system 时 reset 应为空, 实际 %zu",
agent_history_len(&b));
agent_free(&a);
@@ -428,6 +428,6 @@ int main(void)
test_take_last_round();
test_repair_pairing();
test_conversation_len();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+13 -13
View File
@@ -1,4 +1,4 @@
/* test_archive.c - 存档命名规则与读写往返(不需要网络) */
/* test_archive.c - 存档命名规则与读写往返 (不需要网络) */
#include "archive.h"
#include "util.h"
@@ -40,7 +40,7 @@ int main(void)
puts("根名:8 位小写字母数字");
char *n1 = NULL;
CHECK(archive_next_name(dir, NULL, NULL, 0, &n1, err, sizeof err) == 0, "生成失败: %s", err);
CHECK(n1 && strlen(n1) == ARCHIVE_ROOT_LEN, "根名应为 %d 位,实际 %s",
CHECK(n1 && strlen(n1) == ARCHIVE_ROOT_LEN, "根名应为 %d 位, 实际 %s",
ARCHIVE_ROOT_LEN, n1 ? n1 : "(null)");
CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]:%s", n1);
free(n1);
@@ -60,7 +60,7 @@ int main(void)
CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对:%s", b);
free(b);
/* 多次生成应当不同(随机性) */
/* 多次生成应当不同 (随机性) */
char *x1 = NULL, *x2 = NULL;
archive_next_name(dir, "a1b2c3d4", NULL, 0, &x1, err, sizeof err);
archive_next_name(dir, "a1b2c3d4", NULL, 0, &x2, err, sizeof err);
@@ -88,7 +88,7 @@ int main(void)
char *fresh = NULL;
CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0, "超限生成失败");
CHECK(fresh && strlen(fresh) == ARCHIVE_ROOT_LEN,
"超限时应退回 %d 位根名,实际 %s(%zu 位)",
"超限时应退回 %d 位根名, 实际 %s (%zu 位)",
ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0);
free(fresh);
puts("前缀:临时存档");
@@ -106,11 +106,11 @@ int main(void)
"新根也应带前缀:%s", tr ? tr : "(null)");
free(tr);
/* 前缀要算进长度上限:原本刚好装得下的,加了 tmp_ 就该退回新根 */
/* 前缀要算进长度上限:原本刚好装得下的, 加了 tmp_ 就该退回新根 */
char *edgep = NULL;
CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0, "边界失败");
CHECK(edgep && strlen(edgep) == 4 + ARCHIVE_ROOT_LEN,
"加了前缀装不下时应退回根名,实际 %s(%zu 位)",
"加了前缀装不下时应退回根名, 实际 %s (%zu 位)",
edgep ? edgep : "(null)", edgep ? strlen(edgep) : 0);
free(edgep);
@@ -120,7 +120,7 @@ int main(void)
JsonValue *msgs = json_new_array();
JsonValue *m = json_new_object();
json_object_set_string(m, "role", "user");
json_object_set_string(m, "content", "你好,暗号是紫水晶");
json_object_set_string(m, "content", "你好, 暗号是紫水晶");
json_array_push(msgs, m);
char *path = NULL, *name = NULL;
@@ -144,7 +144,7 @@ int main(void)
CHECK(model && strcmp(model, "test-model") == 0, "model 应往返");
CHECK(created && strstr(created, "2023-") != NULL,
"created 应可读:%s", created ? created : "(null)");
/* saved 是"这份存档写出时的时间",与 created(这段对话的开始时间)是两回事 */
/* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 */
CHECK(saved && strstr(saved, "2023-") != NULL,
"saved 应可读:%s", saved ? saved : "(null)");
@@ -154,25 +154,25 @@ int main(void)
puts("列举");
ArchiveEntry *list = NULL;
size_t n = archive_list(dir, &list);
CHECK(n == 1, "应有 1 份存档,实际 %zu", n);
CHECK(n == 1, "应有 1 份存档, 实际 %zu", n);
if (n == 1) {
CHECK(strncmp(list[0].name, "parentname_", 11) == 0, "名字应保留");
CHECK(list[0].count == 1, "条数应为 1,实际 %ld", list[0].count);
CHECK(list[0].count == 1, "条数应为 1, 实际 %ld", list[0].count);
CHECK(list[0].created != NULL, "时间应从内容里读出来");
}
archive_list_free(list, n);
/* 非 ligent 存档(没有 format 标记)应被忽略 */
/* 非 ligent 存档 (没有 format 标记)应被忽略 */
char *jd = xasprintf("%s/junk.json", dir);
FILE *j2 = fopen(jd, "w");
if (j2) { fputs("{\"hello\":1}", j2); fclose(j2); }
n = archive_list(dir, &list);
CHECK(n == 1, "非 ligent 存档应被忽略,实际 %zu", n);
CHECK(n == 1, "非 ligent 存档应被忽略, 实际 %zu", n);
archive_list_free(list, n);
unlink(jd); free(jd);
unlink(path); free(path); free(name);
rmdir(dir);
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+16 -16
View File
@@ -1,7 +1,7 @@
/* test_attach.c - /include 的暂存、glob 展开与体积/类型判断
*
* 交互式挑选(attach_pick)要真终端才能测,由 e2e 第 55 段覆盖;
* 这里测的是不需要人操作的那一半。
* 交互式挑选 (attach_pick)要真终端才能测, 由 e2e 第 55 段覆盖;
* 这里测的是不需要人操作的那一半.
*/
#include "attach.h"
#include "image.h"
@@ -52,8 +52,8 @@ static size_t count_type(const JsonValue *parts, const char *type)
return n;
}
/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到。
* 答案和计数放在一起,免得"计数版只实现了一种答案"这种测试自己的错。 */
/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到.
* 答案和计数放在一起, 免得"计数版只实现了一种答案"这种测试自己的错. */
static int ask_count;
static int ask_answer;
static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; }
@@ -64,7 +64,7 @@ int main(void)
char *dir = mkdtemp(tmpl);
if (!dir) { perror("mkdtemp"); return 2; }
/* 备份目录也指到临时目录,别污染仓库 */
/* 备份目录也指到临时目录, 别污染仓库 */
image_set_bindir(dir);
char *f_c = xasprintf("%s/a.c", dir);
@@ -78,7 +78,7 @@ int main(void)
write_file(f_bin, "\x00\x01\x02binary", 9);
write_file(f_png, PNG, sizeof PNG);
{
/* 超过单个文件上限(256 KB) */
/* 超过单个文件上限 (256 KB) */
size_t n = ATTACH_MAX_FILE + 1024;
char *big = xmalloc(n);
memset(big, 'x', n);
@@ -103,13 +103,13 @@ int main(void)
CHECK(txt && strstr(txt, "int main") != NULL, "内容带上了");
CHECK(txt && strncmp(txt, "===== ", 6) == 0, "块开头标了来源路径");
CHECK(txt && strchr(txt, '\n') != NULL, "结尾有换行");
/* 只在本地记的来源,绝不能进块里(那会跟着发给服务端) */
/* 只在本地记的来源, 绝不能进块里 (那会跟着发给服务端) */
CHECK(p0 && json_get_string(p0, "source") == NULL,
"块里不该有自造的 source 字段");
json_free(parts);
CHECK(attach_count() == 0, "取走之后暂存清空");
puts("取走即清空,clear 也是");
puts("取走即清空, clear 也是");
attach_glob(f_c, NULL, NULL, &skipped, err, sizeof err);
CHECK(attach_count() == 1, "再附带一次");
attach_clear();
@@ -119,15 +119,15 @@ int main(void)
puts("二进制文件跳过");
attach_clear();
added = attach_glob(f_bin, NULL, NULL, &skipped, err, sizeof err);
CHECK(added == 0 && skipped == 1, "二进制被跳过(added=%d skipped=%zu)",
CHECK(added == 0 && skipped == 1, "二进制被跳过 (added=%d skipped=%zu)",
added, skipped);
puts("超过单个文件上限的跳过");
attach_clear();
added = attach_glob(f_big, NULL, NULL, &skipped, err, sizeof err);
CHECK(added == 0 && skipped == 1, "太大的文本被跳过(added=%d)", added);
CHECK(added == 0 && skipped == 1, "太大的文本被跳过 (added=%d)", added);
puts("图片:认内容不认扩展名,并走备份引用");
puts("图片:认内容不认扩展名, 并走备份引用");
attach_clear();
added = attach_glob(f_png, NULL, NULL, &skipped, err, sizeof err);
CHECK(added == 1, "png 被收下");
@@ -136,17 +136,17 @@ int main(void)
CHECK(count_type(parts, "ligent_image") == 1, "应当是一个 ligent_image 引用块");
json_free(parts);
puts("模型不收图时跳过图片(回调只在遇到图片时被问)");
puts("模型不收图时跳过图片 (回调只在遇到图片时被问)");
attach_clear();
ask_count = 0; ask_answer = 1;
added = attach_glob(f_txt, counting_ask, NULL, &skipped, err, sizeof err);
CHECK(added == 1, "纯文本照带");
CHECK(ask_count == 0, "纯文本不该去问'能不能收图'(问了一次就是白探测)");
CHECK(ask_count == 0, "纯文本不该去问'能不能收图' (问了一次就是白探测)");
attach_clear();
ask_count = 0; ask_answer = 0;
added = attach_glob(f_png, counting_ask, NULL, &skipped, err, sizeof err);
CHECK(added == 0 && skipped == 1, "回调说不行就跳过");
CHECK(ask_count == 1, "遇到图片时问了一次,实际 %d", ask_count);
CHECK(ask_count == 1, "遇到图片时问了一次, 实际 %d", ask_count);
puts("匹配不到 / 参数非法");
attach_clear();
@@ -161,7 +161,7 @@ int main(void)
attach_clear();
char *pat = xasprintf("%s/*.txt", dir);
added = attach_glob(pat, NULL, NULL, &skipped, err, sizeof err);
CHECK(added == 1, "只有 b.txt 能带(big.txt 超限),实际 %d", added);
CHECK(added == 1, "只有 b.txt 能带 (big.txt 超限), 实际 %d", added);
CHECK(skipped == 1, "big.txt 被跳过");
free(pat);
attach_clear();
@@ -172,6 +172,6 @@ int main(void)
if (system(cmd) != 0) { }
free(cmd);
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+20 -20
View File
@@ -1,13 +1,13 @@
/* test_capability.c - 能力探测的纯逻辑部分
*
* 探测序列本身(基线 -> 合并 -> 拆开)要发 HTTP,属于端到端范畴,
* 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴,
* 由 e2e 第 51 段对着 mock 覆盖:正常模型 / 不收图片 / 不收 tools /
* 探测失败(401)/ --no-probe 五种走法都在那儿验。
* 探测失败 (401)/ --no-probe 五种走法都在那儿验.
*
* 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询。
* 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话,
* 但 CAP_NO 恰好是 0,而 Agent 是 memset 出来的,忘了显式初始化就会一声不响地
* 把所有工具摘掉。这个 bug 真的发生过一次。
* 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询.
* 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话,
* 但 CAP_NO 恰好是 0, 而 Agent 是 memset 出来的, 忘了显式初始化就会一声不响地
* 把所有工具摘掉. 这个 bug 真的发生过一次.
*/
#include "capability.h"
#include "llm.h"
@@ -31,9 +31,9 @@ static int checks = 0;
int main(void)
{
/* CAP_NO 必须是 0 —— Agent 是 memset 出来的,整个 capability 模块的
* 设计都建立在"零值不能是某个真实结论"之上。这条断言是那个 bug 的护栏。 */
CHECK(CAP_NO == 0, "CAP_NO 应为 0(零值即'不支持'是事故的根源)");
/* CAP_NO 必须是 0 —— Agent 是 memset 出来的, 整个 capability 模块的
* 设计都建立在"零值不能是某个真实结论"之上. 这条断言是那个 bug 的护栏. */
CHECK(CAP_NO == 0, "CAP_NO 应为 0 (零值即'不支持'是事故的根源)");
CHECK(CAP_UNKNOWN != CAP_NO, "未知与不支持必须是两个不同的值");
CHECK(CAP_UNKNOWN != CAP_YES, "未知与支持必须是两个不同的值");
@@ -41,12 +41,12 @@ int main(void)
CHECK(strcmp(cap_name(CAP_YES), "支持") == 0, "YES");
CHECK(strcmp(cap_name(CAP_NO), "不支持") == 0, "NO");
CHECK(strcmp(cap_name(CAP_UNKNOWN), "未测") == 0, "UNKNOWN");
/* 任何不是 YES/NO 的值都该落到"未测",而不是碰巧落到某一个 */
/* 任何不是 YES/NO 的值都该落到"未测", 而不是碰巧落到某一个 */
CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测");
puts("关掉探测:一律未测,绝不冒充结论");
/* 用可变数组而不是字面量:LlmConfig 的这些字段是 char *(真要释放的),
* 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告。 */
puts("关掉探测:一律未测, 绝不冒充结论");
/* 用可变数组而不是字面量:LlmConfig 的这些字段是 char * (真要释放的),
* 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告. */
char model[] = "some-model";
char base_url[] = "http://127.0.0.1:1/v1";
LlmConfig cfg;
@@ -58,17 +58,17 @@ int main(void)
cap_cache_clear();
Capabilities c;
memset(&c, 0xAB, sizeof c); /* 故意填脏,验证会被完整覆盖 */
memset(&c, 0xAB, sizeof c); /* 故意填脏, 验证会被完整覆盖 */
int probed = cap_get(&cfg, 0, &c, 0, NULL, 0);
CHECK(probed == 0, "被禁用时不该声称探测过");
CHECK(c.probed == 0, "probed 应为 0");
CHECK(c.vision == CAP_UNKNOWN, "禁用时 vision 应为未测,实际 %d", c.vision);
CHECK(c.tools == CAP_UNKNOWN, "禁用时 tools 应为未测,实际 %d", c.tools);
CHECK(c.vision == CAP_UNKNOWN, "禁用时 vision 应为未测, 实际 %d", c.vision);
CHECK(c.tools == CAP_UNKNOWN, "禁用时 tools 应为未测, 实际 %d", c.tools);
CHECK(c.conclusive == 0, "禁用时不该声称有结论");
/* 关键回归:禁用时的结果拿去决定工具表,必须"全都留着" */
CHECK(c.vision != CAP_NO, "禁用时不能表现为不支持(否则会藏掉 read_image)");
CHECK(c.tools != CAP_NO, "禁用时不能表现为不支持(否则会摘掉整张工具表)");
/* 关键回归:禁用时的结果拿去决定工具表, 必须"全都留着" */
CHECK(c.vision != CAP_NO, "禁用时不能表现为不支持 (否则会藏掉 read_image)");
CHECK(c.tools != CAP_NO, "禁用时不能表现为不支持 (否则会摘掉整张工具表)");
puts("缓存查询");
Capabilities got;
@@ -87,6 +87,6 @@ int main(void)
cap_cache_clear();
CHECK(1, "空指针路径没有崩");
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+42 -42
View File
@@ -44,14 +44,14 @@ static void test_defaults(void)
LigentConfig c;
config_defaults(&c);
CHECK(strcmp(c.model, "deepseek-flash") == 0,
"默认模型应为 deepseek-flash,实际 %s", c.model);
"默认模型应为 deepseek-flash, 实际 %s", c.model);
CHECK(strcmp(c.base_url, "https://api.deepseek.com/v1") == 0,
"默认 base_url 应为 DeepSeek,实际 %s", c.base_url);
"默认 base_url 应为 DeepSeek, 实际 %s", c.base_url);
CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空");
CHECK(c.stream == 1, "默认应开启流式");
CHECK(c.use_tools == 1, "默认应启用工具");
/* 默认不限往返次数:0(或负数)= 不限,只有正数才是上限 */
CHECK(c.max_steps == 0, "默认应不限往返次数,实际 %d", c.max_steps);
/* 默认不限往返次数:0 (或负数)= 不限, 只有正数才是上限 */
CHECK(c.max_steps == 0, "默认应不限往返次数, 实际 %d", c.max_steps);
config_free(&c);
}
@@ -70,13 +70,13 @@ static void test_env_key(void)
setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1);
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-deepseek") == 0,
"应读 $DEEPSEEK_API_KEY,实际 [%s]", c.api_key);
"应读 $DEEPSEEK_API_KEY, 实际 [%s]", c.api_key);
/* LIGENT_API_KEY 作为显式覆盖优先 */
setenv("LIGENT_API_KEY", "sk-explicit", 1);
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
"LIGENT_API_KEY 应覆盖,实际 [%s]", c.api_key);
"LIGENT_API_KEY 应覆盖, 实际 [%s]", c.api_key);
/* OPENAI_API_KEY 只在端点确实是 OpenAI 时才兜底 ——
* 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */
@@ -92,7 +92,7 @@ static void test_env_key(void)
c.base_url = xstrdup("https://api.openai.com/v1");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-openai") == 0,
"OpenAI 端点应兜底到 OPENAI_API_KEY,实际 %s", c.api_key);
"OpenAI 端点应兜底到 OPENAI_API_KEY, 实际 %s", c.api_key);
unset_all();
config_free(&c);
@@ -121,7 +121,7 @@ static void test_env_others(void)
unset_all();
config_free(&c);
/* 环境变量是临时开关:写错了应当被忽略,而不是把程序拦下来 */
/* 环境变量是临时开关:写错了应当被忽略, 而不是把程序拦下来 */
config_defaults(&c);
setenv("LIGENT_THINKING", "随便写的", 1);
setenv("LIGENT_REASONING_EFFORT", "超高", 1);
@@ -198,7 +198,7 @@ static void test_file_thinking(void)
unlink(t2);
}
/* 写错了必须报错,不能静默忽略 —— 配置文件是长期存在的,静默会让人困惑 */
/* 写错了必须报错, 不能静默忽略 —— 配置文件是长期存在的, 静默会让人困惑 */
char t3[] = "/tmp/ligent_think_XXXXXX";
fd = mkstemp(t3);
if (fd >= 0) {
@@ -259,7 +259,7 @@ static void test_file_override(void)
CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, "base_url 覆盖");
CHECK(strcmp(c.model, "qwen2.5:14b") == 0, "model 覆盖");
CHECK(strcmp(c.api_key, "sk-from-env") == 0,
"${MY_SECRET} 应被展开,实际 [%s]", c.api_key);
"${MY_SECRET} 应被展开, 实际 [%s]", c.api_key);
CHECK(c.temperature > 0.29 && c.temperature < 0.31, "temperature 覆盖");
CHECK(c.max_tokens == 4096, "max_tokens 覆盖");
CHECK(c.max_steps == 9, "max_steps 覆盖");
@@ -267,10 +267,10 @@ static void test_file_override(void)
CHECK(c.use_tools == 0, "tools 覆盖");
CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径");
/* 坏配置应报错而不是静默通过。
/* 坏配置应报错而不是静默通过.
*
* 解析器的报错跟着界面语言走(json_parse_ui),所以下面这段断语先把语言
* 钉住,再顺手把英文那份也验一遍 —— 位置信息两种语言都得有。 */
* 解析器的报错跟着界面语言走 (json_parse_ui), 所以下面这段断语先把语言
* 钉住, 再顺手把英文那份也验一遍 —— 位置信息两种语言都得有. */
char tmpl2[] = "/tmp/ligent_bad_XXXXXX";
int fd2 = mkstemp(tmpl2);
if (fd2 >= 0) {
@@ -284,7 +284,7 @@ static void test_file_override(void)
CHECK(config_load_file(&c2, tmpl2, err, sizeof err) != 0,
"非法 JSON 应加载失败");
CHECK(err[0] != '\0', "应给出错误信息");
/* 报错必须带上文件路径与出错位置,否则用户无从下手 */
/* 报错必须带上文件路径与出错位置, 否则用户无从下手 */
CHECK(strstr(err, tmpl2) != NULL, "错误信息应含文件路径: %s", err);
CHECK(strstr(err, "行") != NULL, "错误信息应含出错位置: %s", err);
config_free(&c2);
@@ -294,7 +294,7 @@ static void test_file_override(void)
config_defaults(&c3);
CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 &&
strstr(err, "line") != NULL,
"英文界面下报错也带位置(且是英文): %s", err);
"英文界面下报错也带位置 (且是英文): %s", err);
config_free(&c3);
i18n_set_lang(LIGENT_LANG_ZH);
unlink(tmpl2);
@@ -305,7 +305,7 @@ static void test_file_override(void)
unset_all();
}
/* 配置只认二进制所在目录:不做用户级、系统级,也不做当前目录查找 */
/* 配置只认二进制所在目录:不做用户级、系统级, 也不做当前目录查找 */
static void test_binary_dir_lookup(void)
{
puts("配置位置 = 二进制所在目录");
@@ -319,7 +319,7 @@ static void test_binary_dir_lookup(void)
CHECK(canon && dir && str_starts_with(canon, dir),
"规范路径应位于二进制目录下: %s / %s", canon, dir);
/* 当前目录放一份 ligent.json,它绝不能被加载 */
/* 当前目录放一份 ligent.json, 它绝不能被加载 */
char tmpl[] = "/tmp/ligent_cwd_XXXXXX";
char *tmp = mkdtemp(tmpl);
if (!tmp) { free(dir); free(canon); return; }
@@ -343,7 +343,7 @@ static void test_binary_dir_lookup(void)
CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json");
CHECK(got_dir && dir && strcmp(got_dir, dir) == 0,
"config_dir 应是二进制目录,实际 %s", got_dir ? got_dir : "(null)");
"config_dir 应是二进制目录, 实际 %s", got_dir ? got_dir : "(null)");
free(got_dir);
free(saved);
@@ -369,16 +369,16 @@ static int load_temp_config(LigentConfig *c, const char *json)
return rc;
}
/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥。
* 这正是用户踩到的坑:配置写了 OpenRouter 的 key,
* shell 里残留的 DEEPSEEK_API_KEY 把它顶掉,于是 A 家的密钥发给了 B 家。 */
/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥.
* 这正是用户踩到的坑:配置写了 OpenRouter 的 key,
* shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */
static void test_env_key_precedence(void)
{
puts("api_key 优先级:厂商环境变量不得越权");
unset_all();
setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1);
/* 1) 配置文件写了 key => 它就是最终值,环境变量顶不掉 */
/* 1) 配置文件写了 key => 它就是最终值, 环境变量顶不掉 */
LigentConfig c;
config_defaults(&c);
CHECK(load_temp_config(&c,
@@ -386,7 +386,7 @@ static void test_env_key_precedence(void)
"\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0,
"配置文件里的 key 不应被残留环境变量顶掉,实际 %s", c.api_key);
"配置文件里的 key 不应被残留环境变量顶掉, 实际 %s", c.api_key);
config_free(&c);
/* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */
@@ -395,19 +395,19 @@ static void test_env_key_precedence(void)
"加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0,
"DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底,实际 %s", c.api_key);
"DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底, 实际 %s", c.api_key);
config_free(&c);
/* 3) 配置文件没写 key + 端点是别家 => 不猜,宁可空着让用户看到明确提示 */
/* 3) 配置文件没写 key + 端点是别家 => 不猜, 宁可空着让用户看到明确提示 */
config_defaults(&c);
CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0,
"加载配置失败");
config_apply_env(&c);
CHECK(c.api_key[0] == '\0',
"base_url 指向别家时不应拿 DeepSeek 的密钥去猜,实际 %s", c.api_key);
"base_url 指向别家时不应拿 DeepSeek 的密钥去猜, 实际 %s", c.api_key);
config_free(&c);
/* 4) LIGENT_API_KEY 是显式覆盖,任何时候都最高 */
/* 4) LIGENT_API_KEY 是显式覆盖, 任何时候都最高 */
setenv("LIGENT_API_KEY", "sk-explicit", 1);
config_defaults(&c);
CHECK(load_temp_config(&c,
@@ -415,46 +415,46 @@ static void test_env_key_precedence(void)
"\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败");
config_apply_env(&c);
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
"LIGENT_API_KEY 应最高优先,实际 %s", c.api_key);
"LIGENT_API_KEY 应最高优先, 实际 %s", c.api_key);
config_free(&c);
unset_all();
}
/* 软链接:配置目录要跟着"用户实际调用那个文件"走。
* 否则把 ligent 软链到 ~/bin 之后,配置会被悄悄挪到源文件所在的目录,
* 用户放在 ~/bin 旁边的 ligent.json 就永远读不到。 */
/* 软链接:配置目录要跟着"用户实际调用那个文件"走.
* 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录,
* 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */
static void test_symlink_dir(void)
{
puts("软链接:配置目录跟着软链接走");
char self[PATH_MAX];
ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
if (n <= 0) { puts(" (没有 /proc/self/exe,跳过)"); return; }
if (n <= 0) { puts(" (没有 /proc/self/exe, 跳过)"); return; }
self[n] = '\0';
char tmpl[] = "/tmp/ligent_link_XXXXXX";
char *dir = mkdtemp(tmpl);
if (!dir) { puts(" (mkdtemp 失败,跳过)"); return; }
if (!dir) { puts(" (mkdtemp 失败, 跳过)"); return; }
char *link = xasprintf("%s/ligent", dir);
if (symlink(self, link) != 0) {
puts(" (symlink 失败,跳过)");
puts(" (symlink 失败, 跳过)");
free(link); rmdir(dir);
return;
}
/* 核心:指向软链接时取软链接所在目录,而不是源文件所在目录 */
/* 核心:指向软链接时取软链接所在目录, 而不是源文件所在目录 */
char *got = config_binary_dir(link);
CHECK(got && strcmp(got, dir) == 0,
"应取软链接所在目录 %s,实际 %s", dir, got ? got : "(null)");
"应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)");
free(got);
/* 路径里的 . 应被规范化,但最后一段仍不解析 */
/* 路径里的 . 应被规范化, 但最后一段仍不解析 */
char *dotted = xasprintf("%s/./ligent", dir);
got = config_binary_dir(dotted);
CHECK(got && strcmp(got, dir) == 0,
"带 . 的路径应规范化,实际 %s", got ? got : "(null)");
"带 . 的路径应规范化, 实际 %s", got ? got : "(null)");
free(got); free(dotted);
/* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */
@@ -463,18 +463,18 @@ static void test_symlink_dir(void)
setenv("PATH", newpath, 1);
got = config_binary_dir("ligent");
CHECK(got && strcmp(got, dir) == 0,
"$PATH 找到软链接时应取它所在目录,实际 %s", got ? got : "(null)");
"$PATH 找到软链接时应取它所在目录, 实际 %s", got ? got : "(null)");
free(got);
if (saved) setenv("PATH", saved, 1); else unsetenv("PATH");
free(saved); free(newpath);
/* 直接用真实文件时,取的还是真实目录 */
/* 直接用真实文件时, 取的还是真实目录 */
char *selfdir = xstrdup(self);
char *slash = strrchr(selfdir, '/');
if (slash) *slash = '\0';
got = config_binary_dir(self);
CHECK(got && strcmp(got, selfdir) == 0,
"真实文件应取真实目录 %s,实际 %s", selfdir, got ? got : "(null)");
"真实文件应取真实目录 %s, 实际 %s", selfdir, got ? got : "(null)");
free(got); free(selfdir);
unlink(link);
@@ -493,6 +493,6 @@ int main(void)
test_binary_dir_lookup();
test_env_key_precedence();
test_symlink_dir();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+14 -14
View File
@@ -1,4 +1,4 @@
/* test_http.c - HTTP 层纯逻辑测试(不联网) */
/* test_http.c - HTTP 层纯逻辑测试 (不联网) */
#include "http.h"
#include "util.h"
@@ -30,7 +30,7 @@ int main(void)
CHECK(http_status_retryable(retryable[i]) == 1,
"HTTP %ld 应当可重试", retryable[i]);
/* 绝不应当重试:这些是确定性错误,重试只是浪费时间和钱 */
/* 绝不应当重试:这些是确定性错误, 重试只是浪费时间和钱 */
long permanent[] = { 400, 401, 403, 404, 405, 413, 415, 422, 501,
200, 201, 204, 301, 0, -1 };
for (size_t i = 0; i < sizeof permanent / sizeof permanent[0]; i++)
@@ -53,12 +53,12 @@ int main(void)
size_t sz = 0;
FILE *f = open_memstream(&buf, &sz);
/* 这条正是曾经漏出去的:libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT,
* 不是 HEADER_OUT,所以只处理 HEADER_OUT 的做法根本挡不住 */
/* 这条正是曾经漏出去的:libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT,
* 不是 HEADER_OUT, 所以只处理 HEADER_OUT 的做法根本挡不住 */
const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360]\n";
http_write_redacted(f, h2, strlen(h2), key);
/* 整块请求头(HEADER_OUT 是整块传来的,不是逐行) */
/* 整块请求头 (HEADER_OUT 是整块传来的, 不是逐行) */
const char *hdrs = "POST /v1/chat/completions HTTP/2\r\n"
"Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n"
"Content-Type: application/json\r\n";
@@ -79,20 +79,20 @@ int main(void)
"请求头里的密钥应脱敏: %.200s", buf);
CHECK(strstr(buf, "TLS handshake") != NULL, "其它调试信息不能被误改");
CHECK(strstr(buf, "no key here") != NULL, "无密钥时原样输出");
/* 一次替换必须整段吃掉,不能留下半个密钥 */
/* 一次替换必须整段吃掉, 不能留下半个密钥 */
CHECK(strstr(buf, "0f114e3dac") == NULL, "不应留下密钥片段");
free(buf);
}
puts("\n空请求体:POST 分支也要扛得住 body == NULL");
{
/* body 允许为 NULL(http_probe 就是这么调的),但 POST 分支以前写的是
* 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误。
/* body 允许为 NULL (http_probe 就是这么调的), 但 POST 分支以前写的是
* 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误.
*
* 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它,
* 所以本测试仍然不碰网络。而 strlen(NULL) 发生在设置 POSTFIELDS 期间、
* curl_easy_perform 之前,也就是说旧代码在这里必崩 —— 这个断言对旧
* 代码是有效的回归守卫,不是走过场。 */
* 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它,
* 所以本测试仍然不碰网络. 而 strlen(NULL) 发生在设置 POSTFIELDS 期间、
* curl_easy_perform 之前, 也就是说旧代码在这里必崩 —— 这个断言对旧
* 代码是有效的回归守卫, 不是走过场. */
static const HttpRetry no_retry = HTTP_RETRY_NONE;
char errbuf[256] = "";
int attempts = 0;
@@ -103,10 +103,10 @@ int main(void)
NULL, &status, &attempts, errbuf, sizeof errbuf);
http_global_cleanup();
CHECK(rc == -1, "非法 URL 应当返回 -1(实际返回 %d)", rc);
CHECK(rc == -1, "非法 URL 应当返回 -1 (实际返回 %d)", rc);
CHECK(errbuf[0] != '\0', "失败时必须给出错误说明");
}
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+26 -26
View File
@@ -1,10 +1,10 @@
/* test_i18n.c - 界面文案表
*
* 这张表是"每种语言一条"。它最容易出的错不是逻辑,而是漏:加了键忘了翻,
* 或者译文里少了个占位符({path} 写成 {paths})—— 用户看到的就是一句缺字的话。
* 所以这里的检查全部是机械的:遍历表,逐条比对。
* 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏:加了键忘了翻,
* 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话.
* 所以这里的检查全部是机械的:遍历表, 逐条比对.
*
* 文案本身好不好不在这里管;"英文界面是不是每句都有着落"才是。
* 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是.
*/
#include "i18n.h"
@@ -26,7 +26,7 @@ static int checks = 0;
} \
} while (0)
/* 收一份占位符名单({name} 里的 name),按出现顺序放进 out */
/* 收一份占位符名单 ({name} 里的 name), 按出现顺序放进 out */
static int placeholders(const char *s, char (*out)[32], int max)
{
int n = 0;
@@ -44,9 +44,9 @@ static int placeholders(const char *s, char (*out)[32], int max)
return n;
}
/* 收一份 printf 转换符名单(%zu、%c、%.20s …),原样带走标志/宽度/长度。
* json.* 那几条是 printf 模板而不是 {name},两种语言只要转换符顺序或类型
* 对不上,snprintf 就会读错参数 —— 比少字更糟,得由机器盯住。 */
/* 收一份 printf 转换符名单 (%zu、%c、%.20s …), 原样带走标志/宽度/长度.
* json.* 那几条是 printf 模板而不是 {name}, 两种语言只要转换符顺序或类型
* 对不上, snprintf 就会读错参数 —— 比少字更糟, 得由机器盯住. */
static int printf_specs(const char *s, char (*out)[16], int max)
{
int n = 0;
@@ -71,9 +71,9 @@ static int printf_specs(const char *s, char (*out)[16], int max)
return n;
}
/* 有没有中日韩字符(含全角标点:〔〕())。英文那一列里出现它,基本就是
/* 有没有中日韩字符 (含全角标点:〔〕 ()). 英文那一列里出现它, 基本就是
* "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对
* 中文括号。顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住。 */
* 中文括号. 顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住. */
static int has_cjk(const char *s)
{
for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) {
@@ -106,8 +106,8 @@ int main(void)
CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符",
m->key);
/* key 唯一性:比较是 O(n²),但每个 key 只记一条检查 —— 写成循环里
* CHECK 的话,check-docs 数出来的"单元项数"就没意义了。 */
/* key 唯一性:比较是 O(n²), 但每个 key 只记一条检查 —— 写成循环里
* CHECK 的话, check-docs 数出来的"单元项数"就没意义了. */
int dup = 0;
for (size_t j = i + 1; j < n; j++)
if (strcmp(tab[j].key, m->key) == 0) dup = 1;
@@ -116,7 +116,7 @@ int main(void)
char pe[16][32], pz[16][32];
int ne = placeholders(m->en ? m->en : "", pe, 16);
int nz = placeholders(m->zh ? m->zh : "", pz, 16);
CHECK(ne == nz, "%s 两种语言的占位符数量不同(en %d / zh %d)",
CHECK(ne == nz, "%s 两种语言的占位符数量不同 (en %d / zh %d)",
m->key, ne, nz);
for (int a = 0; a < ne && a < nz; a++) {
int found = 0;
@@ -124,7 +124,7 @@ int main(void)
if (strcmp(pe[a], pz[b]) == 0) found = 1;
CHECK(found, "%s:英文的 {%s} 在中文里没有对应", m->key, pe[a]);
}
/* 译文里多出一个原文没有的占位符,同样会在运行时漏字 */
/* 译文里多出一个原文没有的占位符, 同样会在运行时漏字 */
for (int a = 0; a < nz && a < ne; a++) {
int found = 0;
for (int b = 0; b < ne; b++)
@@ -133,21 +133,21 @@ int main(void)
}
}
/* 表里有一部分键是**当 printf 模板用的**(repl.usage.*、repl.model.row、
* json.* …):调用方直接把 t(key) 交给 printf/snprintf。这些条目的
* {name} 检查管不到,得单独比转换符 —— 少一个 %s 会读到别的参数,
* 类型写错(%d 对 %zu)更是直接踩内存。 */
/* 表里有一部分键是**当 printf 模板用的** (repl.usage.*、repl.model.row、
* json.* …):调用方直接把 t(key) 交给 printf/snprintf. 这些条目的
* {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数,
* 类型写错 (%d 对 %zu)更是直接踩内存. */
puts("printf 模板:两种语言的转换符必须一模一样");
for (size_t i = 0; i < n; i++) {
const LigentMsg *m = &tab[i];
char se[16][16], sz[16][16];
int ce = printf_specs(m->en ? m->en : "", se, 16);
int cz = printf_specs(m->zh ? m->zh : "", sz, 16);
if (ce == 0 && cz == 0) continue; /* 不是模板,跳过 */
CHECK(ce == cz, "%s:转换符个数不同(en %d / zh %d)", m->key, ce, cz);
if (ce == 0 && cz == 0) continue; /* 不是模板, 跳过 */
CHECK(ce == cz, "%s:转换符个数不同 (en %d / zh %d)", m->key, ce, cz);
for (int a = 0; a < ce && a < cz; a++)
CHECK(strcmp(se[a], sz[a]) == 0,
"%s:第 %d 个转换符不同(en %s / zh %s)",
"%s:第 %d 个转换符不同 (en %s / zh %s)",
m->key, a + 1, se[a], sz[a]);
}
@@ -162,7 +162,7 @@ int main(void)
CHECK(i18n_lang_name()[0] == 'e', "i18n_lang_name");
CHECK(strstr(t("cli.err.unknown_option"), "unknown") != NULL, "英文那条");
CHECK(strncmp(t("no.such.key"), "[?", 2) == 0,
"漏掉的键要显眼地露出来(实际 %s),不能变成空白", t("no.such.key"));
"漏掉的键要显眼地露出来 (实际 %s), 不能变成空白", t("no.such.key"));
puts("占位符替换按名字走");
i18n_set_lang(LIGENT_LANG_EN);
@@ -170,11 +170,11 @@ int main(void)
CHECK(strstr(r, "--nope") != NULL, "值要填进去");
CHECK(strstr(r, "{option}") == NULL, "填过的占位符不该留下");
CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL), "{option}") != NULL,
"名字对不上时原样保留,别静默吞掉");
"名字对不上时原样保留, 别静默吞掉");
i18n_set_lang(LIGENT_LANG_ZH);
CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL), "选项X") != NULL,
"中文模板同样能填");
/* 同一句里多个占位符,且顺序与调用顺序相反 */
/* 同一句里多个占位符, 且顺序与调用顺序相反 */
i18n_set_lang(LIGENT_LANG_EN);
const char *two = tf("doctor.val.match_bad", "guess", "OpenAI",
"owner", "DeepSeek", NULL);
@@ -192,7 +192,7 @@ int main(void)
CHECK(i18n_parse_lang("") < 0, "空串返回 -1");
CHECK(i18n_parse_lang(NULL) < 0, "NULL 返回 -1");
puts("环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG,认不出/没设 -> 英文");
puts("环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG, 认不出/没设 -> 英文");
unsetenv("LIGENT_LANG");
unsetenv("LC_ALL");
unsetenv("LC_MESSAGES");
@@ -214,6 +214,6 @@ int main(void)
unsetenv("LANG");
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "清干净之后仍是英文");
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+22 -22
View File
@@ -30,7 +30,7 @@ static const char WEBP[] = "RIFF\x00\x00\x00\x00WEBPVP8 ";
static void test_sniff(void)
{
puts("按文件内容认格式(不看扩展名)");
puts("按文件内容认格式 (不看扩展名)");
const char *ext = NULL, *mime = NULL;
CHECK(image_sniff((const unsigned char *)PNG, 14, &ext, &mime) == 0 &&
@@ -44,7 +44,7 @@ static void test_sniff(void)
CHECK(image_sniff((const unsigned char *)WEBP, 16, &ext, &mime) == 0 &&
strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0, "WebP");
/* 认不出来要明确失败,别猜一个格式报上去 */
/* 认不出来要明确失败, 别猜一个格式报上去 */
CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) != 0,
"杂文本不该被认成图片");
CHECK(image_sniff((const unsigned char *)"\x89PNG", 4, &ext, &mime) != 0,
@@ -82,7 +82,7 @@ static void test_backup(void)
CHECK(back && len == n && memcmp(back, data, n) == 0, "备份内容应逐字节一致");
free(back); free(p1);
/* 再存一次应该拿到不同的名字(随机后缀),而不是互相覆盖 */
/* 再存一次应该拿到不同的名字 (随机后缀), 而不是互相覆盖 */
char *b2 = image_backup_save(dir, data, n, "png");
CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号:%s / %s",
b1, b2 ? b2 : "(null)");
@@ -112,21 +112,21 @@ static void test_base64(void)
size_t n = strlen(cases[i].in);
char *got = base64_encode((const unsigned char *)cases[i].in, n);
CHECK(got && strcmp(got, cases[i].want) == 0,
"\"%s\" 应编码成 %s,实际 %s", cases[i].in, cases[i].want,
"\"%s\" 应编码成 %s, 实际 %s", cases[i].in, cases[i].want,
got ? got : "(null)");
CHECK(base64_encoded_len(n) == strlen(cases[i].want),
"长度函数应与实际一致(%zu)", n);
"长度函数应与实际一致 (%zu)", n);
free(got);
}
/* 二进制字节(0x00 / 0xFF 这类不能被当字符串处理) */
/* 二进制字节 (0x00 / 0xFF 这类不能被当字符串处理) */
const unsigned char bin[] = { 0x00, 0xFF, 0x10, 0x80 };
char *b = base64_encode(bin, sizeof bin);
CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码,实际 %s",
CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码, 实际 %s",
b ? b : "(null)");
free(b);
}
/* 造一张内容合法(靠文件头就能嗅探出来)的假图,返回字节数 */
/* 造一张内容合法 (靠文件头就能嗅探出来)的假图, 返回字节数 */
static size_t fake_png(unsigned char *out, size_t n)
{
static const char magic[] = "\x89PNG\r\n\x1a\n";
@@ -137,7 +137,7 @@ static size_t fake_png(unsigned char *out, size_t n)
return n;
}
/* 把一段备份引用塞进一条 user 消息,模拟 read_image 之后的历史 */
/* 把一段备份引用塞进一条 user 消息, 模拟 read_image 之后的历史 */
static const char *ref_message_type = "ligent_image";
static JsonValue *history_with_ref(const char *backup)
@@ -191,18 +191,18 @@ static void test_expand(void)
"附件消息应有一个 content 块");
JsonValue *wp = json_array_get(wc, 0);
CHECK(strcmp(json_get_string(wp, "type"), "image_url") == 0,
"发送形态的类型应是 image_url,实际 %s",
"发送形态的类型应是 image_url, 实际 %s",
json_get_string(wp, "type"));
JsonValue *iu = json_object_get(wp, "image_url");
const char *url = iu ? json_get_string(iu, "url") : NULL;
CHECK(url && strncmp(url, "data:image/png;base64,", 22) == 0,
"应是 data: URL,实际 %.40s", url ? url : "(null)");
"应是 data: URL, 实际 %.40s", url ? url : "(null)");
/* base64 段应能解回原字节 —— 这里只验长度与首尾,避免在测试里再写一个解码器 */
/* base64 段应能解回原字节 —— 这里只验长度与首尾, 避免在测试里再写一个解码器 */
CHECK(url && strlen(url) == 22 + base64_encoded_len(n),
"data URL 长度应等于前缀 + base64 长度");
/* 关键不变量:原始历史没被改动,且里面没有图片字节 */
/* 关键不变量:原始历史没被改动, 且里面没有图片字节 */
char *orig = json_dump(msgs);
CHECK(strstr(orig, "ligent_image") != NULL, "原历史里应还是引用");
CHECK(strstr(orig, "base64") == NULL, "原历史里不该出现 base64");
@@ -223,23 +223,23 @@ static void test_expand(void)
CHECK(strcmp(json_get_string(wp2, "type"), "text") == 0,
"发送形态应降级成 text 块");
const char *dt = json_get_string(wp2, "text");
CHECK(dt && strstr(dt, "无法交给模型") != NULL, "应说明原因,实际 %s",
CHECK(dt && strstr(dt, "无法交给模型") != NULL, "应说明原因, 实际 %s",
dt ? dt : "(null)");
json_free(wire);
/* 降级是**就地**的:原历史里的坏引用应该已经被换掉,否则每轮都会重试 */
/* 降级是**就地**的:原历史里的坏引用应该已经被换掉, 否则每轮都会重试 */
char *after = json_dump(msgs);
CHECK(strstr(after, "ligent_image") == NULL, "原历史里的坏引用应已被清掉");
CHECK(strstr(after, "ligent_note") != NULL, "应换成内部说明块");
free(after);
/* 再来一次不该重复降级(历史已经干净了) */
/* 再来一次不该重复降级 (历史已经干净了) */
degraded = 0;
wire = image_expand_for_request(msgs, &degraded);
CHECK(degraded == 0, "第二次不该再降级");
json_free(wire);
/* image_has_refs:降级之后应为 0,这正是 4xx 兜底的判据 */
/* image_has_refs:降级之后应为 0, 这正是 4xx 兜底的判据 */
CHECK(image_has_refs(msgs) == 0, "降级后历史里不该还有图片引用");
json_free(msgs);
@@ -247,13 +247,13 @@ static void test_expand(void)
JsonValue *m2 = history_with_ref(backup);
CHECK(image_has_refs(m2) == 1, "有引用时应报 1");
int dn = image_degrade_all(m2);
CHECK(dn == 1, "应降级 1 处,实际 %d", dn);
CHECK(dn == 1, "应降级 1 处, 实际 %d", dn);
CHECK(image_has_refs(m2) == 0, "降级后不该还有引用");
JsonValue *c2 = json_object_get(json_array_get(m2, 1), "content");
const char *t2 = json_get_string(json_array_get(c2, 0), "text");
/* 给模型的话必须是能直接照做的中文,而不是服务端的英文报错 */
/* 给模型的话必须是能直接照做的中文, 而不是服务端的英文报错 */
CHECK(t2 && strstr(t2, "不符合 API 所需的格式标准") != NULL,
"应说明格式不符,实际 %s", t2 ? t2 : "(null)");
"应说明格式不符, 实际 %s", t2 ? t2 : "(null)");
CHECK(t2 && strstr(t2, "请规范化图片") != NULL, "应给出下一步动作");
CHECK(t2 && strchr(t2, '\n') == NULL, "说明不该带换行");
json_free(m2);
@@ -290,7 +290,7 @@ static void test_pending(void)
if (arr && json_array_size(arr) == 2) {
JsonValue *p0 = json_array_get(arr, 0);
CHECK(strcmp(json_get_string(p0, "type"), "ligent_image") == 0,
"类型应是内部引用块,不是协议块");
"类型应是内部引用块, 不是协议块");
CHECK(strcmp(json_get_string(p0, "backup"), "1700000000_aaaa.png") == 0,
"第一个备份名");
CHECK(json_get_string(p0, "file_id") == NULL,
@@ -313,6 +313,6 @@ int main(void)
test_base64();
test_expand();
test_pending();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+13 -13
View File
@@ -80,8 +80,8 @@ static void test_numbers(void)
json_free(v);
/* 严格按 RFC 8259 扫描数字:这些以前全都被接受 —— strtod 会把 "1e" 读成 1、
* "5." 读成 5,于是一个手写错的数字会被静默当成另一个数用下去(配置文件、
* 存档都是手写的,用户只会在很久之后从一个不相干的现象里发现它)。 */
* "5." 读成 5, 于是一个手写错的数字会被静默当成另一个数用下去 (配置文件、
* 存档都是手写的, 用户只会在很久之后从一个不相干的现象里发现它). */
const char *bad_nums[] = {
"01", "-01", "5.", ".5", "+1", "1e", "1e+", "1e-", "1.2e", "-", "1_000",
};
@@ -167,7 +167,7 @@ static void test_nested_depth(void)
json_free(ok);
}
/* 报错必须能定位:只有"语法错了"而不知道错在哪,等于没有报错 */
/* 报错必须能定位:只有"语法错了"而不知道错在哪, 等于没有报错 */
static void test_error_location(void)
{
puts("test_error_location");
@@ -220,17 +220,17 @@ static void test_error_location(void)
json_free(v);
}
/* 序列化出去的东西必须是**合法 UTF-8**。
/* 序列化出去的东西必须是**合法 UTF-8**.
*
* 服务端会因为请求体里有非法 UTF-8 直接回
* 400 ... invalid unicode code point
* 而那段坏数据一旦进了对话历史,之后每次请求都会失败 —— 除非丢掉整轮对话。
* 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话.
* 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
* 字节字符、二进制垃圾。所以这条不能只靠"在入口清理",必须在序列化器这层兜住:
* 出口是唯一能保证"发出去的永远合法"的地方。
* 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住:
* 出口是唯一能保证"发出去的永远合法"的地方.
*
* 这个校验函数故意用 utf8_seq_len 自己走一遍,而不是信任 dump 的实现 ——
* 断言的是**产物**的性质,不是实现的自述。 */
* 这个校验函数故意用 utf8_seq_len 自己走一遍, 而不是信任 dump 的实现 ——
* 断言的是**产物**的性质, 不是实现的自述. */
static int utf8_all_valid(const char *s)
{
size_t n = strlen(s), i = 0;
@@ -260,11 +260,11 @@ static void test_dump_utf8_safety(void)
CHECK(utf8_seq_len("\xf5\x80\x80\x80", 4) == 0, "超出 U+10FFFF 应非法");
CHECK(utf8_seq_len("\xe4\x41\x42", 3) == 0, "续字节格式不对应非法");
/* 各种坏字节经 json_dump 之后,整份输出必须是合法 UTF-8 */
/* 各种坏字节经 json_dump 之后, 整份输出必须是合法 UTF-8 */
static const char *bad[] = {
"a\x80" "b", /* 孤立续字节 */
"a\xe4\xb8" "b", /* 截断的 3 字节序列 */
"a\xc0\xaf" "b", /* 过长编码(历史上被用来夹带 '/') */
"a\xc0\xaf" "b", /* 过长编码 (历史上被用来夹带 '/') */
"a\xed\xa0\x80" "b", /* UTF-16 代理区 */
"a\xf5\x80\x80\x80" "b", /* 超出 U+10FFFF */
"\xff\xfe", /* 二进制垃圾 */
@@ -275,7 +275,7 @@ static void test_dump_utf8_safety(void)
json_object_set_string(o, "content", bad[k]);
char *d = json_dump(o);
CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k, d);
/* 而且我们的解析器(和服务端一样严格)必须收得下 */
/* 而且我们的解析器 (和服务端一样严格)必须收得下 */
char err[256];
JsonValue *back = json_parse(d, err, sizeof err);
CHECK(back != NULL, "第 %zu 个输出无法解析回来: %s", k, err);
@@ -310,6 +310,6 @@ int main(void)
test_nested_depth();
test_error_location();
test_dump_utf8_safety();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+36 -36
View File
@@ -1,4 +1,4 @@
/* test_lineedit.c - 行编辑器的缓冲区逻辑与历史记录(不需要终端) */
/* test_lineedit.c - 行编辑器的缓冲区逻辑与历史记录 (不需要终端) */
#include "lineedit.h"
#include "util.h"
@@ -23,9 +23,9 @@ static int checks = 0;
#define BUFEQ(lb, want) \
CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \
"内容应为 [%s],实际 [%s]", want, (lb).buf)
"内容应为 [%s], 实际 [%s]", want, (lb).buf)
/* 读一个文本文件的前 n 字节到 buf(用于检查落盘格式) */
/* 读一个文本文件的前 n 字节到 buf (用于检查落盘格式) */
static void slurp(const char *path, char *buf, size_t buflen)
{
buf[0] = '\0';
@@ -52,7 +52,7 @@ static void test_history_file(void)
CHECK(line_history_append(path, "") == 0, "空串不写文件");
CHECK(access(path, F_OK) != 0, "空串不该把文件创建出来");
/* 追加两条,再读回来 */
/* 追加两条, 再读回来 */
CHECK(line_history_add("echo hello") == 1, "第一次应记下");
line_history_append(path, "echo hello");
CHECK(line_history_add("echo hello") == 0, "与上一条重复应被忽略");
@@ -62,11 +62,11 @@ static void test_history_file(void)
char raw[512];
slurp(path, raw, sizeof raw);
CHECK(strcmp(raw, "echo hello\n/think high\n") == 0,
"普通命令应原样一行,实际 %s", raw);
"普通命令应原样一行, 实际 %s", raw);
line_history_clear();
CHECK(line_history_load(path, 100) == 0, "加载应成功");
CHECK(line_history_len() == 2, "应读回 2 条,实际 %zu", line_history_len());
CHECK(line_history_len() == 2, "应读回 2 条, 实际 %zu", line_history_len());
CHECK(strcmp(line_history_get(0), "echo hello") == 0, "第 0 条应还原");
CHECK(strcmp(line_history_get(1), "/think high") == 0, "第 1 条应还原");
line_history_clear();
@@ -82,22 +82,22 @@ static void test_history_file(void)
slurp(path, raw, sizeof raw);
CHECK(strchr(raw, '\n') != NULL && strstr(raw, "\n\n") == NULL,
"每条只占一行,实际 %s", raw);
"每条只占一行, 实际 %s", raw);
int newlines = 0;
for (const char *p = raw; *p; p++) if (*p == '\n') newlines++;
CHECK(newlines == 3, "3 条历史应占 3 行,实际 %d 行", newlines);
CHECK(newlines == 3, "3 条历史应占 3 行, 实际 %d 行", newlines);
line_history_clear();
line_history_load(path, 100);
CHECK(line_history_len() == 3, "应读回 3 条,实际 %zu", line_history_len());
CHECK(line_history_len() == 3, "应读回 3 条, 实际 %zu", line_history_len());
CHECK(strcmp(line_history_get(0), "echo C:\\Users\\me") == 0,
"反斜杠应原样还原,实际 [%s]", line_history_get(0));
"反斜杠应原样还原, 实际 [%s]", line_history_get(0));
CHECK(line_history_get(1) && strcmp(line_history_get(1), "第一行\n第二行") == 0,
"多行条目应整条还原,实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)");
"多行条目应整条还原, 实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)");
CHECK(strcmp(line_history_get(2), "中文历史") == 0, "中文应原样还原");
line_history_clear();
/* 超过上限:只留最新的 keep 条,并且文件被就地截短 */
/* 超过上限:只留最新的 keep 条, 并且文件被就地截短 */
unlink(path);
for (int i = 0; i < 10; i++) {
char *s = xasprintf("cmd-%d", i);
@@ -108,13 +108,13 @@ static void test_history_file(void)
int lines = 0;
slurp(path, raw, sizeof raw);
for (const char *p = raw; *p; p++) if (*p == '\n') lines++;
CHECK(lines == 10, "截短前应有 10 行,实际 %d", lines);
CHECK(lines == 10, "截短前应有 10 行, 实际 %d", lines);
line_history_clear();
line_history_load(path, 4);
CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条,实际 %zu", line_history_len());
CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条, 实际 %zu", line_history_len());
CHECK(line_history_get(0) && strcmp(line_history_get(0), "cmd-6") == 0,
"应保留最新的 4 条,第一条应是 cmd-6,实际 [%s]",
"应保留最新的 4 条, 第一条应是 cmd-6, 实际 [%s]",
line_history_get(0) ? line_history_get(0) : "(null)");
CHECK(line_history_get(3) && strcmp(line_history_get(3), "cmd-9") == 0,
"最后一条应是 cmd-9");
@@ -123,12 +123,12 @@ static void test_history_file(void)
slurp(path, raw, sizeof raw);
lines = 0;
for (const char *p = raw; *p; p++) if (*p == '\n') lines++;
CHECK(lines == 4, "文件应被就地截短成 4 行,实际 %d", lines);
CHECK(lines == 4, "文件应被就地截短成 4 行, 实际 %d", lines);
/* 截短后的文件读回来还是那 4 条 */
line_history_clear();
line_history_load(path, 100);
CHECK(line_history_len() == 4, "截短后应只读到 4 条,实际 %zu", line_history_len());
CHECK(line_history_len() == 4, "截短后应只读到 4 条, 实际 %zu", line_history_len());
line_history_clear();
unlink(path);
@@ -144,7 +144,7 @@ int main(void)
linebuf_insert(&lb, "hello", 5);
BUFEQ(lb, "hello");
CHECK(lb.pos == 5, "插入后光标应在末尾,实际 %zu", lb.pos);
CHECK(lb.pos == 5, "插入后光标应在末尾, 实际 %zu", lb.pos);
/* 在中间插入 */
linebuf_home(&lb);
@@ -153,7 +153,7 @@ int main(void)
linebuf_right(&lb);
linebuf_insert(&lb, "XY", 2);
BUFEQ(lb, "heXYllo");
CHECK(lb.pos == 4, "插入后光标应后移,实际 %zu", lb.pos);
CHECK(lb.pos == 4, "插入后光标应后移, 实际 %zu", lb.pos);
/* 退格删的是光标前的字符 */
linebuf_backspace(&lb);
@@ -171,20 +171,20 @@ int main(void)
puts("UTF-8:按字符而非字节移动");
linebuf_init(&lb);
linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */
CHECK(lb.len == 9, "应为 9 字节,实际 %zu", lb.len);
CHECK(lb.len == 9, "应为 9 字节, 实际 %zu", lb.len);
linebuf_left(&lb);
CHECK(lb.pos == 8, "左移一格应为 8,实际 %zu", lb.pos);
CHECK(lb.pos == 8, "左移一格应为 8, 实际 %zu", lb.pos);
linebuf_left(&lb);
linebuf_left(&lb);
linebuf_left(&lb);
CHECK(lb.pos == 3, "再左移三格应停在第二个汉字之后,实际 %zu", lb.pos);
CHECK(lb.pos == 3, "再左移三格应停在第二个汉字之后, 实际 %zu", lb.pos);
linebuf_left(&lb);
linebuf_left(&lb);
CHECK(lb.pos == 0, "应能移到行首,实际 %zu", lb.pos);
CHECK(lb.pos == 0, "应能移到行首, 实际 %zu", lb.pos);
linebuf_left(&lb);
CHECK(lb.pos == 0, "行首再左移不应越界");
/* 退格必须整字删除,不能留下半个字符 */
/* 退格必须整字删除, 不能留下半个字符 */
linebuf_end(&lb);
linebuf_backspace(&lb);
BUFEQ(lb, "中文ab");
@@ -205,10 +205,10 @@ int main(void)
linebuf_right(&lb); /* 光标到 'a' 之后 */
linebuf_delete(&lb); /* 删掉 '中' */
BUFEQ(lb, "ab");
CHECK(lb.pos == 1, "删除后光标不动,实际 %zu", lb.pos);
CHECK(lb.pos == 1, "删除后光标不动, 实际 %zu", lb.pos);
linebuf_free(&lb);
puts("Home / End 走的是逻辑行,不是整个缓冲区");
puts("Home / End 走的是逻辑行, 不是整个缓冲区");
linebuf_init(&lb);
linebuf_set(&lb, "one two three");
linebuf_home(&lb);
@@ -216,24 +216,24 @@ int main(void)
linebuf_end(&lb);
CHECK(lb.pos == lb.len, "单行时 End 到行尾");
/* 多行(粘贴进来的那种):Home/End 只在本行内走 */
/* 多行 (粘贴进来的那种):Home/End 只在本行内走 */
linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */
CHECK(lb.len == 11, "应为 11 字节,实际 %zu", lb.len);
CHECK(lb.len == 11, "应为 11 字节, 实际 %zu", lb.len);
linebuf_home(&lb);
CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首(8),实际 %zu", lb.pos);
CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首 (8), 实际 %zu", lb.pos);
linebuf_end(&lb);
CHECK(lb.pos == 11, "End 应到本行行尾(11),实际 %zu", lb.pos);
CHECK(lb.pos == 11, "End 应到本行行尾 (11), 实际 %zu", lb.pos);
lb.pos = 5; /* 落在 "bbb" 中间 */
linebuf_home(&lb);
CHECK(lb.pos == 4, "中间行按 Home 应到该行行首(4),实际 %zu", lb.pos);
CHECK(lb.pos == 4, "中间行按 Home 应到该行行首 (4), 实际 %zu", lb.pos);
linebuf_end(&lb);
CHECK(lb.pos == 7, "中间行按 End 应到该行行尾(7),实际 %zu", lb.pos);
CHECK(lb.pos == 7, "中间行按 End 应到该行行尾 (7), 实际 %zu", lb.pos);
lb.pos = 4; /* 正好在行首 */
linebuf_home(&lb);
CHECK(lb.pos == 4, "已在行首时 Home 不应跳到整个缓冲区开头");
lb.pos = 7; /* 正好在行尾(换行符前) */
lb.pos = 7; /* 正好在行尾 (换行符前) */
linebuf_end(&lb);
CHECK(lb.pos == 7, "已在行尾时 End 不应跳过换行符");
@@ -260,9 +260,9 @@ int main(void)
CHECK(line_history_len() == 0, "初始应为空");
line_history_add("first");
line_history_add("second");
line_history_add("second"); /* 与上一条重复,应忽略 */
line_history_add("second"); /* 与上一条重复, 应忽略 */
line_history_add(""); /* 空行不入历史 */
CHECK(line_history_len() == 2, "应有 2 条,实际 %zu", line_history_len());
CHECK(line_history_len() == 2, "应有 2 条, 实际 %zu", line_history_len());
CHECK(strcmp(line_history_get(0), "first") == 0, "第 0 条");
CHECK(strcmp(line_history_get(1), "second") == 0, "第 1 条");
CHECK(line_history_get(2) == NULL, "越界应返回 NULL");
@@ -274,6 +274,6 @@ int main(void)
test_history_file();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+25 -25
View File
@@ -1,7 +1,7 @@
/* test_markdown.c - 流式 Markdown 渲染器测试
*
* 最关键的不变量:把同一段文本任意切分后逐片喂入,输出必须与一次性喂入
* 完全一致。SSE 的分片位置是随机的,这条不成立就会漏出"半个星号"。
* 最关键的不变量:把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入
* 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号".
*/
#include "markdown.h"
#include "util.h"
@@ -24,7 +24,7 @@ static int checks = 0;
} \
} while (0)
/* 去掉 ANSI 转义,得到"肉眼可见"的文本 */
/* 去掉 ANSI 转义, 得到"肉眼可见"的文本 */
static char *strip_ansi(const char *s)
{
StrBuf out;
@@ -50,7 +50,7 @@ static void show_escaped(const char *s)
}
}
/* 渲染到内存流;chunk == 0 表示一次性喂入 */
/* 渲染到内存流; chunk == 0 表示一次性喂入 */
static char *do_render(const char *input, int enabled, size_t chunk)
{
char *buf = NULL;
@@ -88,7 +88,7 @@ static char *render(const char *in, int enabled)
static void test_split_invariance(void)
{
puts("分片不变性(一次性 / 3 字节 / 逐字节 必须一致)");
puts("分片不变性 (一次性 / 3 字节 / 逐字节 必须一致)");
static const char *samples[] = {
"**粗体**\n",
"普通 *斜体* 文本\n",
@@ -121,8 +121,8 @@ static void test_split_invariance(void)
"```\n# 不是标题\n[x](y)\n```\n",
"ls -la | grep foo\n",
"a | b\nc | d\n",
/* 以下三个是模糊测试找到并最小化出来的历史缺陷,
* 每个都曾让"一次性喂"与"分片喂"结果不一致,留作回归见证 */
/* 以下三个是模糊测试找到并最小化出来的历史缺陷,
* 每个都曾让"一次性喂"与"分片喂"结果不一致, 留作回归见证 */
"1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */
"- |", /* try_block 提前 return 跳过了列表规则 */
"s|\n-", /* 表格判断依赖分片边界 */
@@ -196,7 +196,7 @@ static void test_bold_italic(void)
CHECK(strcmp(o, "2 * 3 * 4\n") == 0, "乘法星号应原样输出: [%s]", o);
free(o);
/* snake_case 不能被误伤(这是不实现 _斜体_ 的原因) */
/* snake_case 不能被误伤 (这是不实现 _斜体_ 的原因) */
o = render("foo_bar_baz\n", 1);
CHECK(strcmp(o, "foo_bar_baz\n") == 0, "下划线标识符不应被改动: [%s]", o);
free(o);
@@ -216,7 +216,7 @@ static void test_bold_italic(void)
static void test_code(void)
{
puts("代码");
/* 行内代码:保留反引号(便于复制),内容着色 */
/* 行内代码:保留反引号 (便于复制), 内容着色 */
char *o = render("用 `ls -la` 看看\n", 1);
char *vis = strip_ansi(o);
CHECK(strcmp(vis, "用 `ls -la` 看看\n") == 0,
@@ -248,7 +248,7 @@ static void test_code(void)
static void test_blocks(void)
{
puts("列表 / 引用 / 分隔线");
/* 列表标记必须保留模型输出的原始字符,只上色,不换成 • */
/* 列表标记必须保留模型输出的原始字符, 只上色, 不换成 • */
char *o = render("- 甲\n- 乙\n", 1);
char *vis = strip_ansi(o);
CHECK(strcmp(vis, "- 甲\n- 乙\n") == 0, "列表应保留原始 - : [%s]", vis);
@@ -326,7 +326,7 @@ static void test_fence_verbatim(void)
"代码块内容应完整: [%s]", o);
free(o);
/* 代码块结束之后,普通文本要恢复渲染 */
/* 代码块结束之后, 普通文本要恢复渲染 */
o = render("```\nx\n```\n**加粗**\n", 1);
CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL,
"代码块之后应恢复渲染: [%s]", o);
@@ -362,7 +362,7 @@ static void test_underscore_italic(void)
CHECK(strcmp(vis, "这是 斜体 文字\n") == 0, "可见文本: [%s]", vis);
free(vis); free(o);
/* 中文紧邻下划线也应生效(非 ASCII 不算词字符) */
/* 中文紧邻下划线也应生效 (非 ASCII 不算词字符) */
o = render("中文_斜体_中文\n", 1);
CHECK(strstr(o, "\033[3m斜体") != NULL, "中文紧邻应生效: [%s]", o);
free(o);
@@ -378,18 +378,18 @@ static void test_underscore_italic(void)
static void test_link_bracket(void)
{
puts("链接:[] 下划线+青色高亮,其余原样");
puts("链接:[] 下划线+青色高亮, 其余原样");
char *o = render("见 [文档](https://example.com/a) 说明\n", 1);
/* 这里是下划线 + 青(SGR 4;36),不是亮蓝。整个项目只用 16 色范围内的
* 码,理由见 src/markdown.c 顶部和 tools/check-colors.sh。 */
/* 这里是下划线 + 青 (SGR 4;36), 不是亮蓝. 整个项目只用 16 色范围内的
* 码, 理由见 src/markdown.c 顶部和 tools/check-colors.sh. */
CHECK(strstr(o, "\033[4;36m[文档]") != NULL,
"[链接文字] 应下划线+青色高亮: [%s]", o);
char *vis = strip_ansi(o);
CHECK(strcmp(vis, "见 [文档](https://example.com/a) 说明\n") == 0,
"可见文本应逐字节保留(含 URL): [%s]", vis);
"可见文本应逐字节保留 (含 URL): [%s]", vis);
free(vis); free(o);
/* 图片也是 [...],同样只高亮方括号部分,整段保留 */
/* 图片也是 [...], 同样只高亮方括号部分, 整段保留 */
o = render("![图](a.png)\n", 1);
vis = strip_ansi(o);
CHECK(strcmp(vis, "![图](a.png)\n") == 0, "图片应保留原文: [%s]", vis);
@@ -418,7 +418,7 @@ static void test_tables(void)
CHECK(strstr(o, "\033[1m") != NULL, "表头应加粗: [%s]", o);
free(vis); free(o);
/* 中文列宽按 2 列计算,边框必须对齐 */
/* 中文列宽按 2 列计算, 边框必须对齐 */
o = render("| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n", 1);
vis = strip_ansi(o);
const char *want2 =
@@ -442,10 +442,10 @@ static void test_tables(void)
CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, vis);
free(vis); free(o);
/* 顶格必须写 '|'。不带首竖线的写法按普通文本处理 ——
/* 顶格必须写 '|'. 不带首竖线的写法按普通文本处理 ——
* 这是为保证"输出与分片方式无关"而做的取舍:
* 判断必须在行的第 0 个字节就能做出,否则分片落在竖线之前时,
* 行的前半段已被当作普通文本输出,结果会随分片方式而变。 */
* 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时,
* 行的前半段已被当作普通文本输出, 结果会随分片方式而变. */
o = render("a | b\n--- | ---\n1 | 2\n", 1);
vis = strip_ansi(o);
CHECK(strcmp(vis, "a | b\n--- | ---\n1 | 2\n") == 0,
@@ -458,7 +458,7 @@ static void test_tables(void)
CHECK(strstr(vis, "┌") == NULL, "缩进的表格不识别: [%s]", vis);
free(vis); free(o);
/* 误判防护:普通文本里出现竖线 + 下一行是短横线,不能当成表格 */
/* 误判防护:普通文本里出现竖线 + 下一行是短横线, 不能当成表格 */
o = render("运行 ls |\n-\n", 1);
vis = strip_ansi(o);
CHECK(strstr(vis, "┌") == NULL, "竖线+短横线不应误判成表格: [%s]", vis);
@@ -480,7 +480,7 @@ static void test_tables(void)
CHECK(strcmp(o, "ls -la | grep foo\n") == 0, "管道命令不应成表: [%s]", o);
free(o);
/* 表格后面紧跟普通文本,两者都要正常 */
/* 表格后面紧跟普通文本, 两者都要正常 */
o = render("| a | b |\n|---|---|\n| 1 | 2 |\n后续段落 **粗体**\n", 1);
vis = strip_ansi(o);
CHECK(strstr(vis, "└───┴───┘\n后续段落 粗体\n") != NULL,
@@ -498,7 +498,7 @@ static void test_long_line_bounded(void)
for (int i = 0; i < 200000; i++) sb_putc(&sb, 'x');
char *o = render(sb.data, 1);
size_t n = strlen(o);
CHECK(n > 100000, "应该已经把内容吐出,而不是无限缓存(实际 %zu 字节)", n);
CHECK(n > 100000, "应该已经把内容吐出, 而不是无限缓存 (实际 %zu 字节)", n);
free(o);
sb_free(&sb);
}
@@ -517,6 +517,6 @@ int main(void)
test_tables();
test_passthrough();
test_long_line_bounded();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+19 -19
View File
@@ -70,7 +70,7 @@ static JsonValue *sample(void)
json_array_push(a, msg("tool", "file1\nfile2"));
json_array_push(a, msg("assistant", "写好了"));
json_array_push(a, msg("user", "改用 C"));
json_array_push(a, msg("assistant", "好的,改成 C"));
json_array_push(a, msg("assistant", "好的, 改成 C"));
json_array_push(a, msg("user", "加个注释"));
json_array_push(a, msg("assistant", "加好了"));
return a;
@@ -82,11 +82,11 @@ static void test_rounds(void)
JsonValue *m = sample();
MetaRounds r;
CHECK(meta_rounds_build(m, &r) == 1, "应该能切出轮次");
CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息,实际 %zu", r.head);
CHECK(r.count == 3, "应切出 3 轮,实际 %zu", r.count);
CHECK(r.starts[0] == 1, "轮 1 从 #1 开始,实际 %zu", r.starts[0]);
CHECK(r.starts[1] == 5, "轮 2 从 #5 开始,实际 %zu", r.starts[1]);
CHECK(r.starts[2] == 7, "轮 3 从 #7 开始,实际 %zu", r.starts[2]);
CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息, 实际 %zu", r.head);
CHECK(r.count == 3, "应切出 3 轮, 实际 %zu", r.count);
CHECK(r.starts[0] == 1, "轮 1 从 #1 开始, 实际 %zu", r.starts[0]);
CHECK(r.starts[1] == 5, "轮 2 从 #5 开始, 实际 %zu", r.starts[1]);
CHECK(r.starts[2] == 7, "轮 3 从 #7 开始, 实际 %zu", r.starts[2]);
meta_rounds_free(&r);
/* 没有 system 也要能切 */
@@ -94,7 +94,7 @@ static void test_rounds(void)
json_array_push(n, msg("user", "x"));
json_array_push(n, msg("assistant", "y"));
CHECK(meta_rounds_build(n, &r) == 1, "没有 system 也能切");
CHECK(r.head == 0 && r.count == 1, "1 轮,从头开始");
CHECK(r.head == 0 && r.count == 1, "1 轮, 从头开始");
meta_rounds_free(&r);
json_free(n);
@@ -106,13 +106,13 @@ static void test_rounds(void)
json_free(only);
/* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** ——
* 判据必须与主循环(agent_opens_round)一致,否则 /meta 报的轮号会和主循环的
* 口径对不上,模型照轮号删下去,删掉的不是它以为的那一轮。
* 判据必须与主循环 (agent_opens_round)一致, 否则 /meta 报的轮号会和主循环的
* 口径对不上, 模型照轮号删下去, 删掉的不是它以为的那一轮.
* 结构:system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */
JsonValue *withimg = json_new_array();
json_array_push(withimg, msg("system", "s"));
json_array_push(withimg, msg("user", "看图"));
json_array_push(withimg, msg("assistant", "ok")); /* 占位,模拟带 tool_calls 的轮内消息 */
json_array_push(withimg, msg("assistant", "ok")); /* 占位, 模拟带 tool_calls 的轮内消息 */
json_array_push(withimg, msg("tool", "结果"));
{
JsonValue *att = json_new_object();
@@ -126,7 +126,7 @@ static void test_rounds(void)
}
json_array_push(withimg, msg("assistant", "看到图了"));
CHECK(meta_rounds_build(withimg, &r) == 1, "带附件的历史也应该能切");
CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮,实际 %zu 轮", r.count);
CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮, 实际 %zu 轮", r.count);
meta_rounds_free(&r);
json_free(withimg);
@@ -159,7 +159,7 @@ static char *call(Agent *a, const char *name, const char *json)
return r;
}
/* 把一段历史挂上,跑 body,再摘下来 */
/* 把一段历史挂上, 跑 body, 再摘下来 */
#define WITH_TOOLS(a) \
do { \
CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \
@@ -223,13 +223,13 @@ static void test_delete_safety(void)
CHECK(json_array_size(a.messages) == before, "仍不该改动");
free(r);
/* 一条合法一条非法:整批都不该动(先校验后执行) */
/* 一条合法一条非法:整批都不该动 (先校验后执行) */
r = call(&a, "delete_messages", "{\"indices\":[1,2]}");
CHECK(r && strstr(r, "不可拆") != NULL, "混了非法项应整体拒绝");
CHECK(json_array_size(a.messages) == before, "整体拒绝时不该改一半");
free(r);
/* 合法删除:删掉第 1 轮里那句 user,并给上下文提示 */
/* 合法删除:删掉第 1 轮里那句 user, 并给上下文提示 */
r = call(&a, "delete_messages", "{\"indices\":[1]}");
CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功:%s", r ? r : "");
CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文");
@@ -298,7 +298,7 @@ static void test_insert_and_undo(void)
free(r);
/* 撤销应回到插入前 */
CHECK(meta_tools_ops() == 1, "应有 1 次改动,实际 %zu", meta_tools_ops());
CHECK(meta_tools_ops() == 1, "应有 1 次改动, 实际 %zu", meta_tools_ops());
r = call(&a, "undo_last_edit", "{}");
CHECK(r && strstr(r, "已撤销") != NULL, "撤销应成功");
CHECK(json_array_size(a.messages) == before, "撤销后条数应还原");
@@ -315,7 +315,7 @@ static void test_insert_and_undo(void)
static void test_read_tools(void)
{
puts("list_rounds / read_messages(只读)");
puts("list_rounds / read_messages (只读)");
Agent a; agent_init(&a);
json_free(a.messages); a.messages = sample();
meta_tools_attach(&a);
@@ -333,7 +333,7 @@ static void test_read_tools(void)
r = call(&a, "read_messages", "{\"from\":2,\"to\":3}");
CHECK(r && strstr(r, "run_command") != NULL, "应能看到工具调用");
CHECK(r && strstr(r, "file1") != NULL, "应能看到工具结果");
CHECK(r && strstr(r, "结构消息,不可单删") != NULL, "结构消息应有标注");
CHECK(r && strstr(r, "结构消息, 不可单删") != NULL, "结构消息应有标注");
free(r);
CHECK(meta_tools_mutating("list_rounds") == 0, "list_rounds 不是写操作");
@@ -359,7 +359,7 @@ static void test_reroot_flag(void)
CHECK(meta_reroot_pending() == 0, "初始不该有标记");
meta_tools_attach(&a);
char *r = call(&a, "list_rounds", "{}"); /* 只读,不该打标记 */
char *r = call(&a, "list_rounds", "{}"); /* 只读, 不该打标记 */
free(r);
CHECK(meta_reroot_pending() == 0, "只读操作不该打标记");
@@ -382,6 +382,6 @@ int main(void)
test_delete_rounds();
test_insert_and_undo();
test_reroot_flag();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+15 -15
View File
@@ -1,12 +1,12 @@
/* test_models.c - /models 响应体的解析
*
* 只测纯解析。拉取(GET {base_url}/models)要走网络,由 e2e 第 52 段对着
* mock 覆盖,那里还顺带验了交互式选择本身(方向键、确认、取消)。
* 只测纯解析. 拉取 (GET {base_url}/models)要走网络, 由 e2e 第 52 段对着
* mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消).
*
* 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭,/models 的返回花样
* 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id。
* 解析器要么跳过,要么去重,但**不能**因此整个失败:列表里少一个模型远好过
* 一个都选不了。
* 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭, /models 的返回花样
* 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id.
* 解析器要么跳过, 要么去重, 但**不能**因此整个失败:列表里少一个模型远好过
* 一个都选不了.
*/
#include "models.h"
@@ -37,9 +37,9 @@ static void test_standard(void)
"]}";
size_t n = 0;
char **l = models_parse(json, &n);
CHECK(l && n == 2, "应解析出 2 个,实际 %zu", n);
CHECK(l && n == 2, "应解析出 2 个, 实际 %zu", n);
if (l && n == 2) {
CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的(第一个 alpha)");
CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的 (第一个 alpha)");
CHECK(strcmp(l[1], "beta") == 0, "第二个 beta");
}
models_free(l, n);
@@ -47,7 +47,7 @@ static void test_standard(void)
static void test_dedupe_and_skip(void)
{
puts("重复项要去重,缺 id 的要跳过");
puts("重复项要去重, 缺 id 的要跳过");
const char *json =
"{\"data\":["
"{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"},"
@@ -55,7 +55,7 @@ static void test_dedupe_and_skip(void)
"]}";
size_t n = 0;
char **l = models_parse(json, &n);
CHECK(l && n == 2, "去重 + 跳过后应剩 2 个,实际 %zu", n);
CHECK(l && n == 2, "去重 + 跳过后应剩 2 个, 实际 %zu", n);
if (l && n == 2) {
CHECK(strcmp(l[0], "a") == 0, "第一个是 a");
CHECK(strcmp(l[1], "b") == 0, "第二个是 b");
@@ -65,7 +65,7 @@ static void test_dedupe_and_skip(void)
static void test_bare_array(void)
{
puts("兼容裸数组(字符串 / 对象都认)");
puts("兼容裸数组 (字符串 / 对象都认)");
size_t n = 0;
char **l = models_parse("[\"alpha\",\"beta\"]", &n);
CHECK(l && n == 2 && strcmp(l[1], "beta") == 0, "字符串数组应能解析");
@@ -79,7 +79,7 @@ static void test_bare_array(void)
static void test_bad_input(void)
{
puts("拿不到模型时返回 NULL,而不是空数组或崩溃");
puts("拿不到模型时返回 NULL, 而不是空数组或崩溃");
size_t n = 123;
CHECK(models_parse(NULL, &n) == NULL && n == 0, "NULL 输入");
n = 123;
@@ -99,7 +99,7 @@ static void test_bad_input(void)
CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && n == 0,
"全是坏条目");
/* out_n 也可为 NULL(调用方只关心数组) */
/* out_n 也可为 NULL (调用方只关心数组) */
char **l = models_parse("{\"data\":[{\"id\":\"x\"}]}", NULL);
CHECK(l != NULL, "out_n 为 NULL 也要能工作");
models_free(l, 1);
@@ -109,7 +109,7 @@ static void test_bad_input(void)
"空的 data 应判为空列表");
CHECK(models_is_empty_list("[]") == 1, "空的裸数组也是空列表");
CHECK(models_is_empty_list("<html>not json</html>") == 0,
"解析不了不算空列表(是两回事)");
"解析不了不算空列表 (是两回事)");
CHECK(models_is_empty_list("{\"data\":\"nope\"}") == 0,
"data 不是数组不算空列表");
CHECK(models_is_empty_list("{\"data\":[{\"id\":\"a\"}]}") == 0,
@@ -128,6 +128,6 @@ int main(void)
test_dedupe_and_skip();
test_bare_array();
test_bad_input();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+21 -21
View File
@@ -1,12 +1,12 @@
/* test_priv.c - 提权模块里那几条"不能搞错"的不变量
*
* 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道,于是 sudo 拿不到
* 控制终端,真机上认证 100% 失败(而替身 sudo 无条件成功,给了个假绿)。
* 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到
* 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿).
* 所以这里把两条硬不变量钉住:
*
* 1. **提权调用里不能有 -n**。带 -n 就等于"不许提问",需要密码时直接失败,
* 而用户要的恰恰是"提权要有身份验证"。
* 2. **没有控制终端时能识别出来**。识别不出来就会起一个等不到输入的进程。
* 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败,
* 而用户要的恰恰是"提权要有身份验证".
* 2. **没有控制终端时能识别出来**. 识别不出来就会起一个等不到输入的进程.
*/
#include "priv.h"
#include "util.h"
@@ -41,32 +41,32 @@ static int argv_has(char *const *a, const char *want)
int main(void)
{
puts("包装成 sudo -u <用户> -- <argv>");
/* 显式转换,和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由:
* priv_sudo_argv 的签名要 char *const[](execvp 的要求),而这里放进去的
* 字面量没有任何人会改写。把整条链路改成非 const 才是真的丢 const 正确性。 */
/* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由:
* priv_sudo_argv 的签名要 char *const[] (execvp 的要求), 而这里放进去的
* 字面量没有任何人会改写. 把整条链路改成非 const 才是真的丢 const 正确性. */
char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x", NULL };
char **w = priv_sudo_argv("root", plain);
CHECK(w != NULL, "应当返回数组");
CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo,实际 %s", w[0]);
CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo, 实际 %s", w[0]);
CHECK(strcmp(w[1], "-u") == 0 && strcmp(w[2], "root") == 0,
"-u <用户> 紧跟其后");
CHECK(strcmp(w[3], "--") == 0,
"必须有 -- :否则命令里的选项会被 sudo 当成自己的");
CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 &&
strcmp(w[6], "rm -rf /tmp/x") == 0,
"原 argv 原样接在后面,不被拆开");
"原 argv 原样接在后面, 不被拆开");
CHECK(w[7] == NULL, "数组要 NULL 结尾");
/* 命门外的那条不变量 */
CHECK(!argv_has(w, "-n"),
"**不能带 -n**:带 -n 就不许提问,需要密码时直接失败 —— "
"**不能带 -n**:带 -n 就不许提问, 需要密码时直接失败 —— "
"而提权的要点就是让 sudo 能问密码");
CHECK(!argv_has(w, "-S"),
"也不能带 -S:那要求把密码从 stdin 喂进去,等于让 ligent 经手密码");
"也不能带 -S:那要求把密码从 stdin 喂进去, 等于让 ligent 经手密码");
free(w);
puts("用户名为空时按 root");
w = priv_sudo_argv(NULL, plain);
CHECK(strcmp(w[2], "root") == 0, "空用户应落到 root,实际 %s", w[2]);
CHECK(strcmp(w[2], "root") == 0, "空用户应落到 root, 实际 %s", w[2]);
free(w);
w = priv_sudo_argv("", plain);
CHECK(strcmp(w[2], "root") == 0, "空串也应落到 root");
@@ -76,18 +76,18 @@ int main(void)
char err[512];
memset(err, 0, sizeof err);
priv_explain(1, "sudo: no tty present and no askpass program specified", err, sizeof err);
CHECK(strstr(err, "终端") != NULL, "缺终端要说终端,实际 %s", err);
CHECK(strstr(err, "终端") != NULL, "缺终端要说终端, 实际 %s", err);
priv_explain(1, "Sorry, try again.", err, sizeof err);
CHECK(strstr(err, "认证") != NULL, "认证失败要说认证,实际 %s", err);
CHECK(strstr(err, "认证") != NULL, "认证失败要说认证, 实际 %s", err);
priv_explain(1, "pluv is not in the sudoers file.", err, sizeof err);
CHECK(strstr(err, "sudo") != NULL && strstr(err, "权限") != NULL,
"没有 sudo 权限要说清楚,实际 %s", err);
/* 认不出来就原样透传:宁可给原文,也不编一句可能不对的解释 */
"没有 sudo 权限要说清楚, 实际 %s", err);
/* 认不出来就原样透传:宁可给原文, 也不编一句可能不对的解释 */
priv_explain(1, "some brand new message", err, sizeof err);
CHECK(strstr(err, "some brand new message") != NULL,
"认不出来要透传原文,实际 %s", err);
"认不出来要透传原文, 实际 %s", err);
priv_explain(127, "无法执行 sudo:No such file or directory", err, sizeof err);
CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚,实际 %s", err);
CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", err);
puts("没有控制终端时能识别出来");
int fds[2];
@@ -111,8 +111,8 @@ int main(void)
close(fds[0]);
while (waitpid(pid, NULL, 0) < 0) { }
CHECK(rd == (ssize_t)sizeof got && got == 0,
"setsid() 之后不该认为有终端(识别不出来就会卡住)");
"setsid() 之后不该认为有终端 (识别不出来就会卡住)");
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+29 -29
View File
@@ -1,7 +1,7 @@
/* test_spinner.c - 等待动画的两段式标签
*
* 动画写的是 stderr,所以这里把 stderr 挪到一个临时文件,驱动几轮之后
* 再把内容读回来断言。spinner_init(1) 是显式开关,测试里不依赖 isatty。
* 动画写的是 stderr, 所以这里把 stderr 挪到一个临时文件, 驱动几轮之后
* 再把内容读回来断言. spinner_init(1) 是显式开关, 测试里不依赖 isatty.
*/
#include "spinner.h"
@@ -25,8 +25,8 @@ static int checks = 0;
} \
} while (0)
/* 把 stderr 接到一个临时文件上,返回文件描述符;*saved 收原来的 stderr。
* 用完必须调用 capture_end 还原,否则后面的失败信息也会被吞掉。 */
/* 把 stderr 接到一个临时文件上, 返回文件描述符; *saved 收原来的 stderr.
* 用完必须调用 capture_end 还原, 否则后面的失败信息也会被吞掉. */
static int capture_begin(char *path, size_t pathlen, int *saved)
{
snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX");
@@ -61,7 +61,7 @@ static void test_two_phase(void)
spinner_init(1);
spinner_start("等待响应");
usleep(400 * 1000);
/* 每来一片思维链都会调一次:第一次生效,后面几次都是同一标签 */
/* 每来一片思维链都会调一次:第一次生效, 后面几次都是同一标签 */
spinner_set_label("思考中");
usleep(120 * 1000);
spinner_set_label("思考中");
@@ -76,9 +76,9 @@ static void test_two_phase(void)
const char *a = strstr(buf, "等待响应");
const char *b = strstr(buf, "思考中");
CHECK(a != NULL, "应打印\"等待响应\",实际 [%.120s]", buf);
CHECK(b != NULL, "应打印\"思考中\",实际 [%.120s]", buf);
CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\",顺序反了");
CHECK(a != NULL, "应打印\"等待响应\", 实际 [%.120s]", buf);
CHECK(b != NULL, "应打印\"思考中\", 实际 [%.120s]", buf);
CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\", 顺序反了");
CHECK(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印");
/* 停在"思考中"之后就不该再有"等待响应"冒出来 */
if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\"");
@@ -93,13 +93,13 @@ static void test_idempotent(void)
CHECK(fd >= 0, "无法重定向 stderr");
if (fd < 0) return;
/* 没 start 就 set:只记标签,不该打印任何东西,也不该崩 */
/* 没 start 就 set:只记标签, 不该打印任何东西, 也不该崩 */
spinner_init(1);
spinner_set_label("思考中");
usleep(50 * 1000);
/* 没 start 就 stop:幂等 */
spinner_stop();
/* start 两次:第二次忽略,不该起第二个线程 */
/* start 两次:第二次忽略, 不该起第二个线程 */
spinner_start("等待响应");
spinner_start("思考中");
usleep(300 * 1000);
@@ -111,7 +111,7 @@ static void test_idempotent(void)
unlink(path);
CHECK(strstr(buf, "等待响应") != NULL, "start 后应开始转");
CHECK(strstr(buf, "思考中") == NULL,
"已在转时再次 start 应被忽略,标签不该被改掉");
"已在转时再次 start 应被忽略, 标签不该被改掉");
/* enabled = 0 时全部是空操作:连重定向都不需要 */
spinner_init(0);
@@ -128,12 +128,12 @@ static double now_sec(void)
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
}
/* spinner_stop 要 pthread_join worker。如果 worker 是 nanosleep 一整帧,
/* spinner_stop 要 pthread_join worker. 如果 worker 是 nanosleep 一整帧,
* 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具
* 来回的用户感知延迟上。 */
* 来回的用户感知延迟上. */
static void test_stop_is_prompt(void)
{
puts("stop 不该等满一帧(250ms)");
puts("stop 不该等满一帧 (250ms)");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
@@ -153,16 +153,16 @@ static void test_stop_is_prompt(void)
char buf[4096];
capture_end(fd, saved, buf, sizeof buf);
unlink(path);
CHECK(worst < 0.10, "stop 应立即返回,最慢一次 %.3fs(帧间隔是 0.25s)", worst);
CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst);
}
/* 帧率必须受 250ms 限制。曾经踩过一次:pthread_cond_timedwait 默认按
* CLOCK_REALTIME 解释超时,而传进去的是 CLOCK_MONOTONIC 的绝对时间
* (开机时长永远小于 epoch 时间),于是超时永远"已经过了" —— worker
* 变成疯狂重绘的忙循环,把 pty 刷屏。 */
/* 帧率必须受 250ms 限制. 曾经踩过一次:pthread_cond_timedwait 默认按
* CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间
* (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker
* 变成疯狂重绘的忙循环, 把 pty 刷屏. */
static void test_frame_rate_is_bounded(void)
{
puts("帧率受 250ms 限制,不能狂刷");
puts("帧率受 250ms 限制, 不能狂刷");
char path[64];
int saved = -1;
int fd = capture_begin(path, sizeof path, &saved);
@@ -180,14 +180,14 @@ static void test_frame_rate_is_bounded(void)
int frames = 0;
for (const char *p = buf; (p = strstr(p, "等待响应")); p++) frames++;
CHECK(frames >= 1, "至少该画一帧,实际 %d", frames);
CHECK(frames <= 5, "0.6 秒内不该超过 5 帧,实际 %d(忙循环?)", frames);
CHECK(frames >= 1, "至少该画一帧, 实际 %d", frames);
CHECK(frames <= 5, "0.6 秒内不该超过 5 帧, 实际 %d (忙循环?)", frames);
}
/* spinner_notice 会先停、打印一条提示、再**接着转**。恢复时它传的是 g_label
* 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label,于是
* 标签被自己清空、退化成默认的"思考中"。症状:每次 HTTP 重试退避之后,屏幕上
* 的"等待响应"都会变成"思考中",而这两段等待的含义恰恰是它要区分的。 */
/* spinner_notice 会先停、打印一条提示、再**接着转**. 恢复时它传的是 g_label
* 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是
* 标签被自己清空、退化成默认的"思考中". 症状:每次 HTTP 重试退避之后, 屏幕上
* 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */
static void test_notice_keeps_label(void)
{
puts("notice 之后标签保持不变");
@@ -209,11 +209,11 @@ static void test_notice_keeps_label(void)
unlink(path);
const char *noticed = strstr(buf, "一条插进来的提示");
CHECK(noticed != NULL, "提示本身应该打出来,实际 [%.200s]", buf);
CHECK(noticed != NULL, "提示本身应该打出来, 实际 [%.200s]", buf);
/* 提示之后剩下的帧里必须还是原来那个标签 */
if (noticed)
CHECK(strstr(noticed, "自定义标签A") != NULL,
"notice 之后标签被改写了(应保持\"自定义标签A\"):\n%.300s",
"notice 之后标签被改写了 (应保持\"自定义标签A\"):\n%.300s",
noticed);
}
@@ -224,6 +224,6 @@ int main(void)
test_stop_is_prompt();
test_frame_rate_is_bounded();
test_notice_keeps_label();
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}
+59 -59
View File
@@ -47,7 +47,7 @@ int main(void)
/* ---- 工具 schema 合法性 ---- */
puts("tools_schema");
JsonValue *schema = tools_schema();
CHECK(json_array_size(schema) == 6, "应有 6 个工具,实际 %zu",
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);
@@ -69,7 +69,7 @@ int main(void)
CHECK(has_image, "schema 里应有 read_image");
}
{
/* line 是集合型参数:schema 里必须是 array(元素 integer),
/* line 是集合型参数:schema 里必须是 array (元素 integer),
* 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法
* 只有 schema 展开对了才作数 */
JsonValue *props = NULL, *reqd = NULL;
@@ -82,21 +82,21 @@ int main(void)
reqd = json_object_get(par, "required");
}
}
/* line 是可选的(省略 = 全文件替换),不该出现在 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");
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",
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",
CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer, 实际 %s",
it ? it : "(缺)");
}
@@ -170,9 +170,9 @@ int main(void)
CHECK(strstr(r, "LINE-TWO") != NULL, "替换结果应落盘: %s", r);
free(r);
/* 省略 line 不再是错误:那是"全文件替换"这条逃生门(见下面那组用例) */
/* 省略 line 不再是错误:那是"全文件替换"这条逃生门 (见下面那组用例) */
/* 行号对不上:必须报出它**真正在哪一行**,否则模型只能反复猜 */
/* 行号对不上:必须报出它**真正在哪一行**, 否则模型只能反复猜 */
r = call("edit_file",
"{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\","
"\"new_string\":\"x\"}", &err);
@@ -189,14 +189,14 @@ int main(void)
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);
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);
@@ -207,7 +207,7 @@ int main(void)
"同一行里多匹配应拒绝: %s", r);
free(r);
/* replace_all 仍然换全文件,但 line 必须指向其中真实的一处 */
/* replace_all 仍然换全文件, 但 line 必须指向其中真实的一处 */
r = call("edit_file",
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\","
"\"replace_all\":true}", &err);
@@ -224,15 +224,15 @@ int main(void)
free(r);
/* ---- edit_file:line 是集合型 ----
* 集合是**搜索范围**(old_string 的起点落在其中任一行即可),不是
* "每行都改一次":真改动的仍是唯一那一处。 */
* 集合是**搜索范围** (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);
@@ -243,17 +243,17 @@ int main(void)
CHECK(strstr(r, "AAA") != NULL && !strstr(r, "aaa"), "替换应落盘: %s", r);
free(r);
/* old_string 跨行时,把覆盖到的几行都填进集合——不必赌起点是第几行 */
/* 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);
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);
@@ -271,10 +271,10 @@ int main(void)
r = call("edit_file",
"{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\","
"\"new_string\":\"bar\"}", &err);
CHECK(err == 0, "重复行号应去重,而不是当成两处: %s", r);
CHECK(err == 0, "重复行号应去重, 而不是当成两处: %s", r);
free(r);
/* 集合里有一行越界 → 和单个行号时一样,报出文件总行数 */
/* 集合里有一行越界 → 和单个行号时一样, 报出文件总行数 */
r = call("edit_file",
"{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\","
"\"new_string\":\"y\"}", &err);
@@ -298,7 +298,7 @@ int main(void)
free(r);
/* ---- 省略 line = 全文件替换所有匹配 ----
* 这是 range 那样的集合参数该有的默认值:不给范围就是不设范围。 */
* 这是 range 那样的集合参数该有的默认值:不给范围就是不设范围. */
r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err);
CHECK(err == 0, "写入 all.txt: %s", r);
free(r);
@@ -312,13 +312,13 @@ int main(void)
CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r);
free(r);
/* 省略 line 时文件里没有 old_string:只说整个文件里没有(没有"哪几行"可说) */
/* 省略 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 给错了类型(不是"想全局"):仍然当场报错,并告诉它可以省略 */
/* line 给错了类型 (不是"想全局"):仍然当场报错, 并告诉它可以省略 */
r = call("edit_file",
"{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\","
"\"new_string\":\"z\"}", &err);
@@ -327,7 +327,7 @@ int main(void)
free(r);
{
/* 65 个行号:超过一次能圈住的范围,要明说 */
/* 65 个行号:超过一次能圈住的范围, 要明说 */
char big[600];
size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":[");
for (int i = 1; i <= 65; i++)
@@ -348,9 +348,9 @@ int main(void)
CHECK(err == 1, "不存在的目录应报错: %s", r);
free(r);
/* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep,
* 子代理(spawn_subagent)整个功能也去掉了。留着断言是为了钉住
* "不要再冒出一个半吊子的替代品"。 */
/* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep,
* 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住
* "不要再冒出一个半吊子的替代品". */
r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err);
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r);
free(r);
@@ -361,10 +361,10 @@ int main(void)
/* ---- 超大文件不能把内存吃干 ---- */
puts("超大文件防护");
{
/* 稀疏文件,创建几乎不耗时;关键是客户端要在读之前就发现它太大。
* 开头 16 KB 写成 'x':这样它是"文本"(探测的前 8 KB 里没有 NUL),
/* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大.
* 开头 16 KB 写成 'x':这样它是"文本" (探测的前 8 KB 里没有 NUL),
* 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到
* "分段读大文件"了。 */
* "分段读大文件"了. */
FILE *big = fopen("huge.txt", "wb");
CHECK(big != NULL, "创建测试文件失败");
if (big) {
@@ -382,14 +382,14 @@ int main(void)
CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r);
free(r);
/* read_file 是分段读的,应当照常可用 */
/* 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");
@@ -434,23 +434,23 @@ int main(void)
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("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 变成垃圾场。 */
* 两条拒绝路径是新的:格式不认识、体积超限. 它们都必须**不备份** ——
* 一张交不出去的图, 留备份只会让 ligent.archive 变成垃圾场. */
puts("read_image:本地就把不合格的图拦掉");
{
char btmp[] = "/tmp/ligent_tools_img_XXXXXX";
@@ -458,7 +458,7 @@ int main(void)
CHECK(bdir != NULL, "建备份临时目录失败");
if (bdir) {
image_set_bindir(bdir);
image_pending_take(); /* 清干净,免得上一段残留 */
image_pending_take(); /* 清干净, 免得上一段残留 */
/* 1) 内容是文本、扩展名骗人 */
FILE *f = fopen("notanimage.png", "wb");
@@ -469,8 +469,8 @@ int main(void)
CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的");
free(r);
/* 2) 体积超限:base64 后超过 IMAGE_MAX_B64。
* 4/3 * 原始大小 = 编码后大小,所以原始要 > 3/4 * 上限。 */
/* 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) {
@@ -491,7 +491,7 @@ int main(void)
CHECK(strstr(r, "内联上限") != NULL, "应说清上限是多少");
free(r);
/* 3) 合法的小图:成功,并且挂上待发附件 */
/* 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);
@@ -513,7 +513,7 @@ int main(void)
int cnt = -1;
if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); }
free(ls);
CHECK(cnt == 1, "只该留 1 份备份(成功那张),实际 %d", cnt);
CHECK(cnt == 1, "只该留 1 份备份 (成功那张), 实际 %d", cnt);
image_set_bindir(".");
}
@@ -521,10 +521,10 @@ int main(void)
/* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ----
*
* read_file 以前用 fgets + 固定 64 KB 缓冲,一行比缓冲长时会被切成"多行"、
* 每块各带一个行号。于是模型按 read_file 看到的行号去 edit_file 改,要么改错
* 地方、要么报越界 —— 而它没有任何办法察觉。工具描述和 README 都写着
* "read_file 打出来的行号可以直接喂给 edit_file 的 line",这里把它钉住。 */
* read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、
* 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错
* 地方、要么报越界 —— 而它没有任何办法察觉. 工具描述和 README 都写着
* "read_file 打出来的行号可以直接喂给 edit_file 的 line", 这里把它钉住. */
puts("超长单行的行号口径");
{
FILE *lf = fopen("longline.txt", "wb");
@@ -537,12 +537,12 @@ int main(void)
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);
"超长行之后的第 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 改,必须命中同一处 */
/* 拿 read_file 报出的行号去 edit_file 改, 必须命中同一处 */
r = call("edit_file",
"{\"path\":\"longline.txt\",\"line\":3,"
"\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err);
@@ -553,8 +553,8 @@ int main(void)
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);
@@ -574,7 +574,7 @@ int main(void)
if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ }
r = call("list_dir", "{\"path\":\"emptydir\"}", &err);
CHECK(err == 0 && strstr(r, "0 个条目") != NULL,
"空目录应报 0 个条目,实际 %s", r);
"空目录应报 0 个条目, 实际 %s", r);
free(r);
rmdir("emptydir");
@@ -588,10 +588,10 @@ int main(void)
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));
"输出应被截断, 实际 %zu 字节", strlen(r));
CHECK(strstr(r, "截断") != NULL, "应提示已截断");
free(r);
printf("\n%d 项检查,%d 项失败\n", checks, failures);
printf("\n%d 项检查, %d 项失败\n", checks, failures);
return failures ? 1 : 0;
}