chore: 规范标点
This commit is contained in:
1 parent
15e79887fa
commit
3dd2231c70
81 files changed
+1751
-1751
No files matched your search
+14
-14
@@ -8,12 +8,12 @@ NULL; 代码把 NULL 当成"没人能回答, 按默认值放行". 于是**在确
|
||||
上按 Ctrl-C 等于批准这次操作**. run_command 侥幸自己查了中断标志才没执行,
|
||||
write_file / edit_file 没查, 文件照写.
|
||||
|
||||
这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西:
|
||||
这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西:
|
||||
Ctrl-C -> 必须拒绝
|
||||
n -> 必须拒绝
|
||||
y -> 必须执行 (否则就是"修过头"了)
|
||||
|
||||
用法:
|
||||
用法:
|
||||
confirm_check.py <ligent 二进制> <mock base-url>
|
||||
"""
|
||||
import os
|
||||
@@ -27,14 +27,14 @@ import time
|
||||
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:
|
||||
@@ -107,7 +107,7 @@ def run_case(binary, base, answer, wait_confirm=6.0):
|
||||
def run_turn_scope(binary, base):
|
||||
"""Always / neVer 的作用域必须是"这一轮", 不是整个会话.
|
||||
|
||||
一轮里让模型连写两个文件:第一次问、答 Always, 第二次不该再问.
|
||||
一轮里让模型连写两个文件: 第一次问、答 Always, 第二次不该再问.
|
||||
然后开新的一轮 (下一条用户消息), 必须**重新问** —— 会话级的开关是
|
||||
/yolo, 不是这个.
|
||||
|
||||
@@ -178,7 +178,7 @@ def run_turn_scope(binary, base):
|
||||
|
||||
pump(1.2)
|
||||
|
||||
# ---- 第一轮:连写两个 ----
|
||||
# ---- 第一轮: 连写两个 ----
|
||||
base_ready = state["ready"]
|
||||
os.write(fd, "WRITE2_TEST 写两个文件\n".encode())
|
||||
wait_for(has_prompt, 8.0)
|
||||
@@ -190,7 +190,7 @@ def run_turn_scope(binary, base):
|
||||
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())
|
||||
@@ -237,7 +237,7 @@ def main():
|
||||
fails += 1
|
||||
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
|
||||
|
||||
# 三种回答:(说明, 发送的字节, 期望文件是否被写)
|
||||
# 三种回答: (说明, 发送的字节, 期望文件是否被写)
|
||||
cases = [
|
||||
("在确认提示上按 Ctrl-C", b"\x03", False),
|
||||
("答 n", b"n\n", False),
|
||||
@@ -246,15 +246,15 @@ def main():
|
||||
|
||||
for label, answer, expect_written in cases:
|
||||
got, written = run_case(binary, base, answer)
|
||||
check("%s:等到了确认提示" % label, got, "没等到确认提示")
|
||||
check("%s:文件%s" % (label, "被写入" if expect_written else "没被写入"),
|
||||
check("%s: 等到了确认提示" % label, got, "没等到确认提示")
|
||||
check("%s: 文件%s" % (label, "被写入" if expect_written else "没被写入"),
|
||||
written == expect_written,
|
||||
"实际 %s" % ("写了" if written else "没写"))
|
||||
|
||||
# 作用域:Always 只活这一轮
|
||||
# 作用域: Always 只活这一轮
|
||||
n1, both, again, n2 = run_turn_scope(binary, base)
|
||||
check("Always:同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1)
|
||||
check("Always:同轮的第二次改动被放行", both, "第二个文件没写")
|
||||
check("Always: 同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1)
|
||||
check("Always: 同轮的第二次改动被放行", both, "第二个文件没写")
|
||||
check("新一轮会重新询问 (作用域仅限本轮)", again and n2 >= 1,
|
||||
"新一轮没有询问")
|
||||
|
||||
@@ -263,7 +263,7 @@ def main():
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
# 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录, 删掉它
|
||||
# 这里**不要** rmtree("build/tmp"): 那是 e2e 各段共用的临时目录, 删掉它
|
||||
# 会连带清掉别人放进去的东西 (我就因此丢过两次日志), 而它自己的三个文件
|
||||
# 上面已经删干净了. 测试辅助脚本只该收拾自己造的东西.
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
===== 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":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的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
|
||||
|
||||
Vendored
+3
-3
@@ -3,7 +3,7 @@
|
||||
===== 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":"读取一张本地图片并把它交给你看 (截图、设计稿、图表、报错弹窗都行). 图片会以 base64 内联进请求, 本机留一份备份、历史里只记对备份的引用. 需要看图片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
|
||||
@@ -29,7 +29,7 @@ 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":{"type":"string","description":"目录路径, 默认当前目录"}},"required":[]}}},{"type":"function","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","functiLine truncated
|
||||
----- 响应头 -----
|
||||
HTTP/1.0 200 OK
|
||||
Server: BaseHTTP/0.6 Python/3.14.7
|
||||
@@ -51,7 +51,7 @@ 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","function":{"name":"list_dir","description":"列出目录内容, 含文件类型与大小. ","parameters":{"tLine 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":{"type":"Line truncated
|
||||
----- 响应头 -----
|
||||
HTTP/1.0 200 OK
|
||||
Server: BaseHTTP/0.6 Python/3.14.7
|
||||
|
||||
+13
-13
@@ -1,19 +1,19 @@
|
||||
/* fuzz.c - 对抗性模糊测试:JSON 解析器 + Markdown 渲染器
|
||||
/* fuzz.c - 对抗性模糊测试: JSON 解析器 + Markdown 渲染器
|
||||
*
|
||||
* 这两个模块是"解析不可信输入"的地方 (服务端响应、模型输出),
|
||||
* 也是最可能出崩溃/越界/泄漏的地方. 所以用随机输入去砸它们.
|
||||
*
|
||||
* 检查的不变量:
|
||||
* 检查的不变量:
|
||||
* JSON
|
||||
* 1. 任何输入都不能崩溃、不能泄漏 (靠 ASan/UBSan/LeakSanitizer)
|
||||
* 2. 解析成功 => dump 再 parse 再 dump, 两次 dump 必须一致 (幂等)
|
||||
* 3. 随机生成的合法 JSON 必须解析成功并完整往返
|
||||
* Markdown
|
||||
* 4. 任何输入都不能崩溃、不能泄漏
|
||||
* 5. 分片不变性:一次性喂 与 任意分片喂 的输出必须逐字节一致
|
||||
* 5. 分片不变性: 一次性喂 与 任意分片喂 的输出必须逐字节一致
|
||||
* 6. 不含任何元字符的输入必须原样输出
|
||||
*
|
||||
* 用法: fuzz [迭代次数] [随机种子]
|
||||
* 用法: fuzz [迭代次数] [随机种子]
|
||||
*/
|
||||
#include "json.h"
|
||||
#include "markdown.h"
|
||||
@@ -104,7 +104,7 @@ 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; }
|
||||
@@ -156,7 +156,7 @@ static int fuzz_json_once(char *err, size_t errlen)
|
||||
|
||||
/* 交给解析器的必须是真正 NUL 结尾的缓冲区 */
|
||||
if (strlen(input) != inlen) {
|
||||
snprintf(err, errlen, "测试器自身出错:缓冲区未正确结尾");
|
||||
snprintf(err, errlen, "测试器自身出错: 缓冲区未正确结尾");
|
||||
sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
@@ -167,7 +167,7 @@ static int fuzz_json_once(char *err, size_t errlen)
|
||||
char *d2 = NULL;
|
||||
JsonValue *v2 = json_parse(d1, err, errlen);
|
||||
if (!v2) {
|
||||
snprintf(err, errlen, "解析成功的 JSON 却无法重新解析:%.120s", d1);
|
||||
snprintf(err, errlen, "解析成功的 JSON 却无法重新解析: %.120s", d1);
|
||||
free(d1);
|
||||
json_free(v);
|
||||
sb_free(&sb);
|
||||
@@ -175,7 +175,7 @@ static int fuzz_json_once(char *err, size_t errlen)
|
||||
}
|
||||
d2 = json_dump(v2);
|
||||
if (strcmp(d1, d2) != 0) {
|
||||
snprintf(err, errlen, "dump 不幂等:\n 输入 %.200s\n 一次 %.200s\n 二次 %.200s",
|
||||
snprintf(err, errlen, "dump 不幂等: \n 输入 %.200s\n 一次 %.200s\n 二次 %.200s",
|
||||
input, d1, d2);
|
||||
free(d1); free(d2);
|
||||
json_free(v); json_free(v2);
|
||||
@@ -264,7 +264,7 @@ static int fuzz_md_once(char *err, size_t errlen)
|
||||
char *whole = md_render(src, len, 0);
|
||||
if (!whole) { sb_free(&sb); return 0; }
|
||||
if (strlen(whole) > MD_RENDER_LIMIT * 4) {
|
||||
snprintf(err, errlen, "输出异常膨胀:输入 %zu 字节 => %zu 字节",
|
||||
snprintf(err, errlen, "输出异常膨胀: 输入 %zu 字节 => %zu 字节",
|
||||
len, strlen(whole));
|
||||
free(whole); sb_free(&sb);
|
||||
return 1;
|
||||
@@ -292,7 +292,7 @@ static int fuzz_md_once(char *err, size_t errlen)
|
||||
/* 无元字符 => 必须逐字节原样 */
|
||||
if (pure) {
|
||||
if (strcmp(whole, src) != 0) {
|
||||
snprintf(err, errlen, "纯净文本被改写了:\n 输入 %.300s\n 输出 %.300s",
|
||||
snprintf(err, errlen, "纯净文本被改写了: \n 输入 %.300s\n 输出 %.300s",
|
||||
src, whole);
|
||||
free(whole); sb_free(&sb);
|
||||
return 1;
|
||||
@@ -315,13 +315,13 @@ 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++) {
|
||||
err[0] = '\0';
|
||||
if (fuzz_json_once(err, sizeof err)) {
|
||||
printf(" ✗ JSON 第 %ld 轮失败:%s\n", i, err);
|
||||
printf(" ✗ JSON 第 %ld 轮失败: %s\n", i, err);
|
||||
if (++json_fail >= 3) break;
|
||||
}
|
||||
}
|
||||
@@ -330,7 +330,7 @@ int main(int argc, char **argv)
|
||||
for (long i = 0; i < iters; i++) {
|
||||
err[0] = '\0';
|
||||
if (fuzz_md_once(err, sizeof err)) {
|
||||
printf(" ✗ Markdown 第 %ld 轮失败:%s\n", i, err);
|
||||
printf(" ✗ Markdown 第 %ld 轮失败: %s\n", i, err);
|
||||
if (++md_fail >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
+40
-40
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mock_server.py - 最小的 OpenAI 兼容服务端, 用于端到端测试 ligent.
|
||||
|
||||
不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性:
|
||||
不依赖任何第三方包. 行为完全由请求内容决定, 保持确定性:
|
||||
|
||||
- 用户消息含 ERROR_TEST -> 返回 HTTP 400 + error 对象
|
||||
- 尚未有 tool 结果 -> 流式返回一个 tool_call (run_command echo ...)
|
||||
@@ -25,7 +25,7 @@ def sse(wfile, obj):
|
||||
|
||||
|
||||
def pairing_error(msgs):
|
||||
"""服务端的硬要求:带 tool_calls 的 assistant 后面必须**紧跟**与每个
|
||||
"""服务端的硬要求: 带 tool_calls 的 assistant 后面必须**紧跟**与每个
|
||||
tool_call_id 一一对应的 tool 消息, 中间不能夹别的东西.
|
||||
|
||||
真实服务端就是这么回 400 的, mock 也照做 —— 这样任何会写坏历史的回归
|
||||
@@ -120,7 +120,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
# doctor 的连通性/重定向检查走 GET.
|
||||
# /redirect 开头故意返回 307, 用来验证客户端能把重定向指出来.
|
||||
# doctor 的认证探测:端点要求认证, 且只认 "Bearer good-key".
|
||||
# doctor 的认证探测: 端点要求认证, 且只认 "Bearer good-key".
|
||||
# 带对 key -> 200 (认证过了)
|
||||
# 带错 key -> 401 (密钥被拒)
|
||||
# 不带 key -> 401 (端点要求认证, 而你没配)
|
||||
@@ -140,7 +140,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# MODEL_LIST=bare 时改回裸数组, MODEL_LIST=empty 时不返回任何模型,
|
||||
# 两种兼容/异常形状都由 e2e 覆盖.
|
||||
if "/models" in self.path:
|
||||
# 模式从**文件**读, 不从环境变量读:mock 是脚本开头起好的,
|
||||
# 模式从**文件**读, 不从环境变量读: mock 是脚本开头起好的,
|
||||
# 给客户端进程设的 env 它根本看不见 (第一版就是这么假通过的).
|
||||
mode = ""
|
||||
mode_file = os.environ.get("MODELS_MODE_FILE", "")
|
||||
@@ -157,7 +157,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif mode == "garbage":
|
||||
payload = b"<html>not json at all</html>"
|
||||
else:
|
||||
# 默认列表里**故意包含当前模型** (deepseek-flash):
|
||||
# 默认列表里**故意包含当前模型** (deepseek-flash):
|
||||
# 这样才验得了"光标初始落在当前项上".
|
||||
# 另外塞一个重复项和一个缺 id 的坏条目, 验去重与跳过.
|
||||
payload = json.dumps({"object": "list", "data": [
|
||||
@@ -191,7 +191,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length)
|
||||
|
||||
# ---- UTF-8 严格校验:真服务端就是这么拒的 ----
|
||||
# ---- UTF-8 严格校验: 真服务端就是这么拒的 ----
|
||||
# DeepSeek 对请求体里非法的 UTF-8 回的是
|
||||
# 400 Failed to parse the request body as JSON:
|
||||
# messages[43].content: invalid unicode code point
|
||||
@@ -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:
|
||||
@@ -237,7 +237,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
tool_msgs = [m for m in messages if m.get("role") == "tool"]
|
||||
tools_sent = body.get("tools")
|
||||
|
||||
# ---- 结构校验:和真服务端一样严 ----
|
||||
# ---- 结构校验: 和真服务端一样严 ----
|
||||
perr = pairing_error(messages)
|
||||
if perr:
|
||||
self.reject(400, perr)
|
||||
@@ -246,7 +246,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# ---- 能力探测序列 ----
|
||||
# ligent 会在第一次真实请求之前发几个试探请求 (基线 / 图片 / tools),
|
||||
# 它们会被真实处理, 所以这里必须排在所有标记分支之前.
|
||||
# 辨认靠形状:刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图.
|
||||
# 辨认靠形状: 刚好一条 user 消息, 内容是 "1", 或者带一张 1x1 的图.
|
||||
def looks_like_probe(msgs):
|
||||
if len(msgs) != 1 or msgs[0].get("role") != "user":
|
||||
return False
|
||||
@@ -264,7 +264,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if looks_like_probe(messages):
|
||||
has_image = isinstance(messages[0].get("content"), list)
|
||||
# 用模型名模拟"不支持某能力"的服务端:400 是明确的拒绝信号
|
||||
# 用模型名模拟"不支持某能力"的服务端: 400 是明确的拒绝信号
|
||||
if "no-vision" in model and has_image:
|
||||
return self.reject(400, "unsupported content type: image_url")
|
||||
if "no-tools" in model and tools_sent:
|
||||
@@ -274,7 +274,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("1", stream, model)
|
||||
return
|
||||
|
||||
# 提权:让模型发一个带 elevated 的 run_command
|
||||
# 提权: 让模型发一个带 elevated 的 run_command
|
||||
if "ELEVATED_TEST" in first_user:
|
||||
if not tool_msgs:
|
||||
args = json.dumps({"command": "echo 提权后的输出", "elevated": True})
|
||||
@@ -293,7 +293,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("ELEV_SAW[%s]" % got.replace("\n", " | "), stream, model)
|
||||
return
|
||||
|
||||
# /include:把收到的 user 内容块报回来.
|
||||
# /include: 把收到的 user 内容块报回来.
|
||||
# 用 first_user 判断——附带之后最新那条 user 是带块的消息, last_user 会漂.
|
||||
if "INCLUDE_TEST" in first_user:
|
||||
texts, imgs, names = 0, 0, []
|
||||
@@ -330,7 +330,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
stream, model)
|
||||
return
|
||||
|
||||
# 权限/参数校验:客户端必须把 tools 传上来
|
||||
# 权限/参数校验: 客户端必须把 tools 传上来
|
||||
assert tools_sent, "请求里没有 tools 字段"
|
||||
|
||||
if "ERROR_TEST" in first_user:
|
||||
@@ -384,7 +384,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
# 复刻 OpenRouter 对"key 格式不对"的回应:头其实带了,
|
||||
# 复刻 OpenRouter 对"key 格式不对"的回应: 头其实带了,
|
||||
# 但文案说的是 "Missing Authentication header", 容易把人引偏
|
||||
if "AUTHFAIL_TEST" in first_user:
|
||||
payload = json.dumps({"error": {"message": "Missing Authentication header",
|
||||
@@ -396,7 +396,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
# 重定向:用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization,
|
||||
# 重定向: 用于验证客户端不静默跟随 (跨源跟随会丢掉 Authorization,
|
||||
# 服务端只会返回 401 "Missing Authentication header", 误导用户去查 key)
|
||||
if "REDIRECT_TEST" in first_user:
|
||||
self.send_response(307)
|
||||
@@ -443,7 +443,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
# 超过客户端响应体上限的超大响应:客户端应当主动中止而不是被撑爆
|
||||
# 超过客户端响应体上限的超大响应: 客户端应当主动中止而不是被撑爆
|
||||
if "HUGE_TEST" in first_user:
|
||||
total = 40 * 1024 * 1024
|
||||
self.send_response(200)
|
||||
@@ -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")
|
||||
@@ -479,7 +479,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.flush()
|
||||
return
|
||||
|
||||
# 传输中途断掉:声明了 Content-Length 却提前关闭连接
|
||||
# 传输中途断掉: 声明了 Content-Length 却提前关闭连接
|
||||
if "TRUNCATE_TEST" in first_user:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
@@ -537,7 +537,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.flush()
|
||||
return
|
||||
|
||||
# 思考模式:验证 reasoning_content 既能被累积, 也能随 assistant 消息
|
||||
# 思考模式: 验证 reasoning_content 既能被累积, 也能随 assistant 消息
|
||||
# 回传. DeepSeek 要求请求带 tools 时把历史里的 reasoning_content 全带上,
|
||||
# 漏了下一轮就 400, 所以这里第二轮专门检查它有没有回来.
|
||||
if "THINK_TEST" in first_user:
|
||||
@@ -562,7 +562,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
# 思考过程分片下发:客户端必须累积, 而不是只留最后一片
|
||||
# 思考过程分片下发: 客户端必须累积, 而不是只留最后一片
|
||||
for part in ("先想一下", "要不要调用工具. "):
|
||||
sse(self.wfile, {"choices": [{"index": 0,
|
||||
"delta": {"reasoning_content": part},
|
||||
@@ -587,8 +587,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body.get("reasoning_effort") or "none"), stream, model)
|
||||
return
|
||||
|
||||
# 慢速响应:先静默一段 (网络/排队), 再吐思维链, 再静默一段,
|
||||
# 最后给正文. 用来验证等待动画的两段标签:"等待响应" → "思考中".
|
||||
# 慢速响应: 先静默一段 (网络/排队), 再吐思维链, 再静默一段,
|
||||
# 最后给正文. 用来验证等待动画的两段标签: "等待响应" → "思考中".
|
||||
# 名字刻意不含 "THINK_TEST" —— 上面那些标记都是按子串匹配的,
|
||||
# 叫 SLOWTHINK_TEST 会被 THINK_TEST 那个分支先截胡.
|
||||
if "SLOWRESP_TEST" in first_user:
|
||||
@@ -604,7 +604,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("答案", stream=True, model=model)
|
||||
return
|
||||
|
||||
# meta 模式:第一轮让 meta 模型调 delete_rounds 删掉第 2 轮,
|
||||
# meta 模式: 第一轮让 meta 模型调 delete_rounds 删掉第 2 轮,
|
||||
# 第二轮收工. 用来端到端验证 /meta 的完整链路.
|
||||
if "META_TEST" in first_user:
|
||||
if not tool_msgs:
|
||||
@@ -626,7 +626,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("已删除第 2 轮. ", stream, model)
|
||||
return
|
||||
|
||||
# 按"最后一条用户消息"触发:上面那些模式都看 first_user,
|
||||
# 按"最后一条用户消息"触发: 上面那些模式都看 first_user,
|
||||
# 同一个进程里的第二轮就再也触发不了了, 没法验证"作用域仅限本轮".
|
||||
if "WRITE_LAST_TEST" in last_user:
|
||||
if messages[-1].get("role") == "user": # 新一轮的开头
|
||||
@@ -656,7 +656,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("第二轮写完了", stream, model)
|
||||
return
|
||||
|
||||
# 发出一个 write_file 调用:用来验证"在确认提示上按 Ctrl-C"到底
|
||||
# 发出一个 write_file 调用: 用来验证"在确认提示上按 Ctrl-C"到底
|
||||
# 会不会把文件写下去
|
||||
if "WRITE_TEST" in first_user:
|
||||
if not tool_msgs:
|
||||
@@ -686,7 +686,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("写完了", stream, model)
|
||||
return
|
||||
|
||||
# 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常:
|
||||
# 读一个**含非法 UTF-8 字节**的文件. 工具输出里出现坏字节的来源很日常:
|
||||
# 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多字节字符、二进制
|
||||
# 文件里混的一小段文本. 这段字节会进对话历史, 然后被序列化进请求体 ——
|
||||
# 如果序列化器原样吐出, 服务端就会 400, 而且之后每次请求都 400.
|
||||
@@ -715,7 +715,7 @@ 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:
|
||||
@@ -725,8 +725,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text("读完了, 替换字符=%d" % got.count("\ufffd"), stream, model)
|
||||
return
|
||||
|
||||
# 一轮里连写两个文件:用来验证 Always/neVer 的作用域就是"这一轮"
|
||||
# (名字刻意不含 "WRITE_TEST", 免得被上面那个分支先截胡)
|
||||
# 一轮里连写两个文件: 用来验证 Always/neVer 的作用域就是"这一轮"
|
||||
# (名字刻意不含 "WRITE_TEST", 以免被上面那个分支先截胡)
|
||||
if "WRITE2_TEST" in first_user:
|
||||
n = len(tool_msgs)
|
||||
if n < 2:
|
||||
@@ -759,8 +759,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
# 让模型去读一个"扩展名是 .png、内容不是图片"的文件,
|
||||
# 验证 read_image 按内容判断、并明确拒绝
|
||||
# 两种"读不了":内容不是图片、以及大得内联不进去.
|
||||
# 注意:mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的
|
||||
# 两种"读不了": 内容不是图片、以及大得内联不进去.
|
||||
# 注意: mock 的标记靠子串匹配, 是平铺的命名空间 —— 上面第 249 行的
|
||||
# HUGE_TEST 分支会吃掉任何含 "HUGE_TEST" 的标记, 所以这里叫 OVERSIZE.
|
||||
# 两者都该在**本地**被拒 —— 工具返回错误, 之后就没有图片块可发.
|
||||
if "IMAGE_BAD_TEST" in last_user or "IMAGE_OVERSIZE_TEST" in last_user:
|
||||
@@ -780,9 +780,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
# 一条 assistant 里发**两个**工具调用, 第一个是 read_image.
|
||||
# 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状:
|
||||
# 这是"图片附件插在两条 tool 消息中间"那个 bug 的最小复现形状:
|
||||
# 只要附件没被攒到最后一起推, 上面的结构校验就会回 400.
|
||||
# 注意用 first_user:图片附件本身也是一条 user 消息, 而且排在最后,
|
||||
# 注意用 first_user: 图片附件本身也是一条 user 消息, 而且排在最后,
|
||||
# 所以第二轮请求的 last_user 是那个附件 (一个 list), 不是这条标记.
|
||||
if "IMAGE_PAIR_TEST" in first_user:
|
||||
if not tool_msgs:
|
||||
@@ -808,7 +808,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
% (len(found), len(tool_msgs)), stream, model)
|
||||
return
|
||||
|
||||
# 图片:先让模型调一次 read_image, 下一轮把收到的图片内联块报回来.
|
||||
# 图片: 先让模型调一次 read_image, 下一轮把收到的图片内联块报回来.
|
||||
# Files API 已经不用了 —— 请求里应该直接是 image_url + data: base64.
|
||||
if "IMAGE_TEST" in first_user:
|
||||
if not tool_msgs:
|
||||
@@ -835,8 +835,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.flush()
|
||||
return
|
||||
|
||||
# 第二轮:请求里应该已经带上内联图片了.
|
||||
# 报文形状:{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}
|
||||
# 第二轮: 请求里应该已经带上内联图片了.
|
||||
# 报文形状: {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}
|
||||
found, notes = [], []
|
||||
for m in messages:
|
||||
c = m.get("content")
|
||||
@@ -856,7 +856,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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
|
||||
@@ -874,7 +874,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_text(reply, stream, model)
|
||||
return
|
||||
|
||||
# edit_file 的**调用展示**:成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照,
|
||||
# edit_file 的**调用展示**: 成功时屏幕上该是"路径 + 行号"加 -/+ 新旧对照,
|
||||
# 而不是一整段参数 JSON (old_string/new_string 原样打出来会刷屏).
|
||||
# EDITTEST_OK 成功剧本, EDITTEST_FAIL 行号越界剧本.
|
||||
if "EDITTEST_OK" in first_user or "EDITTEST_FAIL" in first_user:
|
||||
@@ -883,7 +883,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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,7 +909,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.flush()
|
||||
return
|
||||
|
||||
# 多步 (4 次工具往返才收工):用来钉住"往返次数默认不限".
|
||||
# 多步 (4 次工具往返才收工): 用来钉住"往返次数默认不限".
|
||||
# 默认配置下必须跑得完; 显式设了正数上限时要停在那个上限上并如实报出.
|
||||
if "MANYSTEPS_TEST" in first_user:
|
||||
done = len(tool_msgs)
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
/* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例
|
||||
*
|
||||
* 判定 (oracle):同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致.
|
||||
* 用法: build/reduce build/md_fail.bin
|
||||
* 判定 (oracle): 同一段输入, "一次性喂" 与 "逐字节喂" 的渲染结果不一致.
|
||||
* 用法: build/reduce build/md_fail.bin
|
||||
*/
|
||||
#include "markdown.h"
|
||||
#include "util.h"
|
||||
@@ -9,7 +9,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* 注意:md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区
|
||||
/* 注意: md_feed 只接受 NUL 结尾的字符串, 而最小化过程中的缓冲区
|
||||
* 是"长度受限"的, 所以必须先做一份带结尾符的副本. */
|
||||
static char *render(const char *src, size_t len, size_t chunk)
|
||||
{
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
交互式行为 (多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把
|
||||
整段 stdin 当成一个任务. 所以这里开一个伪终端, 逐条发送提示词.
|
||||
|
||||
用法:
|
||||
用法:
|
||||
repl_drive.py --bin ./ligent --base-url URL --prompt "第一轮" --prompt "第二轮"
|
||||
"""
|
||||
import argparse
|
||||
@@ -40,7 +40,7 @@ def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5,
|
||||
def drain(timeout, idle=0.5, until_ready=False):
|
||||
"""等到"回到提示符" (until_ready), 或者输出彻底安静下来.
|
||||
|
||||
必须按标记等, 不能只按安静等:等待动画每 250ms 画一帧, pty 永远
|
||||
必须按标记等, 不能只按安静等: 等待动画每 250ms 画一帧, pty 永远
|
||||
不会安静超过 idle, 于是每次都要白等满 timeout —— 而 mock 其实几
|
||||
毫秒就答复了. 这是整个 e2e 从 126 秒掉下来的关键.
|
||||
"""
|
||||
@@ -115,7 +115,7 @@ def main():
|
||||
help="原始按键序列 (支持 \\x1b 转义), 按顺序发送")
|
||||
a = ap.parse_args()
|
||||
|
||||
# --no-history:被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾.
|
||||
# --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
|
||||
|
||||
+23
-23
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""repl_edit_check.py - 验证 REPL 的行编辑行为 (方向键、Home/End、退格、历史)
|
||||
|
||||
只抓字节流是不够的:光标移动是靠转义序列实现的, 必须把输出回放到一个
|
||||
只抓字节流是不够的: 光标移动是靠转义序列实现的, 必须把输出回放到一个
|
||||
终端模型里才能知道"屏幕上最终长什么样". 所以这里带一个极简终端模拟器,
|
||||
只实现本编辑器用到的那几条控制序列.
|
||||
"""
|
||||
@@ -18,7 +18,7 @@ ROWS = 200
|
||||
|
||||
|
||||
class Term:
|
||||
"""极简终端模型:只实现 \\r \\n \\b、CSI 光标移动、CSI J/K 清屏"""
|
||||
"""极简终端模型: 只实现 \\r \\n \\b、CSI 光标移动、CSI J/K 清屏"""
|
||||
|
||||
def __init__(self, cols=COLS, rows=ROWS):
|
||||
self.cols, self.rows = cols, rows
|
||||
@@ -100,7 +100,7 @@ class Term:
|
||||
|
||||
def to_bytes(s):
|
||||
# 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节.
|
||||
# 不能直接用 unicode_escape:一条串里既有转义又有中文时, latin-1 那步会报错.
|
||||
# 不能直接用 unicode_escape: 一条串里既有转义又有中文时, latin-1 那步会报错.
|
||||
if isinstance(s, bytes):
|
||||
return s
|
||||
out = bytearray()
|
||||
@@ -147,7 +147,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03):
|
||||
def drain(timeout, idle=idle, until_ready=False):
|
||||
"""安静下来就返回, 不把整个 timeout 睡满.
|
||||
|
||||
idle 取得很小 (0.03s)是有依据的:行编辑器每次按键都在一个 fflush
|
||||
idle 取得很小 (0.03s)是有依据的: 行编辑器每次按键都在一个 fflush
|
||||
里把整屏重绘写完, 所以一次 read 之后基本就没了. 而提交输入触发请求
|
||||
时, 等待动画每 250ms 一帧, 永远不会安静, 于是照样等到 timeout ——
|
||||
那条路径的行为和以前一致, 不会因为 idle 调小而抢跑.
|
||||
@@ -179,7 +179,7 @@ def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03):
|
||||
drain(settle)
|
||||
|
||||
# 刻意不发 Ctrl-C (它会在光标处打出 ^C 标记并覆盖两个字),
|
||||
# 直接关掉 pty:最后那次重绘就是缓冲区的真值
|
||||
# 直接关掉 pty: 最后那次重绘就是缓冲区的真值
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
@@ -194,7 +194,7 @@ 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, 猜长了每处都在
|
||||
裸 pty 会话里不要用 time.sleep 猜时间: 猜短了会 flaky, 猜长了每处都在
|
||||
白等. 进程退出 (EOF)也算等到. """
|
||||
READY = b"\x1b[?2004h"
|
||||
end = time.time() + timeout
|
||||
@@ -226,19 +226,19 @@ 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 显式打开.
|
||||
args = ['--no-color', '--no-confirm', '--no-auto-save', '--api-key', 'test',
|
||||
'--base-url', base or 'http://127.0.0.1:1/v1']
|
||||
|
||||
# 被驱动的实例会读写输入历史. 整轮都指到临时文件:既不往用户真实的
|
||||
# 被驱动的实例会读写输入历史. 整轮都指到临时文件: 既不往用户真实的
|
||||
# ligent.history 里写测试垃圾, 方向键用例的结果也不会取决于开发者机器
|
||||
# 上碰巧有什么历史.
|
||||
scratch = os.path.join(tempfile.gettempdir(), "ligent_edit_hist_%d" % os.getpid())
|
||||
os.environ["LIGENT_HISTORY"] = scratch
|
||||
|
||||
cases = []
|
||||
# 方向键左移后插入:光标必须落在中间
|
||||
# 方向键左移后插入: 光标必须落在中间
|
||||
cases.append(("左方向键移动光标后插入",
|
||||
['hello world'] + [LEFT] * 5 + ['X'],
|
||||
'ligent> hello Xworld'))
|
||||
@@ -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(("粘贴后可继续编辑",
|
||||
@@ -294,7 +294,7 @@ def main():
|
||||
'ligent> 中文Xabc'))
|
||||
|
||||
# 粘贴进来的原始字节里可能夹着控制字符. read_paste 是原样收下的 (不走
|
||||
# classify), 所以必须清洗:终端按制表位展开 \t, 编辑器只当 1 列;
|
||||
# classify), 所以必须清洗: 终端按制表位展开 \t, 编辑器只当 1 列;
|
||||
# ESC 会被终端真的执行掉; \r 把光标拉回行首.
|
||||
cases.append(("粘贴里的制表符按制表位展开",
|
||||
[PASTE + 'a\\tb' + PASTE_END, 'X'],
|
||||
@@ -338,15 +338,15 @@ def main():
|
||||
# 上面的用例都带 --no-color, 所以这里单独补一组彩色的.
|
||||
color_args = [a for a in args if a != '--no-color'] + ['--color']
|
||||
color_cases = [
|
||||
("彩色提示符:左移后插入",
|
||||
("彩色提示符: 左移后插入",
|
||||
['hello world'] + [LEFT] * 5 + ['X'], 'ligent> hello Xworld'),
|
||||
("彩色提示符:Home 后插入",
|
||||
("彩色提示符: Home 后插入",
|
||||
['world', '\\x01', 'hello '], 'ligent> hello world'),
|
||||
("彩色提示符:End 后追加",
|
||||
("彩色提示符: End 后追加",
|
||||
['abc', '\\x05', '!'], 'ligent> abc!'),
|
||||
("彩色提示符:中文按字符移动",
|
||||
("彩色提示符: 中文按字符移动",
|
||||
['中文abc'] + [LEFT] * 3 + ['X'], 'ligent> 中文Xabc'),
|
||||
("彩色提示符:退格",
|
||||
("彩色提示符: 退格",
|
||||
['abcd', '\\x7f'], 'ligent> abc'),
|
||||
]
|
||||
for name, keys, want in color_cases:
|
||||
@@ -363,7 +363,7 @@ def main():
|
||||
print(" 实际: %r" % got)
|
||||
|
||||
# 上面那些用例只比屏幕内容, 而颜色在终端模型里是被剥掉的 —— 包没包住
|
||||
# 输入的字看不出来. 所以这里直接查字节:绿色区间必须一直延续到用户敲的
|
||||
# 输入的字看不出来. 所以这里直接查字节: 绿色区间必须一直延续到用户敲的
|
||||
# 内容之后. 只包提示符的话, 提示符自带的 reset 会让输入掉回默认色.
|
||||
raw = run(binary, color_args, ['greenme'])
|
||||
txt = raw.decode('utf-8', 'replace')
|
||||
@@ -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'
|
||||
@@ -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:
|
||||
|
||||
+12
-12
@@ -1,25 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放.
|
||||
|
||||
为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过, 而 mock
|
||||
为什么需要它: ligent 的协议适配一直只在自建的 mock 服务端上验证过, 而 mock
|
||||
是我们自己写的 —— 它接受什么, 完全取决于**我们以为**服务端会接受什么. 真实
|
||||
服务端的差异是最大的未知数.
|
||||
|
||||
/log 导出的是**原始字节** (请求体、响应头、未经解析的响应原文, 流式就是原始
|
||||
SSE), 正好够原样伺候回去. 所以这里不重写一个"模拟客户端", 而是当**离线
|
||||
服务端**:把录下来的响应按顺序发给一个真的 ligent 实例, 于是 http.c 的分块
|
||||
服务端**: 把录下来的响应按顺序发给一个真的 ligent 实例, 于是 http.c 的分块
|
||||
读取、llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑.
|
||||
|
||||
用法:
|
||||
replay.py --summary FILE 这份录制里有什么
|
||||
replay.py --serve PORT FILE [--strict] 当离线服务端
|
||||
replay.py --check FILE 自检:能不能解析出结构完整的往返
|
||||
replay.py --check FILE 自检: 能不能解析出结构完整的往返
|
||||
|
||||
--strict 会把每个进来的请求体与录制时那一份做**结构比对** (顶层键、消息角色
|
||||
序列、工具名集合), 不一致就回 400 并在 stderr 打印差异. 这是这个工具最有用
|
||||
的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言.
|
||||
的一半: 它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言.
|
||||
|
||||
注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求, 而那几轮
|
||||
注意回放时要给 ligent 加 --no-probe: 能力探测会插进额外的请求, 而那几轮
|
||||
不在录制里. 录制时也建议关掉 (LIGENT_PROBE=0), 两边一致.
|
||||
"""
|
||||
import argparse
|
||||
@@ -34,7 +34,7 @@ import threading
|
||||
def parse_log(text):
|
||||
"""把一份 /log 导出切成若干次 HTTP 往返.
|
||||
|
||||
格式 (见 http.c 的记录点):
|
||||
格式 (见 http.c 的记录点):
|
||||
===== <stamp> =====
|
||||
请求: POST <url>
|
||||
----- 请求体 (N 字节) -----
|
||||
@@ -67,7 +67,7 @@ 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
|
||||
@@ -91,7 +91,7 @@ def parse_log(text):
|
||||
i += 1
|
||||
i += 1 # 跳过空行
|
||||
|
||||
# 响应原文:直到"结束"行
|
||||
# 响应原文: 直到"结束"行
|
||||
start = i
|
||||
while i < len(lines) and not lines[i].startswith("----- 结束: "):
|
||||
i += 1
|
||||
@@ -112,7 +112,7 @@ def parse_log(text):
|
||||
|
||||
|
||||
def shape_of(req_bytes):
|
||||
"""一个请求体的**结构指纹**:只看形状, 不看内容.
|
||||
"""一个请求体的**结构指纹**: 只看形状, 不看内容.
|
||||
|
||||
内容每轮都不一样 (历史在长), 但形状必须稳定 —— 顶层键、消息角色序列、
|
||||
工具名集合. 客户端一旦改了报文形状, 这里就会显示出来.
|
||||
@@ -173,7 +173,7 @@ class ReplayServer:
|
||||
if want != got:
|
||||
self.mismatch += 1
|
||||
sys.stderr.write(
|
||||
"replay: 第 %d 个请求的**结构**和录制时不一致:\n"
|
||||
"replay: 第 %d 个请求的**结构**和录制时不一致: \n"
|
||||
" 录制时: %s\n"
|
||||
" 现在 : %s\n"
|
||||
% (self.pos, json.dumps(want, ensure_ascii=False),
|
||||
@@ -232,7 +232,7 @@ def handle_conn(conn, srv):
|
||||
head += "Content-Length: %d\r\nConnection: close\r\n\r\n" % len(resp)
|
||||
conn.sendall(head.encode() + resp)
|
||||
except Exception as e: # noqa: BLE001 - 服务端不该因一个连接死掉
|
||||
sys.stderr.write("replay: 连接处理出错:%s\n" % e)
|
||||
sys.stderr.write("replay: 连接处理出错: %s\n" % e)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
@@ -258,7 +258,7 @@ def main():
|
||||
ex = parse_log(text)
|
||||
|
||||
if a.summary or a.check:
|
||||
print("%s:%d 次往返" % (a.file, len(ex)))
|
||||
print("%s: %d 次往返" % (a.file, len(ex)))
|
||||
for i, e in enumerate(ex):
|
||||
r = e["resp"]
|
||||
kind = "SSE" if r.lstrip().startswith(b"data:") else "JSON"
|
||||
|
||||
+99
-99
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# run_e2e.sh - 端到端测试:用本地 mock 服务端跑通 ligent 的完整链路
|
||||
# run_e2e.sh - 端到端测试: 用本地 mock 服务端跑通 ligent 的完整链路
|
||||
set -u
|
||||
|
||||
# 把脚本自己的 stdin 断掉.
|
||||
#
|
||||
# 这不是洁癖:ligent 在写操作前会问 (Y/n), 而它在 stdin 不是终端时默认放行.
|
||||
# 这不是洁癖: ligent 在写操作前会问 (Y/n), 而它在 stdin 不是终端时默认放行.
|
||||
# 于是"直接调用 $BIN"的用例在 CI 里一路通过, 在开发者的终端里却会停下来
|
||||
# 等人敲回车 —— 同一份脚本, 两种行为. 交互用例都走 pty 驱动脚本, 不靠这里
|
||||
# 的 stdin, 所以断开它不会误伤任何东西.
|
||||
@@ -12,7 +12,7 @@ exec </dev/null
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# 默认测刚编出来的那个, 但允许覆盖 —— 静态产物 (ligent-static)也应当能过同一
|
||||
# 套用例, 否则"静态构建可用"就只等于"它能启动":
|
||||
# 套用例, 否则"静态构建可用"就只等于"它能启动":
|
||||
# BIN=$ROOT/ligent-static tests/run_e2e.sh
|
||||
BIN="${BIN:-$ROOT/ligent}"
|
||||
PASS=0
|
||||
@@ -44,32 +44,32 @@ PORT="$port"
|
||||
export LIGENT_HISTORY="$ROOT/build/e2e.history"
|
||||
rm -f "$LIGENT_HISTORY" "$LIGENT_HISTORY.tmp"
|
||||
|
||||
# 默认关掉能力探测:它会在每个会话的第一次真实请求前插进一串试探请求,
|
||||
# 默认关掉能力探测: 它会在每个会话的第一次真实请求前插进一串试探请求,
|
||||
# 而下面几十段用例都是按"请求序列"写的, 会被它整体推后一位.
|
||||
# 探测本身由第 51 段专门覆盖 (那段自己把这个变量打开).
|
||||
export LIGENT_PROBE=0
|
||||
|
||||
# 界面语言锁成中文:下面这些断言写的是中文文案, 而开发者的 LANG 或 CI 的
|
||||
# 界面语言锁成中文: 下面这些断言写的是中文文案, 而开发者的 LANG 或 CI 的
|
||||
# C locale 会让同一份脚本在两种语言下各红一半. 英文路径由第 59 段专门覆盖.
|
||||
export LIGENT_LANG=zh
|
||||
|
||||
# 配置隔离. ligent 读的是"二进制旁边那份 ligent.json", 而被测二进制就在仓库根
|
||||
# —— 于是开发者自己那份 (改过 temperature、reasoning_effort……)会混进请求体,
|
||||
# 让"默认请求长什么样"这类断言时绿时红:同一份代码, 换个人的机器结论就不同.
|
||||
# 让"默认请求长什么样"这类断言时绿时红: 同一份代码, 换个人的机器结论就不同.
|
||||
# 测试得自证, 所以这里指向一份空配置; 要测配置加载的用例自己带 -c.
|
||||
export LIGENT_CONFIG="$ROOT/build/tmp/e2e-empty.json"
|
||||
printf '{}\n' > "$LIGENT_CONFIG"
|
||||
|
||||
# 存档目录 (<二进制目录>/ligent.archive)是**运行期状态**, 不是构建产物:
|
||||
# 存档目录 (<二进制目录>/ligent.archive)是**运行期状态**, 不是构建产物:
|
||||
# 里面是用户自己的 /export 存档和图片备份. 而下面几十段用例为了隔离会反复
|
||||
# `rm -rf` 它, 被测二进制又常常就在仓库根 —— 也就是说直接跑 e2e 会把人家的
|
||||
# 存档删掉. 所以开机先看一眼:非空就整体挪到临时目录 (打印路径, 内容不动),
|
||||
# 存档删掉. 所以开机先看一眼: 非空就整体挪到临时目录 (打印路径, 内容不动),
|
||||
# 之后也不再碰它. 挪不动就直接停 —— 宁可测试不跑, 也不能把东西删了.
|
||||
ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive"
|
||||
if [ -d "$ARCHIVE_DIR" ] && [ -n "$(ls -A "$ARCHIVE_DIR" 2>/dev/null)" ]; then
|
||||
SAVED_ARCHIVE="$(mktemp -d "${TMPDIR:-/tmp}/ligent.archive.saved.XXXXXX")"
|
||||
if mv "$ARCHIVE_DIR" "$SAVED_ARCHIVE/ligent.archive"; then
|
||||
echo "注意:$ARCHIVE_DIR 里有东西, 已整体移到"
|
||||
echo "注意: $ARCHIVE_DIR 里有东西, 已整体移到"
|
||||
echo " $SAVED_ARCHIVE/ligent.archive (e2e 需要这个目录是干净的)"
|
||||
echo
|
||||
else
|
||||
@@ -180,7 +180,7 @@ printf '%s' "$OUT" | grep -q $'\033\[' && r=1 || r=0
|
||||
check "--no-color 时不含任何 ANSI 转义" $r
|
||||
|
||||
echo
|
||||
echo "== 9. 网络健壮性:瞬态失败重试 =="
|
||||
echo "== 9. 网络健壮性: 瞬态失败重试 =="
|
||||
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "RETRY_TEST" 2>&1)"
|
||||
contains "重试后成功" "$OUT"; check "503 之后重试并最终成功" $?
|
||||
contains "重试 (第 1 次)" "$OUT"; check "第一次失败后有退避重试" $?
|
||||
@@ -202,7 +202,7 @@ contains "已重试 1 次" "$OUT"; check "错误信息说明重试了几次" $?
|
||||
contains "mock 持续不可用" "$OUT"; check "服务端的说明被透传" $?
|
||||
|
||||
echo
|
||||
echo "== 12. 流式传输中途断连:保留已收到的内容 =="
|
||||
echo "== 12. 流式传输中途断连: 保留已收到的内容 =="
|
||||
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "TRUNCATE_TEST" 2>&1)"
|
||||
contains "半途崩溃" "$OUT"; check "已收到的部分内容被保留" $?
|
||||
contains "传输中断" "$OUT"; check "明确提示传输中断" $?
|
||||
@@ -225,8 +225,8 @@ OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no
|
||||
contains "超过" "$OUT"; check "非流式路径同样有上限" $?
|
||||
|
||||
echo
|
||||
echo "== 15. 上下文超限:如实报错, 历史一个字节都不动 =="
|
||||
# 这里以前测的是"超限后自动丢弃最老的一轮再重试". 那套机制已经整个删掉:
|
||||
echo "== 15. 上下文超限: 如实报错, 历史一个字节都不动 =="
|
||||
# 这里以前测的是"超限后自动丢弃最老的一轮再重试". 那套机制已经整个删掉:
|
||||
# 判据只是错误文案里的关键词, 代价却是**不可逆地**删掉几轮对话. 现在的行为是
|
||||
# 把服务端的原始错误报出来、历史原样保留, 怎么处理由用户决定 (/back、/reset).
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
@@ -236,7 +236,7 @@ contains "第一轮" "$OUT"; check "第一轮正常" $?
|
||||
contains "maximum context length" "$OUT"; check "超限时报出服务端的原始错误" $?
|
||||
contains "已丢弃最老" "$OUT" && r=1 || r=0
|
||||
check "不再自动丢弃历史" $r
|
||||
# 两轮 user + 第一轮的 assistant = 3 条:第二轮报错没有留下残缺消息, 也没删东西
|
||||
# 两轮 user + 第一轮的 assistant = 3 条: 第二轮报错没有留下残缺消息, 也没删东西
|
||||
contains "历史消息 3 条" "$OUT"
|
||||
check "历史条数原样 (system 不计)" $?
|
||||
|
||||
@@ -267,7 +267,7 @@ contains "example.invalid" "$OUT"; check "报出重定向目标地址" $?
|
||||
|
||||
echo
|
||||
echo "== 18. --verbose 不得泄露 API key =="
|
||||
# 必须有 --no-confirm:这一轮会触发工具调用, 缺了它就会在真终端上问 (Y/n),
|
||||
# 必须有 --no-confirm: 这一轮会触发工具调用, 缺了它就会在真终端上问 (Y/n),
|
||||
# 整个套件停在那里等人敲回车. 脚本开头的 exec </dev/null 是兜底, 这里写明
|
||||
# 是为了不依赖那个兜底.
|
||||
OUT="$("$BIN" --api-key "sk-VERBOSE-LEAK-CHECK-9876" --base-url "$BASE" \
|
||||
@@ -299,7 +299,7 @@ echo
|
||||
echo "== 21. REPL 行编辑 (方向键 / Home / End / 退格 / 历史) =="
|
||||
# 这个套件要跑十几秒. 用 $(...) 捕获的话, 输出被接到管道会变成块缓冲,
|
||||
# 中途十几秒一点动静都没有 —— 看着就像卡住了在等输入. 所以用 tee 边走边打;
|
||||
# python 的 -u 和 sed 的 -u 都不能省:stdout 接了管道就变块缓冲,
|
||||
# python 的 -u 和 sed 的 -u 都不能省: stdout 接了管道就变块缓冲,
|
||||
# 得每一环都关掉才对.
|
||||
python3 -u tests/repl_edit_check.py "$BIN" "$BASE" 2>&1 \
|
||||
| sed -u 's/^/ /' | tee build/edit_check.out
|
||||
@@ -372,7 +372,7 @@ PYEOF
|
||||
check "存档是自描述的 JSON (name/parent/created/messages)" $?
|
||||
|
||||
echo
|
||||
echo "== 30b. 谱系命名:分支看得见 =="
|
||||
echo "== 30b. 谱系命名: 分支看得见 =="
|
||||
AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | head -1)"
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--auto-save --prompt "/import $AROOT" --prompt "n" --prompt "分支甲" \
|
||||
@@ -424,7 +424,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--prompt "/import $SECRET" --prompt "y" --prompt "/exit" --wait 1.2 2>&1)"
|
||||
contains "紫水晶" "$OUT"; check "回答 y 时打印历史内容" $?
|
||||
contains "助手" "$OUT"; check "打印时带角色标签" $?
|
||||
# 完整输出:system 提示词也展开, 不是只报个体积
|
||||
# 完整输出: system 提示词也展开, 不是只报个体积
|
||||
contains "你是用户的 AI 助手" "$OUT"; check "system 提示词全文展开" $?
|
||||
# 工具调用的参数要完整, 不能只报字节数
|
||||
contains '"command": "echo hello-from-tool"' "$OUT"; check "工具调用参数完整打印" $?
|
||||
@@ -449,7 +449,7 @@ printf '%s' "$OUT" | grep -q $'\033\[2J' ; check "发出清屏转义序列" $?
|
||||
N1="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | head -1)"
|
||||
N2="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | tail -1)"
|
||||
[ -n "$N1" ] && [ "$N1" = "$N2" ]; check "/cls 前后历史条数不变 ($N1)" $?
|
||||
# /cls 和 /clear 是两回事:/clear 是 /reset 的别名
|
||||
# /cls 和 /clear 是两回事: /clear 是 /reset 的别名
|
||||
contains "已清空对话历史" "$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--prompt "你好" --prompt "/clear" --wait 1.2 2>&1)"
|
||||
check "/clear 仍然清空对话 (没被 /cls 抢走)" $?
|
||||
@@ -609,7 +609,7 @@ check "关闭确认期间不再询问" $r
|
||||
|
||||
echo
|
||||
echo "== 41. 思考模式与级别 =="
|
||||
# 默认:不发 thinking / reasoning_effort, 由服务端决定
|
||||
# 默认: 不发 thinking / reasoning_effort, 由服务端决定
|
||||
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \
|
||||
"THINK_TEST 默认" 2>&1)"
|
||||
contains "THINK[echo=yes thinking=none effort=none]" "$OUT"; check "默认不发思考字段" $?
|
||||
@@ -640,7 +640,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--wait 0.6 2>&1)"
|
||||
contains "由服务端决定" "$OUT"; check "/think 无参数显示当前设置" $?
|
||||
contains "思考级别已设为 high" "$OUT"; check "/think LEVEL 设置级别" $?
|
||||
contains "思考级别:high" "$OUT"; check "级别被记住" $?
|
||||
contains "思考级别: high" "$OUT"; check "级别被记住" $?
|
||||
contains "关闭 (发 thinking.type=disabled)" "$OUT"; check "/think off 关闭思考" $?
|
||||
contains "无法识别" "$OUT"; check "/think 非法值给出提示" $?
|
||||
|
||||
@@ -666,7 +666,7 @@ echo "== 44. /meta 元编辑对话历史 =="
|
||||
ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive"
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
# 全都在同一个进程里做完:谱系 (g_lineage)是进程内状态,
|
||||
# 全都在同一个进程里做完: 谱系 (g_lineage)是进程内状态,
|
||||
# 换一个 ligent 进程它就不知道上一份存档叫什么, 会另起一个新根.
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
@@ -702,7 +702,7 @@ python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
msgs = d.get("messages", [])
|
||||
# 结构必须仍然自洽:带 tool_calls 的 assistant 后面必须紧跟 tool
|
||||
# 结构必须仍然自洽: 带 tool_calls 的 assistant 后面必须紧跟 tool
|
||||
ok = True
|
||||
for i, m in enumerate(msgs):
|
||||
if m.get("role") == "assistant" and m.get("tool_calls"):
|
||||
@@ -713,7 +713,7 @@ PYEOF
|
||||
check "删改之后工具调用配对仍然完整" $?
|
||||
|
||||
# meta 模式里的未知命令不该被当成给模型的指令
|
||||
# 单引号很关键:双引号里 bash 会先把 $((6*7)) 算成 42,
|
||||
# 单引号很关键: 双引号里 bash 会先把 $((6*7)) 算成 42,
|
||||
# 那断言就成了"回显里有没有 42", 毫无意义.
|
||||
# 命令写成"输出和输入不同"的样子, 因为行编辑器会把敲进去的字原样回显,
|
||||
# 直接 grep 命令文本永远命中, 证明不了它有没有被执行.
|
||||
@@ -727,7 +727,7 @@ rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
echo
|
||||
echo "== 45. 确认提示上的每种回答 =="
|
||||
# 重点:在确认提示上按 Ctrl-C 必须等于拒绝.
|
||||
# 重点: 在确认提示上按 Ctrl-C 必须等于拒绝.
|
||||
# confirm_tool 用 fgets 读输入, 而 SIGINT 会让它 EINTR 返回 NULL ——
|
||||
# 以前 NULL 被当成"没人能回答, 按默认值放行", 于是 Ctrl-C 批准了写盘.
|
||||
OUT="$(python3 tests/confirm_check.py "$BIN" "$BASE" 2>&1)"
|
||||
@@ -745,7 +745,7 @@ contains "连通性" "$OUT"; check "报告了连通性" $?
|
||||
contains "HTTP 状态" "$OUT"; check "报告了 HTTP 状态码" $?
|
||||
contains "结论" "$OUT"; check "给出了结论" $?
|
||||
|
||||
# 认证这一节必须区分三种情况:密钥对 / 密钥错 / 根本没配.
|
||||
# 认证这一节必须区分三种情况: 密钥对 / 密钥错 / 根本没配.
|
||||
# 以前 http_probe 压根不带密钥, 于是任何要求认证的端点都回 401, 而 doctor
|
||||
# 一律说"密钥被拒绝" —— 把用户引到一个根本不存在的问题上.
|
||||
AUTHED="http://127.0.0.1:$PORT/authed"
|
||||
@@ -758,7 +758,7 @@ OUT="$(env -u LIGENT_API_KEY -u DEEPSEEK_API_KEY -u OPENAI_API_KEY \
|
||||
contains "端点要求认证, 但当前没有配置 API key" "$OUT"; check "没配密钥时不说"被拒绝"" $?
|
||||
contains "不是网络问题" "$OUT"; check "并说明这跟网络无关" $?
|
||||
|
||||
# 404 也不能被说成"密钥没问题":请求根本没走到认证
|
||||
# 404 也不能被说成"密钥没问题": 请求根本没走到认证
|
||||
OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key good-key \
|
||||
--base-url "http://127.0.0.1:$PORT/nope" 2>&1)"
|
||||
contains "这个地址不存在" "$OUT"; check "404 报地址不对" $?
|
||||
@@ -773,8 +773,8 @@ contains "example.invalid" "$OUT"; check "报出跳转目标" $?
|
||||
contains "不自动跟随重定向" "$OUT"; check "解释了为什么不跟随" $?
|
||||
|
||||
echo
|
||||
echo "== 48. read_image:图片以 base64 内联发出, 历史里只留备份引用 =="
|
||||
# 自己造测试图:一张最小的合法 PNG, 和一个"扩展名是 .png、内容不是图片"的文件,
|
||||
echo "== 48. read_image: 图片以 base64 内联发出, 历史里只留备份引用 =="
|
||||
# 自己造测试图: 一张最小的合法 PNG, 和一个"扩展名是 .png、内容不是图片"的文件,
|
||||
# 以及一张故意超过内联上限的大图. 不能依赖 build/ 里已有的东西 ——
|
||||
# make clean 会把它整个删掉.
|
||||
mkdir -p build/tmp
|
||||
@@ -803,7 +803,7 @@ OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
contains "已读入图片" "$OUT"; check "read_image 读入成功" $?
|
||||
contains "base64" "$OUT"; check "工具结果说明了会以 base64 内联" $?
|
||||
|
||||
# 关键:模型在下一轮**真的看到了**内联图片 (图片只能出现在 user 消息里,
|
||||
# 关键: 模型在下一轮**真的看到了**内联图片 (图片只能出现在 user 消息里,
|
||||
# 而 read_image 是工具、结果只能是字符串, 所以中间补了一条附件消息)
|
||||
contains "IMAGE[data:image/png:" "$OUT"; check "模型收到了内联 base64 图片" $?
|
||||
contains "历史消息 5 条" "$OUT"
|
||||
@@ -821,7 +821,7 @@ sys.exit(0 if ok else 1)
|
||||
PYEOF
|
||||
check "备份名形如 <时间戳>_<4位>.<ext>" $?
|
||||
|
||||
# 这是这一版最要紧的不变量:**归档里不能有图片字节**.
|
||||
# 这是这一版最要紧的不变量: **归档里不能有图片字节**.
|
||||
# 请求里发的是 base64, 但存进历史 / 归档的必须是那个几十字节的引用.
|
||||
python3 - "$ARCHIVE_DIR" "build/tmp/dot.png" <<'PYEOF'
|
||||
import base64, glob, json, sys
|
||||
@@ -829,7 +829,7 @@ import base64, glob, json, sys
|
||||
d, png_path = sys.argv[1], sys.argv[2]
|
||||
raw = open(glob.glob(d + "/*.json")[0], encoding="utf-8").read()
|
||||
|
||||
# 判据只能是**图片载荷本身**. 不能拿 "base64" 这个词, 也不能拿 "data:image":
|
||||
# 判据只能是**图片载荷本身**. 不能拿 "base64" 这个词, 也不能拿 "data:image":
|
||||
# 工具结果里会正当地写"base64 内联后约…", mock 的回复里还会出现
|
||||
# "IMAGE[data:image/png:92]" 这种对收到内容的描述. 真正该消失的是那串载荷.
|
||||
payload = base64.b64encode(open(png_path, "rb").read()).decode()
|
||||
@@ -862,7 +862,7 @@ check "归档里只有对备份的引用、没有图片字节" $?
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
echo
|
||||
echo "== 49. read_image:服务端拒图时把图片摘掉重试, 而不是每轮都失败 =="
|
||||
echo "== 49. read_image: 服务端拒图时把图片摘掉重试, 而不是每轮都失败 =="
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
# 三轮必须在**同一个** repl 进程里. mock 的 told_once 是一次性的, 重开一个
|
||||
# 进程它就不再拒了; 而且"降级要就地生效"这件事本身也只能在同一会话里验.
|
||||
@@ -880,7 +880,7 @@ contains "IMAGE[none]" "$OUT"; check "重试时请求里确实没有图片了" $
|
||||
contains "不符合 API 所需的格式标准" "$OUT"; check "模型收到了"请规范化图片"的说明" $?
|
||||
contains "请规范化图片" "$OUT"; check "说明里给了下一步动作" $?
|
||||
|
||||
# 降级是**就地**的:同一会话的下一轮也不该再有图片,
|
||||
# 降级是**就地**的: 同一会话的下一轮也不该再有图片,
|
||||
# 否则每轮都会重新踩同一个 4xx, 用户只能 /reset.
|
||||
LAST_IMG="$(printf '%s\n' "$OUT" | grep -o 'IMAGE\[[^]]*\]' | tail -1)"
|
||||
[ "$LAST_IMG" = "IMAGE[none]" ]; check "下一轮也是干净的 ($LAST_IMG)" $?
|
||||
@@ -890,14 +890,14 @@ N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)"
|
||||
[ "$N" -eq 1 ]; check "备份没有被重复写 ($N 份)" $?
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
echo "== 50. read_image:不认识的格式、以及过大的图, 都要在本地拒绝 =="
|
||||
echo "== 50. read_image: 不认识的格式、以及过大的图, 都要在本地拒绝 =="
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
--prompt "IMAGE_BAD_TEST 读一个假图片" --prompt "/exit" --wait 1.3 2>&1)"
|
||||
contains "不认识的图片格式" "$OUT"; check "按内容判断并拒绝 (扩展名是 .png 也没用)" $?
|
||||
|
||||
# 过大的图:本地当场拒, 不备份、不发请求
|
||||
# 过大的图: 本地当场拒, 不备份、不发请求
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
--prompt "IMAGE_OVERSIZE_TEST 读一张大图" --prompt "/exit" --wait 1.5 2>&1)"
|
||||
@@ -907,7 +907,7 @@ N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)"
|
||||
[ "$N" -eq 0 ]; check "被拒的图不备份 ($N 份)" $?
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
echo "== 51. 能力探测序列:按模型能力决定暴露哪些工具 =="
|
||||
echo "== 51. 能力探测序列: 按模型能力决定暴露哪些工具 =="
|
||||
# 这一段自己把 LIGENT_PROBE 打开 (脚本全局是关的, 理由见文件开头).
|
||||
PROBE="LIGENT_PROBE=1"
|
||||
run_probe() { # run_probe <模型名> <额外参数...>
|
||||
@@ -917,17 +917,17 @@ run_probe() { # run_probe <模型名> <额外参数...>
|
||||
--extra="--model" --extra="$model" "$@" 2>&1
|
||||
}
|
||||
|
||||
# --- 正常模型:探测跑过, read_image 在 ---
|
||||
# --- 正常模型: 探测跑过, read_image 在 ---
|
||||
OUT="$(run_probe probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)"
|
||||
contains "TOOLS[" "$OUT"; check "探测之后请求正常发出" $?
|
||||
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'
|
||||
check "支持图片的模型:工具表里有 read_image" $?
|
||||
check "支持图片的模型: 工具表里有 read_image" $?
|
||||
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command'
|
||||
check "其他工具不受影响" $?
|
||||
contains "模型能力:多模态 (图片输入)支持" "$OUT"; check "探测后打一条结论日志" $?
|
||||
contains "模型能力: 多模态 (图片输入)支持" "$OUT"; check "探测后打一条结论日志" $?
|
||||
contains "工具调用 支持" "$OUT"; check "日志里也说明工具调用能力" $?
|
||||
|
||||
# --- 模型明确不收图片:read_image 必须从工具表里消失 ---
|
||||
# --- 模型明确不收图片: read_image 必须从工具表里消失 ---
|
||||
OUT="$(run_probe no-vision-model --prompt "TOOLS_ECHO_TEST 看看工具表" \
|
||||
--prompt "/tools" --prompt "/exit" --wait 2.0)"
|
||||
contains "多模态 (图片输入)不支持 (已隐藏 read_image)" "$OUT"
|
||||
@@ -942,13 +942,13 @@ printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command'
|
||||
check "只摘掉 read_image, 别的工具留着" $?
|
||||
contains "read_image 已隐藏" "$OUT"; check "/tools 不列已经藏掉的工具" $?
|
||||
|
||||
# --- 模型完全不收 tools:整张表都不发 ---
|
||||
# --- 模型完全不收 tools: 整张表都不发 ---
|
||||
OUT="$(run_probe no-tools-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)"
|
||||
contains "工具调用 不支持 (不再发送工具表)" "$OUT"
|
||||
check "如实说明了工具调用能力与动作" $?
|
||||
contains "TOOLS[]" "$OUT"; check "工具表确实是空的" $?
|
||||
|
||||
# --- 探测结论不确定:一律按"支持"处理, 宁可多暴露 ---
|
||||
# --- 探测结论不确定: 一律按"支持"处理, 宁可多暴露 ---
|
||||
# broken-probe 模型只在**探测请求**上回 401, 真实请求照常.
|
||||
OUT="$(run_probe broken-probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)"
|
||||
contains "多模态 (图片输入)未测" "$OUT"; check "探不出来时如实说明是未测" $?
|
||||
@@ -956,11 +956,11 @@ contains "没测出来的一律按「支持」处理" "$OUT"; check "说明按
|
||||
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'
|
||||
check "结论不确定时不藏工具 (宁可多暴露)" $?
|
||||
|
||||
# --- 关掉探测:一个试探请求都不发, 工具表保持完整 ---
|
||||
# --- 关掉探测: 一个试探请求都不发, 工具表保持完整 ---
|
||||
OUT="$(env LIGENT_PROBE=0 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
--prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 1.6 2>&1)"
|
||||
if printf '%s\n' "$OUT" | grep -q '检测模型能力\|模型能力:'; then
|
||||
if printf '%s\n' "$OUT" | grep -q '检测模型能力\|模型能力: '; then
|
||||
check "--no-probe 时既不该发探测请求, 也不该有结论日志" 1
|
||||
else
|
||||
check "--no-probe 时既不该发探测请求, 也不该有结论日志" 0
|
||||
@@ -968,8 +968,8 @@ fi
|
||||
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'
|
||||
check "--no-probe 时工具表保持完整" $?
|
||||
|
||||
echo "== 52. /model 不带参数:打印在线列表, 输入行里选序号或直接写名字 =="
|
||||
# 这一段刻意不用光标菜单, 而是 /import 那种"列表 + 普通输入行":
|
||||
echo "== 52. /model 不带参数: 打印在线列表, 输入行里选序号或直接写名字 =="
|
||||
# 这一段刻意不用光标菜单, 而是 /import 那种"列表 + 普通输入行":
|
||||
# 既能填序号, 也能直接写一个**列表里没有**的模型名 (内部模型就走这条).
|
||||
run_model() { # run_model <要依次输入的行...>; 第一行固定是 /model
|
||||
local a=()
|
||||
@@ -979,7 +979,7 @@ run_model() { # run_model <要依次输入的行...>; 第一行固定是 /model
|
||||
--prompt "/model" "${a[@]}" --prompt "/exit" --wait 1.4 2>&1
|
||||
}
|
||||
|
||||
# --- 列表本身:编号、当前模型被标出、去重与坏条目被跳过 ---
|
||||
# --- 列表本身: 编号、当前模型被标出、去重与坏条目被跳过 ---
|
||||
OUT="$(run_model "2")"
|
||||
contains "正在获取模型列表 ($BASE/models)" "$OUT"; check "用的是标准的 /models 路径" $?
|
||||
contains "服务端列出了 3 个模型" "$OUT"
|
||||
@@ -991,7 +991,7 @@ contains "选择序号, 或直接输入模型名" "$OUT"; check "输入行的提
|
||||
# --- 按序号选 ---
|
||||
contains "模型已切换为 alpha-model" "$OUT"; check "填序号能选中对应模型" $?
|
||||
|
||||
# --- 直接写一个**列表里没有**的名字:这是内部模型的入口, 必须允许 ---
|
||||
# --- 直接写一个**列表里没有**的名字: 这是内部模型的入口, 必须允许 ---
|
||||
OUT="$(run_model "internal-model-v9")"
|
||||
contains "模型已切换为 internal-model-v9" "$OUT"
|
||||
check "能直接写列表里没有的模型名 (内部模型走这条)" $?
|
||||
@@ -1010,7 +1010,7 @@ else
|
||||
check "取消之后什么都没改" 0
|
||||
fi
|
||||
|
||||
# --- 序号越界:报错并取消, 不能当成模型名去用 ---
|
||||
# --- 序号越界: 报错并取消, 不能当成模型名去用 ---
|
||||
OUT="$(run_model "9")"
|
||||
contains "序号 9 超出范围" "$OUT"; check "序号越界要报出来" $?
|
||||
if printf '%s\n' "$OUT" | grep -q "模型已切换"; then
|
||||
@@ -1036,7 +1036,7 @@ else
|
||||
check "带参数时不去拉列表" 0
|
||||
fi
|
||||
|
||||
# --- 服务端返回空列表:说清楚, 并提示还能直接指定名字 ---
|
||||
# --- 服务端返回空列表: 说清楚, 并提示还能直接指定名字 ---
|
||||
printf 'empty' > build/tmp/models_mode
|
||||
OUT="$(run_model "1")"
|
||||
rm -f build/tmp/models_mode
|
||||
@@ -1044,7 +1044,7 @@ contains "拿不到模型列表" "$OUT"; check "空列表时如实报错" $?
|
||||
contains "空的模型列表" "$OUT"; check "说清是空列表而不是网络故障" $?
|
||||
contains "/model <名字>" "$OUT"; check "提示还能直接指定名字" $?
|
||||
|
||||
# --- 服务端返回的不是 JSON:也不能崩, 同样给出可用的退路 ---
|
||||
# --- 服务端返回的不是 JSON: 也不能崩, 同样给出可用的退路 ---
|
||||
printf 'garbage' > build/tmp/models_mode
|
||||
OUT="$(run_model "1")"
|
||||
rm -f build/tmp/models_mode
|
||||
@@ -1058,11 +1058,11 @@ rm -f build/tmp/models_mode
|
||||
contains "服务端列出了 2 个模型" "$OUT"; check "裸数组形状也能解析" $?
|
||||
contains "模型已切换为 beta-model" "$OUT"; check "裸数组里也能按序号选" $?
|
||||
|
||||
echo "== 53. 一条 assistant 里两个工具调用 + 图片:附件不能插在 tool 消息中间 =="
|
||||
echo "== 53. 一条 assistant 里两个工具调用 + 图片: 附件不能插在 tool 消息中间 =="
|
||||
# 回归用例. 曾经的写法是"每推完一条 tool 消息就把图片附件推出去", 于是
|
||||
# assistant 带两个调用、第一个是 read_image 时, 历史变成
|
||||
# assistant(tool_calls) / tool(读图) / user(附件) / tool(另一个)
|
||||
# 中间那条 user 把 tool 段截断了, 服务端回 400:
|
||||
# 中间那条 user 把 tool 段截断了, 服务端回 400:
|
||||
# An assistant message with 'tool_calls' must be followed by tool messages
|
||||
# responding to each 'tool_call_id'. (insufficient tool messages ...)
|
||||
# 而且这段历史留在上下文里, 之后每一轮都会 400 —— 用户只能 /reset.
|
||||
@@ -1090,11 +1090,11 @@ else
|
||||
fi
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
# 顺带验一下修复路径本身:mock 校验会让坏历史当场 400, 所以这里换个角度 ——
|
||||
# 顺带验一下修复路径本身: mock 校验会让坏历史当场 400, 所以这里换个角度 ——
|
||||
# 直接看 agent_repair_tool_pairing 的单元测试 (test_agent).
|
||||
|
||||
echo "== 54. /back 报的条数不含 system 提示词 =="
|
||||
# 用户报过一个:聊一句「hi」再 /back, 屏幕显示"历史剩 1 条", 但那段对话其实
|
||||
# 用户报过一个: 聊一句「hi」再 /back, 屏幕显示"历史剩 1 条", 但那段对话其实
|
||||
# 已经空了 —— 那 1 条是 system 提示词. system 是配置不是对话, 不该算进"历史".
|
||||
# 现在 /history、/back、/unback、/export、/import 统一用"不含 system"的口径.
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
@@ -1107,7 +1107,7 @@ check "/history 也报 0 条 (与 /back 口径一致)" $?
|
||||
contains "历史回到 4 条" "$OUT"; check "/unback 之后回到 4 条" $?
|
||||
contains "不含 system" "$OUT"; check "说清楚这个口径不含 system 提示词" $?
|
||||
|
||||
echo "== 55. /include:把文件附带进下一条消息 =="
|
||||
echo "== 55. /include: 把文件附带进下一条消息 =="
|
||||
mkdir -p build/tmp/inc/sub
|
||||
printf 'int main(void){return 0;}\n' > build/tmp/inc/a.c
|
||||
printf 'hello text\n' > build/tmp/inc/b.txt
|
||||
@@ -1131,7 +1131,7 @@ inc() { # inc <prompt...>; 先切到场景目录 (/cd 是 REPL 命令, 没有同
|
||||
--prompt "/cd build/tmp/inc" "$@" --wait 1.5 2>&1
|
||||
}
|
||||
|
||||
# --- glob 参数:命中几个就附带几个 ---
|
||||
# --- glob 参数: 命中几个就附带几个 ---
|
||||
OUT="$(inc --prompt "/include *.c" --prompt "INCLUDE_TEST 看看" --prompt "/exit")"
|
||||
contains "已附带 1 个文件" "$OUT"; check "glob 展开并暂存" $?
|
||||
contains "INC[text=2 img=0 files=a.c]" "$OUT"
|
||||
@@ -1143,11 +1143,11 @@ OUT="$(inc --prompt "/include *.c" --prompt "/include *.txt" \
|
||||
--prompt "INCLUDE_TEST 两个" --prompt "/exit")"
|
||||
contains "INC[text=3 img=0" "$OUT"; check "两次附带的文件都发出去了" $?
|
||||
|
||||
# --- 图片:存备份、以 ligent_image 引用参与请求 ---
|
||||
# --- 图片: 存备份、以 ligent_image 引用参与请求 ---
|
||||
OUT="$(inc --prompt "/include pic.png" --prompt "INCLUDE_TEST 看图" --prompt "/export" --prompt "/exit")"
|
||||
contains "〔图片〕" "$OUT"; check "识别出是图片并标注" $?
|
||||
contains "INC[text=1 img=1" "$OUT"; check "图片以 image_url 内联发出" $?
|
||||
# 备份必须落在**二进制所在目录**, 不是当前工作目录 —— 这一条是有来历的:
|
||||
# 备份必须落在**二进制所在目录**, 不是当前工作目录 —— 这一条是有来历的:
|
||||
# image_set_bindir() 曾经从来没被调用过, 于是备份跟着 cwd 走, /cd 到别处
|
||||
# 带图就会写到那儿, 之后从别处发消息会因为找不到备份把图降级掉.
|
||||
# 测试一直从二进制所在目录跑, "." 恰好等于正确值, 所以一直没暴露.
|
||||
@@ -1171,7 +1171,7 @@ rm -rf "$ARCHIVE_DIR"
|
||||
# --- 内容不是图片的 .png 按文本附带 (认内容不认扩展名) ---
|
||||
OUT="$(inc --prompt "/include fake.png" --prompt "INCLUDE_TEST 假的" --prompt "/exit")"
|
||||
contains "INC[text=2 img=0 files=fake.png]" "$OUT"
|
||||
check "按内容判断:不是图片就当文本带" $?
|
||||
check "按内容判断: 不是图片就当文本带" $?
|
||||
|
||||
# --- 二进制被跳过 ---
|
||||
printf '\x00\x01\x02binary' > build/tmp/inc/bin.dat
|
||||
@@ -1183,14 +1183,14 @@ contains "二进制" "$OUT"; check "说清跳过原因" $?
|
||||
OUT="$(inc --prompt "/include 不存在的模式*.zzz" --prompt "/exit")"
|
||||
contains "没有匹配到文件" "$OUT"; check "匹配不到要说出来" $?
|
||||
|
||||
# --- 交互式:进目录、退回来、选文件 ---
|
||||
# --- 交互式: 进目录、退回来、选文件 ---
|
||||
# 序号 3 是 sub/ (a.c / b.txt / sub 排序后), 进去再回车结束;
|
||||
# 然后回头选一个文件.
|
||||
OUT="$(inc --prompt "/include" --prompt "3" --prompt "" \
|
||||
--prompt "/include" --prompt "1" --prompt "" --prompt "/exit")"
|
||||
contains "进入交互式选择" "$OUT"; check "无参数进入交互式" $?
|
||||
contains " (目录)" "$OUT"; check "目录被标出来" $?
|
||||
contains "当前目录:" "$OUT"; check "显示了当前目录" $?
|
||||
contains "当前目录: " "$OUT"; check "显示了当前目录" $?
|
||||
contains "已选中" "$OUT"; check "选中后有回执" $?
|
||||
|
||||
# --- clear ---
|
||||
@@ -1199,7 +1199,7 @@ OUT="$(inc --prompt "/include *.c" --prompt "/include clear" \
|
||||
contains "已清空待附带的文件" "$OUT"; check "/include clear 清空暂存" $?
|
||||
contains "INC[text=1 img=0" "$OUT"; check "清空之后只发用户那句话" $?
|
||||
|
||||
# --- 模型不支持视觉时:文本照带, 图片跳过并说明 ---
|
||||
# --- 模型不支持视觉时: 文本照带, 图片跳过并说明 ---
|
||||
OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
--extra=--model --extra=no-vision-model \
|
||||
@@ -1220,8 +1220,8 @@ OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$
|
||||
contains "INC[text=1 img=1" "$OUT"; check "支持视觉时图片正常附带" $?
|
||||
rm -rf "$ARCHIVE_DIR"
|
||||
|
||||
echo "== 56. run_command elevated:经 sudo 提权执行单条命令 =="
|
||||
# 全程用**替身 sudo**:不需要真权限, 也永远不会碰到谁的真实密码.
|
||||
echo "== 56. run_command elevated: 经 sudo 提权执行单条命令 =="
|
||||
# 全程用**替身 sudo**: 不需要真权限, 也永远不会碰到谁的真实密码.
|
||||
# 它把 argv 记下来, 于是"ligent 到底怎么调 sudo 的"能断言到参数级.
|
||||
FAKEBIN="$ROOT/build/tmp/fakebin"
|
||||
mkdir -p "$FAKEBIN"
|
||||
@@ -1242,7 +1242,7 @@ elev() { # elev <额外环境变量...> -- <repl 参数...>
|
||||
PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" "$@" 2>&1
|
||||
}
|
||||
|
||||
# --- 模型请求提权:命令经 sudo 以 root 跑, 输出照常被捕获 ---
|
||||
# --- 模型请求提权: 命令经 sudo 以 root 跑, 输出照常被捕获 ---
|
||||
OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
--prompt "ELEVATED_TEST 提权跑个命令" --prompt "/exit" --wait 1.8)"
|
||||
@@ -1250,10 +1250,10 @@ contains "提权后的输出" "$OUT"; check "提权命令真的执行了, 输出
|
||||
contains "ELEV_SAW[提权后的输出" "$OUT"; check "输出回填给了模型" $?
|
||||
grep -q '^-u root -- /bin/sh -c echo 提权后的输出$' "$SULOG"
|
||||
check "调用形式是 sudo -u root -- <argv> (argv 形式, 不经多余 shell)" $?
|
||||
# 这一条是这次设计的核心:**不能带 -n**. 带 -n 就意味着"不许提问",
|
||||
# 这一条是这次设计的核心: **不能带 -n**. 带 -n 就意味着"不许提问",
|
||||
# 于是需要密码时直接失败 —— 而用户明确要的是"提权要有身份验证".
|
||||
printf '%s\n' "$(cat "$SULOG")" | grep -q -- '-n' && r=1 || r=0
|
||||
[ "$r" -eq 0 ]; check "不带 -n:需要密码时要让 sudo 能问出来" $?
|
||||
[ "$r" -eq 0 ]; check "不带 -n: 需要密码时要让 sudo 能问出来" $?
|
||||
|
||||
# --- 确认提示必须先把要跑的东西摆出来 ---
|
||||
OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
@@ -1262,7 +1262,7 @@ OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
contains "即将执行 run_command" "$OUT"; check "提权命令同样过确认提示" $?
|
||||
contains "提权后的输出" "$OUT"; check "确认之后才执行" $?
|
||||
|
||||
# --- 认证失败:模型要收到能理解的话, 而不是一片空白 ---
|
||||
# --- 认证失败: 模型要收到能理解的话, 而不是一片空白 ---
|
||||
OUT="$(PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" FAKE_SUDO_REJECT=1 \
|
||||
python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
|
||||
@@ -1271,7 +1271,7 @@ contains "提权命令失败" "$OUT"; check "提权失败要告诉模型" $?
|
||||
contains "认证没有通过" "$OUT"; check "说清是认证没过 (而不是命令本身的问题)" $?
|
||||
contains "不要反复重试" "$OUT"; check "明确叫它别反复重试" $?
|
||||
|
||||
# --- 没有控制终端时:直接拒绝, 绝不发起一个等不到输入的进程 ---
|
||||
# --- 没有控制终端时: 直接拒绝, 绝不发起一个等不到输入的进程 ---
|
||||
# setsid 摘掉控制终端, 正是"没人能输密码"的情形.
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
OUT="$(setsid "$BIN" --api-key test --base-url "$BASE" --no-color \
|
||||
@@ -1283,11 +1283,11 @@ if command -v setsid >/dev/null 2>&1; then
|
||||
# 而且它必须是**立刻**失败, 不能卡在那儿等密码
|
||||
contains "没有可用的控制终端" "$OUT" || true
|
||||
else
|
||||
echo " (跳过:没有 setsid)"
|
||||
echo " (跳过: 没有 setsid)"
|
||||
fi
|
||||
|
||||
echo "== 57. 离线回放:把 /log 录下来的真实会话当夹具 =="
|
||||
# 这是 /log 存在的**真正理由**:录一次真的, 之后离线反复回放.
|
||||
echo "== 57. 离线回放: 把 /log 录下来的真实会话当夹具 =="
|
||||
# 这是 /log 存在的**真正理由**: 录一次真的, 之后离线反复回放.
|
||||
# 录的是原始字节 (请求体 + 未解析的响应原文), 所以回放时 http.c 的分块读取、
|
||||
# llm.c 的 SSE 行切分与 tool_calls 增量重组, 全都在真实字节上跑.
|
||||
FIX="$ROOT/tests/fixtures/session.log"
|
||||
@@ -1311,7 +1311,7 @@ sys.exit(0 if s.connect_ex(('127.0.0.1',$1))==0 else 1)" && break
|
||||
done
|
||||
}
|
||||
|
||||
# --- 用离线服务端跑**真的** ligent:它应当沿着录制里的路径走完 ---
|
||||
# --- 用离线服务端跑**真的** ligent: 它应当沿着录制里的路径走完 ---
|
||||
replay_serve 8899 "$FIX" --strict
|
||||
OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8899/v1" --no-color \
|
||||
--no-confirm --no-auto-save --no-history --no-probe "please run it" 2>&1)"
|
||||
@@ -1320,7 +1320,7 @@ kill $RPID 2>/dev/null; wait $RPID 2>/dev/null
|
||||
[ "$RC" -eq 0 ]; check "在录制上跑完一轮 (退出码 $RC)" $?
|
||||
contains "hello-from-tool" "$OUT"; check "工具调用从被切碎的 SSE 里正确重组" $?
|
||||
contains "FINAL:" "$OUT"; check "拿到了录制里的最终回答" $?
|
||||
# strict:请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言
|
||||
# strict: 请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言
|
||||
if grep -q "结构" "$RLOG"; then
|
||||
check "请求结构与录制一致" 1
|
||||
grep -A2 "结构" "$RLOG" | head -4
|
||||
@@ -1328,7 +1328,7 @@ else
|
||||
check "请求结构与录制一致 (strict 没报差异)" 0
|
||||
fi
|
||||
|
||||
# --- 错误体:录下来的 400 也要能被原样解析出可读信息 ---
|
||||
# --- 错误体: 录下来的 400 也要能被原样解析出可读信息 ---
|
||||
replay_serve 8898 "$ROOT/tests/fixtures/error.log" --strict
|
||||
OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8898/v1" --no-color \
|
||||
--no-confirm --no-auto-save --no-history --no-probe --retries 0 \
|
||||
@@ -1340,7 +1340,7 @@ contains "mock 模拟的服务端错误" "$OUT"; check "错误体从真实字节
|
||||
|
||||
# --- 请求比录制多时, 回放器要明确报出来 (而不是静静回错东西)---
|
||||
replay_serve 8897 "$ROOT/tests/fixtures/error.log" --strict
|
||||
# 录制里只有 1 次往返. 跑两次:第二次就没东西可回了.
|
||||
# 录制里只有 1 次往返. 跑两次: 第二次就没东西可回了.
|
||||
for _ in 1 2; do
|
||||
"$BIN" --api-key test --base-url "http://127.0.0.1:8897/v1" --no-color \
|
||||
--no-confirm --no-auto-save --no-history --no-probe --retries 0 \
|
||||
@@ -1350,8 +1350,8 @@ kill $RPID 2>/dev/null; wait $RPID 2>/dev/null
|
||||
contains "没有更多响应" "$(cat "$RLOG")"
|
||||
check "录制用完后明确报出来 (而不是静静回错东西)" $?
|
||||
|
||||
echo "== 58. 工具输出里混进非法 UTF-8:不能让之后每个请求都被拒 =="
|
||||
# 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
|
||||
echo "== 58. 工具输出里混进非法 UTF-8: 不能让之后每个请求都被拒 =="
|
||||
# 坏字节的来源很日常: 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
|
||||
# 字节字符、二进制文件里混的一小段. 它会跟着工具结果进对话历史, 再被序列化
|
||||
# 进请求体 —— 序列化器若原样吐出, DeepSeek 回
|
||||
# 400 ... invalid unicode code point
|
||||
@@ -1366,10 +1366,10 @@ case "$OUT" in
|
||||
*"替换字符=0"*) rc=1;; # 一个都没替换 —— 坏字节原样发出去了
|
||||
*) rc=0;;
|
||||
esac
|
||||
check "请求体是合法 UTF-8:服务端没拒, 且坏字节被换成了 U+FFFD" $rc
|
||||
check "请求体是合法 UTF-8: 服务端没拒, 且坏字节被换成了 U+FFFD" $rc
|
||||
contains "读完了" "$OUT"; check "这一轮正常跑完" $?
|
||||
|
||||
echo "== 59. 界面语言:--lang / LIGENT_LANG / LC_* =="
|
||||
echo "== 59. 界面语言: --lang / LIGENT_LANG / LC_* =="
|
||||
# 前面几十段都跑在顶层锁死的 zh 下 (见文件开头的 export). 这一段专门走英文,
|
||||
# 因为"翻译漏了"和"翻译错了"只有真的把每条路径跑一遍才看得出来.
|
||||
OUT="$(LIGENT_LANG=en "$BIN" --help 2>&1)"
|
||||
@@ -1404,19 +1404,19 @@ contains "只接受 auto / en / zh" "$OUT"; check "拒绝时说清可选值" $?
|
||||
|
||||
# doctor 是最长的一段界面文案, 分节括号也要跟着语言走
|
||||
OUT="$(LIGENT_LANG=en "$BIN" doctor --offline 2>&1)"
|
||||
# 注意:方括号在 case 模式里是字符类, 所以这里只断言括号里的文字
|
||||
# 注意: 方括号在 case 模式里是字符类, 所以这里只断言括号里的文字
|
||||
contains "binary & config location" "$OUT"; check "英文分节标题" $?
|
||||
contains "Bottom line:" "$OUT"; check "英文结论行" $?
|
||||
OUT="$(LIGENT_LANG=zh "$BIN" doctor --offline 2>&1)"
|
||||
contains "【二进制与配置位置】" "$OUT"; check "中文分节用【】" $?
|
||||
contains "结论:" "$OUT"; check "中文结论行" $?
|
||||
contains "结论: " "$OUT"; check "中文结论行" $?
|
||||
|
||||
echo
|
||||
echo "== 60. 单轮往返次数:默认不限, 正数才是上限 =="
|
||||
echo "== 60. 单轮往返次数: 默认不限, 正数才是上限 =="
|
||||
# 默认 (配置文件里没写 max_steps)要能跑完 4 次工具往返 —— 这正是"默认不限"
|
||||
# 的可观测形式:任何小上限都会在这里当场暴露.
|
||||
# 的可观测形式: 任何小上限都会在这里当场暴露.
|
||||
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "MANYSTEPS_TEST" 2>&1)"
|
||||
contains "STEPS_DONE[4]" "$OUT"; check "默认不限往返次数:4 次工具往返跑完" $?
|
||||
contains "STEPS_DONE[4]" "$OUT"; check "默认不限往返次数: 4 次工具往返跑完" $?
|
||||
contains "run_command" "$OUT"; check "每一步的工具调用都真实执行了" $?
|
||||
|
||||
# 显式写 0 也是不限 (0 = 不限是这条接口的约定)
|
||||
@@ -1442,9 +1442,9 @@ OUT="$("$BIN" --print-config 2>&1)"
|
||||
contains "max_steps : 不限" "$OUT"; check "--print-config 显示不限" $?
|
||||
|
||||
echo
|
||||
echo "== 61. edit_file 的调用行:\"路径\" [行号], 成功后给带行号的新旧对照 =="
|
||||
echo "== 61. edit_file 的调用行: \"路径\" [行号], 成功后给带行号的新旧对照 =="
|
||||
# 以前这一行是把整个参数 JSON 原样打出来 (截 256 字节)—— 对 edit_file 来说那是
|
||||
# 一整段 old_string/new_string:既刷屏, 又看不出到底要改哪里. 现在摘要只有
|
||||
# 一整段 old_string/new_string: 既刷屏, 又看不出到底要改哪里. 现在摘要只有
|
||||
# "哪个文件、哪几行" (路径带引号、行号用方括号), 改动内容用带行号的 -/+ 两段摆开.
|
||||
mkprobe() {
|
||||
python3 -c "
|
||||
@@ -1460,7 +1460,7 @@ check "调用行是 \"路径\" [行号], 不再抖 JSON" $?
|
||||
contains " │ - 2 OLD1" "$OUT"; check "旧内容逐行显示, 且带文件行号" $?
|
||||
contains " │ - 9 OLD8" "$OUT"; check "行号随行递增 (2..9)" $?
|
||||
contains " │ + 2 NEW" "$OUT"
|
||||
check "新内容也带行号 (两侧起始行号相同:替换就落在原处)" $?
|
||||
check "新内容也带行号 (两侧起始行号相同: 替换就落在原处)" $?
|
||||
contains "还有 4 行" "$OUT"; check "每侧最多 8 行, 其余折成一句" $?
|
||||
contains " │ - 10 OLD9" "$OUT" && r=1 || r=0
|
||||
check "第 9 行起不再逐行铺开" $r
|
||||
@@ -1469,10 +1469,10 @@ check "参数 JSON 一个字节都不上屏" $r
|
||||
contains "DONE[" "$OUT"; check "工具确实执行了" $?
|
||||
|
||||
echo
|
||||
echo "== 62. 确认提示:先摆带行号的新旧对照, 再问 Y/n =="
|
||||
echo "== 62. 确认提示: 先摆带行号的新旧对照, 再问 Y/n =="
|
||||
# 这一版把确认提示里的"替换 <旧> -> <新>"删掉了 —— 两边各截 60 字符、压成一行,
|
||||
# 看不出动了几行、动的是哪几行. 现在摆的是和事后展示同一份带行号 diff,
|
||||
# 而且必须在提问**之前**:先让人点头、再让人看改什么, 那是反的.
|
||||
# 而且必须在提问**之前**: 先让人点头、再让人看改什么, 那是反的.
|
||||
mkprobe
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--extra=--confirm --extra=--no-auto-save --extra=--no-color \
|
||||
@@ -1490,7 +1490,7 @@ i_diff = t.find("- 2 OLD1")
|
||||
i_ask = t.find("继续?")
|
||||
raise SystemExit(0 if 0 <= i_diff < i_ask else 1)
|
||||
PY
|
||||
check "顺序:先摆 diff、再提问" $?
|
||||
check "顺序: 先摆 diff、再提问" $?
|
||||
contains "替换:" "$OUT" && r=1 || r=0
|
||||
check "不再出现「替换: 旧 -> 新」那一行" $r
|
||||
# 确认过的改动不该在执行后再摆一遍 diff (否则像是要改两次)
|
||||
@@ -1498,7 +1498,7 @@ contains "已在 build/tmp/edit_probe.txt 第 2 行替换 1 处" "$OUT"
|
||||
check "确认之后只给一句回执, 不重复 diff" $?
|
||||
|
||||
echo
|
||||
echo "== 63. edit_file 失败时:同样只有摘要 + 错误 =="
|
||||
echo "== 63. edit_file 失败时: 同样只有摘要 + 错误 =="
|
||||
mkprobe
|
||||
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "EDITTEST_FAIL" 2>&1)"
|
||||
contains '{/} edit_file "build/tmp/edit_probe.txt" [999]' "$OUT"
|
||||
@@ -1508,14 +1508,14 @@ contains '"old_string"' "$OUT" && r=1 || r=0
|
||||
check "失败时同样不抖 JSON" $r
|
||||
|
||||
echo
|
||||
echo "== 64. /probe:手动测一次能力 (绕开 --no-probe 与缓存)=="
|
||||
echo "== 64. /probe: 手动测一次能力 (绕开 --no-probe 与缓存)=="
|
||||
# 这一段的 e2e 全局设了 LIGENT_PROBE=0 (各处用例都按"请求序列"写, 别被探测整体推后),
|
||||
# 正好拿来验证 /probe 的两条语义:无视那个开关, 也不吃缓存 (敲两次就测两次).
|
||||
# 正好拿来验证 /probe 的两条语义: 无视那个开关, 也不吃缓存 (敲两次就测两次).
|
||||
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
|
||||
--prompt "/probe" --wait 1.5 \
|
||||
--prompt "/probe" --wait 1.5 --prompt "/exit" 2>&1)"
|
||||
contains "模型能力:" "$OUT"; check "--no-probe 下 /probe 仍会真的探测" $?
|
||||
n=$(printf '%s' "$OUT" | grep -c "模型能力:")
|
||||
contains "模型能力: " "$OUT"; check "--no-probe 下 /probe 仍会真的探测" $?
|
||||
n=$(printf '%s' "$OUT" | grep -c "模型能力: ")
|
||||
[ "${n:-0}" -ge 2 ]; check "每次 /probe 都重测一遍 (不吃缓存), 实际 $n 次" $?
|
||||
contains "支持" "$OUT"; check "结论照启动时的样子打出来" $?
|
||||
|
||||
@@ -1526,7 +1526,7 @@ OUT="$("$BIN" --help 2>&1)"
|
||||
contains "/probe" "$OUT"; check "--help 的 REPL 命令列表里也有" $?
|
||||
|
||||
echo
|
||||
echo "== 65. /history:一行摘要 + 完整对话 =="
|
||||
echo "== 65. /history: 一行摘要 + 完整对话 =="
|
||||
# 摘要行报的是"不含 system"的口径 (与 /usage、/back、/export 一致), 下面列的
|
||||
# 是含 system 的全部消息 —— 两个数不同, 所以第二行得自己说清楚, 否则用户会
|
||||
# 以为打印漏了或者多了一条.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
|
||||
交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面.
|
||||
不清洗的话模型看到的就是几百个空格加一串光标定位; 清过头又会把命令和
|
||||
状态行一起吃掉. 这里用 printf 造出确定的输入来钉住行为:
|
||||
状态行一起吃掉. 这里用 printf 造出确定的输入来钉住行为:
|
||||
|
||||
1. 备用屏幕 (vim/less/top 走这条路)——画面必须省略, 只留首尾标记;
|
||||
2. 成片空格 (nano/top 靠空格重绘)——必须压掉, 但短缩进要原样保留;
|
||||
3. 大输出里夹着的备用屏幕——跨 read 也要能认出来.
|
||||
|
||||
用法:
|
||||
用法:
|
||||
shell_screen_check.py <ligent 二进制> <mock base-url>
|
||||
"""
|
||||
import glob
|
||||
@@ -38,7 +38,7 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
|
||||
else:
|
||||
os.environ["TERM"] = term
|
||||
|
||||
# 每轮都清空归档:归档名是随机的, 按文件名排序取"最后一个"会拿到上一轮.
|
||||
# 每轮都清空归档: 归档名是随机的, 按文件名排序取"最后一个"会拿到上一轮.
|
||||
bindir = os.path.dirname(os.path.abspath(binary))
|
||||
shutil.rmtree(os.path.join(bindir, "ligent.archive"), ignore_errors=True)
|
||||
|
||||
@@ -48,7 +48,7 @@ def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
|
||||
os._exit(127)
|
||||
|
||||
# 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号.
|
||||
# 只按"输出安静"等是不行的:等待动画每 250ms 画一帧, pty 永远不会安静
|
||||
# 只按"输出安静"等是不行的: 等待动画每 250ms 画一帧, pty 永远不会安静
|
||||
# 超过 idle, 于是每次都要白等满整个额度.
|
||||
PROMPT_READY = b"\x1b[?2004h"
|
||||
ready_seen = 0
|
||||
@@ -135,7 +135,7 @@ def main():
|
||||
fails += 1
|
||||
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
|
||||
|
||||
# ---- 1. 备用屏幕:画面省略, 只留首尾标记 ----
|
||||
# ---- 1. 备用屏幕: 画面省略, 只留首尾标记 ----
|
||||
print("备用屏幕 (vim/less 路径)")
|
||||
rec = drive(binary, base, [
|
||||
r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'",
|
||||
@@ -154,7 +154,7 @@ def main():
|
||||
check("标记之外的输出还在", NORMAL_TEXT in rec)
|
||||
check("记录里没有裸转义符", "\x1b" not in rec)
|
||||
|
||||
# ---- 2. 成片空格:压掉, 但缩进保留 ----
|
||||
# ---- 2. 成片空格: 压掉, 但缩进保留 ----
|
||||
print("成片空格 (nano/top 重绘路径)")
|
||||
rec = drive(binary, base, [
|
||||
r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'",
|
||||
@@ -210,7 +210,7 @@ def main():
|
||||
check("拿到记录", False, "没有生成归档")
|
||||
continue
|
||||
check("%s 时补上可用 TERM" % label, "TERM_IS=xterm-256color" in rec,
|
||||
"实际记录:%s" % rec.strip().replace("\n", " | ")[-80:])
|
||||
"实际记录: %s" % rec.strip().replace("\n", " | ")[-80:])
|
||||
|
||||
shutil.rmtree(archive, ignore_errors=True)
|
||||
print("\n%d 项检查, %d 项失败" % (total, fails))
|
||||
|
||||
+15
-15
@@ -77,7 +77,7 @@ static char *dump_history(const Agent *a)
|
||||
return json_dump(a->messages);
|
||||
}
|
||||
|
||||
/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息.
|
||||
/* 配对不变量: 任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息.
|
||||
* 服务端就是这么要求的, 违反了下一轮直接 400 —— 所以每次动过历史之后都要查. */
|
||||
static void check_pairing(Agent *a)
|
||||
{
|
||||
@@ -100,7 +100,7 @@ static void check_pairing(Agent *a)
|
||||
/* 一轮一轮摘下去 (/back 走的就是这条路), 历史在每一步都必须仍然自洽.
|
||||
*
|
||||
* 这里原来测的是"上下文超限自动裁剪" (agent_drop_oldest_round)—— 那套机制
|
||||
* 已经**整个删掉**:它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的
|
||||
* 已经**整个删掉**: 它的判据只是错误文案里的关键词, 却会不可逆地删掉最老的
|
||||
* 几轮对话. 现在超限就是如实报错、历史一个字节都不动, 怎么处理由用户决定
|
||||
* (/back 摘一轮、/reset 重来). 但"摘掉一整轮之后 tool 配对不能散"这条
|
||||
* 不变量与由谁触发无关, 所以留着继续测. */
|
||||
@@ -160,7 +160,7 @@ static void test_take_last_round(void)
|
||||
free(h);
|
||||
json_free(r);
|
||||
|
||||
/* 关键:工具调用与结果必须整组一起走 */
|
||||
/* 关键: 工具调用与结果必须整组一起走 */
|
||||
r = agent_take_last_round(&a);
|
||||
CHECK(r && json_array_size(r) == 4, "第一轮应 4 条, 实际 %zu",
|
||||
r ? json_array_size(r) : 0);
|
||||
@@ -188,7 +188,7 @@ static void test_take_last_round(void)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 结构体检:assistant(tool_calls) 与 tool 消息的配对 */
|
||||
/* 结构体检: assistant(tool_calls) 与 tool 消息的配对 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* 造一条 assistant, 带 count 个工具调用 (id 依次是 c0, c1, ...) */
|
||||
@@ -262,7 +262,7 @@ static void test_repair_pairing(void)
|
||||
{
|
||||
int fixed = 0;
|
||||
|
||||
puts("结构体检:本来没问题就不该动它");
|
||||
puts("结构体检: 本来没问题就不该动它");
|
||||
JsonValue *ok = json_new_array();
|
||||
json_array_push(ok, mk_plain("user", "跑一下"));
|
||||
json_array_push(ok, mk_assistant(2));
|
||||
@@ -275,8 +275,8 @@ static void test_repair_pairing(void)
|
||||
CHECK(fixed == 0, "合法历史的修复处数应为 0");
|
||||
json_free(ok);
|
||||
|
||||
puts("缺 tool 消息:补齐, 且补在原位");
|
||||
/* 复现真实报错:assistant 带两个调用, 中间夹了一条 user (图省事直接
|
||||
puts("缺 tool 消息: 补齐, 且补在原位");
|
||||
/* 复现真实报错: assistant 带两个调用, 中间夹了一条 user (图省事直接
|
||||
* 模仿当年那个 bug 的形状), 第二个调用没人回应 */
|
||||
JsonValue *bad = json_new_array();
|
||||
json_array_push(bad, mk_plain("user", "看看图"));
|
||||
@@ -289,9 +289,9 @@ static void test_repair_pairing(void)
|
||||
CHECK(r != NULL, "对不上时应该返回重建好的数组");
|
||||
if (r) {
|
||||
CHECK(pairing_ok(r), "修完之后配对必须成立");
|
||||
/* 两处:给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉
|
||||
/* 两处: 给 c1 补一条占位 (它没被回应), 再把错位的那条 c1 结果丢掉
|
||||
* (它被 user 消息隔开, 已经不在 tool 段里了 —— 留着就是孤儿).
|
||||
* 丢掉真实结果听着可惜, 但结构坏了只能二选一:要么丢它, 要么留一段
|
||||
* 丢掉真实结果听着可惜, 但结构坏了只能二选一: 要么丢它, 要么留一段
|
||||
* 服务端必然拒绝的历史. 模型会从占位消息里知道该重跑. */
|
||||
CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿, 实际 %d", fixed);
|
||||
CHECK(json_array_size(r) == 5,
|
||||
@@ -307,7 +307,7 @@ static void test_repair_pairing(void)
|
||||
}
|
||||
json_free(bad);
|
||||
|
||||
puts("末尾悬空:assistant 之后再没有消息");
|
||||
puts("末尾悬空: assistant 之后再没有消息");
|
||||
JsonValue *tail = json_new_array();
|
||||
json_array_push(tail, mk_plain("user", "跑一下"));
|
||||
json_array_push(tail, mk_assistant(1));
|
||||
@@ -317,7 +317,7 @@ static void test_repair_pairing(void)
|
||||
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("谁也不是"));
|
||||
@@ -343,19 +343,19 @@ static void test_repair_pairing(void)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 面向用户的历史口径:不含 system */
|
||||
/* 面向用户的历史口径: 不含 system */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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,
|
||||
@@ -395,7 +395,7 @@ int main(void)
|
||||
agent_add_user(&a, "U1");
|
||||
CHECK(agent_history_len(&a) == 2, "应 2 条");
|
||||
|
||||
/* 关键不变量:重复设置 system 应替换而不是追加 */
|
||||
/* 关键不变量: 重复设置 system 应替换而不是追加 */
|
||||
agent_add_system(&a, "S2");
|
||||
CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加, 实际 %zu 条",
|
||||
count_role(&a, "system"));
|
||||
|
||||
+20
-20
@@ -37,38 +37,38 @@ int main(void)
|
||||
if (!dir) { perror("mkdtemp"); return 2; }
|
||||
char err[256];
|
||||
|
||||
puts("根名:8 位小写字母数字");
|
||||
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",
|
||||
ARCHIVE_ROOT_LEN, n1 ? n1 : "(null)");
|
||||
CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]:%s", n1);
|
||||
CHECK(n1 && is_alnum_lower(n1, strlen(n1)), "根名字符集应为 [a-z0-9]: %s", n1);
|
||||
free(n1);
|
||||
|
||||
/* 空字符串与 NULL 等价:都表示"没有来源" */
|
||||
/* 空字符串与 NULL 等价: 都表示"没有来源" */
|
||||
char *n2 = NULL;
|
||||
CHECK(archive_next_name(dir, "", NULL, 0, &n2, err, sizeof err) == 0, "空 lineage 应可用");
|
||||
CHECK(n2 && strlen(n2) == ARCHIVE_ROOT_LEN, "空 lineage 也应给出根名");
|
||||
free(n2);
|
||||
|
||||
puts("分支名:来源 + _ + 3 位");
|
||||
puts("分支名: 来源 + _ + 3 位");
|
||||
char *b = NULL;
|
||||
CHECK(archive_next_name(dir, "a1b2c3d4", NULL, 0, &b, err, sizeof err) == 0,
|
||||
"分支生成失败: %s", err);
|
||||
CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头:%s", b);
|
||||
CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对:%s", b);
|
||||
CHECK(b && is_alnum_lower(b + 9, ARCHIVE_BRANCH_LEN), "后缀字符集不对:%s", b);
|
||||
CHECK(b && strncmp(b, "a1b2c3d4_", 9) == 0, "应以来源加下划线开头: %s", b);
|
||||
CHECK(b && strlen(b) == 8 + 1 + ARCHIVE_BRANCH_LEN, "分支长度不对: %s", b);
|
||||
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);
|
||||
CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同:%s / %s", x1, x2);
|
||||
CHECK(x1 && x2 && strcmp(x1, x2) != 0, "两次生成不应相同: %s / %s", x1, x2);
|
||||
free(x1); free(x2);
|
||||
|
||||
puts("长度上限:接不下就重新起根");
|
||||
/* 边界:来源长度 + 1 + 3 + ".json" <= 240 */
|
||||
puts("长度上限: 接不下就重新起根");
|
||||
/* 边界: 来源长度 + 1 + 3 + ".json" <= 240 */
|
||||
size_t maxlineage = ARCHIVE_NAME_MAX - 1 - ARCHIVE_BRANCH_LEN - strlen(".json");
|
||||
char *ok = xmalloc(maxlineage + 1);
|
||||
memset(ok, 'a', maxlineage);
|
||||
@@ -78,7 +78,7 @@ int main(void)
|
||||
CHECK(archive_next_name(dir, ok, NULL, 0, &edge, err, sizeof err) == 0, "边界生成失败");
|
||||
CHECK(edge && strncmp(edge, "aaa", 3) == 0, "刚好装得下时应继续接分支");
|
||||
CHECK(edge && strlen(edge) + strlen(".json") <= ARCHIVE_NAME_MAX,
|
||||
"完整文件名不应超上限:%zu", edge ? strlen(edge) + 5 : 0);
|
||||
"完整文件名不应超上限: %zu", edge ? strlen(edge) + 5 : 0);
|
||||
free(edge);
|
||||
|
||||
/* 再多一位就装不下了 */
|
||||
@@ -91,22 +91,22 @@ int main(void)
|
||||
"超限时应退回 %d 位根名, 实际 %s (%zu 位)",
|
||||
ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0);
|
||||
free(fresh);
|
||||
puts("前缀:临时存档");
|
||||
puts("前缀: 临时存档");
|
||||
char *tp = NULL;
|
||||
CHECK(archive_next_name(dir, "a1b2c3d4", "tmp_", 0, &tp, err, sizeof err) == 0,
|
||||
"带前缀生成失败");
|
||||
CHECK(tp && strncmp(tp, "tmp_a1b2c3d4_", 13) == 0,
|
||||
"前缀应在最前面、其后才是分支名:%s", tp ? tp : "(null)");
|
||||
"前缀应在最前面、其后才是分支名: %s", tp ? tp : "(null)");
|
||||
free(tp);
|
||||
|
||||
char *tr = NULL;
|
||||
CHECK(archive_next_name(dir, NULL, "tmp_", 0, &tr, err, sizeof err) == 0, "带前缀建根失败");
|
||||
CHECK(tr && strncmp(tr, "tmp_", 4) == 0 &&
|
||||
strlen(tr) == 4 + ARCHIVE_ROOT_LEN,
|
||||
"新根也应带前缀:%s", tr ? tr : "(null)");
|
||||
"新根也应带前缀: %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,
|
||||
@@ -130,8 +130,8 @@ int main(void)
|
||||
&path, &name, err, sizeof err) == 0,
|
||||
"导出失败: %s", err);
|
||||
CHECK(name && strncmp(name, "parentname_", 11) == 0,
|
||||
"导出名应挂在来源下:%s", name ? name : "(null)");
|
||||
CHECK(path && access(path, R_OK) == 0, "文件应存在:%s", path ? path : "");
|
||||
"导出名应挂在来源下: %s", name ? name : "(null)");
|
||||
CHECK(path && access(path, R_OK) == 0, "文件应存在: %s", path ? path : "");
|
||||
|
||||
char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, *iparent = NULL;
|
||||
JsonValue *back = archive_load(path, &created, &saved, &model, &iname, &iparent,
|
||||
@@ -140,13 +140,13 @@ int main(void)
|
||||
CHECK(back && json_array_size(back) == 1, "消息条数应为 1");
|
||||
CHECK(iname && name && strcmp(iname, name) == 0, "内容里的 name 应与文件名一致");
|
||||
CHECK(iparent && strcmp(iparent, "parentname") == 0,
|
||||
"内容里的 parent 应记下来源:%s", iparent ? iparent : "(null)");
|
||||
"内容里的 parent 应记下来源: %s", iparent ? iparent : "(null)");
|
||||
CHECK(model && strcmp(model, "test-model") == 0, "model 应往返");
|
||||
CHECK(created && strstr(created, "2023-") != NULL,
|
||||
"created 应可读:%s", created ? created : "(null)");
|
||||
"created 应可读: %s", created ? created : "(null)");
|
||||
/* saved 是"这份存档写出时的时间", 与 created (这段对话的开始时间)是两回事 */
|
||||
CHECK(saved && strstr(saved, "2023-") != NULL,
|
||||
"saved 应可读:%s", saved ? saved : "(null)");
|
||||
"saved 应可读: %s", saved ? saved : "(null)");
|
||||
|
||||
json_free(back); json_free(msgs);
|
||||
free(created); free(saved); free(model); free(iname); free(iparent);
|
||||
|
||||
+4
-4
@@ -53,7 +53,7 @@ static size_t count_type(const JsonValue *parts, const char *type)
|
||||
}
|
||||
|
||||
/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到.
|
||||
* 答案和计数放在一起, 免得"计数版只实现了一种答案"这种测试自己的错. */
|
||||
* 答案和计数放在一起, 以免"计数版只实现了一种答案"这种测试自己的错. */
|
||||
static int ask_count;
|
||||
static int ask_answer;
|
||||
static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; }
|
||||
@@ -86,7 +86,7 @@ int main(void)
|
||||
free(big);
|
||||
}
|
||||
|
||||
puts("glob 展开:命中几个就暂存几个");
|
||||
puts("glob 展开: 命中几个就暂存几个");
|
||||
attach_clear();
|
||||
size_t skipped = 0;
|
||||
char err[256];
|
||||
@@ -127,7 +127,7 @@ int main(void)
|
||||
added = attach_glob(f_big, NULL, NULL, &skipped, err, sizeof err);
|
||||
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 被收下");
|
||||
@@ -151,7 +151,7 @@ int main(void)
|
||||
puts("匹配不到 / 参数非法");
|
||||
attach_clear();
|
||||
added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因:%s", err);
|
||||
CHECK(added == -1 && err[0], "匹配不到返回 -1 并给出原因: %s", err);
|
||||
added = attach_glob(NULL, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == -1, "NULL 模式返回 -1");
|
||||
added = attach_glob("", NULL, NULL, &skipped, err, sizeof err);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* test_capability.c - 能力探测的纯逻辑部分
|
||||
*
|
||||
* 探测序列本身 (基线 -> 合并 -> 拆开)要发 HTTP, 属于端到端范畴,
|
||||
* 由 e2e 第 51 段对着 mock 覆盖:正常模型 / 不收图片 / 不收 tools /
|
||||
* 由 e2e 第 51 段对着 mock 覆盖: 正常模型 / 不收图片 / 不收 tools /
|
||||
* 探测失败 (401)/ --no-probe 五种走法都在那儿验.
|
||||
*
|
||||
* 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询.
|
||||
* 这里只测不需要网络的: 名字映射、关闭探测时的语义、缓存查询.
|
||||
* 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话,
|
||||
* 但 CAP_NO 恰好是 0, 而 Agent 是 memset 出来的, 忘了显式初始化就会一声不响地
|
||||
* 把所有工具摘掉. 这个 bug 真的发生过一次.
|
||||
@@ -44,8 +44,8 @@ int main(void)
|
||||
/* 任何不是 YES/NO 的值都该落到"未测", 而不是碰巧落到某一个 */
|
||||
CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测");
|
||||
|
||||
puts("关掉探测:一律未测, 绝不冒充结论");
|
||||
/* 用可变数组而不是字面量:LlmConfig 的这些字段是 char * (真要释放的),
|
||||
puts("关掉探测: 一律未测, 绝不冒充结论");
|
||||
/* 用可变数组而不是字面量: LlmConfig 的这些字段是 char * (真要释放的),
|
||||
* 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告. */
|
||||
char model[] = "some-model";
|
||||
char base_url[] = "http://127.0.0.1:1/v1";
|
||||
@@ -66,7 +66,7 @@ int main(void)
|
||||
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, "禁用时不能表现为不支持 (否则会摘掉整张工具表)");
|
||||
|
||||
|
||||
+10
-10
@@ -50,7 +50,7 @@ static void test_defaults(void)
|
||||
CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空");
|
||||
CHECK(c.stream == 1, "默认应开启流式");
|
||||
CHECK(c.use_tools == 1, "默认应启用工具");
|
||||
/* 默认不限往返次数:0 (或负数)= 不限, 只有正数才是上限 */
|
||||
/* 默认不限往返次数: 0 (或负数)= 不限, 只有正数才是上限 */
|
||||
CHECK(c.max_steps == 0, "默认应不限往返次数, 实际 %d", c.max_steps);
|
||||
config_free(&c);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ static void test_env_key(void)
|
||||
config_apply_env(&c);
|
||||
CHECK(c.api_key[0] == '\0', "无环境变量时 key 应为空");
|
||||
|
||||
/* 只设 DEEPSEEK_API_KEY:默认就应读它 */
|
||||
/* 只设 DEEPSEEK_API_KEY: 默认就应读它 */
|
||||
setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1);
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-deepseek") == 0,
|
||||
@@ -82,7 +82,7 @@ static void test_env_key(void)
|
||||
* 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */
|
||||
unset_all();
|
||||
setenv("OPENAI_API_KEY", "sk-openai", 1);
|
||||
/* 模拟"配置文件没写 api_key":必须先清掉上一步留下的值 */
|
||||
/* 模拟"配置文件没写 api_key": 必须先清掉上一步留下的值 */
|
||||
free(c.api_key);
|
||||
c.api_key = xstrdup("");
|
||||
config_apply_env(&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);
|
||||
@@ -305,7 +305,7 @@ static void test_file_override(void)
|
||||
unset_all();
|
||||
}
|
||||
|
||||
/* 配置只认二进制所在目录:不做用户级、系统级, 也不做当前目录查找 */
|
||||
/* 配置只认二进制所在目录: 不做用户级、系统级, 也不做当前目录查找 */
|
||||
static void test_binary_dir_lookup(void)
|
||||
{
|
||||
puts("配置位置 = 二进制所在目录");
|
||||
@@ -370,11 +370,11 @@ static int load_temp_config(LigentConfig *c, const char *json)
|
||||
}
|
||||
|
||||
/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥.
|
||||
* 这正是用户踩到的坑:配置写了 OpenRouter 的 key,
|
||||
* 这正是用户踩到的坑: 配置写了 OpenRouter 的 key,
|
||||
* shell 里残留的 DEEPSEEK_API_KEY 把它顶掉, 于是 A 家的密钥发给了 B 家. */
|
||||
static void test_env_key_precedence(void)
|
||||
{
|
||||
puts("api_key 优先级:厂商环境变量不得越权");
|
||||
puts("api_key 优先级: 厂商环境变量不得越权");
|
||||
unset_all();
|
||||
setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1);
|
||||
|
||||
@@ -421,12 +421,12 @@ static void test_env_key_precedence(void)
|
||||
unset_all();
|
||||
}
|
||||
|
||||
/* 软链接:配置目录要跟着"用户实际调用那个文件"走.
|
||||
/* 软链接: 配置目录要跟着"用户实际调用那个文件"走.
|
||||
* 否则把 ligent 软链到 ~/bin 之后, 配置会被悄悄挪到源文件所在的目录,
|
||||
* 用户放在 ~/bin 旁边的 ligent.json 就永远读不到. */
|
||||
static void test_symlink_dir(void)
|
||||
{
|
||||
puts("软链接:配置目录跟着软链接走");
|
||||
puts("软链接: 配置目录跟着软链接走");
|
||||
|
||||
char self[PATH_MAX];
|
||||
ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
|
||||
@@ -444,7 +444,7 @@ static void test_symlink_dir(void)
|
||||
return;
|
||||
}
|
||||
|
||||
/* 核心:指向软链接时取软链接所在目录, 而不是源文件所在目录 */
|
||||
/* 核心: 指向软链接时取软链接所在目录, 而不是源文件所在目录 */
|
||||
char *got = config_binary_dir(link);
|
||||
CHECK(got && strcmp(got, dir) == 0,
|
||||
"应取软链接所在目录 %s, 实际 %s", dir, got ? got : "(null)");
|
||||
|
||||
+5
-5
@@ -22,7 +22,7 @@ static int checks = 0;
|
||||
|
||||
int main(void)
|
||||
{
|
||||
puts("重试判定:只重试服务端明确报错的情况");
|
||||
puts("重试判定: 只重试服务端明确报错的情况");
|
||||
|
||||
/* 应当重试 */
|
||||
long retryable[] = { 408, 409, 429, 500, 502, 503, 504 };
|
||||
@@ -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,7 +53,7 @@ int main(void)
|
||||
size_t sz = 0;
|
||||
FILE *f = open_memstream(&buf, &sz);
|
||||
|
||||
/* 这条正是曾经漏出去的:libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT,
|
||||
/* 这条正是曾经漏出去的: 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);
|
||||
@@ -84,12 +84,12 @@ int main(void)
|
||||
free(buf);
|
||||
}
|
||||
|
||||
puts("\n空请求体:POST 分支也要扛得住 body == NULL");
|
||||
puts("\n空请求体: POST 分支也要扛得住 body == NULL");
|
||||
{
|
||||
/* body 允许为 NULL (http_probe 就是这么调的), 但 POST 分支以前写的是
|
||||
* 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误.
|
||||
*
|
||||
* 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它,
|
||||
* 这里故意用一个格式非法的 URL: libcurl 会在真正联网之前就拒绝它,
|
||||
* 所以本测试仍然不碰网络. 而 strlen(NULL) 发生在设置 POSTFIELDS 期间、
|
||||
* curl_easy_perform 之前, 也就是说旧代码在这里必崩 —— 这个断言对旧
|
||||
* 代码是有效的回归守卫, 不是走过场. */
|
||||
|
||||
+13
-13
@@ -1,8 +1,8 @@
|
||||
/* test_i18n.c - 界面文案表
|
||||
*
|
||||
* 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏:加了键忘了翻,
|
||||
* 这张表是"每种语言一条". 它最容易出的错不是逻辑, 而是漏: 加了键忘了翻,
|
||||
* 或者译文里少了个占位符 ({path} 写成 {paths})—— 用户看到的就是一句缺字的话.
|
||||
* 所以这里的检查全部是机械的:遍历表, 逐条比对.
|
||||
* 所以这里的检查全部是机械的: 遍历表, 逐条比对.
|
||||
*
|
||||
* 文案本身好不好不在这里管; "英文界面是不是每句都有着落"才是.
|
||||
*/
|
||||
@@ -71,7 +71,7 @@ static int printf_specs(const char *s, char (*out)[16], int max)
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 有没有中日韩字符 (含全角标点:〔〕 ()). 英文那一列里出现它, 基本就是
|
||||
/* 有没有中日韩字符 (含全角标点: 〔〕 ()). 英文那一列里出现它, 基本就是
|
||||
* "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对
|
||||
* 中文括号. 顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住. */
|
||||
static int has_cjk(const char *s)
|
||||
@@ -106,12 +106,12 @@ int main(void)
|
||||
CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符",
|
||||
m->key);
|
||||
|
||||
/* key 唯一性:比较是 O(n²), 但每个 key 只记一条检查 —— 写成循环里
|
||||
/* 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;
|
||||
CHECK(!dup, "key 重复:%s", m->key);
|
||||
CHECK(!dup, "key 重复: %s", m->key);
|
||||
|
||||
char pe[16][32], pz[16][32];
|
||||
int ne = placeholders(m->en ? m->en : "", pe, 16);
|
||||
@@ -122,32 +122,32 @@ int main(void)
|
||||
int found = 0;
|
||||
for (int b = 0; b < nz; b++)
|
||||
if (strcmp(pe[a], pz[b]) == 0) found = 1;
|
||||
CHECK(found, "%s:英文的 {%s} 在中文里没有对应", m->key, pe[a]);
|
||||
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++)
|
||||
if (strcmp(pz[a], pe[b]) == 0) found = 1;
|
||||
CHECK(found, "%s:中文的 {%s} 在英文里没有对应", m->key, pz[a]);
|
||||
CHECK(found, "%s: 中文的 {%s} 在英文里没有对应", m->key, pz[a]);
|
||||
}
|
||||
}
|
||||
|
||||
/* 表里有一部分键是**当 printf 模板用的** (repl.usage.*、repl.model.row、
|
||||
* json.* …):调用方直接把 t(key) 交给 printf/snprintf. 这些条目的
|
||||
* json.* …): 调用方直接把 t(key) 交给 printf/snprintf. 这些条目的
|
||||
* {name} 检查管不到, 得单独比转换符 —— 少一个 %s 会读到别的参数,
|
||||
* 类型写错 (%d 对 %zu)更是直接踩内存. */
|
||||
puts("printf 模板:两种语言的转换符必须一模一样");
|
||||
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);
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ int main(void)
|
||||
const char *two = tf("doctor.val.match_bad", "guess", "OpenAI",
|
||||
"owner", "DeepSeek", NULL);
|
||||
CHECK(strstr(two, "DeepSeek") && strstr(two, "OpenAI"), "两个值都要在");
|
||||
CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符:%s", two);
|
||||
CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符: %s", two);
|
||||
|
||||
puts("语言名解析");
|
||||
CHECK(i18n_parse_lang("zh") == LIGENT_LANG_ZH, "zh");
|
||||
@@ -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");
|
||||
|
||||
+15
-15
@@ -56,7 +56,7 @@ static void test_sniff(void)
|
||||
|
||||
static void test_backup(void)
|
||||
{
|
||||
puts("本地备份:命名、内容、不重复");
|
||||
puts("本地备份: 命名、内容、不重复");
|
||||
char tmpl[] = "/tmp/ligent_img_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
CHECK(dir != NULL, "建临时目录失败");
|
||||
@@ -70,11 +70,11 @@ static void test_backup(void)
|
||||
if (!b1) { return; }
|
||||
|
||||
/* 名字形如 <上传时间戳>_<4位随机>.<ext> */
|
||||
CHECK(strlen(b1) > 5, "名字不该太短:%s", b1);
|
||||
CHECK(strlen(b1) > 5, "名字不该太短: %s", b1);
|
||||
char *dot = strrchr(b1, '.');
|
||||
CHECK(dot && strcmp(dot, ".png") == 0, "扩展名应从嗅探结果来:%s", b1);
|
||||
CHECK(dot - b1 >= 7, "时间戳 + 下划线 + 4 位随机:%s", b1);
|
||||
CHECK(b1[dot - b1 - 5] == '_', "下划线位置应在随机后缀前:%s", b1);
|
||||
CHECK(dot && strcmp(dot, ".png") == 0, "扩展名应从嗅探结果来: %s", b1);
|
||||
CHECK(dot - b1 >= 7, "时间戳 + 下划线 + 4 位随机: %s", b1);
|
||||
CHECK(b1[dot - b1 - 5] == '_', "下划线位置应在随机后缀前: %s", b1);
|
||||
|
||||
char *p1 = image_backup_path(dir, b1);
|
||||
size_t len = 0;
|
||||
@@ -84,7 +84,7 @@ static void test_backup(void)
|
||||
|
||||
/* 再存一次应该拿到不同的名字 (随机后缀), 而不是互相覆盖 */
|
||||
char *b2 = image_backup_save(dir, data, n, "png");
|
||||
CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号:%s / %s",
|
||||
CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号: %s / %s",
|
||||
b1, b2 ? b2 : "(null)");
|
||||
free(b2);
|
||||
|
||||
@@ -97,8 +97,8 @@ static void test_backup(void)
|
||||
|
||||
static void test_base64(void)
|
||||
{
|
||||
puts("base64:长度、补位、往返");
|
||||
/* 用几组长度覆盖三种补位情况:3n、3n+1、3n+2 */
|
||||
puts("base64: 长度、补位、往返");
|
||||
/* 用几组长度覆盖三种补位情况: 3n、3n+1、3n+2 */
|
||||
struct { const char *in; const char *want; } cases[] = {
|
||||
{ "", "" },
|
||||
{ "f", "Zg==" },
|
||||
@@ -164,7 +164,7 @@ static JsonValue *history_with_ref(const char *backup)
|
||||
|
||||
static void test_expand(void)
|
||||
{
|
||||
puts("请求前展开:引用 -> image_url + base64");
|
||||
puts("请求前展开: 引用 -> image_url + base64");
|
||||
char tmpl[] = "/tmp/ligent_imgx_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
CHECK(dir != NULL, "建临时目录失败");
|
||||
@@ -202,7 +202,7 @@ static void test_expand(void)
|
||||
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");
|
||||
@@ -210,7 +210,7 @@ static void test_expand(void)
|
||||
free(orig);
|
||||
json_free(wire);
|
||||
|
||||
/* --- 备份没了:就地降级 --- */
|
||||
/* --- 备份没了: 就地降级 --- */
|
||||
char *bpath = image_backup_path(dir, backup);
|
||||
CHECK(unlink(bpath) == 0, "删掉备份文件");
|
||||
free(bpath);
|
||||
@@ -227,7 +227,7 @@ static void test_expand(void)
|
||||
dt ? dt : "(null)");
|
||||
json_free(wire);
|
||||
|
||||
/* 降级是**就地**的:原历史里的坏引用应该已经被换掉, 否则每轮都会重试 */
|
||||
/* 降级是**就地**的: 原历史里的坏引用应该已经被换掉, 否则每轮都会重试 */
|
||||
char *after = json_dump(msgs);
|
||||
CHECK(strstr(after, "ligent_image") == NULL, "原历史里的坏引用应已被清掉");
|
||||
CHECK(strstr(after, "ligent_note") != NULL, "应换成内部说明块");
|
||||
@@ -239,11 +239,11 @@ static void test_expand(void)
|
||||
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);
|
||||
|
||||
/* --- 4xx 兜底:image_degrade_all --- */
|
||||
/* --- 4xx 兜底: image_degrade_all --- */
|
||||
JsonValue *m2 = history_with_ref(backup);
|
||||
CHECK(image_has_refs(m2) == 1, "有引用时应报 1");
|
||||
int dn = image_degrade_all(m2);
|
||||
@@ -280,7 +280,7 @@ static void test_expand(void)
|
||||
|
||||
static void test_pending(void)
|
||||
{
|
||||
puts("待发附件:取走即清空");
|
||||
puts("待发附件: 取走即清空");
|
||||
image_pending_take(); /* 先清干净 */
|
||||
|
||||
image_pending_add("1700000000_aaaa.png");
|
||||
|
||||
+7
-7
@@ -79,7 +79,7 @@ static void test_numbers(void)
|
||||
CHECK(json_number_value(json_array_get(v, 5), 0) == 123456789012.0, "[5]");
|
||||
json_free(v);
|
||||
|
||||
/* 严格按 RFC 8259 扫描数字:这些以前全都被接受 —— strtod 会把 "1e" 读成 1、
|
||||
/* 严格按 RFC 8259 扫描数字: 这些以前全都被接受 —— strtod 会把 "1e" 读成 1、
|
||||
* "5." 读成 5, 于是一个手写错的数字会被静默当成另一个数用下去 (配置文件、
|
||||
* 存档都是手写的, 用户只会在很久之后从一个不相干的现象里发现它). */
|
||||
const char *bad_nums[] = {
|
||||
@@ -167,14 +167,14 @@ static void test_nested_depth(void)
|
||||
json_free(ok);
|
||||
}
|
||||
|
||||
/* 报错必须能定位:只有"语法错了"而不知道错在哪, 等于没有报错 */
|
||||
/* 报错必须能定位: 只有"语法错了"而不知道错在哪, 等于没有报错 */
|
||||
static void test_error_location(void)
|
||||
{
|
||||
puts("test_error_location");
|
||||
char err[256];
|
||||
JsonValue *v;
|
||||
|
||||
/* 尾随逗号:行号必须指向真正出问题的那一行 */
|
||||
/* 尾随逗号: 行号必须指向真正出问题的那一行 */
|
||||
v = json_parse("{\n \"a\": 1,\n}\n", err, sizeof err);
|
||||
CHECK(v == NULL, "尾随逗号应报错");
|
||||
CHECK(strstr(err, "第 3 行") != NULL, "应报出行号: %s", err);
|
||||
@@ -182,7 +182,7 @@ static void test_error_location(void)
|
||||
CHECK(strstr(err, "}") != NULL, "应回显出错处原文: %s", err);
|
||||
json_free(v);
|
||||
|
||||
/* 未闭合的数组:位置应指向输入结束处 */
|
||||
/* 未闭合的数组: 位置应指向输入结束处 */
|
||||
v = json_parse("[1, 2\n", err, sizeof err);
|
||||
CHECK(v == NULL, "未闭合数组应报错");
|
||||
CHECK(strstr(err, "第 2 行") != NULL, "未闭合应指向结尾: %s", err);
|
||||
@@ -225,8 +225,8 @@ static void test_error_location(void)
|
||||
* 服务端会因为请求体里有非法 UTF-8 直接回
|
||||
* 400 ... invalid unicode code point
|
||||
* 而那段坏数据一旦进了对话历史, 之后每次请求都会失败 —— 除非丢掉整轮对话.
|
||||
* 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
|
||||
* 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住:
|
||||
* 坏字节的来源很日常: 某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
|
||||
* 字节字符、二进制垃圾. 所以这条不能只靠"在入口清理", 必须在序列化器这层兜住:
|
||||
* 出口是唯一能保证"发出去的永远合法"的地方.
|
||||
*
|
||||
* 这个校验函数故意用 utf8_seq_len 自己走一遍, 而不是信任 dump 的实现 ——
|
||||
@@ -246,7 +246,7 @@ static void test_dump_utf8_safety(void)
|
||||
{
|
||||
puts("test_dump_utf8_safety");
|
||||
|
||||
/* utf8_seq_len:合法序列返回长度 */
|
||||
/* utf8_seq_len: 合法序列返回长度 */
|
||||
CHECK(utf8_seq_len("\xe4\xb8\xad", 3) == 3, "合法 CJK 应是 3 字节");
|
||||
CHECK(utf8_seq_len("A", 1) == 1, "ASCII 应是 1 字节");
|
||||
CHECK(utf8_seq_len("\xf0\x9f\x98\x80", 4) == 4, "emoji 应是 4 字节");
|
||||
|
||||
@@ -71,7 +71,7 @@ static void test_history_file(void)
|
||||
CHECK(strcmp(line_history_get(1), "/think high") == 0, "第 1 条应还原");
|
||||
line_history_clear();
|
||||
|
||||
/* 反斜杠和换行必须能原样往返:一条历史在文件里只占一行 */
|
||||
/* 反斜杠和换行必须能原样往返: 一条历史在文件里只占一行 */
|
||||
unlink(path);
|
||||
line_history_add("echo C:\\Users\\me");
|
||||
line_history_append(path, "echo C:\\Users\\me");
|
||||
@@ -97,7 +97,7 @@ static void test_history_file(void)
|
||||
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);
|
||||
@@ -168,7 +168,7 @@ int main(void)
|
||||
BUFEQ(lb, "eXllo");
|
||||
linebuf_free(&lb);
|
||||
|
||||
puts("UTF-8:按字符而非字节移动");
|
||||
puts("UTF-8: 按字符而非字节移动");
|
||||
linebuf_init(&lb);
|
||||
linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */
|
||||
CHECK(lb.len == 9, "应为 9 字节, 实际 %zu", lb.len);
|
||||
@@ -216,7 +216,7 @@ 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);
|
||||
linebuf_home(&lb);
|
||||
|
||||
+15
-15
@@ -1,6 +1,6 @@
|
||||
/* test_markdown.c - 流式 Markdown 渲染器测试
|
||||
*
|
||||
* 最关键的不变量:把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入
|
||||
* 最关键的不变量: 把同一段文本任意切分后逐片喂入, 输出必须与一次性喂入
|
||||
* 完全一致. SSE 的分片位置是随机的, 这条不成立就会漏出"半个星号".
|
||||
*/
|
||||
#include "markdown.h"
|
||||
@@ -191,7 +191,7 @@ static void test_bold_italic(void)
|
||||
CHECK(strcmp(vis, "斜\n") == 0, "斜体标记应被吃掉: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 字面星号:乘法 */
|
||||
/* 字面星号: 乘法 */
|
||||
o = render("2 * 3 * 4\n", 1);
|
||||
CHECK(strcmp(o, "2 * 3 * 4\n") == 0, "乘法星号应原样输出: [%s]", o);
|
||||
free(o);
|
||||
@@ -206,7 +206,7 @@ static void test_bold_italic(void)
|
||||
CHECK(strstr(o, "\033[0m\n") != NULL, "行尾应复位样式: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 嵌套:粗体里的斜体 */
|
||||
/* 嵌套: 粗体里的斜体 */
|
||||
o = render("**粗 *斜* 粗**\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "粗 斜 粗\n") == 0, "嵌套可见文本: [%s]", vis);
|
||||
@@ -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,
|
||||
@@ -238,7 +238,7 @@ static void test_code(void)
|
||||
"语言标识与内容应完整保留: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 未闭合的代码块:不能吞掉后面的内容 */
|
||||
/* 未闭合的代码块: 不能吞掉后面的内容 */
|
||||
o = render("```\ncode\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "```\ncode\n") == 0, "未闭合代码块: [%s]", vis);
|
||||
@@ -263,7 +263,7 @@ static void test_blocks(void)
|
||||
CHECK(strstr(o, "\033[36m+\033[0m") != NULL, "列表标记 + 应着色: [%s]", o);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 表格不受影响:仍然是框线字符 */
|
||||
/* 表格不受影响: 仍然是框线字符 */
|
||||
o = render("| a |\n|---|\n| 1 |\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strstr(vis, "┌") && strstr(vis, "│") && strstr(vis, "└"),
|
||||
@@ -309,8 +309,8 @@ static void test_passthrough(void)
|
||||
|
||||
static void test_fence_verbatim(void)
|
||||
{
|
||||
puts("围栏代码块:全部不渲染");
|
||||
/* 纯代码块:输出里不应出现任何 ANSI 转义 */
|
||||
puts("围栏代码块: 全部不渲染");
|
||||
/* 纯代码块: 输出里不应出现任何 ANSI 转义 */
|
||||
char *o = render("```c\nint x = 1; // **不是粗体**\n```\n", 1);
|
||||
CHECK(strstr(o, "\033") == NULL,
|
||||
"代码块内不应有任何 ANSI 转义: [%s]", o);
|
||||
@@ -367,7 +367,7 @@ static void test_underscore_italic(void)
|
||||
CHECK(strstr(o, "\033[3m斜体") != NULL, "中文紧邻应生效: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 关键保护:下划线标识符不能被当成斜体 */
|
||||
/* 关键保护: 下划线标识符不能被当成斜体 */
|
||||
o = render("foo_bar_baz 和 __init__ 和 a_b_c\n", 1);
|
||||
CHECK(strstr(o, "\033[3m") == NULL,
|
||||
"下划线标识符不应被渲染为斜体: [%s]", o);
|
||||
@@ -378,7 +378,7 @@ 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. */
|
||||
@@ -405,7 +405,7 @@ static void test_link_bracket(void)
|
||||
static void test_tables(void)
|
||||
{
|
||||
puts("表格");
|
||||
/* ASCII 表格:逐字节比对完整边框输出 */
|
||||
/* ASCII 表格: 逐字节比对完整边框输出 */
|
||||
char *o = render("| a | b |\n|---|---|\n| 1 | 22 |\n", 1);
|
||||
char *vis = strip_ansi(o);
|
||||
const char *want =
|
||||
@@ -443,7 +443,7 @@ static void test_tables(void)
|
||||
free(vis); free(o);
|
||||
|
||||
/* 顶格必须写 '|'. 不带首竖线的写法按普通文本处理 ——
|
||||
* 这是为保证"输出与分片方式无关"而做的取舍:
|
||||
* 这是为保证"输出与分片方式无关"而做的取舍:
|
||||
* 判断必须在行的第 0 个字节就能做出, 否则分片落在竖线之前时,
|
||||
* 行的前半段已被当作普通文本输出, 结果会随分片方式而变. */
|
||||
o = render("a | b\n--- | ---\n1 | 2\n", 1);
|
||||
@@ -452,13 +452,13 @@ static void test_tables(void)
|
||||
"无首竖线应原样输出: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 首竖线前有缩进同理:不认 */
|
||||
/* 首竖线前有缩进同理: 不认 */
|
||||
o = render(" | a |\n |---|\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
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);
|
||||
@@ -491,7 +491,7 @@ static void test_tables(void)
|
||||
static void test_long_line_bounded(void)
|
||||
{
|
||||
puts("超长待处理缓冲应有界");
|
||||
/* 一个没有换行的超长 fence 信息行:渲染器不能无限缓存 */
|
||||
/* 一个没有换行的超长 fence 信息行: 渲染器不能无限缓存 */
|
||||
StrBuf sb;
|
||||
sb_init(&sb);
|
||||
sb_puts(&sb, "```");
|
||||
|
||||
+16
-16
@@ -1,4 +1,4 @@
|
||||
/* test_meta.c - meta 历史编辑:轮次切分、结构安全、各工具的行为 */
|
||||
/* test_meta.c - meta 历史编辑: 轮次切分、结构安全、各工具的行为 */
|
||||
#include "meta.h"
|
||||
#include "json.h"
|
||||
#include "util.h"
|
||||
@@ -50,7 +50,7 @@ static JsonValue *assistant_call(const char *id, const char *name, const char *a
|
||||
return m;
|
||||
}
|
||||
|
||||
/* 造一段典型历史:
|
||||
/* 造一段典型历史:
|
||||
* #0 system
|
||||
* #1 user A ← 轮 1
|
||||
* #2 assistant(tool_calls)
|
||||
@@ -98,7 +98,7 @@ static void test_rounds(void)
|
||||
meta_rounds_free(&r);
|
||||
json_free(n);
|
||||
|
||||
/* 只有 system:没有可编辑的东西 */
|
||||
/* 只有 system: 没有可编辑的东西 */
|
||||
JsonValue *only = json_new_array();
|
||||
json_array_push(only, msg("system", "s"));
|
||||
CHECK(meta_rounds_build(only, &r) == 0, "只有 system 时不该切出轮次");
|
||||
@@ -108,7 +108,7 @@ static void test_rounds(void)
|
||||
/* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** ——
|
||||
* 判据必须与主循环 (agent_opens_round)一致, 否则 /meta 报的轮号会和主循环的
|
||||
* 口径对不上, 模型照轮号删下去, 删掉的不是它以为的那一轮.
|
||||
* 结构:system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */
|
||||
* 结构: 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", "看图"));
|
||||
@@ -135,7 +135,7 @@ static void test_rounds(void)
|
||||
|
||||
static void test_deletable(void)
|
||||
{
|
||||
puts("结构安全:哪些消息能单独删");
|
||||
puts("结构安全: 哪些消息能单独删");
|
||||
JsonValue *m = sample();
|
||||
CHECK(meta_deletable(m, 0) == 0, "system 不可删");
|
||||
CHECK(meta_deletable(m, 1) == 1, "user 可删");
|
||||
@@ -152,7 +152,7 @@ static char *call(Agent *a, const char *name, const char *json)
|
||||
{
|
||||
char err[256];
|
||||
JsonValue *args = json_parse(json, err, sizeof err);
|
||||
if (!args) { printf(" (测试自身出错:%s)\n", err); return xstrdup(""); }
|
||||
if (!args) { printf(" (测试自身出错: %s)\n", err); return xstrdup(""); }
|
||||
int is_err = 0;
|
||||
char *r = meta_tools_call(name, args, &is_err);
|
||||
json_free(args);
|
||||
@@ -175,7 +175,7 @@ static void test_replace(void)
|
||||
|
||||
int err = 0;
|
||||
char *r = call(&a, "replace_message", "{\"index\":1,\"content\":\"帮我用 C 写\"}");
|
||||
CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功:%s", r ? r : "");
|
||||
CHECK(err == 0 && r && strstr(r, "已改写") != NULL, "应改写成功: %s", r ? r : "");
|
||||
JsonValue *m1 = json_array_get(a.messages, 1);
|
||||
CHECK(strcmp(json_get_string(m1, "content"), "帮我用 C 写") == 0, "内容应被替换");
|
||||
CHECK(strcmp(json_get_string(m1, "role"), "user") == 0, "角色不该变");
|
||||
@@ -190,7 +190,7 @@ static void test_replace(void)
|
||||
|
||||
/* tool 结果和 system 都不该被动 */
|
||||
r = call(&a, "replace_message", "{\"index\":3,\"content\":\"x\"}");
|
||||
CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝:%s", r ? r : "");
|
||||
CHECK(r && strstr(r, "tool 结果") != NULL, "改 tool 结果应被拒绝: %s", r ? r : "");
|
||||
free(r);
|
||||
r = call(&a, "replace_message", "{\"index\":0,\"content\":\"x\"}");
|
||||
CHECK(r && strstr(r, "system") != NULL, "改 system 应被拒绝");
|
||||
@@ -223,15 +223,15 @@ 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(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功: %s", r ? r : "");
|
||||
CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文");
|
||||
CHECK(json_array_size(a.messages) == before - 1, "条数应减一");
|
||||
free(r);
|
||||
@@ -254,9 +254,9 @@ static void test_delete_rounds(void)
|
||||
|
||||
size_t before = json_array_size(a.messages);
|
||||
char *r = call(&a, "delete_rounds", "{\"from\":2,\"to\":2}");
|
||||
CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮:%s", r ? r : "");
|
||||
CHECK(r && strstr(r, "已删除轮 2..2") != NULL, "应删掉第 2 轮: %s", r ? r : "");
|
||||
CHECK(json_array_size(a.messages) == before - 2, "第 2 轮是 2 条消息");
|
||||
/* 剩下的结构必须还是自洽的:#2 的 tool_calls 后面仍跟着 tool */
|
||||
/* 剩下的结构必须还是自洽的: #2 的 tool_calls 后面仍跟着 tool */
|
||||
CHECK(meta_deletable(a.messages, 2) == 0, "#2 仍应是结构消息");
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 3), "role"), "tool") == 0,
|
||||
"tool 结果应仍紧跟它的调用");
|
||||
@@ -269,7 +269,7 @@ static void test_delete_rounds(void)
|
||||
|
||||
/* 越界 */
|
||||
r = call(&a, "delete_rounds", "{\"from\":99,\"to\":99}");
|
||||
CHECK(r && (strstr(r, "不合法") != NULL), "越界轮号应报错:%s", r ? r : "");
|
||||
CHECK(r && (strstr(r, "不合法") != NULL), "越界轮号应报错: %s", r ? r : "");
|
||||
free(r);
|
||||
|
||||
meta_tools_detach();
|
||||
@@ -286,7 +286,7 @@ static void test_insert_and_undo(void)
|
||||
size_t before = json_array_size(a.messages);
|
||||
char *r = call(&a, "insert_message",
|
||||
"{\"after\":8,\"role\":\"user\",\"content\":\"这个项目必须用 C99\"}");
|
||||
CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功:%s", r ? r : "");
|
||||
CHECK(r && strstr(r, "插入一条 user") != NULL, "应插入成功: %s", r ? r : "");
|
||||
CHECK(json_array_size(a.messages) == before + 1, "条数应加一");
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 9), "content"),
|
||||
"这个项目必须用 C99") == 0, "应插在 #8 之后");
|
||||
@@ -321,7 +321,7 @@ static void test_read_tools(void)
|
||||
meta_tools_attach(&a);
|
||||
|
||||
char *r = call(&a, "list_rounds", "{}");
|
||||
CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮:%s", r ? r : "");
|
||||
CHECK(r && strstr(r, "共 3 轮") != NULL, "应报告 3 轮: %s", r ? r : "");
|
||||
CHECK(r && strstr(r, "轮 1") != NULL && strstr(r, "轮 3") != NULL, "应列出每轮");
|
||||
CHECK(r && strstr(r, "Python") != NULL, "应带开头预览");
|
||||
free(r);
|
||||
|
||||
+3
-3
@@ -3,9 +3,9 @@
|
||||
* 只测纯解析. 拉取 (GET {base_url}/models)要走网络, 由 e2e 第 52 段对着
|
||||
* mock 覆盖, 那里还顺带验了交互式选择本身 (方向键、确认、取消).
|
||||
*
|
||||
* 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭, /models 的返回花样
|
||||
* 为什么要容忍这么多种形状: OpenAI 兼容是个很大的家庭, /models 的返回花样
|
||||
* 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id.
|
||||
* 解析器要么跳过, 要么去重, 但**不能**因此整个失败:列表里少一个模型远好过
|
||||
* 解析器要么跳过, 要么去重, 但**不能**因此整个失败: 列表里少一个模型远好过
|
||||
* 一个都选不了.
|
||||
*/
|
||||
#include "models.h"
|
||||
@@ -29,7 +29,7 @@ static int checks = 0;
|
||||
|
||||
static void test_standard(void)
|
||||
{
|
||||
puts("标准形状:{\"data\":[{\"id\":...}]}");
|
||||
puts("标准形状: {\"data\":[{\"id\":...}]}");
|
||||
const char *json =
|
||||
"{\"object\":\"list\",\"data\":["
|
||||
"{\"id\":\"alpha\",\"object\":\"model\",\"owned_by\":\"x\"},"
|
||||
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
/* test_priv.c - 提权模块里那几条"不能搞错"的不变量
|
||||
*
|
||||
* 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到
|
||||
* 这个模块摔过一跤: 上一版把认证路径也 setsid + 接管道, 于是 sudo 拿不到
|
||||
* 控制终端, 真机上认证 100% 失败 (而替身 sudo 无条件成功, 给了个假绿).
|
||||
* 所以这里把两条硬不变量钉住:
|
||||
* 所以这里把两条硬不变量钉住:
|
||||
*
|
||||
* 1. **提权调用里不能有 -n**. 带 -n 就等于"不许提问", 需要密码时直接失败,
|
||||
* 而用户要的恰恰是"提权要有身份验证".
|
||||
@@ -41,7 +41,7 @@ static int argv_has(char *const *a, const char *want)
|
||||
int main(void)
|
||||
{
|
||||
puts("包装成 sudo -u <用户> -- <argv>");
|
||||
/* 显式转换, 和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由:
|
||||
/* 显式转换, 和 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 };
|
||||
@@ -51,17 +51,17 @@ int main(void)
|
||||
CHECK(strcmp(w[1], "-u") == 0 && strcmp(w[2], "root") == 0,
|
||||
"-u <用户> 紧跟其后");
|
||||
CHECK(strcmp(w[3], "--") == 0,
|
||||
"必须有 -- :否则命令里的选项会被 sudo 当成自己的");
|
||||
"必须有 -- : 否则命令里的选项会被 sudo 当成自己的");
|
||||
CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 &&
|
||||
strcmp(w[6], "rm -rf /tmp/x") == 0,
|
||||
"原 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");
|
||||
@@ -82,11 +82,11 @@ int main(void)
|
||||
priv_explain(1, "pluv is not in the sudoers file.", err, sizeof err);
|
||||
CHECK(strstr(err, "sudo") != NULL && strstr(err, "权限") != NULL,
|
||||
"没有 sudo 权限要说清楚, 实际 %s", err);
|
||||
/* 认不出来就原样透传:宁可给原文, 也不编一句可能不对的解释 */
|
||||
/* 认不出来就原样透传: 宁可给原文, 也不编一句可能不对的解释 */
|
||||
priv_explain(1, "some brand new message", err, sizeof err);
|
||||
CHECK(strstr(err, "some brand new message") != NULL,
|
||||
"认不出来要透传原文, 实际 %s", err);
|
||||
priv_explain(127, "无法执行 sudo:No such file or directory", err, sizeof err);
|
||||
priv_explain(127, "无法执行 sudo: No such file or directory", err, sizeof err);
|
||||
CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚, 实际 %s", err);
|
||||
|
||||
puts("没有控制终端时能识别出来");
|
||||
|
||||
@@ -51,7 +51,7 @@ static void capture_end(int fd, int saved, char *buf, size_t buflen)
|
||||
|
||||
static void test_two_phase(void)
|
||||
{
|
||||
puts("两段式标签:等待响应 → 思考中");
|
||||
puts("两段式标签: 等待响应 → 思考中");
|
||||
char path[64];
|
||||
int saved = -1;
|
||||
int fd = capture_begin(path, sizeof path, &saved);
|
||||
@@ -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("思考中");
|
||||
@@ -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:幂等 */
|
||||
/* 没 start 就 stop: 幂等 */
|
||||
spinner_stop();
|
||||
/* start 两次:第二次忽略, 不该起第二个线程 */
|
||||
/* start 两次: 第二次忽略, 不该起第二个线程 */
|
||||
spinner_start("等待响应");
|
||||
spinner_start("思考中");
|
||||
usleep(300 * 1000);
|
||||
@@ -113,7 +113,7 @@ static void test_idempotent(void)
|
||||
CHECK(strstr(buf, "思考中") == NULL,
|
||||
"已在转时再次 start 应被忽略, 标签不该被改掉");
|
||||
|
||||
/* enabled = 0 时全部是空操作:连重定向都不需要 */
|
||||
/* enabled = 0 时全部是空操作: 连重定向都不需要 */
|
||||
spinner_init(0);
|
||||
spinner_start("等待响应");
|
||||
spinner_set_label("思考中");
|
||||
@@ -156,7 +156,7 @@ static void test_stop_is_prompt(void)
|
||||
CHECK(worst < 0.10, "stop 应立即返回, 最慢一次 %.3fs (帧间隔是 0.25s)", worst);
|
||||
}
|
||||
|
||||
/* 帧率必须受 250ms 限制. 曾经踩过一次:pthread_cond_timedwait 默认按
|
||||
/* 帧率必须受 250ms 限制. 曾经踩过一次: pthread_cond_timedwait 默认按
|
||||
* CLOCK_REALTIME 解释超时, 而传进去的是 CLOCK_MONOTONIC 的绝对时间
|
||||
* (开机时长永远小于 epoch 时间), 于是超时永远"已经过了" —— worker
|
||||
* 变成疯狂重绘的忙循环, 把 pty 刷屏. */
|
||||
@@ -186,7 +186,7 @@ static void test_frame_rate_is_bounded(void)
|
||||
|
||||
/* spinner_notice 会先停、打印一条提示、再**接着转**. 恢复时它传的是 g_label
|
||||
* 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label, 于是
|
||||
* 标签被自己清空、退化成默认的"思考中". 症状:每次 HTTP 重试退避之后, 屏幕上
|
||||
* 标签被自己清空、退化成默认的"思考中". 症状: 每次 HTTP 重试退避之后, 屏幕上
|
||||
* 的"等待响应"都会变成"思考中", 而这两段等待的含义恰恰是它要区分的. */
|
||||
static void test_notice_keeps_label(void)
|
||||
{
|
||||
@@ -213,7 +213,7 @@ static void test_notice_keeps_label(void)
|
||||
/* 提示之后剩下的帧里必须还是原来那个标签 */
|
||||
if (noticed)
|
||||
CHECK(strstr(noticed, "自定义标签A") != NULL,
|
||||
"notice 之后标签被改写了 (应保持\"自定义标签A\"):\n%.300s",
|
||||
"notice 之后标签被改写了 (应保持\"自定义标签A\"): \n%.300s",
|
||||
noticed);
|
||||
}
|
||||
|
||||
|
||||
+30
-30
@@ -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;
|
||||
@@ -158,7 +158,7 @@ int main(void)
|
||||
CHECK(err == 1, "读不存在的文件应报错: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- edit_file:替换由行号钉住 ---- */
|
||||
/* ---- edit_file: 替换由行号钉住 ---- */
|
||||
puts("edit_file");
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\","
|
||||
@@ -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);
|
||||
@@ -180,7 +180,7 @@ int main(void)
|
||||
CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 越界行号:要连文件总行数一起说清楚 */
|
||||
/* 越界行号: 要连文件总行数一起说清楚 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\","
|
||||
"\"new_string\":\"y\"}", &err);
|
||||
@@ -188,7 +188,7 @@ int main(void)
|
||||
"越界行号应报错并给出总行数: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 唯一性只要求**在这一行里**唯一:同一个词出现在别的行不该拦住替换
|
||||
/* 唯一性只要求**在这一行里**唯一: 同一个词出现在别的行不该拦住替换
|
||||
* (这正是"全文唯一"最烦人的地方——文件一长, 随便一个短串都不唯一) */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\","
|
||||
@@ -223,16 +223,16 @@ int main(void)
|
||||
CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- edit_file:line 是集合型 ----
|
||||
/* ---- edit_file: line 是集合型 ----
|
||||
* 集合是**搜索范围** (old_string 的起点落在其中任一行即可), 不是
|
||||
* "每行都改一次":真改动的仍是唯一那一处. */
|
||||
puts("edit_file:line 集合");
|
||||
* "每行都改一次": 真改动的仍是唯一那一处. */
|
||||
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);
|
||||
@@ -264,7 +264,7 @@ int main(void)
|
||||
"集合里多处匹配应拒绝: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 重复的行号要去重:不然同一处匹配会被数成两处 */
|
||||
/* 重复的行号要去重: 不然同一处匹配会被数成两处 */
|
||||
r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err);
|
||||
CHECK(err == 0, "写入 dup3.txt: %s", r);
|
||||
free(r);
|
||||
@@ -282,7 +282,7 @@ int main(void)
|
||||
"集合里的越界行号应报错并给出总行数: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 写法本身不对的三种:空数组、非整数元素、一次给太多 */
|
||||
/* 写法本身不对的三种: 空数组、非整数元素、一次给太多 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"set.txt\",\"line\":[],\"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,7 +348,7 @@ int main(void)
|
||||
CHECK(err == 1, "不存在的目录应报错: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep,
|
||||
/* 这两个工具都已经删掉: 搜索交给模型自己用 run_command 里的真 grep,
|
||||
* 子代理 (spawn_subagent)整个功能也去掉了. 留着断言是为了钉住
|
||||
* "不要再冒出一个半吊子的替代品". */
|
||||
r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err);
|
||||
@@ -362,7 +362,7 @@ int main(void)
|
||||
puts("超大文件防护");
|
||||
{
|
||||
/* 稀疏文件, 创建几乎不耗时; 关键是客户端要在读之前就发现它太大.
|
||||
* 开头 16 KB 写成 'x':这样它是"文本" (探测的前 8 KB 里没有 NUL),
|
||||
* 开头 16 KB 写成 'x': 这样它是"文本" (探测的前 8 KB 里没有 NUL),
|
||||
* 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到
|
||||
* "分段读大文件"了. */
|
||||
FILE *big = fopen("huge.txt", "wb");
|
||||
@@ -389,7 +389,7 @@ int main(void)
|
||||
unlink("huge.txt");
|
||||
}
|
||||
|
||||
/* ---- 二进制文件:所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */
|
||||
/* ---- 二进制文件: 所有处理文本的工具都必须拒绝 (而且要说明该用什么) ---- */
|
||||
puts("二进制拒绝");
|
||||
{
|
||||
FILE *bf = fopen("bin.dat", "wb");
|
||||
@@ -447,18 +447,18 @@ int main(void)
|
||||
CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理");
|
||||
CHECK(tool_mutating(NULL) == 0, "空名字不应崩");
|
||||
|
||||
/* ---- read_image:只收真的能内联进去的图 ----
|
||||
/* ---- read_image: 只收真的能内联进去的图 ----
|
||||
*
|
||||
* 两条拒绝路径是新的:格式不认识、体积超限. 它们都必须**不备份** ——
|
||||
* 两条拒绝路径是新的: 格式不认识、体积超限. 它们都必须**不备份** ——
|
||||
* 一张交不出去的图, 留备份只会让 ligent.archive 变成垃圾场. */
|
||||
puts("read_image:本地就把不合格的图拦掉");
|
||||
puts("read_image: 本地就把不合格的图拦掉");
|
||||
{
|
||||
char btmp[] = "/tmp/ligent_tools_img_XXXXXX";
|
||||
char *bdir = mkdtemp(btmp);
|
||||
CHECK(bdir != NULL, "建备份临时目录失败");
|
||||
if (bdir) {
|
||||
image_set_bindir(bdir);
|
||||
image_pending_take(); /* 清干净, 免得上一段残留 */
|
||||
image_pending_take(); /* 清干净, 以免上一段残留 */
|
||||
|
||||
/* 1) 内容是文本、扩展名骗人 */
|
||||
FILE *f = fopen("notanimage.png", "wb");
|
||||
@@ -469,7 +469,7 @@ int main(void)
|
||||
CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的");
|
||||
free(r);
|
||||
|
||||
/* 2) 体积超限:base64 后超过 IMAGE_MAX_B64.
|
||||
/* 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");
|
||||
@@ -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);
|
||||
@@ -519,7 +519,7 @@ int main(void)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ----
|
||||
/* ---- 超长单行: read_file 报出的行号必须与 edit_file 的口径一致 ----
|
||||
*
|
||||
* read_file 以前用 fgets + 固定 64 KB 缓冲, 一行比缓冲长时会被切成"多行"、
|
||||
* 每块各带一个行号. 于是模型按 read_file 看到的行号去 edit_file 改, 要么改错
|
||||
@@ -537,7 +537,7 @@ 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);
|
||||
@@ -553,7 +553,7 @@ 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);
|
||||
@@ -562,14 +562,14 @@ int main(void)
|
||||
fclose(hf);
|
||||
r = call("read_file", "{\"path\":\"hugeline.txt\"}", &err);
|
||||
CHECK(strstr(r, "第 1 行本身就超过") != NULL,
|
||||
"应说清是这一行超过上限:%.160s", r);
|
||||
"应说清是这一行超过上限: %.160s", r);
|
||||
CHECK(strstr(r, " 2\t") == NULL, "不能把剩下的半个行伪装成第 2 行");
|
||||
free(r);
|
||||
remove("hugeline.txt");
|
||||
remove("longline.txt");
|
||||
}
|
||||
|
||||
/* ---- 空目录:qsort 的第一个参数会拿到 NULL ---- */
|
||||
/* ---- 空目录: qsort 的第一个参数会拿到 NULL ---- */
|
||||
puts("list_dir 空目录");
|
||||
if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ }
|
||||
r = call("list_dir", "{\"path\":\"emptydir\"}", &err);
|
||||
|
||||
Reference in new issue
Block a user