chore: 规范标点
This commit is contained in:
1 parent
e54390a9f8
commit
15e79887fa
84 files changed
+5439
-5398
No files matched your search
+25
-25
@@ -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
|
||||
|
||||
|
||||
|
||||
Vendored
+8
-8
@@ -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 -----
|
||||
|
||||
|
||||
Vendored
+22
-22
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+28
-28
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, °raded);
|
||||
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
@@ -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
@@ -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
@@ -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("\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "\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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user