init
This commit is contained in:
commit
e54390a9f8
85 files changed
+29923
No files matched your search
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python3
|
||||
"""confirm_check.py - 确认提示上的每一种回答都必须被正确处理
|
||||
|
||||
重点是 Ctrl-C。
|
||||
|
||||
`confirm_tool` 用 fgets 读输入,而 SIGINT 会让 fgets 以 EINTR 失败、返回
|
||||
NULL;代码把 NULL 当成"没人能回答,按默认值放行"。于是**在确认提示
|
||||
上按 Ctrl-C 等于批准这次操作**。run_command 侥幸自己查了中断标志才没执行,
|
||||
write_file / edit_file 没查,文件照写。
|
||||
|
||||
这个脚本把三种回答都跑一遍,直接看磁盘上有没有留下东西:
|
||||
Ctrl-C -> 必须拒绝
|
||||
n -> 必须拒绝
|
||||
y -> 必须执行(否则就是"修过头"了)
|
||||
|
||||
用法:
|
||||
confirm_check.py <ligent 二进制> <mock base-url>
|
||||
"""
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 探针文件放在 build/ 下,别弄脏仓库根目录
|
||||
# WRITE_TEST 写这一个;WRITE2_TEST 写 PROBE1 / PROBE2(一轮里两次)
|
||||
PROBE = "build/tmp/confirm_probe.txt"
|
||||
PROBE1 = "build/tmp/confirm_probe1.txt"
|
||||
PROBE2 = "build/tmp/confirm_probe2.txt"
|
||||
# 只认稳定前缀:选项列表是会变的(现在是 Y/N/Always/neVer),
|
||||
# 写死整串会在改提示文案时把测试弄坏。
|
||||
MARK = "继续?"
|
||||
PROMPT_READY = b"\x1b[?2004h"
|
||||
|
||||
|
||||
def run_case(binary, base, answer, wait_confirm=6.0):
|
||||
"""跑一轮:触发 write_file → 等确认提示出现 → 回答 → 看文件在不在。"""
|
||||
try:
|
||||
os.unlink(PROBE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
os.environ["LIGENT_API_KEY"] = "sk-test"
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execv(binary, [binary, "--base-url", base, "--no-color",
|
||||
"--confirm", "--no-auto-save", "--no-history"])
|
||||
os._exit(127)
|
||||
|
||||
out = bytearray()
|
||||
|
||||
def pump(seconds, idle=0.3):
|
||||
"""读到"安静"或超时为止"""
|
||||
end = time.time() + seconds
|
||||
last = time.time()
|
||||
while time.time() < end:
|
||||
r, _, _ = select.select([fd], [], [], 0.02)
|
||||
if r:
|
||||
try:
|
||||
d = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return False
|
||||
if not d:
|
||||
return False
|
||||
out.extend(d)
|
||||
last = time.time()
|
||||
elif time.time() - last > idle:
|
||||
return True
|
||||
return True
|
||||
|
||||
def wait_for(text, seconds):
|
||||
"""一直读到 text 出现(或超时)。按标记等,不靠 sleep 猜。"""
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
if text.encode("utf-8") in bytes(out):
|
||||
return True
|
||||
r, _, _ = select.select([fd], [], [], 0.05)
|
||||
if r:
|
||||
try:
|
||||
d = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return False
|
||||
if not d:
|
||||
return False
|
||||
out.extend(d)
|
||||
return text.encode("utf-8") in bytes(out)
|
||||
|
||||
pump(1.2) # 横幅 + 第一个提示符
|
||||
os.write(fd, "WRITE_TEST 写个文件\n".encode())
|
||||
got_prompt = wait_for(MARK, wait_confirm)
|
||||
if got_prompt:
|
||||
os.write(fd, answer)
|
||||
pump(1.5)
|
||||
os.write(fd, b"\x03")
|
||||
pump(0.3)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
return got_prompt, os.path.exists(PROBE)
|
||||
|
||||
|
||||
def run_turn_scope(binary, base):
|
||||
"""Always / neVer 的作用域必须是"这一轮",不是整个会话。
|
||||
|
||||
一轮里让模型连写两个文件:第一次问、答 Always,第二次不该再问。
|
||||
然后开新的一轮(下一条用户消息),必须**重新问** —— 会话级的开关是
|
||||
/yolo,不是这个。
|
||||
|
||||
全程按标记同步("继续?"出现 / 回到提示符),不靠 sleep 猜时间。
|
||||
"""
|
||||
for p in (PROBE1, PROBE2):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
os.environ["LIGENT_API_KEY"] = "sk-test"
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execv(binary, [binary, "--base-url", base, "--no-color",
|
||||
"--confirm", "--no-auto-save", "--no-history"])
|
||||
os._exit(127)
|
||||
|
||||
out = bytearray()
|
||||
state = {"ready": 0, "tail": b""}
|
||||
READY = b"\x1b[?2004h" # 行编辑器每次回到提示符都会发它
|
||||
|
||||
def note(chunk):
|
||||
buf = state["tail"] + chunk
|
||||
state["ready"] += buf.count(READY)
|
||||
state["tail"] = buf[-(len(READY) - 1):]
|
||||
|
||||
def pump(seconds, idle=0.3):
|
||||
end = time.time() + seconds
|
||||
last = time.time()
|
||||
while time.time() < end:
|
||||
r, _, _ = select.select([fd], [], [], 0.02)
|
||||
if r:
|
||||
try:
|
||||
d = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return
|
||||
if not d:
|
||||
return
|
||||
out.extend(d)
|
||||
note(d)
|
||||
last = time.time()
|
||||
elif time.time() - last > idle:
|
||||
return
|
||||
|
||||
def wait_for(pred, seconds):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
if pred():
|
||||
return True
|
||||
r, _, _ = select.select([fd], [], [], 0.03)
|
||||
if r:
|
||||
try:
|
||||
d = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return False
|
||||
if not d:
|
||||
return False
|
||||
out.extend(d)
|
||||
note(d)
|
||||
return pred()
|
||||
|
||||
def has_prompt():
|
||||
return "继续?".encode("utf-8") in bytes(out)
|
||||
|
||||
def prompt_count():
|
||||
return bytes(out).count("继续?".encode("utf-8"))
|
||||
|
||||
pump(1.2)
|
||||
|
||||
# ---- 第一轮:连写两个 ----
|
||||
base_ready = state["ready"]
|
||||
os.write(fd, "WRITE2_TEST 写两个文件\n".encode())
|
||||
wait_for(has_prompt, 8.0)
|
||||
os.write(fd, b"Always\n")
|
||||
# 等这一轮彻底跑完(回到提示符),别用固定时长猜
|
||||
wait_for(lambda: state["ready"] > base_ready, 10.0)
|
||||
pump(0.3)
|
||||
first_turn_prompts = prompt_count()
|
||||
both_written = os.path.exists(PROBE1) and os.path.exists(PROBE2)
|
||||
|
||||
|
||||
# ---- 第二轮:换一条用户消息,必须重新问 ----
|
||||
base_ready = state["ready"]
|
||||
before = prompt_count()
|
||||
os.write(fd, "WRITE_LAST_TEST 再来一轮\n".encode())
|
||||
got_again = wait_for(lambda: prompt_count() > before, 8.0)
|
||||
second_prompts = prompt_count() - before
|
||||
os.write(fd, b"N\n")
|
||||
pump(1.0)
|
||||
|
||||
os.write(fd, b"\x03")
|
||||
pump(0.3)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for p in (PROBE1, PROBE2):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
return first_turn_prompts, both_written, got_again, second_prompts
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: confirm_check.py <二进制> <base-url>")
|
||||
return 2
|
||||
binary, base = sys.argv[1], sys.argv[2]
|
||||
os.makedirs(os.path.dirname(PROBE), exist_ok=True)
|
||||
|
||||
fails = 0
|
||||
checks = 0
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
nonlocal fails, checks
|
||||
checks += 1
|
||||
if ok:
|
||||
print(" ✓ %s" % name)
|
||||
else:
|
||||
fails += 1
|
||||
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
|
||||
|
||||
# 三种回答:(说明, 发送的字节, 期望文件是否被写)
|
||||
cases = [
|
||||
("在确认提示上按 Ctrl-C", b"\x03", False),
|
||||
("答 n", b"n\n", False),
|
||||
("答 y", b"y\n", True),
|
||||
]
|
||||
|
||||
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 "没被写入"),
|
||||
written == expect_written,
|
||||
"实际 %s" % ("写了" if written else "没写"))
|
||||
|
||||
# 作用域:Always 只活这一轮
|
||||
n1, both, again, n2 = run_turn_scope(binary, base)
|
||||
check("Always:同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1)
|
||||
check("Always:同轮的第二次改动被放行", both, "第二个文件没写")
|
||||
check("新一轮会重新询问(作用域仅限本轮)", again and n2 >= 1,
|
||||
"新一轮没有询问")
|
||||
|
||||
for p in (PROBE, PROBE1, PROBE2):
|
||||
try:
|
||||
os.unlink(p)
|
||||
except OSError:
|
||||
pass
|
||||
# 这里**不要** rmtree("build/tmp"):那是 e2e 各段共用的临时目录,删掉它
|
||||
# 会连带清掉别人放进去的东西(我就因此丢过两次日志),而它自己的三个文件
|
||||
# 上面已经删干净了。测试辅助脚本只该收拾自己造的东西。
|
||||
|
||||
print("\n%d 项检查,%d 项失败" % (checks, fails))
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
# ligent 收发记录 —— 导出 2026-09-21 17:09:35,共 5981 字节
|
||||
|
||||
===== 2026-09-21 17:09:35 =====
|
||||
请求: POST http://127.0.0.1:8882/v1/chat/completions
|
||||
----- 请求体 (5533 字节) -----
|
||||
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容,含文件类型与大小。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径,默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看(截图、设计稿、图表、Line truncated
|
||||
----- 响应头 -----
|
||||
HTTP/1.0 400 Bad Request
|
||||
Server: BaseHTTP/0.6 Python/3.14.7
|
||||
Date: Mon, 21 Sep 2026 09:09:35 GMT
|
||||
Content-Type: application/json
|
||||
Content-Length: 112
|
||||
|
||||
{"error": {"message": "mock \u6a21\u62df\u7684\u670d\u52a1\u7aef\u9519\u8bef", "type": "invalid_request_error"}}
|
||||
----- 结束: HTTP 400 -----
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Vendored
+76
@@ -0,0 +1,76 @@
|
||||
# ligent 收发记录 —— 导出 2026-09-21 17:09:24,共 19586 字节
|
||||
|
||||
===== 2026-09-21 17:09:24 =====
|
||||
请求: POST http://127.0.0.1:8881/v1/chat/completions
|
||||
----- 请求体 (5508 字节) -----
|
||||
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容,含文件类型与大小。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"目录路径,默认当前目录"}},"required":[]}}},{"type":"function","function":{"name":"read_image","description":"读取一张本地图片并把它交给你看(截图、设计稿、图表、报错弹窗都行)。图Line truncated
|
||||
----- 响应头 -----
|
||||
HTTP/1.0 200 OK
|
||||
Server: BaseHTTP/0.6 Python/3.14.7
|
||||
Date: Mon, 21 Sep 2026 09:09:24 GMT
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": {"name": "run_command", "arguments": ""}}]}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\"command\": \"echo"}}]}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": " hello-from-tool\"}"}}]}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
|
||||
----- 结束: HTTP 200 -----
|
||||
|
||||
===== 2026-09-21 17:09:24 =====
|
||||
请求: POST http://127.0.0.1:8881/v1/chat/completions
|
||||
----- 请求体 (5765 字节) -----
|
||||
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","function":{"name":"list_dir","description":"列出目录内容,含文件类型与大小。","parameters":{"type":"object","properties":{"path":Line truncated
|
||||
----- 响应头 -----
|
||||
HTTP/1.0 200 OK
|
||||
Server: BaseHTTP/0.6 Python/3.14.7
|
||||
Date: Mon, 21 Sep 2026 09:09:24 GMT
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"content": "FINAL: hello-from-t"}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"content": "ool / [exit code: 0]"}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
|
||||
----- 结束: HTTP 200 -----
|
||||
|
||||
===== 2026-09-21 17:09:24 =====
|
||||
请求: POST http://127.0.0.1:8881/v1/chat/completions
|
||||
----- 请求体 (5905 字节) -----
|
||||
{"model":"deepseek-flash","messages":[{"role":"system","content":"你是用户的 AI 助手\n\n你可以调用工具来读写文件和执行命令。\n工作原则:\n1. 先观察再动手:不确定文件内容或目录结构时,先用 list_dir / read_file 查看,不要凭猜测写代码。搜内容用 run_command(如 grep -rn 模式 目录)。\n2. 修改已有文件优先用 edit_file:它**必须**带上 read_file 显示的行号 line,替换只发生在那一行里。只有创建新文件或需要整体重写时才用 write_file。\n3. 工具报错时,先读错误信息定位原因,再决定换一种方式;不要重复执行同一个失败的命令。\n4. 回答用中文,简洁直接,不要把大段代码重复贴回来。完成任务后用一两句话说明改了什么、验证结果如何。\n5. 不要执行破坏性的操作(例如删除用户数据、强制推送),除非用户明确要求。\n6. read_file / write_file / edit_file 只处理文本:二进制文件会被拒绝。看图片用 read_image,看其它二进制用 run_command(file / xxd / strings)。\n\n除非必要, 否则不要执行命令.\n除非用户说明, 不得直接或间接(如使用curl)获取任何在线资源.\n"},{"role":"user","content":"please run it"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"run_command","arguments":"{\"command\": \"echo hello-from-tool\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"hello-from-tool\n[exit code: 0]"},{"role":"assistant","content":"FINAL: hello-from-tool / [exit code: 0]"},{"role":"user","content":"ERROR_TEST 制造一个服务端错误"}],"tools":[{"type":"function","function":{"name":"run_command","description":"在工作目录中执行 shell 命令,返回合并后的 stdout/stderr 与退出码。适合编译、运行测试、git 操作等。","parameters":{"type":"object","properties":{"command":{"type":"string","description":"要执行的 shell 命令(由 /bin/sh -c 执行,支持管道与重定向)"},"elevated":{"type":"boolean","description":"置 true 时以 root 执行这一条命令。**只在真的撞到权限错误之后才用**,不要预防性地加。加了之后:你会先被要求确认(看清要跑什么),再由 sudo 向用户要密码 —— 密码是用户敲的,你敲不出来。所以被拒绝时不要反复重试,说明你需要用户亲自做什么即可。"},"timeout":{"type":"integer","description":"超时秒数,默认 60,最大 600"}},"required":["command"]}}},{"type":"function","function":{"name":"read_file","description":"读取文本文件内容,返回带行号的文本;行号可以直接喂给 edit_file 的 line。大文件请用 offset/limit 分段读取。**二进制文件会被拒绝**(它只处理文本)。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径,相对于工作目录或绝对路径"},"offset":{"type":"integer","description":"起始行号(从 1 开始),默认 1"},"limit":{"type":"integer","description":"最多读取的行数,默认 2000"}},"required":["path"]}}},{"type":"function","function":{"name":"write_file","description":"创建或整体覆盖一个文本文件(会覆盖原有内容)。修改已有文件优先用 edit_file。目标已经是二进制文件时会被拒绝覆盖。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"文件路径;父目录不存在会自动创建。**已经存在的二进制文件会被拒绝覆盖**"},"content":{"type":"string","description":"要写入的完整文件内容"}},"required":["path","content"]}}},{"type":"function","function":{"name":"edit_file","description":"在文件中做精确字符串替换。**必须给 line**(read_file 打出来的行号):替换只发生在那一行里,old_string 也只需在那一行里唯一 —— 不必为了\"全文唯一\"把 old_string 写得很长。行号对不上会报出 old_string 真正在哪几行,照着改 line 即可。","parameters":{"type":"object","properties":{"path":{"type":"string","description":"要修改的文件路径"},"line":{"type":"integer","description":"old_string 所在的行号,**必填**。就是 read_file 打出来的那个行号(从 1 开始)—— 没见过这个文件就先 read_file 看一眼。替换只会发生在这一行里。"},"old_string":{"type":"string","description":"被替换的原文,必须与文件内容逐字符一致(含缩进),且**在 line 那一行里**只有一处匹配(匹配可以跨行,起点落在该行即可)"},"new_string":{"type":"string","description":"替换后的新文本"},"replace_all":{"type":"boolean","description":"为 true 时替换**全文件**里所有匹配(line 仍必须指向其中真实的一处),默认 false(只替换 line 那一行上的那一处)"}},"required":["path","line","old_string","new_string"]}}},{"type":"function","functLine truncated
|
||||
----- 响应头 -----
|
||||
HTTP/1.0 200 OK
|
||||
Server: BaseHTTP/0.6 Python/3.14.7
|
||||
Date: Mon, 21 Sep 2026 09:09:24 GMT
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"content": "FINAL: hello-from-t"}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {"content": "ool / [exit code: 0]"}, "finish_reason": null}]}
|
||||
|
||||
data: {"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
|
||||
----- 结束: HTTP 200 -----
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
/* fuzz.c - 对抗性模糊测试:JSON 解析器 + Markdown 渲染器
|
||||
*
|
||||
* 这两个模块是"解析不可信输入"的地方(服务端响应、模型输出),
|
||||
* 也是最可能出崩溃/越界/泄漏的地方。所以用随机输入去砸它们。
|
||||
*
|
||||
* 检查的不变量:
|
||||
* JSON
|
||||
* 1. 任何输入都不能崩溃、不能泄漏(靠 ASan/UBSan/LeakSanitizer)
|
||||
* 2. 解析成功 => dump 再 parse 再 dump,两次 dump 必须一致(幂等)
|
||||
* 3. 随机生成的合法 JSON 必须解析成功并完整往返
|
||||
* Markdown
|
||||
* 4. 任何输入都不能崩溃、不能泄漏
|
||||
* 5. 分片不变性:一次性喂 与 任意分片喂 的输出必须逐字节一致
|
||||
* 6. 不含任何元字符的输入必须原样输出
|
||||
*
|
||||
* 用法: fuzz [迭代次数] [随机种子]
|
||||
*/
|
||||
#include "json.h"
|
||||
#include "markdown.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
static uint64_t RNG;
|
||||
|
||||
static uint32_t rnd(void)
|
||||
{
|
||||
RNG ^= RNG << 13;
|
||||
RNG ^= RNG >> 7;
|
||||
RNG ^= RNG << 17;
|
||||
return (uint32_t)(RNG >> 32);
|
||||
}
|
||||
|
||||
static uint32_t rnd_below(uint32_t n) { return n ? rnd() % n : 0; }
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* JSON */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static const char *STR_POOL[] = {
|
||||
"", "a", "hello", "中文", "😀", "line\nbreak", "tab\there", "quote\"x",
|
||||
"back\\slash", "ctrl\x01\x1f", "surrogate\u00e9", "aaaaaaaaaaaaaaaaaaaa",
|
||||
"/", "\x7f", "\xc3\xa9\xe4\xb8\xad",
|
||||
};
|
||||
|
||||
static void gen_json_string(StrBuf *sb)
|
||||
{
|
||||
const char *s = STR_POOL[rnd_below(sizeof STR_POOL / sizeof STR_POOL[0])];
|
||||
char *q = json_quote_string(s);
|
||||
sb_puts(sb, q);
|
||||
free(q);
|
||||
}
|
||||
|
||||
/* 递归生成合法 JSON */
|
||||
static void gen_json(StrBuf *sb, int depth)
|
||||
{
|
||||
uint32_t kind = rnd_below(depth > 3 ? 4 : 6);
|
||||
switch (kind) {
|
||||
case 0: sb_puts(sb, "null"); break;
|
||||
case 1: sb_puts(sb, rnd() & 1 ? "true" : "false"); break;
|
||||
case 2: {
|
||||
char buf[64];
|
||||
int mode = (int)rnd_below(3);
|
||||
if (mode == 0) snprintf(buf, sizeof buf, "%d", (int)rnd() - 1000);
|
||||
else if (mode == 1) snprintf(buf, sizeof buf, "%.6g", (double)rnd() / 7.0);
|
||||
else snprintf(buf, sizeof buf, "%de%d", (int)rnd_below(1000), (int)rnd_below(20) - 10);
|
||||
sb_puts(sb, buf);
|
||||
break;
|
||||
}
|
||||
case 3: gen_json_string(sb); break;
|
||||
case 4: {
|
||||
sb_putc(sb, '[');
|
||||
uint32_t n = rnd_below(5);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
if (i) sb_putc(sb, ',');
|
||||
gen_json(sb, depth + 1);
|
||||
}
|
||||
sb_putc(sb, ']');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
sb_putc(sb, '{');
|
||||
uint32_t n = rnd_below(5);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
if (i) sb_putc(sb, ',');
|
||||
gen_json_string(sb);
|
||||
sb_putc(sb, ':');
|
||||
gen_json(sb, depth + 1);
|
||||
}
|
||||
sb_putc(sb, '}');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 返回 1 表示这一轮失败 */
|
||||
static int fuzz_json_once(char *err, size_t errlen)
|
||||
{
|
||||
StrBuf sb;
|
||||
sb_init(&sb);
|
||||
|
||||
int mutate = (int)rnd_below(2);
|
||||
if (mutate) {
|
||||
/* 变异模式:先造合法 JSON,再随机破坏 */
|
||||
gen_json(&sb, 0);
|
||||
size_t n = sb.len;
|
||||
if (n == 0) { sb_free(&sb); return 0; }
|
||||
/* 每次突变都必须基于"当前"长度。早期版本用了突变前记下的旧长度,
|
||||
* 截断之后再按下标写就会覆盖掉结尾的 '\0',后续 strlen 冲出分配区,
|
||||
* 让 ASan 在 json_parse 里报出假阳性 —— 模糊测试器自己不可信比没有更糟。 */
|
||||
uint32_t muts = 1 + rnd_below(4);
|
||||
for (uint32_t i = 0; i < muts && sb.len > 0; i++) {
|
||||
uint32_t op = rnd_below(3);
|
||||
if (op == 0) { /* 翻一个字节(下标恒 < len) */
|
||||
sb.data[rnd_below((uint32_t)sb.len)] = (char)(rnd() & 0xFF);
|
||||
} else if (op == 1) { /* 截断 */
|
||||
sb.len = rnd_below((uint32_t)sb.len);
|
||||
} else { /* 插入一个字节 */
|
||||
if (sb.len < 4096) {
|
||||
size_t pos = rnd_below((uint32_t)sb.len + 1);
|
||||
sb_reserve(&sb, 1);
|
||||
memmove(sb.data + pos + 1, sb.data + pos, sb.len - pos + 1);
|
||||
sb.data[pos] = (char)"{}[]\",:0123456789.eE+-truefalsn"[rnd_below(30)];
|
||||
sb.len++;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* 无论怎么突变,结尾符必须是好的 */
|
||||
if (sb.data) sb.data[sb.len] = '\0';
|
||||
n = sb.len;
|
||||
} else {
|
||||
/* 纯随机字节 */
|
||||
uint32_t n = rnd_below(256);
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
/* 偏向 JSON 元字符,提高触发率 */
|
||||
static const char meta[] = "{}[]\",:0123456789.eE+-truefalsn \t\n\\/u";
|
||||
char c = (rnd() & 3) ? meta[rnd_below(sizeof meta - 1)]
|
||||
: (char)(rnd() & 0xFF);
|
||||
sb_putc(&sb, c);
|
||||
}
|
||||
}
|
||||
|
||||
const char *input = sb.data ? sb.data : "";
|
||||
size_t inlen = sb.len;
|
||||
|
||||
/* 输入里不能有 NUL,否则不是合法的 C 字符串测试对象 */
|
||||
for (size_t i = 0; i < inlen; i++) {
|
||||
if (input[i] == '\0') {
|
||||
sb_free(&sb);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 交给解析器的必须是真正 NUL 结尾的缓冲区 */
|
||||
if (strlen(input) != inlen) {
|
||||
snprintf(err, errlen, "测试器自身出错:缓冲区未正确结尾");
|
||||
sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
|
||||
JsonValue *v = json_parse(input, err, errlen);
|
||||
if (v) {
|
||||
char *d1 = json_dump(v);
|
||||
char *d2 = NULL;
|
||||
JsonValue *v2 = json_parse(d1, err, errlen);
|
||||
if (!v2) {
|
||||
snprintf(err, errlen, "解析成功的 JSON 却无法重新解析:%.120s", d1);
|
||||
free(d1);
|
||||
json_free(v);
|
||||
sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
d2 = json_dump(v2);
|
||||
if (strcmp(d1, d2) != 0) {
|
||||
snprintf(err, errlen, "dump 不幂等:\n 输入 %.200s\n 一次 %.200s\n 二次 %.200s",
|
||||
input, d1, d2);
|
||||
free(d1); free(d2);
|
||||
json_free(v); json_free(v2);
|
||||
sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
free(d1); free(d2);
|
||||
json_free(v2);
|
||||
json_free(v);
|
||||
}
|
||||
sb_free(&sb);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Markdown */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
#define MD_RENDER_LIMIT (1u << 20)
|
||||
|
||||
/* 渲染到内存;chunk==0 表示一次性 */
|
||||
static char *md_render(const char *src, size_t len, size_t chunk)
|
||||
{
|
||||
char *buf = NULL;
|
||||
size_t size = 0;
|
||||
FILE *f = open_memstream(&buf, &size);
|
||||
if (!f) return NULL;
|
||||
|
||||
MdRenderer *r = md_new(1);
|
||||
md_set_out(r, f);
|
||||
if (chunk == 0) {
|
||||
md_feed(r, src);
|
||||
} else {
|
||||
for (size_t i = 0; i < len; i += chunk) {
|
||||
size_t n = len - i < chunk ? len - i : chunk;
|
||||
char *tmp = xstrndup(src + i, n);
|
||||
md_feed(r, tmp);
|
||||
free(tmp);
|
||||
}
|
||||
}
|
||||
md_finish(r);
|
||||
md_free(r);
|
||||
fclose(f);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int markdown_metachar(int c)
|
||||
{
|
||||
/* 这些字符会触发渲染器改写输出;其余字符必须原样通过 */
|
||||
return c == '*' || c == '_' || c == '`' || c == '[' || c == ']' ||
|
||||
c == '#' || c == '>' || c == '-' || c == '+' || c == '|' ||
|
||||
c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
static int fuzz_md_once(char *err, size_t errlen)
|
||||
{
|
||||
StrBuf sb;
|
||||
sb_init(&sb);
|
||||
|
||||
static const char alpha[] =
|
||||
"#*_-+>`[]|\\ \tabc123.:/~\n\r\u4e2d\U0001f600\"'()=;,\xc3\xa9";
|
||||
size_t alen = sizeof alpha - 1;
|
||||
|
||||
uint32_t n = rnd_below(400);
|
||||
int pure = (int)rnd_below(4) == 0; /* 1/4 概率生成"无元字符"的干净文本 */
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
char c;
|
||||
if (pure) {
|
||||
do {
|
||||
c = alpha[rnd_below((uint32_t)alen)];
|
||||
} while (markdown_metachar((unsigned char)c) || c == '\0');
|
||||
} else {
|
||||
uint32_t mode = rnd_below(10);
|
||||
if (mode < 7) c = alpha[rnd_below((uint32_t)alen)];
|
||||
else if (mode < 9) c = (char)(0x20 + rnd_below(0x5F));
|
||||
else c = (char)(rnd() & 0xFF); /* 偶尔来点非法 UTF-8 */
|
||||
}
|
||||
if (c == '\0') c = 'x';
|
||||
sb_putc(&sb, c);
|
||||
}
|
||||
|
||||
const char *src = sb.data ? sb.data : "";
|
||||
size_t len = sb.len;
|
||||
if (len == 0) { sb_free(&sb); return 0; }
|
||||
|
||||
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 字节",
|
||||
len, strlen(whole));
|
||||
free(whole); sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* 分片不变性 */
|
||||
size_t chunks[4];
|
||||
for (int k = 0; k < 4; k++) chunks[k] = 1 + rnd_below(8);
|
||||
for (int k = 0; k < 4; k++) {
|
||||
char *part = md_render(src, len, chunks[k]);
|
||||
if (!part) continue;
|
||||
if (strcmp(whole, part) != 0) {
|
||||
snprintf(err, errlen,
|
||||
"分片不变性被破坏(%zu 字节/片)",
|
||||
chunks[k]);
|
||||
/* 落盘,方便用 build/reduce 做最小化 */
|
||||
FILE *fp = fopen("build/md_fail.bin", "wb");
|
||||
if (fp) { fwrite(src, 1, len, fp); fclose(fp); }
|
||||
free(part); free(whole); sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
free(part);
|
||||
}
|
||||
|
||||
/* 无元字符 => 必须逐字节原样 */
|
||||
if (pure) {
|
||||
if (strcmp(whole, src) != 0) {
|
||||
snprintf(err, errlen, "纯净文本被改写了:\n 输入 %.300s\n 输出 %.300s",
|
||||
src, whole);
|
||||
free(whole); sb_free(&sb);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
free(whole);
|
||||
sb_free(&sb);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
long iters = (argc > 1) ? strtol(argv[1], NULL, 10) : 3000;
|
||||
RNG = (argc > 2) ? strtoull(argv[2], NULL, 10) : 0x9E3779B97F4A7C15ULL;
|
||||
if (!RNG) RNG = 1;
|
||||
|
||||
char err[512];
|
||||
long json_fail = 0, md_fail = 0;
|
||||
|
||||
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);
|
||||
if (++json_fail >= 3) break;
|
||||
}
|
||||
}
|
||||
printf(" JSON %ld 轮,%ld 次失败\n", iters, json_fail);
|
||||
|
||||
for (long i = 0; i < iters; i++) {
|
||||
err[0] = '\0';
|
||||
if (fuzz_md_once(err, sizeof err)) {
|
||||
printf(" ✗ Markdown 第 %ld 轮失败:%s\n", i, err);
|
||||
if (++md_fail >= 3) break;
|
||||
}
|
||||
}
|
||||
printf(" Markdown %ld 轮,%ld 次失败\n", iters, md_fail);
|
||||
|
||||
if (json_fail || md_fail) {
|
||||
printf("\n模糊测试发现 %ld 个问题\n", json_fail + md_fail);
|
||||
return 1;
|
||||
}
|
||||
printf("\n模糊测试通过(无崩溃、无泄漏、不变量全部成立)\n");
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+122
@@ -0,0 +1,122 @@
|
||||
/* reduce.c - 把 fuzz 找到的失败输入最小化到最短复现样例
|
||||
*
|
||||
* 判定(oracle):同一段输入,"一次性喂" 与 "逐字节喂" 的渲染结果不一致。
|
||||
* 用法: build/reduce build/md_fail.bin
|
||||
*/
|
||||
#include "markdown.h"
|
||||
#include "util.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* 注意:md_feed 只接受 NUL 结尾的字符串,而最小化过程中的缓冲区
|
||||
* 是"长度受限"的,所以必须先做一份带结尾符的副本。 */
|
||||
static char *render(const char *src, size_t len, size_t chunk)
|
||||
{
|
||||
char *text = xmalloc(len + 1);
|
||||
memcpy(text, src, len);
|
||||
text[len] = '\0';
|
||||
|
||||
char *buf = NULL;
|
||||
size_t size = 0;
|
||||
FILE *f = open_memstream(&buf, &size);
|
||||
if (!f) { free(text); return NULL; }
|
||||
MdRenderer *r = md_new(1);
|
||||
md_set_out(r, f);
|
||||
if (chunk == 0) {
|
||||
md_feed(r, text);
|
||||
} else {
|
||||
for (size_t i = 0; i < len; i += chunk) {
|
||||
size_t n = len - i < chunk ? len - i : chunk;
|
||||
char *t = xstrndup(text + i, n);
|
||||
md_feed(r, t);
|
||||
free(t);
|
||||
}
|
||||
}
|
||||
md_finish(r);
|
||||
md_free(r);
|
||||
fclose(f);
|
||||
free(text);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* 只要"一次性"与"逐字节"不一致就算失败 */
|
||||
static int fails(const char *src, size_t len)
|
||||
{
|
||||
if (len == 0) return 0;
|
||||
/* md_feed 以 NUL 结尾,输入里不能有 NUL */
|
||||
for (size_t i = 0; i < len; i++) if (src[i] == '\0') return 0;
|
||||
char *a = render(src, len, 0);
|
||||
char *b = render(src, len, 1);
|
||||
int bad = (a && b && strcmp(a, b) != 0);
|
||||
if (bad) {
|
||||
/* 确认真的不是"空输出 vs 空输出"这种假阳性 */
|
||||
if (strcmp(a, b) == 0) bad = 0;
|
||||
}
|
||||
free(a); free(b);
|
||||
return bad;
|
||||
}
|
||||
|
||||
static void show(const char *label, const char *buf, size_t len)
|
||||
{
|
||||
printf("%s (%zu 字节): ", label, len);
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)buf[i];
|
||||
if (c == '\n') printf("\\n");
|
||||
else if (c == '\t') printf("\\t");
|
||||
else if (c == 0x1b) printf("<ESC>");
|
||||
else if (c >= 0x20 && c < 0x7f) putchar(c);
|
||||
else printf("\\x%02x", c);
|
||||
}
|
||||
putchar('\n');
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
if (argc < 2) { fputs("用法: reduce <失败输入文件>\n", stderr); return 2; }
|
||||
size_t len = 0;
|
||||
char *buf = read_entire_file(argv[1], &len);
|
||||
if (!buf) { perror("读取失败"); return 2; }
|
||||
if (!fails(buf, len)) { puts("该输入当前不复现失败"); free(buf); return 1; }
|
||||
|
||||
puts("== 原始失败输入 ==");
|
||||
show(" ", buf, len);
|
||||
{
|
||||
char *a = render(buf, len, 0), *b = render(buf, len, 1);
|
||||
show(" 一次性", a, strlen(a));
|
||||
show(" 逐字节", b, strlen(b));
|
||||
free(a); free(b);
|
||||
}
|
||||
|
||||
/* 贪心块删除 */
|
||||
size_t gran = len / 2;
|
||||
while (gran >= 1) {
|
||||
size_t i = 0;
|
||||
while (i + gran <= len) {
|
||||
char *save = xmalloc(gran);
|
||||
memcpy(save, buf + i, gran);
|
||||
memmove(buf + i, buf + i + gran, len - i - gran);
|
||||
if (fails(buf, len - gran)) {
|
||||
len -= gran;
|
||||
} else {
|
||||
memmove(buf + i + gran, buf + i, len - i - gran);
|
||||
memcpy(buf + i, save, gran);
|
||||
i += gran;
|
||||
}
|
||||
free(save);
|
||||
}
|
||||
if (gran == 1) break;
|
||||
gran /= 2;
|
||||
}
|
||||
|
||||
printf("\n== 最小复现样例 (%zu 字节) ==\n", len);
|
||||
show(" ", buf, len);
|
||||
{
|
||||
char *a = render(buf, len, 0), *b = render(buf, len, 1);
|
||||
show(" 一次性", a, strlen(a));
|
||||
show(" 逐字节", b, strlen(b));
|
||||
free(a); free(b);
|
||||
}
|
||||
free(buf);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""repl_drive.py - 用 pty 驱动 ligent 的 REPL,跑多轮对话并打印全部输出。
|
||||
|
||||
交互式行为(多轮上下文、/命令、行编辑)没法用管道测 —— 管道模式会把
|
||||
整段 stdin 当成一个任务。所以这里开一个伪终端,逐条发送提示词。
|
||||
|
||||
用法:
|
||||
repl_drive.py --bin ./ligent --base-url URL --prompt "第一轮" --prompt "第二轮"
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def drive(binary, args, prompts, wait_per_prompt=2.0, quiet_after=1.5,
|
||||
raw_keys=None):
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execv(binary, [binary] + args)
|
||||
os._exit(127)
|
||||
|
||||
out = bytearray()
|
||||
# 行编辑器每次回到提示符都会打开括号粘贴,所以这个序列就是"轮到用户输入了"
|
||||
# 的信号。用它做同步点,比"等输出安静下来"可靠也快得多。
|
||||
PROMPT_READY = b"\x1b[?2004h"
|
||||
ready_seen = 0
|
||||
tail = b""
|
||||
|
||||
def note(chunk):
|
||||
# 标记可能被 read 切断,所以要连着上一块的尾巴一起找
|
||||
nonlocal tail, ready_seen
|
||||
buf = tail + chunk
|
||||
ready_seen += buf.count(PROMPT_READY)
|
||||
tail = buf[-(len(PROMPT_READY) - 1):]
|
||||
|
||||
def drain(timeout, idle=0.5, until_ready=False):
|
||||
"""等到"回到提示符"(until_ready),或者输出彻底安静下来。
|
||||
|
||||
必须按标记等,不能只按安静等:等待动画每 250ms 画一帧,pty 永远
|
||||
不会安静超过 idle,于是每次都要白等满 timeout —— 而 mock 其实几
|
||||
毫秒就答复了。这是整个 e2e 从 126 秒掉下来的关键。
|
||||
"""
|
||||
want = ready_seen + 1 if until_ready else None
|
||||
deadline = time.time() + timeout
|
||||
last = time.time()
|
||||
while time.time() < deadline:
|
||||
r, _, _ = select.select([fd], [], [], 0.02)
|
||||
if r:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return False
|
||||
if not chunk:
|
||||
return False
|
||||
out.extend(chunk)
|
||||
note(chunk)
|
||||
last = time.time()
|
||||
if want is not None and ready_seen >= want:
|
||||
return True
|
||||
elif time.time() - last > idle:
|
||||
return True
|
||||
return True
|
||||
|
||||
drain(quiet_after, until_ready=True) # 等横幅画完、第一次回到提示符
|
||||
if raw_keys is not None:
|
||||
# 逐字节发送原始按键序列(用于测方向键等编辑行为)
|
||||
for chunk, pause in raw_keys:
|
||||
os.write(fd, chunk if isinstance(chunk, bytes) else chunk.encode("utf-8"))
|
||||
drain(pause)
|
||||
os.write(fd, b"/exit\n")
|
||||
drain(0.8)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", out.decode("utf-8", "replace"))
|
||||
|
||||
for p in prompts:
|
||||
os.write(fd, (p + "\n").encode("utf-8"))
|
||||
if not drain(wait_per_prompt, until_ready=True):
|
||||
break
|
||||
os.write(fd, b"/exit\n")
|
||||
drain(0.8)
|
||||
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
|
||||
text = out.decode("utf-8", "replace")
|
||||
return re.sub(r"\x1b\[[0-9;]*m", "", text) # 去掉 ANSI 样式
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--bin", default="./ligent")
|
||||
ap.add_argument("--base-url", required=True)
|
||||
ap.add_argument("--api-key", default="test")
|
||||
ap.add_argument("--prompt", action="append", default=[])
|
||||
ap.add_argument("--extra", action="append", default=[])
|
||||
ap.add_argument("--wait", type=float, default=2.0)
|
||||
ap.add_argument("--keys", action="append", default=[],
|
||||
help="原始按键序列(支持 \\x1b 转义),按顺序发送")
|
||||
a = ap.parse_args()
|
||||
|
||||
# --no-history:被驱动的实例不该往用户真实的 ligent.history 里写测试垃圾。
|
||||
# 需要历史的调用方用 --extra=--history PATH(排在后面,会覆盖这一条)。
|
||||
args = ["--api-key", a.api_key, "--base-url", a.base_url, "--no-color",
|
||||
"--no-confirm", "--no-auto-save", "--no-history"] + a.extra
|
||||
if a.keys:
|
||||
raw = [(k.encode("utf-8").decode("unicode_escape").encode("latin-1"), 0.25)
|
||||
for k in a.keys]
|
||||
sys.stdout.write(drive(a.bin, args, [], a.wait, raw_keys=raw))
|
||||
else:
|
||||
sys.stdout.write(drive(a.bin, args, a.prompt, a.wait))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+502
@@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env python3
|
||||
"""repl_edit_check.py - 验证 REPL 的行编辑行为(方向键、Home/End、退格、历史)
|
||||
|
||||
只抓字节流是不够的:光标移动是靠转义序列实现的,必须把输出回放到一个
|
||||
终端模型里才能知道"屏幕上最终长什么样"。所以这里带一个极简终端模拟器,
|
||||
只实现本编辑器用到的那几条控制序列。
|
||||
"""
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
COLS = 80
|
||||
ROWS = 200
|
||||
|
||||
|
||||
class Term:
|
||||
"""极简终端模型:只实现 \\r \\n \\b、CSI 光标移动、CSI J/K 清屏"""
|
||||
|
||||
def __init__(self, cols=COLS, rows=ROWS):
|
||||
self.cols, self.rows = cols, rows
|
||||
self.grid = [[' '] * cols for _ in range(rows)]
|
||||
self.row = self.col = 0
|
||||
|
||||
def _clear_from_cursor(self):
|
||||
for c in range(self.col, self.cols):
|
||||
self.grid[self.row][c] = ' '
|
||||
for r in range(self.row + 1, self.rows):
|
||||
for c in range(self.cols):
|
||||
self.grid[r][c] = ' '
|
||||
|
||||
def feed(self, data):
|
||||
i, n = 0, len(data)
|
||||
while i < n:
|
||||
ch = data[i]
|
||||
if ch == '\x1b':
|
||||
j = i + 1
|
||||
if j < n and data[j] in '[O':
|
||||
j += 1
|
||||
start = j
|
||||
# CSI 的参数可以包含 ? < > ! 等私有标记,一律扫到终结符为止
|
||||
while j < n and not ('@' <= data[j] <= '~'):
|
||||
j += 1
|
||||
params = data[start:j]
|
||||
final = data[j] if j < n else ''
|
||||
num = int(params) if params.isdigit() else 1
|
||||
if final == 'A':
|
||||
self.row = max(0, self.row - num)
|
||||
elif final == 'B':
|
||||
self.row = min(self.rows - 1, self.row + num)
|
||||
elif final == 'C':
|
||||
self.col = min(self.cols - 1, self.col + num)
|
||||
elif final == 'D':
|
||||
self.col = max(0, self.col - num)
|
||||
elif final == 'J':
|
||||
self._clear_from_cursor()
|
||||
elif final == 'K':
|
||||
for c in range(self.col, self.cols):
|
||||
self.grid[self.row][c] = ' '
|
||||
i = j + 1
|
||||
continue
|
||||
i += 1
|
||||
continue
|
||||
if ch == '\r':
|
||||
self.col = 0
|
||||
elif ch == '\n':
|
||||
self.row = min(self.rows - 1, self.row + 1)
|
||||
self.col = 0
|
||||
elif ch == '\b':
|
||||
self.col = max(0, self.col - 1)
|
||||
elif ch == '\x07':
|
||||
pass
|
||||
elif ch == '\t':
|
||||
self.col = min(self.cols - 1, (self.col // 8 + 1) * 8)
|
||||
else:
|
||||
self.grid[self.row][self.col] = ch
|
||||
self.col += 1
|
||||
if self.col >= self.cols:
|
||||
self.col = 0
|
||||
self.row = min(self.rows - 1, self.row + 1)
|
||||
i += 1
|
||||
|
||||
def lines(self):
|
||||
return [''.join(r).rstrip() for r in self.grid]
|
||||
|
||||
def nonempty(self):
|
||||
ls = self.lines()
|
||||
last = max((i for i, l in enumerate(ls) if l), default=-1)
|
||||
return ls[:last + 1]
|
||||
|
||||
def last_prompt_line(self):
|
||||
for line in reversed(self.nonempty()):
|
||||
if line.startswith('ligent> '):
|
||||
return line
|
||||
return ''
|
||||
|
||||
|
||||
def to_bytes(s):
|
||||
# 把源码里写的十六进制转义与真实非 ASCII 字符一起转成字节。
|
||||
# 不能直接用 unicode_escape:一条串里既有转义又有中文时,latin-1 那步会报错。
|
||||
if isinstance(s, bytes):
|
||||
return s
|
||||
out = bytearray()
|
||||
i = 0
|
||||
while i < len(s):
|
||||
if s[i] == '\\' and i + 1 < len(s):
|
||||
nxt = s[i + 1]
|
||||
if nxt == 'x' and i + 4 <= len(s):
|
||||
try:
|
||||
out.append(int(s[i + 2:i + 4], 16))
|
||||
i += 4
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
simple = {'n': 10, 'r': 13, 't': 9, '\\': 92}
|
||||
if nxt in simple:
|
||||
out.append(simple[nxt])
|
||||
i += 2
|
||||
continue
|
||||
out.extend(s[i].encode('utf-8'))
|
||||
i += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def run(binary, args, keys, keyset_settle=0.10, settle=0.45, idle=0.03):
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.environ['TERM'] = 'xterm-256color'
|
||||
os.execv(binary, [binary] + args)
|
||||
os._exit(127)
|
||||
|
||||
out = bytearray()
|
||||
# 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"可以开始敲了"的信号
|
||||
PROMPT_READY = b"\x1b[?2004h"
|
||||
ready_seen = 0
|
||||
tail = b""
|
||||
|
||||
def note(chunk):
|
||||
nonlocal tail, ready_seen
|
||||
buf = tail + chunk
|
||||
ready_seen += buf.count(PROMPT_READY)
|
||||
tail = buf[-(len(PROMPT_READY) - 1):]
|
||||
|
||||
def drain(timeout, idle=idle, until_ready=False):
|
||||
"""安静下来就返回,不把整个 timeout 睡满。
|
||||
|
||||
idle 取得很小(0.03s)是有依据的:行编辑器每次按键都在一个 fflush
|
||||
里把整屏重绘写完,所以一次 read 之后基本就没了。而提交输入触发请求
|
||||
时,等待动画每 250ms 一帧,永远不会安静,于是照样等到 timeout ——
|
||||
那条路径的行为和以前一致,不会因为 idle 调小而抢跑。
|
||||
"""
|
||||
want = ready_seen + 1 if until_ready else None
|
||||
end = time.time() + timeout
|
||||
last = time.time()
|
||||
while time.time() < end:
|
||||
r, _, _ = select.select([fd], [], [], 0.02)
|
||||
if r:
|
||||
try:
|
||||
d = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return
|
||||
if not d:
|
||||
return
|
||||
out.extend(d)
|
||||
note(d)
|
||||
last = time.time()
|
||||
if want is not None and ready_seen >= want:
|
||||
return
|
||||
elif time.time() - last > idle:
|
||||
return
|
||||
|
||||
drain(settle, until_ready=True)
|
||||
for k in keys:
|
||||
os.write(fd, to_bytes(k))
|
||||
drain(keyset_settle)
|
||||
drain(settle)
|
||||
|
||||
# 刻意不发 Ctrl-C(它会在光标处打出 ^C 标记并覆盖两个字),
|
||||
# 直接关掉 pty:最后那次重绘就是缓冲区的真值
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def wait_ready(fd, timeout=3.0):
|
||||
"""等到行编辑器回到提示符(它会打开括号粘贴)。
|
||||
|
||||
裸 pty 会话里不要用 time.sleep 猜时间:猜短了会 flaky,猜长了每处都在
|
||||
白等。进程退出(EOF)也算等到。"""
|
||||
READY = b"\x1b[?2004h"
|
||||
end = time.time() + timeout
|
||||
tail = b""
|
||||
while time.time() < end:
|
||||
r, _, _ = select.select([fd], [], [], 0.02)
|
||||
if not r:
|
||||
continue
|
||||
try:
|
||||
d = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return False
|
||||
if not d:
|
||||
return False
|
||||
buf = tail + d
|
||||
if READY in buf:
|
||||
return True
|
||||
tail = buf[-(len(READY) - 1):]
|
||||
return False
|
||||
|
||||
|
||||
LEFT, RIGHT = '\\x1b[D', '\\x1b[C'
|
||||
UP, DOWN = '\\x1b[A', '\\x1b[B'
|
||||
HOME_END = ('\\x1b[H', '\\x1b[F')
|
||||
BS, CTRLC = '\\x7f', '\\x03'
|
||||
|
||||
|
||||
def main():
|
||||
binary = sys.argv[1] if len(sys.argv) > 1 else './ligent'
|
||||
base = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
# 编辑测试不会真的发请求,但程序在缺 key 时会直接退出,所以必须给一个
|
||||
# 默认关掉确认与自动存档:它们会打断被测行为。
|
||||
# 专门测确认的用例用 --extra 显式打开。
|
||||
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'))
|
||||
cases.append(("右方向键回到末尾",
|
||||
['hello world'] + [LEFT] * 5 + [RIGHT, RIGHT, 'X'],
|
||||
'ligent> hello woXrld'))
|
||||
cases.append(("Home 回到行首后插入",
|
||||
['world', '\\x01', 'hello '],
|
||||
'ligent> hello world'))
|
||||
cases.append(("CSI Home 序列(\\x1b[H)",
|
||||
['world', HOME_END[0], 'hello '],
|
||||
'ligent> hello world'))
|
||||
cases.append(("End 序列(\\x1b[F)回到末尾",
|
||||
['world', HOME_END[0], 'hello ', HOME_END[1], '!'],
|
||||
'ligent> hello world!'))
|
||||
cases.append(("行首再左移不越界",
|
||||
['abc', HOME_END[0], LEFT, LEFT, 'Z'],
|
||||
'ligent> Zabc'))
|
||||
cases.append(("退格删除",
|
||||
['helloX', BS],
|
||||
'ligent> hello'))
|
||||
cases.append(("中间退格删的是光标前的字符",
|
||||
['abcd', LEFT, LEFT, BS],
|
||||
'ligent> acd'))
|
||||
cases.append(("Delete 键删的是光标处的字符",
|
||||
['abcd', LEFT, LEFT, '\\x1b[3~'],
|
||||
'ligent> abd'))
|
||||
cases.append(("Ctrl-K 删到行尾",
|
||||
['abcd', LEFT, LEFT, '\\x0b'],
|
||||
'ligent> ab'))
|
||||
cases.append(("Ctrl-U 删到行首",
|
||||
['abcd', LEFT, LEFT, '\\x15'],
|
||||
'ligent> cd'))
|
||||
PASTE = '\\x1b[200~'
|
||||
PASTE_END = '\\x1b[201~'
|
||||
cases.append(("粘贴多行:整段插入,不逐行提交",
|
||||
[PASTE + 'line1\nline2\nline3' + PASTE_END],
|
||||
['ligent> line1', 'line2', 'line3']))
|
||||
cases.append(("粘贴后可继续编辑",
|
||||
[PASTE + 'aaa\nbbb' + PASTE_END, 'X'],
|
||||
['ligent> aaa', 'bbbX']))
|
||||
cases.append(("粘贴内容里含中文",
|
||||
[PASTE + '\u4e2d\u6587\n\u7b2c\u4e8c\u884c' + PASTE_END],
|
||||
['ligent> 中文', '第二行']))
|
||||
cases.append(("粘贴多行后 Home 只回到本行行首",
|
||||
[PASTE + 'aaa\nbbb\nccc' + PASTE_END, '\\x01', 'X'],
|
||||
['ligent> aaa', 'bbb', 'Xccc']))
|
||||
cases.append(("粘贴多行后 End 只到本行行尾",
|
||||
[PASTE + 'aaa\nbbb\nccc' + PASTE_END, '\\x01', '\\x05', 'Y'],
|
||||
['ligent> aaa', 'bbb', 'cccY']))
|
||||
cases.append(("中文按字符移动光标",
|
||||
['中文abc'] + [LEFT] * 3 + ['X'],
|
||||
'ligent> 中文Xabc'))
|
||||
|
||||
# 粘贴进来的原始字节里可能夹着控制字符。read_paste 是原样收下的(不走
|
||||
# classify),所以必须清洗:终端按制表位展开 \t,编辑器只当 1 列;
|
||||
# ESC 会被终端真的执行掉;\r 把光标拉回行首。
|
||||
cases.append(("粘贴里的制表符按制表位展开",
|
||||
[PASTE + 'a\\tb' + PASTE_END, 'X'],
|
||||
'ligent> ' + 'a' + ' ' * 7 + 'bX'))
|
||||
cases.append(("制表符展开后光标位置正确(左移再插入)",
|
||||
[PASTE + 'a\\tb' + PASTE_END, LEFT, 'X'],
|
||||
'ligent> ' + 'a' + ' ' * 7 + 'Xb'))
|
||||
cases.append(("粘贴里的 ESC 序列被丢弃,不让它执行",
|
||||
[PASTE + 'before\\x1b[2Jafter' + PASTE_END],
|
||||
'ligent> beforeafter'))
|
||||
cases.append(("粘贴里的 CRLF 归一成 LF",
|
||||
[PASTE + 'a\\r\\nb' + PASTE_END],
|
||||
['ligent> a', 'b']))
|
||||
|
||||
fail = 0
|
||||
for name, keys, want in cases:
|
||||
raw = run(binary, args, keys)
|
||||
term = Term()
|
||||
term.feed(raw.decode('utf-8', 'replace'))
|
||||
if isinstance(want, list):
|
||||
tail = term.nonempty()[-len(want):]
|
||||
got = '\n'.join(tail)
|
||||
ok = (tail == want)
|
||||
want = '\n'.join(want)
|
||||
else:
|
||||
got = term.last_prompt_line()
|
||||
ok = (got == want)
|
||||
# 旧实现会把方向键回显成字面量 ^[[D
|
||||
literal_escapes = '^[[' in raw.decode('utf-8', 'replace')
|
||||
if not ok or literal_escapes:
|
||||
fail += 1
|
||||
print(" ✗ %s" % name)
|
||||
print(" 期望: %r" % want)
|
||||
print(" 实际: %r" % got)
|
||||
if literal_escapes:
|
||||
print(" 输出里出现了字面量转义文本 ^[[(方向键没被识别)")
|
||||
else:
|
||||
print(" ✓ %s" % name)
|
||||
|
||||
# 提示符带颜色时,那串转义序列不能占列宽 —— 光标定位必须和不着色时一致。
|
||||
# 上面的用例都带 --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 后插入",
|
||||
['world', '\\x01', 'hello '], 'ligent> hello world'),
|
||||
("彩色提示符:End 后追加",
|
||||
['abc', '\\x05', '!'], 'ligent> abc!'),
|
||||
("彩色提示符:中文按字符移动",
|
||||
['中文abc'] + [LEFT] * 3 + ['X'], 'ligent> 中文Xabc'),
|
||||
("彩色提示符:退格",
|
||||
['abcd', '\\x7f'], 'ligent> abc'),
|
||||
]
|
||||
for name, keys, want in color_cases:
|
||||
raw = run(binary, color_args, keys)
|
||||
term = Term()
|
||||
term.feed(raw.decode('utf-8', 'replace'))
|
||||
got = term.last_prompt_line()
|
||||
if got == want:
|
||||
print(" ✓ %s" % name)
|
||||
else:
|
||||
fail += 1
|
||||
print(" ✗ %s" % name)
|
||||
print(" 期望: %r" % want)
|
||||
print(" 实际: %r" % got)
|
||||
|
||||
# 上面那些用例只比屏幕内容,而颜色在终端模型里是被剥掉的 —— 包没包住
|
||||
# 输入的字看不出来。所以这里直接查字节:绿色区间必须一直延续到用户敲的
|
||||
# 内容之后。只包提示符的话,提示符自带的 reset 会让输入掉回默认色。
|
||||
raw = run(binary, color_args, ['greenme'])
|
||||
txt = raw.decode('utf-8', 'replace')
|
||||
start = txt.rfind('\x1b[32m')
|
||||
tail = txt[start:] if start >= 0 else ''
|
||||
end = tail.find('\x1b[0m')
|
||||
inside = tail[:end] if start >= 0 and end >= 0 else ''
|
||||
if 'greenme' in inside:
|
||||
print(" ✓ 输入的字也被包在绿色里")
|
||||
else:
|
||||
fail += 1
|
||||
print(" ✗ 输入的字也被包在绿色里")
|
||||
print(" 绿色区间: %r" % inside)
|
||||
|
||||
# 粘贴多行后提交:必须作为"一条"消息发出去(回显里能看到内嵌换行)
|
||||
if base:
|
||||
raw = run(binary, args,
|
||||
[PASTE + 'ECHO_TEST 第一行\n第二行\n第三行' + PASTE_END, '\\r'],
|
||||
keyset_settle=1.2, settle=0.8, idle=0.5)
|
||||
term = Term()
|
||||
term.feed(raw.decode('utf-8', 'replace'))
|
||||
joined = '\n'.join(term.nonempty())
|
||||
if 'ECHO[ECHO_TEST 第一行\\n第二行\\n第三行]' in joined:
|
||||
print(" ✓ 粘贴的多行作为一条消息提交")
|
||||
else:
|
||||
fail += 1
|
||||
print(" ✗ 粘贴的多行作为一条消息提交")
|
||||
print(" 屏幕尾部: %r" % term.nonempty()[-6:])
|
||||
|
||||
# 上下方向键取历史:需要真的提交一行,所以只有在给了服务端时才测
|
||||
if base:
|
||||
raw = run(binary, args, ['recalldemo', '\\r'], keyset_settle=0.8,
|
||||
settle=0.6, idle=0.5)
|
||||
term = Term()
|
||||
term.feed(raw.decode('utf-8', 'replace'))
|
||||
raw2 = run(binary, args, ['firstline', '\\r', '\\x1b[A'],
|
||||
keyset_settle=0.8, settle=0.6, idle=0.5)
|
||||
term2 = Term()
|
||||
term2.feed(raw2.decode('utf-8', 'replace'))
|
||||
lines = [l for l in term2.nonempty() if l.startswith('ligent> ')]
|
||||
if lines and 'firstline' in lines[-1]:
|
||||
print(" ✓ 上方向键取回上一条输入")
|
||||
else:
|
||||
fail += 1
|
||||
print(" ✗ 上方向键取回上一条输入")
|
||||
print(" 提示符行: %r" % lines[-3:])
|
||||
cases.append(("history", [], "ligent> firstline"))
|
||||
|
||||
# 历史要跨进程:上一个实例敲过的,新实例按上方向键就能翻到。
|
||||
# 用独立的 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'
|
||||
os.execv(binary, [binary] + args)
|
||||
os._exit(127)
|
||||
wait_ready(fd) # 等横幅画完、回到提示符
|
||||
os.write(fd, b'live_append\r')
|
||||
wait_ready(fd) # 等这条提交并落盘
|
||||
content = ""
|
||||
if os.path.exists(hist):
|
||||
with open(hist, encoding='utf-8') as fh:
|
||||
content = fh.read()
|
||||
if 'live_append' in content:
|
||||
print(" ✓ 提交后立刻落盘(进程未退出)")
|
||||
else:
|
||||
fail += 1
|
||||
print(" ✗ 提交后立刻落盘(进程未退出)")
|
||||
print(" 文件内容: %r" % content)
|
||||
# 继续敲两条再正常退出,供下一个实例翻
|
||||
for k in ('persist_one\r', '/think high\r'):
|
||||
os.write(fd, k.encode())
|
||||
wait_ready(fd)
|
||||
os.write(fd, b'/exit\r')
|
||||
wait_ready(fd) # 退出后这里会读到 EOF
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 文件里依次是 live_append / persist_one / /think high / /exit,
|
||||
# 所以按三次上方向键才轮到 persist_one
|
||||
raw3 = run(binary, args, [UP, UP, UP], keyset_settle=0.3, settle=0.6, idle=0.03)
|
||||
term3 = Term()
|
||||
term3.feed(raw3.decode('utf-8', 'replace'))
|
||||
lines3 = [l for l in term3.nonempty() if l.startswith('ligent> ')]
|
||||
if lines3 and 'persist_one' in lines3[-1]:
|
||||
print(" ✓ 新实例能翻到上一个实例的历史")
|
||||
else:
|
||||
fail += 1
|
||||
print(" ✗ 新实例能翻到上一个实例的历史")
|
||||
print(" 提示符行: %r" % lines3[-3:])
|
||||
cases.append(("history-persist", [], "ligent> persist_one"))
|
||||
finally:
|
||||
for p in (hist, hist + ".tmp"):
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
os.environ["LIGENT_HISTORY"] = scratch # 还给整轮共用的那个
|
||||
|
||||
# Ctrl-C 应当丢弃当前行并回到新提示符
|
||||
raw = run(binary, args, ['garbage', CTRLC, 'typed', CTRLC], idle=0.5)
|
||||
term = Term()
|
||||
term.feed(raw.decode('utf-8', 'replace'))
|
||||
prompts = [l for l in term.nonempty() if l.startswith('ligent> ')]
|
||||
# 期望: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:
|
||||
fail += 1
|
||||
print(" ✗ Ctrl-C 丢弃当前行并开新行")
|
||||
print(" 提示符行: %r" % prompts[-3:])
|
||||
else:
|
||||
print(" ✓ Ctrl-C 丢弃当前行并开新行")
|
||||
|
||||
total = len(cases) + len(color_cases) + 2
|
||||
print()
|
||||
print("通过 %d 项,失败 %d 项" % (total - fail, fail))
|
||||
for p in (scratch, scratch + ".tmp"):
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
os.environ.pop("LIGENT_HISTORY", None)
|
||||
return 1 if fail else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""replay.py - 把 /log 录下来的真实会话当成测试夹具回放。
|
||||
|
||||
为什么需要它:ligent 的协议适配一直只在自建的 mock 服务端上验证过,而 mock
|
||||
是我们自己写的 —— 它接受什么,完全取决于**我们以为**服务端会接受什么。真实
|
||||
服务端的差异是最大的未知数。
|
||||
|
||||
/log 导出的是**原始字节**(请求体、响应头、未经解析的响应原文,流式就是原始
|
||||
SSE),正好够原样伺候回去。所以这里不重写一个"模拟客户端",而是当**离线
|
||||
服务端**:把录下来的响应按顺序发给一个真的 ligent 实例,于是 http.c 的分块
|
||||
读取、llm.c 的 SSE 行切分与 tool_calls 增量重组,全都在真实字节上跑。
|
||||
|
||||
用法:
|
||||
replay.py --summary FILE 这份录制里有什么
|
||||
replay.py --serve PORT FILE [--strict] 当离线服务端
|
||||
replay.py --check FILE 自检:能不能解析出结构完整的往返
|
||||
|
||||
--strict 会把每个进来的请求体与录制时那一份做**结构比对**(顶层键、消息角色
|
||||
序列、工具名集合),不一致就回 400 并在 stderr 打印差异。这是这个工具最有用
|
||||
的一半:它把"今天的客户端还在发同样的报文形状"变成一条会失败的断言。
|
||||
|
||||
注意回放时要给 ligent 加 --no-probe:能力探测会插进额外的请求,而那几轮
|
||||
不在录制里。录制时也建议关掉(LIGENT_PROBE=0),两边一致。
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
# ---------- 解析 /log 导出 ----------
|
||||
|
||||
|
||||
def parse_log(text):
|
||||
"""把一份 /log 导出切成若干次 HTTP 往返。
|
||||
|
||||
格式(见 http.c 的记录点):
|
||||
===== <stamp> =====
|
||||
请求: POST <url>
|
||||
----- 请求体 (N 字节) -----
|
||||
<N 字节>
|
||||
----- 响应头 -----
|
||||
<header 行...>
|
||||
<空行>
|
||||
<响应原文>
|
||||
----- 结束: HTTP <status> -----
|
||||
|
||||
日志是**滚动缓冲**,开头那一段可能是被截断的,所以要容忍缺标记 —— 跳过
|
||||
解析不出来的块,而不是整个失败。
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if not (lines[i].startswith("===== ") and lines[i].endswith(" =====")):
|
||||
i += 1
|
||||
continue
|
||||
stamp = lines[i][6:-6]
|
||||
i += 1
|
||||
ex = {"stamp": stamp, "url": None, "method": None,
|
||||
"req": b"", "status": None, "headers": [], "resp": b""}
|
||||
|
||||
# 请求行
|
||||
if i < len(lines) and lines[i].startswith("请求: "):
|
||||
parts = lines[i][len("请求: "):].split(" ", 1)
|
||||
ex["method"] = parts[0]
|
||||
ex["url"] = parts[1] if len(parts) > 1 else ""
|
||||
i += 1
|
||||
|
||||
# 请求体:按标记里给出的字节数精确取,比按行取稳
|
||||
if i < len(lines) and lines[i].startswith("----- 请求体 ("):
|
||||
n = int(lines[i].split("(")[1].split(" ")[0])
|
||||
i += 1
|
||||
# 从这一行起累加字节,直到凑够 n
|
||||
blob = ""
|
||||
while i < len(lines) and len(blob.encode()) < n:
|
||||
blob += (lines[i] + "\n")
|
||||
i += 1
|
||||
ex["req"] = blob.encode()[:n]
|
||||
# 上面多读了一行就把多余的还回去(blob 里包含了尾部换行)
|
||||
consumed = len(blob.encode())
|
||||
if consumed > n:
|
||||
# 把这一行重新交回处理(只可能多出换行)
|
||||
pass
|
||||
|
||||
# 响应头
|
||||
if i < len(lines) and lines[i] == "----- 响应头 -----":
|
||||
i += 1
|
||||
while i < len(lines) and lines[i] != "":
|
||||
ex["headers"].append(lines[i])
|
||||
i += 1
|
||||
i += 1 # 跳过空行
|
||||
|
||||
# 响应原文:直到"结束"行
|
||||
start = i
|
||||
while i < len(lines) and not lines[i].startswith("----- 结束: "):
|
||||
i += 1
|
||||
ex["resp"] = ("\n".join(lines[start:i])).encode()
|
||||
|
||||
if i < len(lines) and lines[i].startswith("----- 结束: "):
|
||||
tail = lines[i]
|
||||
try:
|
||||
ex["status"] = int(tail.split("HTTP ")[1].split()[0])
|
||||
except (IndexError, ValueError):
|
||||
ex["status"] = 200
|
||||
i += 1
|
||||
|
||||
# 结构不完整(多半是滚动缓冲把开头截了)就丢掉
|
||||
if ex["url"] and ex["status"] is not None:
|
||||
out.append(ex)
|
||||
return out
|
||||
|
||||
|
||||
def shape_of(req_bytes):
|
||||
"""一个请求体的**结构指纹**:只看形状,不看内容。
|
||||
|
||||
内容每轮都不一样(历史在长),但形状必须稳定 —— 顶层键、消息角色序列、
|
||||
工具名集合。客户端一旦改了报文形状,这里就会显示出来。
|
||||
"""
|
||||
try:
|
||||
body = json.loads(req_bytes.decode("utf-8", "replace"))
|
||||
except Exception as e:
|
||||
return {"error": "不是合法 JSON: %s" % e}
|
||||
msgs = body.get("messages") or []
|
||||
tools = body.get("tools") or []
|
||||
names = []
|
||||
for t in tools:
|
||||
n = ((t or {}).get("function") or {}).get("name")
|
||||
if n:
|
||||
names.append(n)
|
||||
return {
|
||||
"keys": sorted(body.keys()),
|
||||
"roles": [m.get("role") for m in msgs],
|
||||
"tools": sorted(names),
|
||||
}
|
||||
|
||||
|
||||
# ---------- 离线服务端 ----------
|
||||
|
||||
|
||||
class ReplayServer:
|
||||
def __init__(self, exchanges, strict, loop):
|
||||
self.ex = exchanges
|
||||
self.strict = strict
|
||||
self.loop = loop
|
||||
self.pos = 0
|
||||
self.mismatch = 0
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def next_exchange(self):
|
||||
with self.lock:
|
||||
if self.pos >= len(self.ex):
|
||||
if self.loop:
|
||||
self.pos = 0
|
||||
else:
|
||||
return None
|
||||
e = self.ex[self.pos]
|
||||
self.pos += 1
|
||||
return e
|
||||
|
||||
def handle(self, method, path, req_bytes):
|
||||
e = self.next_exchange()
|
||||
if e is None:
|
||||
sys.stderr.write(
|
||||
"replay: 录制里没有更多响应了(已经回了 %d 个)。\n"
|
||||
" 客户端比录制时多发了请求 —— 多半是多了能力探测(回放时请加 "
|
||||
"--no-probe)或者客户端行为变了。\n" % len(self.ex))
|
||||
return 500, {}, b'{"error":{"message":"replay: out of recorded responses"}}'
|
||||
|
||||
if self.strict:
|
||||
want = shape_of(e["req"])
|
||||
got = shape_of(req_bytes)
|
||||
if want != got:
|
||||
self.mismatch += 1
|
||||
sys.stderr.write(
|
||||
"replay: 第 %d 个请求的**结构**和录制时不一致:\n"
|
||||
" 录制时: %s\n"
|
||||
" 现在 : %s\n"
|
||||
% (self.pos, json.dumps(want, ensure_ascii=False),
|
||||
json.dumps(got, ensure_ascii=False)))
|
||||
return (400, {"Content-Type": "application/json"},
|
||||
json.dumps({"error": {
|
||||
"message": "replay strict: request shape changed"}}
|
||||
).encode())
|
||||
|
||||
ctype = "application/json"
|
||||
for h in e["headers"]:
|
||||
if h.lower().startswith("content-type:"):
|
||||
ctype = h.split(":", 1)[1].strip()
|
||||
hdrs = {"Content-Type": ctype}
|
||||
return e["status"], hdrs, e["resp"]
|
||||
|
||||
|
||||
def serve(exchanges, port, strict, loop):
|
||||
srv = ReplayServer(exchanges, strict, loop)
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(("127.0.0.1", port))
|
||||
s.listen(8)
|
||||
print("replay server on %d(%d 个录制往返%s)"
|
||||
% (port, len(exchanges), ",strict" if strict else ""), flush=True)
|
||||
|
||||
while True:
|
||||
conn, _ = s.accept()
|
||||
threading.Thread(target=handle_conn, args=(conn, srv),
|
||||
daemon=True).start()
|
||||
|
||||
|
||||
def handle_conn(conn, srv):
|
||||
try:
|
||||
f = conn.makefile("rb")
|
||||
req_line = f.readline()
|
||||
if not req_line:
|
||||
return
|
||||
parts = req_line.decode("latin-1").split()
|
||||
method = parts[0] if parts else "GET"
|
||||
path = parts[1] if len(parts) > 1 else "/"
|
||||
|
||||
clen = 0
|
||||
while True:
|
||||
line = f.readline()
|
||||
if not line or line in (b"\r\n", b"\n"):
|
||||
break
|
||||
if line.lower().startswith(b"content-length:"):
|
||||
clen = int(line.split(b":", 1)[1].strip())
|
||||
body = f.read(clen) if clen > 0 else b""
|
||||
|
||||
status, hdrs, resp = srv.handle(method, path, body)
|
||||
head = "HTTP/1.1 %d %s\r\n" % (status, "OK" if status == 200 else "Error")
|
||||
for k, v in hdrs.items():
|
||||
head += "%s: %s\r\n" % (k, v)
|
||||
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)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- 入口 ----------
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="回放 /log 录下来的真实会话")
|
||||
ap.add_argument("file")
|
||||
ap.add_argument("--summary", action="store_true", help="只打印这份录制里有什么")
|
||||
ap.add_argument("--check", action="store_true", help="自检能否解析成完整往返")
|
||||
ap.add_argument("--serve", type=int, metavar="PORT", help="当离线服务端")
|
||||
ap.add_argument("--strict", action="store_true",
|
||||
help="比对每个请求的结构,不一致就回 400")
|
||||
ap.add_argument("--loop", action="store_true", help="响应用完后从头再来")
|
||||
a = ap.parse_args()
|
||||
|
||||
text = open(a.file, encoding="utf-8", errors="replace").read()
|
||||
ex = parse_log(text)
|
||||
|
||||
if a.summary or a.check:
|
||||
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"
|
||||
ntool = r.count(b'"tool_calls"')
|
||||
print(" #%d HTTP %s %-28s 请求 %5d 字节 → 响应 %6d 字节 %s%s"
|
||||
% (i + 1, e["status"], (e["url"] or "").split("/")[-1],
|
||||
len(e["req"]), len(r), kind,
|
||||
" 含 tool_calls" if ntool else ""))
|
||||
if a.check:
|
||||
bad = [i + 1 for i, e in enumerate(ex) if not e["req"] or e["status"] is None]
|
||||
if bad:
|
||||
print("解析不完整的往返: %s" % bad, file=sys.stderr)
|
||||
return 1
|
||||
print("解析自检通过")
|
||||
return 0
|
||||
|
||||
if a.serve:
|
||||
if not ex:
|
||||
print("这份录制里没有可回放的往返", file=sys.stderr)
|
||||
return 1
|
||||
serve(ex, a.serve, a.strict, a.loop)
|
||||
return 0
|
||||
|
||||
ap.error("要么 --summary/--check,要么 --serve PORT")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+1544
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""shell_screen_check.py - 验证 /shell 记录里对全屏程序的处理
|
||||
|
||||
交互式 shell 的原始输出里混着转义序列、重绘空格和全屏程序的整屏画面。
|
||||
不清洗的话模型看到的就是几百个空格加一串光标定位;清过头又会把命令和
|
||||
状态行一起吃掉。这里用 printf 造出确定的输入来钉住行为:
|
||||
|
||||
1. 备用屏幕(vim/less/top 走这条路)——画面必须省略,只留首尾标记;
|
||||
2. 成片空格(nano/top 靠空格重绘)——必须压掉,但短缩进要原样保留;
|
||||
3. 大输出里夹着的备用屏幕——跨 read 也要能认出来。
|
||||
|
||||
用法:
|
||||
shell_screen_check.py <ligent 二进制> <mock base-url>
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
ALT_TEXT = "备用屏幕里的机密内容"
|
||||
NORMAL_TEXT = "正常输出"
|
||||
|
||||
|
||||
def drive(binary, base, commands, wait=1.6, term="xterm-256color"):
|
||||
"""在 pty 里跑 ligent,进 /shell 执行 commands,导出后返回记录文本。
|
||||
|
||||
term=None 表示不设 TERM,用来观察退化行为。"""
|
||||
saved = os.environ.copy()
|
||||
os.environ["LIGENT_API_KEY"] = "sk-test"
|
||||
# 没有 TERM 的话 less/vim 会退回非全屏模式,测不到备用屏幕那条路。
|
||||
if term is None:
|
||||
os.environ.pop("TERM", None)
|
||||
else:
|
||||
os.environ["TERM"] = term
|
||||
|
||||
# 每轮都清空归档:归档名是随机的,按文件名排序取"最后一个"会拿到上一轮。
|
||||
bindir = os.path.dirname(os.path.abspath(binary))
|
||||
shutil.rmtree(os.path.join(bindir, "ligent.archive"), ignore_errors=True)
|
||||
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execv(binary, [binary, "--base-url", base, "--no-confirm", "--auto-save"])
|
||||
os._exit(127)
|
||||
|
||||
# 行编辑器每次回到提示符都会打开括号粘贴 —— 用它当"轮到输入了"的信号。
|
||||
# 只按"输出安静"等是不行的:等待动画每 250ms 画一帧,pty 永远不会安静
|
||||
# 超过 idle,于是每次都要白等满整个额度。
|
||||
PROMPT_READY = b"\x1b[?2004h"
|
||||
ready_seen = 0
|
||||
tail = b""
|
||||
|
||||
def note(chunk):
|
||||
nonlocal tail, ready_seen
|
||||
buf = tail + chunk
|
||||
ready_seen += buf.count(PROMPT_READY)
|
||||
tail = buf[-(len(PROMPT_READY) - 1):]
|
||||
|
||||
def drain(seconds, idle=0.4, until_ready=False):
|
||||
want = ready_seen + 1 if until_ready else None
|
||||
end = time.time() + seconds
|
||||
last = time.time()
|
||||
while time.time() < end:
|
||||
r, _, _ = select.select([fd], [], [], 0.02)
|
||||
if r:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError:
|
||||
return
|
||||
if not chunk:
|
||||
return
|
||||
note(chunk)
|
||||
last = time.time()
|
||||
if want is not None and ready_seen >= want:
|
||||
return
|
||||
elif time.time() - last > idle:
|
||||
return
|
||||
|
||||
try:
|
||||
drain(1.5, until_ready=True) # 等横幅画完、回到提示符
|
||||
for line in ["/shell"] + commands + ["exit", "/export", "/exit"]:
|
||||
os.write(fd, line.encode("utf-8") + b"\n")
|
||||
drain(wait, until_ready=True)
|
||||
finally:
|
||||
for fn in (lambda: os.close(fd), lambda: os.waitpid(pid, 0)):
|
||||
try:
|
||||
fn()
|
||||
except OSError:
|
||||
pass
|
||||
os.environ.clear()
|
||||
os.environ.update(saved)
|
||||
|
||||
bindir = os.path.dirname(os.path.abspath(binary))
|
||||
files = sorted(glob.glob(os.path.join(bindir, "ligent.archive", "*.json")))
|
||||
if not files:
|
||||
return None
|
||||
with open(files[0], encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
users = [m["content"] for m in data["messages"] if m["role"] == "user"]
|
||||
return users[-1] if users else ""
|
||||
|
||||
|
||||
def between_markers(rec, start="[全屏程序运行中,画面内容已省略]", end="[全屏程序结束]"):
|
||||
"""取出"全屏程序"首尾标记之间的内容。
|
||||
|
||||
命令回显必然把输入里的字面文本也录进去,所以不能直接断言"机密文本不在
|
||||
记录里"——只能断言它没出现在真正被省略的那段区间内。"""
|
||||
parts = rec.split(start)
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return parts[1].split(end)[0]
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: shell_screen_check.py <二进制> <base-url>")
|
||||
return 2
|
||||
binary, base = sys.argv[1], sys.argv[2]
|
||||
bindir = os.path.dirname(os.path.abspath(binary))
|
||||
archive = os.path.join(bindir, "ligent.archive")
|
||||
|
||||
fails = 0
|
||||
total = 0
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
nonlocal fails, total
|
||||
total += 1
|
||||
if ok:
|
||||
print(" ✓ %s" % name)
|
||||
else:
|
||||
fails += 1
|
||||
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
|
||||
|
||||
# ---- 1. 备用屏幕:画面省略,只留首尾标记 ----
|
||||
print("备用屏幕(vim/less 路径)")
|
||||
rec = drive(binary, base, [
|
||||
r"printf '\033[?1049h" + ALT_TEXT + r"\033[?1049l'",
|
||||
"echo " + NORMAL_TEXT,
|
||||
])
|
||||
if rec is None:
|
||||
check("拿到记录", False, "没有生成归档")
|
||||
return 1
|
||||
check("拿到记录", True)
|
||||
body = between_markers(rec)
|
||||
check("首尾标记成对出现", body is not None, "没有找到完整的一对标记")
|
||||
check("全屏区间内没有画面内容", body is not None and ALT_TEXT not in body,
|
||||
"机密内容漏出来了")
|
||||
check("留下开始标记", "全屏程序运行中" in rec)
|
||||
check("留下结束标记", "全屏程序结束" in rec)
|
||||
check("标记之外的输出还在", NORMAL_TEXT in rec)
|
||||
check("记录里没有裸转义符", "\x1b" not in rec)
|
||||
|
||||
# ---- 2. 成片空格:压掉,但缩进保留 ----
|
||||
print("成片空格(nano/top 重绘路径)")
|
||||
rec = drive(binary, base, [
|
||||
r"printf 'A'; printf '%0.s ' $(seq 1 200); printf 'B\n'",
|
||||
r"printf ' 缩进四个空格\n'",
|
||||
])
|
||||
if rec is None:
|
||||
check("拿到记录", False, "没有生成归档")
|
||||
return 1
|
||||
check("拿到记录", True)
|
||||
check("200 个空格被压缩", not re.search(r" {9,}", rec), "还留着成片空白")
|
||||
check("压缩后仍保留 A 和 B", "A" in rec and "B" in rec)
|
||||
check("短缩进原样保留", " 缩进四个空格" in rec, "缩进被误伤")
|
||||
blank = sum(1 for ch in rec if ch in " \n\t") / max(1, len(rec))
|
||||
check("空白占比低于 40%", blank < 0.40, "实际 %.0f%%" % (100 * blank))
|
||||
|
||||
# ---- 3. 大输出里夹着的备用屏幕 ----
|
||||
print("大输出(跨 read)里的备用屏幕")
|
||||
rec = drive(binary, base, [
|
||||
"head -c 8000 /dev/zero | tr '\\0' 'x'; printf '\\033[?1049h"
|
||||
+ ALT_TEXT + "\\033[?1049l'",
|
||||
])
|
||||
if rec is None:
|
||||
check("拿到记录", False, "没有生成归档")
|
||||
return 1
|
||||
check("拿到记录", True)
|
||||
body = between_markers(rec)
|
||||
check("跨块后仍认得出备用屏幕", body is not None and ALT_TEXT not in body,
|
||||
"切断后漏了画面")
|
||||
check("跨块标记成对出现", body is not None)
|
||||
|
||||
# ---- 4. 同一个会话里进两次全屏,标记必须成对 ----
|
||||
print("连续两次进入全屏")
|
||||
rec = drive(binary, base, [
|
||||
r"printf '\033[?1049hAAA\033[?1049l'; printf '\033[?1049hBBB\033[?1049l'",
|
||||
])
|
||||
if rec is None:
|
||||
check("拿到记录", False, "没有生成归档")
|
||||
return 1
|
||||
check("拿到记录", True)
|
||||
check("两次开始标记", rec.count("[全屏程序运行中,画面内容已省略]") == 2,
|
||||
"实际 %d 个" % rec.count("[全屏程序运行中,画面内容已省略]"))
|
||||
check("两次结束标记", rec.count("[全屏程序结束]") == 2,
|
||||
"实际 %d 个" % rec.count("[全屏程序结束]"))
|
||||
|
||||
# ---- 5. TERM 退化时补一个像样的值 ----
|
||||
# TERM=dumb 时 nano 会放弃备用屏幕、改成逐行重绘,整段忽略就失效了。
|
||||
# 而 ligent 自己的 REPL 就在用光标定位,说明终端并非真的 dumb。
|
||||
for bad in ("dumb", None):
|
||||
label = "TERM=%s" % (bad if bad else "(未设置)")
|
||||
print("退化的 %s" % label)
|
||||
rec = drive(binary, base, ["echo TERM_IS=$TERM"], term=bad)
|
||||
if rec is None:
|
||||
check("拿到记录", False, "没有生成归档")
|
||||
continue
|
||||
check("%s 时补上可用 TERM" % label, "TERM_IS=xterm-256color" in rec,
|
||||
"实际记录:%s" % rec.strip().replace("\n", " | ")[-80:])
|
||||
|
||||
shutil.rmtree(archive, ignore_errors=True)
|
||||
print("\n%d 项检查,%d 项失败" % (total, fails))
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,433 @@
|
||||
/* test_agent.c - agent 上下文管理(不涉及网络) */
|
||||
#include "agent.h"
|
||||
#include "json.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static size_t count_role(const Agent *a, const char *role)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < json_array_size(a->messages); i++) {
|
||||
const char *r = json_get_string(json_array_get(a->messages, i), "role");
|
||||
if (r && strcmp(r, role) == 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 上下文超限的自动恢复 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void add_msg(Agent *a, const char *role, const char *content)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", role);
|
||||
json_object_set_string(m, "content", content);
|
||||
json_array_push(a->messages, m);
|
||||
}
|
||||
|
||||
/* 造一条 assistant 带 tool_calls 的消息 */
|
||||
static void add_assistant_tool_call(Agent *a, const char *id, const char *name)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", "assistant");
|
||||
json_object_set_null(m, "content");
|
||||
JsonValue *tcs = json_new_array();
|
||||
JsonValue *tc = json_new_object();
|
||||
json_object_set_string(tc, "id", id);
|
||||
json_object_set_string(tc, "type", "function");
|
||||
JsonValue *fn = json_new_object();
|
||||
json_object_set_string(fn, "name", name);
|
||||
json_object_set_string(fn, "arguments", "{}");
|
||||
json_object_set(tc, "function", fn);
|
||||
json_array_push(tcs, tc);
|
||||
json_object_set(m, "tool_calls", tcs);
|
||||
json_array_push(a->messages, m);
|
||||
}
|
||||
|
||||
static void add_tool_result(Agent *a, const char *id, const char *content)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", "tool");
|
||||
json_object_set_string(m, "tool_call_id", id);
|
||||
json_object_set_string(m, "content", content);
|
||||
json_array_push(a->messages, m);
|
||||
}
|
||||
|
||||
/* 序列化历史,便于断言 */
|
||||
static char *dump_history(const Agent *a)
|
||||
{
|
||||
return json_dump(a->messages);
|
||||
}
|
||||
|
||||
/* 配对不变量:任何 assistant(tool_calls) 后面必须**紧跟**同 id 的 tool 消息。
|
||||
* 服务端就是这么要求的,违反了下一轮直接 400 —— 所以每次动过历史之后都要查。 */
|
||||
static void check_pairing(Agent *a)
|
||||
{
|
||||
size_t n = json_array_size(a->messages);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
JsonValue *m = json_array_get(a->messages, i);
|
||||
JsonValue *tcs = json_object_get(m, "tool_calls");
|
||||
if (!tcs) continue;
|
||||
const char *id = json_get_string(json_array_get(tcs, 0), "id");
|
||||
CHECK(i + 1 < n, "tool_calls 之后必须有消息");
|
||||
if (i + 1 < n) {
|
||||
JsonValue *nxt = json_array_get(a->messages, i + 1);
|
||||
const char *nid = json_get_string(nxt, "tool_call_id");
|
||||
CHECK(nid && id && strcmp(nid, id) == 0,
|
||||
"tool 结果必须紧跟其 tool_call(第 %zu 条)", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 一轮一轮摘下去(/back 走的就是这条路),历史在每一步都必须仍然自洽。
|
||||
*
|
||||
* 这里原来测的是"上下文超限自动裁剪"(agent_drop_oldest_round)—— 那套机制
|
||||
* 已经**整个删掉**:它的判据只是错误文案里的关键词,却会不可逆地删掉最老的
|
||||
* 几轮对话。现在超限就是如实报错、历史一个字节都不动,怎么处理由用户决定
|
||||
* (/back 摘一轮、/reset 重来)。但"摘掉一整轮之后 tool 配对不能散"这条
|
||||
* 不变量与由谁触发无关,所以留着继续测。 */
|
||||
static void test_take_rounds_keeps_pairing(void)
|
||||
{
|
||||
puts("逐轮摘掉之后历史仍然自洽");
|
||||
Agent a;
|
||||
agent_init(&a);
|
||||
agent_add_system(&a, "SYS");
|
||||
for (int i = 1; i <= 4; i++) {
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof buf, "U%d", i);
|
||||
add_msg(&a, "user", buf);
|
||||
add_assistant_tool_call(&a, "call_x", "read_file");
|
||||
add_tool_result(&a, "call_x", "R");
|
||||
add_msg(&a, "assistant", "A");
|
||||
}
|
||||
|
||||
int rounds = 0;
|
||||
for (;;) {
|
||||
JsonValue *r = agent_take_last_round(&a);
|
||||
if (!r) break;
|
||||
json_free(r);
|
||||
rounds++;
|
||||
check_pairing(&a); /* 每摘一轮都查一遍配对 */
|
||||
}
|
||||
CHECK(rounds == 4, "共应摘掉 4 轮,实际 %d", rounds);
|
||||
CHECK(agent_history_len(&a) == 1, "最后应只剩 system,实际 %zu",
|
||||
agent_history_len(&a));
|
||||
agent_free(&a);
|
||||
}
|
||||
|
||||
static void test_take_last_round(void)
|
||||
{
|
||||
puts("摘掉最近一轮(/back 用)");
|
||||
Agent a;
|
||||
agent_init(&a);
|
||||
agent_add_system(&a, "SYS");
|
||||
add_msg(&a, "user", "U1");
|
||||
add_assistant_tool_call(&a, "call_1", "run_command");
|
||||
add_tool_result(&a, "call_1", "R1");
|
||||
add_msg(&a, "assistant", "A1");
|
||||
add_msg(&a, "user", "U2");
|
||||
add_msg(&a, "assistant", "A2");
|
||||
|
||||
CHECK(agent_history_len(&a) == 7, "初始 7 条,实际 %zu", agent_history_len(&a));
|
||||
|
||||
JsonValue *r = agent_take_last_round(&a);
|
||||
CHECK(r != NULL, "应摘到一轮");
|
||||
CHECK(r && json_array_size(r) == 2, "最后一轮应 2 条,实际 %zu",
|
||||
r ? json_array_size(r) : 0);
|
||||
CHECK(agent_history_len(&a) == 5, "摘完应剩 5 条,实际 %zu",
|
||||
agent_history_len(&a));
|
||||
char *h = dump_history(&a);
|
||||
CHECK(strstr(h, "U2") == NULL, "U2 应被摘掉");
|
||||
CHECK(strstr(h, "U1") != NULL, "U1 应保留");
|
||||
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);
|
||||
h = dump_history(&a);
|
||||
CHECK(strstr(h, "call_1") == NULL && strstr(h, "R1") == NULL,
|
||||
"工具调用与结果应一并摘掉,不能只删一半");
|
||||
CHECK(strstr(h, "SYS") != NULL, "system 必须保留");
|
||||
free(h);
|
||||
json_free(r);
|
||||
|
||||
/* 只剩 system 时无可摘 */
|
||||
r = agent_take_last_round(&a);
|
||||
CHECK(r == NULL, "没有用户轮次时应返回 NULL");
|
||||
json_free(r);
|
||||
CHECK(agent_history_len(&a) == 1, "历史应只剩 system");
|
||||
agent_free(&a);
|
||||
|
||||
/* 没有 user 消息的历史 */
|
||||
Agent b;
|
||||
agent_init(&b);
|
||||
add_msg(&b, "assistant", "只有助手");
|
||||
CHECK(agent_take_last_round(&b) == NULL, "没有 user 时不应摘任何东西");
|
||||
CHECK(agent_history_len(&b) == 1, "历史不应被改动");
|
||||
agent_free(&b);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 结构体检:assistant(tool_calls) 与 tool 消息的配对 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* 造一条 assistant,带 count 个工具调用(id 依次是 c0, c1, ...) */
|
||||
static JsonValue *mk_assistant(size_t count)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", "assistant");
|
||||
JsonValue *tcs = json_new_array();
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
char id[16];
|
||||
snprintf(id, sizeof id, "c%zu", i);
|
||||
JsonValue *tc = json_new_object();
|
||||
json_object_set_string(tc, "id", id);
|
||||
json_object_set_string(tc, "type", "function");
|
||||
JsonValue *fn = json_new_object();
|
||||
json_object_set_string(fn, "name", "run_command");
|
||||
json_object_set_string(fn, "arguments", "{}");
|
||||
json_object_set(tc, "function", fn);
|
||||
json_array_push(tcs, tc);
|
||||
}
|
||||
json_object_set(m, "tool_calls", tcs);
|
||||
return m;
|
||||
}
|
||||
|
||||
static JsonValue *mk_tool(const char *id)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", "tool");
|
||||
json_object_set_string(m, "tool_call_id", id);
|
||||
json_object_set_string(m, "content", "ok");
|
||||
return m;
|
||||
}
|
||||
|
||||
static JsonValue *mk_plain(const char *role, const char *text)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", role);
|
||||
json_object_set_string(m, "content", text);
|
||||
return m;
|
||||
}
|
||||
|
||||
/* 数一数某个 assistant 之后紧跟的 tool 消息覆盖了几个 id */
|
||||
static const char *nth_role(const JsonValue *msgs, size_t i)
|
||||
{
|
||||
return json_get_string(json_array_get((JsonValue *)msgs, i), "role");
|
||||
}
|
||||
|
||||
static int pairing_ok(const JsonValue *msgs)
|
||||
{
|
||||
size_t n = json_array_size((JsonValue *)msgs);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
JsonValue *m = json_array_get((JsonValue *)msgs, i);
|
||||
const char *r = json_get_string(m, "role");
|
||||
if (!r || strcmp(r, "assistant") != 0) continue;
|
||||
JsonValue *tcs = json_object_get(m, "tool_calls");
|
||||
if (!tcs || json_array_size(tcs) == 0) continue;
|
||||
for (size_t k = 0; k < json_array_size(tcs); k++) {
|
||||
const char *id = json_get_string(json_array_get(tcs, k), "id");
|
||||
size_t j = i + 1;
|
||||
int found = 0;
|
||||
for (; j < n && strcmp(nth_role(msgs, j), "tool") == 0; j++)
|
||||
if (strcmp(json_get_string(json_array_get((JsonValue *)msgs, j),
|
||||
"tool_call_id"), id) == 0) found = 1;
|
||||
if (!found) return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void test_repair_pairing(void)
|
||||
{
|
||||
int fixed = 0;
|
||||
|
||||
puts("结构体检:本来没问题就不该动它");
|
||||
JsonValue *ok = json_new_array();
|
||||
json_array_push(ok, mk_plain("user", "跑一下"));
|
||||
json_array_push(ok, mk_assistant(2));
|
||||
json_array_push(ok, mk_tool("c0"));
|
||||
json_array_push(ok, mk_tool("c1"));
|
||||
json_array_push(ok, mk_plain("assistant", "做完了"));
|
||||
CHECK(pairing_ok(ok), "构造出来的历史本身应是合法的");
|
||||
CHECK(agent_repair_tool_pairing(ok, &fixed) == NULL,
|
||||
"合法历史不该被重建(NULL = 什么都没改)");
|
||||
CHECK(fixed == 0, "合法历史的修复处数应为 0");
|
||||
json_free(ok);
|
||||
|
||||
puts("缺 tool 消息:补齐,且补在原位");
|
||||
/* 复现真实报错:assistant 带两个调用,中间夹了一条 user(图省事直接
|
||||
* 模仿当年那个 bug 的形状),第二个调用没人回应 */
|
||||
JsonValue *bad = json_new_array();
|
||||
json_array_push(bad, mk_plain("user", "看看图"));
|
||||
json_array_push(bad, mk_assistant(2));
|
||||
json_array_push(bad, mk_tool("c0"));
|
||||
json_array_push(bad, mk_plain("user", "图片附件"));
|
||||
json_array_push(bad, mk_tool("c1"));
|
||||
fixed = 0;
|
||||
JsonValue *r = agent_repair_tool_pairing(bad, &fixed);
|
||||
CHECK(r != NULL, "对不上时应该返回重建好的数组");
|
||||
if (r) {
|
||||
CHECK(pairing_ok(r), "修完之后配对必须成立");
|
||||
/* 两处:给 c1 补一条占位(它没被回应),再把错位的那条 c1 结果丢掉
|
||||
* (它被 user 消息隔开,已经不在 tool 段里了 —— 留着就是孤儿)。
|
||||
* 丢掉真实结果听着可惜,但结构坏了只能二选一:要么丢它,要么留一段
|
||||
* 服务端必然拒绝的历史。模型会从占位消息里知道该重跑。 */
|
||||
CHECK(fixed == 2, "应补 1 处、丢 1 处孤儿,实际 %d", fixed);
|
||||
CHECK(json_array_size(r) == 5,
|
||||
"补一条、丢一条之后仍是 5 条,实际 %zu", json_array_size(r));
|
||||
/* 占位消息必须紧跟在 assistant 的 tool 段里,而不是被塞到最后 */
|
||||
CHECK(strcmp(nth_role(r, 2), "tool") == 0 &&
|
||||
strcmp(nth_role(r, 3), "tool") == 0,
|
||||
"两条 tool 消息应紧跟在 assistant 之后");
|
||||
const char *c = json_get_string(json_array_get(r, 3), "content");
|
||||
CHECK(c && strstr(c, "没有执行") == NULL && strstr(c, "没有得到结果") != NULL,
|
||||
"占位消息要说明它没拿到结果,实际 %s", c ? c : "(null)");
|
||||
json_free(r);
|
||||
}
|
||||
json_free(bad);
|
||||
|
||||
puts("末尾悬空:assistant 之后再没有消息");
|
||||
JsonValue *tail = json_new_array();
|
||||
json_array_push(tail, mk_plain("user", "跑一下"));
|
||||
json_array_push(tail, mk_assistant(1));
|
||||
fixed = 0;
|
||||
r = agent_repair_tool_pairing(tail, &fixed);
|
||||
CHECK(r && pairing_ok(r) && fixed == 1, "结尾缺的也要补上(fixed=%d)", fixed);
|
||||
json_free(r);
|
||||
json_free(tail);
|
||||
|
||||
puts("孤儿 tool 消息:没有对应的 tool_calls,要丢掉");
|
||||
JsonValue *orphan = json_new_array();
|
||||
json_array_push(orphan, mk_plain("user", "你好"));
|
||||
json_array_push(orphan, mk_tool("谁也不是"));
|
||||
JsonValue *r2 = agent_repair_tool_pairing(orphan, &fixed);
|
||||
CHECK(fixed == 1, "孤儿 tool 消息应被计为 1 处,实际 %d", fixed);
|
||||
CHECK(r2 != NULL && json_array_size(r2) == 1, "孤儿应被丢掉,只剩 user");
|
||||
json_free(r2);
|
||||
json_free(orphan);
|
||||
|
||||
puts("空输入与边界");
|
||||
fixed = 7;
|
||||
CHECK(agent_repair_tool_pairing(NULL, &fixed) == NULL && fixed == 0, "NULL 输入");
|
||||
JsonValue *empty = json_new_array();
|
||||
CHECK(agent_repair_tool_pairing(empty, &fixed) == NULL, "空历史没什么可修");
|
||||
json_free(empty);
|
||||
/* out_fixed 可以为 NULL */
|
||||
JsonValue *b2 = json_new_array();
|
||||
json_array_push(b2, mk_assistant(1));
|
||||
JsonValue *r3 = agent_repair_tool_pairing(b2, NULL);
|
||||
json_free(r3);
|
||||
json_free(b2);
|
||||
CHECK(1, "out_fixed 为 NULL 时不崩");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 面向用户的历史口径:不含 system */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_conversation_len(void)
|
||||
{
|
||||
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 条,但对话是空的 */
|
||||
agent_add_system(&a, "你是助手");
|
||||
CHECK(agent_history_len(&a) == 1, "只有 system 时数组长度 1");
|
||||
CHECK(agent_conversation_len(&a) == 0,
|
||||
"只有 system 时**对话**长度必须是 0(用户报的就是这里)");
|
||||
|
||||
add_msg(&a, "user", "hi");
|
||||
add_msg(&a, "assistant", "你好");
|
||||
CHECK(agent_history_len(&a) == 3, "system + 一问一答 = 3");
|
||||
CHECK(agent_conversation_len(&a) == 2, "对话长度 2");
|
||||
|
||||
/* 没有 system 的历史(比如裸构造的)不该被误减 */
|
||||
Agent b;
|
||||
agent_init(&b);
|
||||
add_msg(&b, "user", "hi");
|
||||
CHECK(agent_history_len(&b) == 1 && agent_conversation_len(&b) == 1,
|
||||
"没有 system 时两个口径一致");
|
||||
|
||||
/* reset 之后回到"只有 system" */
|
||||
agent_reset(&a);
|
||||
CHECK(agent_history_len(&a) == 1 && agent_conversation_len(&a) == 0,
|
||||
"reset 之后数组剩 system、对话为空");
|
||||
|
||||
agent_free(&a);
|
||||
agent_free(&b);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
Agent a;
|
||||
agent_init(&a);
|
||||
CHECK(a.messages && a.messages->type == JSON_ARRAY, "messages 应为数组");
|
||||
|
||||
agent_add_system(&a, "S1");
|
||||
CHECK(agent_history_len(&a) == 1, "应只有 1 条 system");
|
||||
CHECK(count_role(&a, "system") == 1, "system 计数");
|
||||
|
||||
agent_add_user(&a, "U1");
|
||||
CHECK(agent_history_len(&a) == 2, "应 2 条");
|
||||
|
||||
/* 关键不变量:重复设置 system 应替换而不是追加 */
|
||||
agent_add_system(&a, "S2");
|
||||
CHECK(count_role(&a, "system") == 1, "重复设置 system 不应叠加,实际 %zu 条",
|
||||
count_role(&a, "system"));
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0,
|
||||
"新 system 应在第 0 条");
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 1), "content"), "U1") == 0,
|
||||
"原有对话应保留");
|
||||
|
||||
agent_add_user(&a, "U2");
|
||||
CHECK(agent_history_len(&a) == 3, "应 3 条");
|
||||
|
||||
agent_reset(&a);
|
||||
CHECK(agent_history_len(&a) == 1, "reset 后只保留 system,实际 %zu",
|
||||
agent_history_len(&a));
|
||||
CHECK(count_role(&a, "system") == 1, "reset 后 system 仍在");
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 0), "content"), "S2") == 0,
|
||||
"reset 保留的是最新 system");
|
||||
|
||||
/* 历史里没有 system 时 reset 应得到空历史 */
|
||||
Agent b;
|
||||
agent_init(&b);
|
||||
agent_add_user(&b, "only user");
|
||||
agent_reset(&b);
|
||||
CHECK(agent_history_len(&b) == 0, "无 system 时 reset 应为空,实际 %zu",
|
||||
agent_history_len(&b));
|
||||
|
||||
agent_free(&a);
|
||||
agent_free(&b);
|
||||
test_take_rounds_keeps_pairing();
|
||||
test_take_last_round();
|
||||
test_repair_pairing();
|
||||
test_conversation_len();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* test_archive.c - 存档命名规则与读写往返(不需要网络) */
|
||||
#include "archive.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int is_alnum_lower(const char *s, size_t n)
|
||||
{
|
||||
for (size_t i = 0; i < n; i++)
|
||||
if (!((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= '0' && s[i] <= '9')))
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
char tmpl[] = "/tmp/ligent_arch_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
if (!dir) { perror("mkdtemp"); return 2; }
|
||||
char err[256];
|
||||
|
||||
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);
|
||||
free(n1);
|
||||
|
||||
/* 空字符串与 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 位");
|
||||
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);
|
||||
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);
|
||||
free(x1); free(x2);
|
||||
|
||||
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);
|
||||
ok[maxlineage] = '\0';
|
||||
|
||||
char *edge = NULL;
|
||||
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);
|
||||
free(edge);
|
||||
|
||||
/* 再多一位就装不下了 */
|
||||
char *too = xmalloc(maxlineage + 2);
|
||||
memset(too, 'a', maxlineage + 1);
|
||||
too[maxlineage + 1] = '\0';
|
||||
char *fresh = NULL;
|
||||
CHECK(archive_next_name(dir, too, NULL, 0, &fresh, err, sizeof err) == 0, "超限生成失败");
|
||||
CHECK(fresh && strlen(fresh) == ARCHIVE_ROOT_LEN,
|
||||
"超限时应退回 %d 位根名,实际 %s(%zu 位)",
|
||||
ARCHIVE_ROOT_LEN, fresh ? fresh : "(null)", fresh ? strlen(fresh) : 0);
|
||||
free(fresh);
|
||||
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)");
|
||||
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)");
|
||||
free(tr);
|
||||
|
||||
/* 前缀要算进长度上限:原本刚好装得下的,加了 tmp_ 就该退回新根 */
|
||||
char *edgep = NULL;
|
||||
CHECK(archive_next_name(dir, ok, "tmp_", 0, &edgep, err, sizeof err) == 0, "边界失败");
|
||||
CHECK(edgep && strlen(edgep) == 4 + ARCHIVE_ROOT_LEN,
|
||||
"加了前缀装不下时应退回根名,实际 %s(%zu 位)",
|
||||
edgep ? edgep : "(null)", edgep ? strlen(edgep) : 0);
|
||||
free(edgep);
|
||||
|
||||
free(ok); free(too);
|
||||
|
||||
puts("导出与载入往返");
|
||||
JsonValue *msgs = json_new_array();
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", "user");
|
||||
json_object_set_string(m, "content", "你好,暗号是紫水晶");
|
||||
json_array_push(msgs, m);
|
||||
|
||||
char *path = NULL, *name = NULL;
|
||||
time_t t = 1700000000;
|
||||
CHECK(archive_export(msgs, dir, "parentname", NULL, 0, t, t + 5,
|
||||
"test-model", "http://example/v1",
|
||||
&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 : "");
|
||||
|
||||
char *created = NULL, *saved = NULL, *model = NULL, *iname = NULL, *iparent = NULL;
|
||||
JsonValue *back = archive_load(path, &created, &saved, &model, &iname, &iparent,
|
||||
err, sizeof err);
|
||||
CHECK(back != NULL, "载入失败: %s", err);
|
||||
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)");
|
||||
CHECK(model && strcmp(model, "test-model") == 0, "model 应往返");
|
||||
CHECK(created && strstr(created, "2023-") != NULL,
|
||||
"created 应可读:%s", created ? created : "(null)");
|
||||
/* saved 是"这份存档写出时的时间",与 created(这段对话的开始时间)是两回事 */
|
||||
CHECK(saved && strstr(saved, "2023-") != NULL,
|
||||
"saved 应可读:%s", saved ? saved : "(null)");
|
||||
|
||||
json_free(back); json_free(msgs);
|
||||
free(created); free(saved); free(model); free(iname); free(iparent);
|
||||
|
||||
puts("列举");
|
||||
ArchiveEntry *list = NULL;
|
||||
size_t n = archive_list(dir, &list);
|
||||
CHECK(n == 1, "应有 1 份存档,实际 %zu", n);
|
||||
if (n == 1) {
|
||||
CHECK(strncmp(list[0].name, "parentname_", 11) == 0, "名字应保留");
|
||||
CHECK(list[0].count == 1, "条数应为 1,实际 %ld", list[0].count);
|
||||
CHECK(list[0].created != NULL, "时间应从内容里读出来");
|
||||
}
|
||||
archive_list_free(list, n);
|
||||
|
||||
/* 非 ligent 存档(没有 format 标记)应被忽略 */
|
||||
char *jd = xasprintf("%s/junk.json", dir);
|
||||
FILE *j2 = fopen(jd, "w");
|
||||
if (j2) { fputs("{\"hello\":1}", j2); fclose(j2); }
|
||||
n = archive_list(dir, &list);
|
||||
CHECK(n == 1, "非 ligent 存档应被忽略,实际 %zu", n);
|
||||
archive_list_free(list, n);
|
||||
unlink(jd); free(jd);
|
||||
|
||||
unlink(path); free(path); free(name);
|
||||
rmdir(dir);
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/* test_attach.c - /include 的暂存、glob 展开与体积/类型判断
|
||||
*
|
||||
* 交互式挑选(attach_pick)要真终端才能测,由 e2e 第 55 段覆盖;
|
||||
* 这里测的是不需要人操作的那一半。
|
||||
*/
|
||||
#include "attach.h"
|
||||
#include "image.h"
|
||||
#include "json.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static void write_file(const char *path, const void *data, size_t n)
|
||||
{
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) { perror(path); exit(2); }
|
||||
if (n && fwrite(data, 1, n, f) != n) { perror("fwrite"); exit(2); }
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static const unsigned char PNG[] = {
|
||||
0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 'p', 'a', 'y', 'l', 'o', 'a', 'd'
|
||||
};
|
||||
|
||||
/* 数一数暂存里某类块有几个 */
|
||||
static size_t count_type(const JsonValue *parts, const char *type)
|
||||
{
|
||||
if (!parts) return 0;
|
||||
size_t n = 0;
|
||||
for (size_t i = 0; i < json_array_size((JsonValue *)parts); i++) {
|
||||
const char *t = json_get_string(json_array_get((JsonValue *)parts, i), "type");
|
||||
if (t && strcmp(t, type) == 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 记一下回调被问了几次 —— 它只在遇到图片时该被问到。
|
||||
* 答案和计数放在一起,免得"计数版只实现了一种答案"这种测试自己的错。 */
|
||||
static int ask_count;
|
||||
static int ask_answer;
|
||||
static int counting_ask(void *ud) { (void)ud; ask_count++; return ask_answer; }
|
||||
|
||||
int main(void)
|
||||
{
|
||||
char tmpl[] = "/tmp/ligent_attach_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
if (!dir) { perror("mkdtemp"); return 2; }
|
||||
|
||||
/* 备份目录也指到临时目录,别污染仓库 */
|
||||
image_set_bindir(dir);
|
||||
|
||||
char *f_c = xasprintf("%s/a.c", dir);
|
||||
char *f_txt = xasprintf("%s/b.txt", dir);
|
||||
char *f_bin = xasprintf("%s/bin.dat", dir);
|
||||
char *f_png = xasprintf("%s/pic.png", dir);
|
||||
char *f_big = xasprintf("%s/big.txt", dir);
|
||||
|
||||
write_file(f_c, "int main(void){return 0;}\n", 26);
|
||||
write_file(f_txt, "hello\n", 6);
|
||||
write_file(f_bin, "\x00\x01\x02binary", 9);
|
||||
write_file(f_png, PNG, sizeof PNG);
|
||||
{
|
||||
/* 超过单个文件上限(256 KB) */
|
||||
size_t n = ATTACH_MAX_FILE + 1024;
|
||||
char *big = xmalloc(n);
|
||||
memset(big, 'x', n);
|
||||
write_file(f_big, big, n);
|
||||
free(big);
|
||||
}
|
||||
|
||||
puts("glob 展开:命中几个就暂存几个");
|
||||
attach_clear();
|
||||
size_t skipped = 0;
|
||||
char err[256];
|
||||
int added = attach_glob(f_c, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 1 && attach_count() == 1, "命中一个文件");
|
||||
CHECK(skipped == 0, "没有跳过任何东西");
|
||||
|
||||
JsonValue *parts = attach_take_parts();
|
||||
CHECK(parts && json_array_size(parts) == 1, "取到 1 个块");
|
||||
JsonValue *p0 = parts ? json_array_get(parts, 0) : NULL;
|
||||
CHECK(p0 && strcmp(json_get_string(p0, "type"), "text") == 0,
|
||||
"文本文件应当是 text 块");
|
||||
const char *txt = p0 ? json_get_string(p0, "text") : NULL;
|
||||
CHECK(txt && strstr(txt, "int main") != NULL, "内容带上了");
|
||||
CHECK(txt && strncmp(txt, "===== ", 6) == 0, "块开头标了来源路径");
|
||||
CHECK(txt && strchr(txt, '\n') != NULL, "结尾有换行");
|
||||
/* 只在本地记的来源,绝不能进块里(那会跟着发给服务端) */
|
||||
CHECK(p0 && json_get_string(p0, "source") == NULL,
|
||||
"块里不该有自造的 source 字段");
|
||||
json_free(parts);
|
||||
CHECK(attach_count() == 0, "取走之后暂存清空");
|
||||
|
||||
puts("取走即清空,clear 也是");
|
||||
attach_glob(f_c, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(attach_count() == 1, "再附带一次");
|
||||
attach_clear();
|
||||
CHECK(attach_count() == 0, "attach_clear 清空");
|
||||
CHECK(attach_take_parts() == NULL, "清空之后取不到东西");
|
||||
|
||||
puts("二进制文件跳过");
|
||||
attach_clear();
|
||||
added = attach_glob(f_bin, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 0 && skipped == 1, "二进制被跳过(added=%d skipped=%zu)",
|
||||
added, skipped);
|
||||
|
||||
puts("超过单个文件上限的跳过");
|
||||
attach_clear();
|
||||
added = attach_glob(f_big, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 0 && skipped == 1, "太大的文本被跳过(added=%d)", added);
|
||||
|
||||
puts("图片:认内容不认扩展名,并走备份引用");
|
||||
attach_clear();
|
||||
added = attach_glob(f_png, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 1, "png 被收下");
|
||||
CHECK(attach_has_image(), "暂存里有图片");
|
||||
parts = attach_take_parts();
|
||||
CHECK(count_type(parts, "ligent_image") == 1, "应当是一个 ligent_image 引用块");
|
||||
json_free(parts);
|
||||
|
||||
puts("模型不收图时跳过图片(回调只在遇到图片时被问)");
|
||||
attach_clear();
|
||||
ask_count = 0; ask_answer = 1;
|
||||
added = attach_glob(f_txt, counting_ask, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 1, "纯文本照带");
|
||||
CHECK(ask_count == 0, "纯文本不该去问'能不能收图'(问了一次就是白探测)");
|
||||
attach_clear();
|
||||
ask_count = 0; ask_answer = 0;
|
||||
added = attach_glob(f_png, counting_ask, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 0 && skipped == 1, "回调说不行就跳过");
|
||||
CHECK(ask_count == 1, "遇到图片时问了一次,实际 %d", ask_count);
|
||||
|
||||
puts("匹配不到 / 参数非法");
|
||||
attach_clear();
|
||||
added = attach_glob("/tmp/绝对不存在的模式*.zzz", NULL, NULL, &skipped, err, sizeof 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);
|
||||
CHECK(added == -1, "空模式返回 -1");
|
||||
|
||||
puts("多个匹配会累加");
|
||||
attach_clear();
|
||||
char *pat = xasprintf("%s/*.txt", dir);
|
||||
added = attach_glob(pat, NULL, NULL, &skipped, err, sizeof err);
|
||||
CHECK(added == 1, "只有 b.txt 能带(big.txt 超限),实际 %d", added);
|
||||
CHECK(skipped == 1, "big.txt 被跳过");
|
||||
free(pat);
|
||||
attach_clear();
|
||||
|
||||
free(f_c); free(f_txt); free(f_bin); free(f_png); free(f_big);
|
||||
image_set_bindir(".");
|
||||
char *cmd = xasprintf("rm -rf %s", tmpl);
|
||||
if (system(cmd) != 0) { }
|
||||
free(cmd);
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* test_capability.c - 能力探测的纯逻辑部分
|
||||
*
|
||||
* 探测序列本身(基线 -> 合并 -> 拆开)要发 HTTP,属于端到端范畴,
|
||||
* 由 e2e 第 51 段对着 mock 覆盖:正常模型 / 不收图片 / 不收 tools /
|
||||
* 探测失败(401)/ --no-probe 五种走法都在那儿验。
|
||||
*
|
||||
* 这里只测不需要网络的:名字映射、关闭探测时的语义、缓存查询。
|
||||
* 其中"关闭探测必须给未测而不是不支持"那条是回归测试 —— 那句话看着像废话,
|
||||
* 但 CAP_NO 恰好是 0,而 Agent 是 memset 出来的,忘了显式初始化就会一声不响地
|
||||
* 把所有工具摘掉。这个 bug 真的发生过一次。
|
||||
*/
|
||||
#include "capability.h"
|
||||
#include "llm.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main(void)
|
||||
{
|
||||
/* CAP_NO 必须是 0 —— Agent 是 memset 出来的,整个 capability 模块的
|
||||
* 设计都建立在"零值不能是某个真实结论"之上。这条断言是那个 bug 的护栏。 */
|
||||
CHECK(CAP_NO == 0, "CAP_NO 应为 0(零值即'不支持'是事故的根源)");
|
||||
CHECK(CAP_UNKNOWN != CAP_NO, "未知与不支持必须是两个不同的值");
|
||||
CHECK(CAP_UNKNOWN != CAP_YES, "未知与支持必须是两个不同的值");
|
||||
|
||||
puts("名字映射");
|
||||
CHECK(strcmp(cap_name(CAP_YES), "支持") == 0, "YES");
|
||||
CHECK(strcmp(cap_name(CAP_NO), "不支持") == 0, "NO");
|
||||
CHECK(strcmp(cap_name(CAP_UNKNOWN), "未测") == 0, "UNKNOWN");
|
||||
/* 任何不是 YES/NO 的值都该落到"未测",而不是碰巧落到某一个 */
|
||||
CHECK(strcmp(cap_name(42), "未测") == 0, "越界值应落到未测");
|
||||
|
||||
puts("关掉探测:一律未测,绝不冒充结论");
|
||||
/* 用可变数组而不是字面量:LlmConfig 的这些字段是 char *(真要释放的),
|
||||
* 而 -Wwrite-strings 下 "字面量" 是 const char * —— 直接赋值会被警告。 */
|
||||
char model[] = "some-model";
|
||||
char base_url[] = "http://127.0.0.1:1/v1";
|
||||
LlmConfig cfg;
|
||||
memset(&cfg, 0, sizeof cfg);
|
||||
cfg.model = model;
|
||||
cfg.base_url = base_url;
|
||||
cfg.timeout = 1;
|
||||
|
||||
cap_cache_clear();
|
||||
|
||||
Capabilities c;
|
||||
memset(&c, 0xAB, sizeof c); /* 故意填脏,验证会被完整覆盖 */
|
||||
int probed = cap_get(&cfg, 0, &c, 0, NULL, 0);
|
||||
CHECK(probed == 0, "被禁用时不该声称探测过");
|
||||
CHECK(c.probed == 0, "probed 应为 0");
|
||||
CHECK(c.vision == CAP_UNKNOWN, "禁用时 vision 应为未测,实际 %d", c.vision);
|
||||
CHECK(c.tools == CAP_UNKNOWN, "禁用时 tools 应为未测,实际 %d", c.tools);
|
||||
CHECK(c.conclusive == 0, "禁用时不该声称有结论");
|
||||
|
||||
/* 关键回归:禁用时的结果拿去决定工具表,必须"全都留着" */
|
||||
CHECK(c.vision != CAP_NO, "禁用时不能表现为不支持(否则会藏掉 read_image)");
|
||||
CHECK(c.tools != CAP_NO, "禁用时不能表现为不支持(否则会摘掉整张工具表)");
|
||||
|
||||
puts("缓存查询");
|
||||
Capabilities got;
|
||||
memset(&got, 0xAB, sizeof got);
|
||||
CHECK(cap_cached(&cfg, &got) == 0, "没测过时缓存应未命中");
|
||||
/* 关掉探测不会写入缓存 */
|
||||
CHECK(cap_cached(&cfg, NULL) == 0, "禁用不该往缓存里写东西");
|
||||
|
||||
cap_cache_clear();
|
||||
CHECK(cap_cached(&cfg, &got) == 0, "清空之后仍应未命中");
|
||||
|
||||
/* 空指针不该崩 */
|
||||
cap_get(NULL, 0, &c, 0, NULL, 0);
|
||||
CHECK(c.vision == CAP_UNKNOWN, "cfg 为空也要给未测");
|
||||
cap_get(&cfg, 0, NULL, 0, NULL, 0);
|
||||
cap_cache_clear();
|
||||
CHECK(1, "空指针路径没有崩");
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/* test_config.c - 配置默认值 / 环境变量 / 配置文件覆盖 */
|
||||
#include "config.h"
|
||||
#include "util.h"
|
||||
#include "i18n.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <limits.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static void unset_all(void)
|
||||
{
|
||||
const char *vars[] = {
|
||||
"LIGENT_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY",
|
||||
"LIGENT_BASE_URL", "LIGENT_MODEL", "LIGENT_SYSTEM",
|
||||
"LIGENT_TEMPERATURE", "LIGENT_MAX_TOKENS", "LIGENT_TIMEOUT",
|
||||
"LIGENT_MAX_STEPS", "LIGENT_STREAM", "LIGENT_TOOLS",
|
||||
"OPENAI_BASE_URL", "LIGENT_CONFIG",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof vars / sizeof vars[0]; i++)
|
||||
unsetenv(vars[i]);
|
||||
}
|
||||
|
||||
static void test_defaults(void)
|
||||
{
|
||||
puts("默认值");
|
||||
unset_all();
|
||||
LigentConfig c;
|
||||
config_defaults(&c);
|
||||
CHECK(strcmp(c.model, "deepseek-flash") == 0,
|
||||
"默认模型应为 deepseek-flash,实际 %s", c.model);
|
||||
CHECK(strcmp(c.base_url, "https://api.deepseek.com/v1") == 0,
|
||||
"默认 base_url 应为 DeepSeek,实际 %s", c.base_url);
|
||||
CHECK(c.api_key && c.api_key[0] == '\0', "默认 api_key 应为空");
|
||||
CHECK(c.stream == 1, "默认应开启流式");
|
||||
CHECK(c.use_tools == 1, "默认应启用工具");
|
||||
/* 默认不限往返次数:0(或负数)= 不限,只有正数才是上限 */
|
||||
CHECK(c.max_steps == 0, "默认应不限往返次数,实际 %d", c.max_steps);
|
||||
config_free(&c);
|
||||
}
|
||||
|
||||
static void test_env_key(void)
|
||||
{
|
||||
puts("环境变量取 key");
|
||||
unset_all();
|
||||
LigentConfig c;
|
||||
config_defaults(&c);
|
||||
|
||||
/* 没有任何 key */
|
||||
config_apply_env(&c);
|
||||
CHECK(c.api_key[0] == '\0', "无环境变量时 key 应为空");
|
||||
|
||||
/* 只设 DEEPSEEK_API_KEY:默认就应读它 */
|
||||
setenv("DEEPSEEK_API_KEY", "sk-deepseek", 1);
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-deepseek") == 0,
|
||||
"应读 $DEEPSEEK_API_KEY,实际 [%s]", c.api_key);
|
||||
|
||||
/* LIGENT_API_KEY 作为显式覆盖优先 */
|
||||
setenv("LIGENT_API_KEY", "sk-explicit", 1);
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
|
||||
"LIGENT_API_KEY 应覆盖,实际 [%s]", c.api_key);
|
||||
|
||||
/* OPENAI_API_KEY 只在端点确实是 OpenAI 时才兜底 ——
|
||||
* 把 OpenAI 的密钥发给 DeepSeek 的端点是没有意义的 */
|
||||
unset_all();
|
||||
setenv("OPENAI_API_KEY", "sk-openai", 1);
|
||||
/* 模拟"配置文件没写 api_key":必须先清掉上一步留下的值 */
|
||||
free(c.api_key);
|
||||
c.api_key = xstrdup("");
|
||||
config_apply_env(&c);
|
||||
CHECK(c.api_key[0] == '\0', "DeepSeek 端点不应兜底到 OPENAI_API_KEY");
|
||||
|
||||
free(c.base_url);
|
||||
c.base_url = xstrdup("https://api.openai.com/v1");
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-openai") == 0,
|
||||
"OpenAI 端点应兜底到 OPENAI_API_KEY,实际 %s", c.api_key);
|
||||
|
||||
unset_all();
|
||||
config_free(&c);
|
||||
}
|
||||
|
||||
static void test_env_others(void)
|
||||
{
|
||||
puts("环境变量其他项");
|
||||
unset_all();
|
||||
LigentConfig c;
|
||||
config_defaults(&c);
|
||||
setenv("LIGENT_MODEL", "my-model", 1);
|
||||
setenv("LIGENT_MAX_STEPS", "7", 1);
|
||||
setenv("LIGENT_STREAM", "0", 1);
|
||||
setenv("LIGENT_TOOLS", "false", 1);
|
||||
setenv("LIGENT_THINKING", "off", 1);
|
||||
setenv("LIGENT_REASONING_EFFORT", "max", 1);
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.model, "my-model") == 0, "LIGENT_MODEL 应生效");
|
||||
CHECK(c.max_steps == 7, "LIGENT_MAX_STEPS 应生效");
|
||||
CHECK(c.stream == 0, "LIGENT_STREAM=0 应关闭流式");
|
||||
CHECK(c.use_tools == 0, "LIGENT_TOOLS=false 应关闭工具");
|
||||
CHECK(c.thinking == THINKING_OFF, "LIGENT_THINKING=off 应关闭思考");
|
||||
CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "max") == 0,
|
||||
"LIGENT_REASONING_EFFORT=max 应生效");
|
||||
unset_all();
|
||||
config_free(&c);
|
||||
|
||||
/* 环境变量是临时开关:写错了应当被忽略,而不是把程序拦下来 */
|
||||
config_defaults(&c);
|
||||
setenv("LIGENT_THINKING", "随便写的", 1);
|
||||
setenv("LIGENT_REASONING_EFFORT", "超高", 1);
|
||||
config_apply_env(&c);
|
||||
CHECK(c.thinking == THINKING_AUTO, "无法识别的 LIGENT_THINKING 应被忽略");
|
||||
CHECK(c.reasoning_effort == NULL, "无法识别的级别应被忽略");
|
||||
unset_all();
|
||||
config_free(&c);
|
||||
}
|
||||
|
||||
static void test_thinking_parse(void)
|
||||
{
|
||||
puts("思考模式 / 级别解析");
|
||||
int t = 123;
|
||||
CHECK(config_parse_thinking("auto", &t) == 0 && t == THINKING_AUTO, "auto");
|
||||
CHECK(config_parse_thinking("", &t) == 0 && t == THINKING_AUTO, "空串 = auto");
|
||||
CHECK(config_parse_thinking("on", &t) == 0 && t == THINKING_ON, "on");
|
||||
CHECK(config_parse_thinking("true", &t) == 0 && t == THINKING_ON, "true");
|
||||
CHECK(config_parse_thinking("enabled", &t) == 0 && t == THINKING_ON, "enabled");
|
||||
CHECK(config_parse_thinking("off", &t) == 0 && t == THINKING_OFF, "off");
|
||||
CHECK(config_parse_thinking("false", &t) == 0 && t == THINKING_OFF, "false");
|
||||
CHECK(config_parse_thinking("disabled", &t) == 0 && t == THINKING_OFF, "disabled");
|
||||
CHECK(config_parse_thinking("maybe", &t) != 0, "无法识别的值应报错");
|
||||
CHECK(config_parse_thinking(NULL, &t) != 0, "NULL 应报错");
|
||||
|
||||
static const char *const good[] = {
|
||||
"minimal", "low", "medium", "high", "xhigh", "max", "ultra", NULL
|
||||
};
|
||||
for (size_t i = 0; good[i]; i++)
|
||||
CHECK(config_effort_valid(good[i]), "%s 应是合法级别", good[i]);
|
||||
CHECK(!config_effort_valid("huge"), "huge 不是合法级别");
|
||||
CHECK(!config_effort_valid(""), "空串不是合法级别");
|
||||
CHECK(!config_effort_valid(NULL), "NULL 不是合法级别");
|
||||
CHECK(strstr(config_effort_list(), "ultra") != NULL, "展示串应含全部级别");
|
||||
CHECK(config_thinking_name(THINKING_AUTO) != NULL, "THINKING_AUTO 有说明");
|
||||
CHECK(config_thinking_name(THINKING_ON) != NULL, "THINKING_ON 有说明");
|
||||
CHECK(config_thinking_name(THINKING_OFF) != NULL, "THINKING_OFF 有说明");
|
||||
}
|
||||
|
||||
static void test_file_thinking(void)
|
||||
{
|
||||
puts("配置文件里的 thinking / reasoning_effort");
|
||||
char err[256];
|
||||
LigentConfig c;
|
||||
|
||||
/* 布尔写法 */
|
||||
char t1[] = "/tmp/ligent_think_XXXXXX";
|
||||
int fd = mkstemp(t1);
|
||||
if (fd < 0) { CHECK(0, "mkstemp 失败"); return; }
|
||||
const char *j1 = "{\"thinking\":false,\"reasoning_effort\":\"medium\"}";
|
||||
ssize_t w = write(fd, j1, strlen(j1));
|
||||
close(fd);
|
||||
CHECK(w == (ssize_t)strlen(j1), "写临时配置失败");
|
||||
config_defaults(&c);
|
||||
CHECK(config_load_file(&c, t1, err, sizeof err) == 0, "加载失败: %s", err);
|
||||
CHECK(c.thinking == THINKING_OFF, "thinking:false 应为关闭");
|
||||
CHECK(c.reasoning_effort && strcmp(c.reasoning_effort, "medium") == 0,
|
||||
"reasoning_effort 应被读入");
|
||||
config_free(&c);
|
||||
unlink(t1);
|
||||
|
||||
/* 字符串写法 */
|
||||
char t2[] = "/tmp/ligent_think_XXXXXX";
|
||||
fd = mkstemp(t2);
|
||||
if (fd >= 0) {
|
||||
const char *j2 = "{\"thinking\":\"on\"}";
|
||||
w = write(fd, j2, strlen(j2));
|
||||
close(fd);
|
||||
CHECK(w == (ssize_t)strlen(j2), "写临时配置失败");
|
||||
config_defaults(&c);
|
||||
CHECK(config_load_file(&c, t2, err, sizeof err) == 0, "加载失败: %s", err);
|
||||
CHECK(c.thinking == THINKING_ON, "thinking:\"on\" 应为开启");
|
||||
config_free(&c);
|
||||
unlink(t2);
|
||||
}
|
||||
|
||||
/* 写错了必须报错,不能静默忽略 —— 配置文件是长期存在的,静默会让人困惑 */
|
||||
char t3[] = "/tmp/ligent_think_XXXXXX";
|
||||
fd = mkstemp(t3);
|
||||
if (fd >= 0) {
|
||||
const char *j3 = "{\"thinking\":\"大概吧\"}";
|
||||
w = write(fd, j3, strlen(j3));
|
||||
close(fd);
|
||||
CHECK(w == (ssize_t)strlen(j3), "写临时配置失败");
|
||||
config_defaults(&c);
|
||||
CHECK(config_load_file(&c, t3, err, sizeof err) != 0,
|
||||
"非法的 thinking 应加载失败");
|
||||
config_free(&c);
|
||||
unlink(t3);
|
||||
}
|
||||
|
||||
char t4[] = "/tmp/ligent_think_XXXXXX";
|
||||
fd = mkstemp(t4);
|
||||
if (fd >= 0) {
|
||||
const char *j4 = "{\"reasoning_effort\":\"超级高\"}";
|
||||
w = write(fd, j4, strlen(j4));
|
||||
close(fd);
|
||||
CHECK(w == (ssize_t)strlen(j4), "写临时配置失败");
|
||||
config_defaults(&c);
|
||||
CHECK(config_load_file(&c, t4, err, sizeof err) != 0,
|
||||
"非法的 reasoning_effort 应加载失败");
|
||||
config_free(&c);
|
||||
unlink(t4);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_file_override(void)
|
||||
{
|
||||
puts("配置文件覆盖 + ${VAR} 展开");
|
||||
unset_all();
|
||||
setenv("MY_SECRET", "sk-from-env", 1);
|
||||
|
||||
char tmpl[] = "/tmp/ligent_cfg_XXXXXX";
|
||||
int fd = mkstemp(tmpl);
|
||||
CHECK(fd >= 0, "mkstemp 失败");
|
||||
if (fd < 0) return;
|
||||
const char *json =
|
||||
"{\"base_url\":\"http://localhost:11434/v1\","
|
||||
"\"api_key\":\"${MY_SECRET}\","
|
||||
"\"model\":\"qwen2.5:14b\","
|
||||
"\"temperature\":0.3,"
|
||||
"\"max_tokens\":4096,"
|
||||
"\"max_steps\":9,"
|
||||
"\"stream\":false,"
|
||||
"\"tools\":false}";
|
||||
ssize_t w = write(fd, json, strlen(json));
|
||||
close(fd);
|
||||
CHECK(w == (ssize_t)strlen(json), "写临时配置失败");
|
||||
|
||||
LigentConfig c;
|
||||
config_defaults(&c);
|
||||
char err[256];
|
||||
int rc = config_load_file(&c, tmpl, err, sizeof err);
|
||||
CHECK(rc == 0, "加载配置失败: %s", err);
|
||||
CHECK(strcmp(c.base_url, "http://localhost:11434/v1") == 0, "base_url 覆盖");
|
||||
CHECK(strcmp(c.model, "qwen2.5:14b") == 0, "model 覆盖");
|
||||
CHECK(strcmp(c.api_key, "sk-from-env") == 0,
|
||||
"${MY_SECRET} 应被展开,实际 [%s]", c.api_key);
|
||||
CHECK(c.temperature > 0.29 && c.temperature < 0.31, "temperature 覆盖");
|
||||
CHECK(c.max_tokens == 4096, "max_tokens 覆盖");
|
||||
CHECK(c.max_steps == 9, "max_steps 覆盖");
|
||||
CHECK(c.stream == 0, "stream 覆盖");
|
||||
CHECK(c.use_tools == 0, "tools 覆盖");
|
||||
CHECK(c.config_path && strcmp(c.config_path, tmpl) == 0, "记录配置文件路径");
|
||||
|
||||
/* 坏配置应报错而不是静默通过。
|
||||
*
|
||||
* 解析器的报错跟着界面语言走(json_parse_ui),所以下面这段断语先把语言
|
||||
* 钉住,再顺手把英文那份也验一遍 —— 位置信息两种语言都得有。 */
|
||||
char tmpl2[] = "/tmp/ligent_bad_XXXXXX";
|
||||
int fd2 = mkstemp(tmpl2);
|
||||
if (fd2 >= 0) {
|
||||
const char *bad = "{ not json ";
|
||||
ssize_t w2 = write(fd2, bad, strlen(bad));
|
||||
(void)w2;
|
||||
close(fd2);
|
||||
LigentConfig c2;
|
||||
i18n_set_lang(LIGENT_LANG_ZH);
|
||||
config_defaults(&c2);
|
||||
CHECK(config_load_file(&c2, tmpl2, err, sizeof err) != 0,
|
||||
"非法 JSON 应加载失败");
|
||||
CHECK(err[0] != '\0', "应给出错误信息");
|
||||
/* 报错必须带上文件路径与出错位置,否则用户无从下手 */
|
||||
CHECK(strstr(err, tmpl2) != NULL, "错误信息应含文件路径: %s", err);
|
||||
CHECK(strstr(err, "行") != NULL, "错误信息应含出错位置: %s", err);
|
||||
config_free(&c2);
|
||||
|
||||
LigentConfig c3;
|
||||
i18n_set_lang(LIGENT_LANG_EN);
|
||||
config_defaults(&c3);
|
||||
CHECK(config_load_file(&c3, tmpl2, err, sizeof err) != 0 &&
|
||||
strstr(err, "line") != NULL,
|
||||
"英文界面下报错也带位置(且是英文): %s", err);
|
||||
config_free(&c3);
|
||||
i18n_set_lang(LIGENT_LANG_ZH);
|
||||
unlink(tmpl2);
|
||||
}
|
||||
|
||||
config_free(&c);
|
||||
unlink(tmpl);
|
||||
unset_all();
|
||||
}
|
||||
|
||||
/* 配置只认二进制所在目录:不做用户级、系统级,也不做当前目录查找 */
|
||||
static void test_binary_dir_lookup(void)
|
||||
{
|
||||
puts("配置位置 = 二进制所在目录");
|
||||
char err[256];
|
||||
|
||||
char *dir = config_binary_dir(NULL);
|
||||
char *canon = config_default_path(NULL);
|
||||
CHECK(dir && *dir, "应能解析出二进制所在目录");
|
||||
CHECK(canon && str_ends_with(canon, "/ligent.json"),
|
||||
"规范路径应以 /ligent.json 结尾: %s", canon ? canon : "(null)");
|
||||
CHECK(canon && dir && str_starts_with(canon, dir),
|
||||
"规范路径应位于二进制目录下: %s / %s", canon, dir);
|
||||
|
||||
/* 当前目录放一份 ligent.json,它绝不能被加载 */
|
||||
char tmpl[] = "/tmp/ligent_cwd_XXXXXX";
|
||||
char *tmp = mkdtemp(tmpl);
|
||||
if (!tmp) { free(dir); free(canon); return; }
|
||||
|
||||
char *fake = xasprintf("%s/ligent.json", tmp);
|
||||
FILE *f = fopen(fake, "w");
|
||||
if (f) { fputs("{\"model\":\"FROM-CWD-ONLY\"}", f); fclose(f); }
|
||||
|
||||
char *saved = getcwd(NULL, 0);
|
||||
int loaded_from_cwd = 0;
|
||||
char *got_dir = NULL;
|
||||
|
||||
if (chdir(tmp) == 0) {
|
||||
LigentConfig c;
|
||||
config_init(&c, NULL, NULL, err, sizeof err);
|
||||
loaded_from_cwd = (c.model && strcmp(c.model, "FROM-CWD-ONLY") == 0);
|
||||
got_dir = c.config_dir ? xstrdup(c.config_dir) : NULL;
|
||||
config_free(&c);
|
||||
if (saved) chdir(saved);
|
||||
}
|
||||
|
||||
CHECK(!loaded_from_cwd, "不应加载当前工作目录下的 ligent.json");
|
||||
CHECK(got_dir && dir && strcmp(got_dir, dir) == 0,
|
||||
"config_dir 应是二进制目录,实际 %s", got_dir ? got_dir : "(null)");
|
||||
|
||||
free(got_dir);
|
||||
free(saved);
|
||||
unlink(fake); /* 先删文件再释放路径字符串 */
|
||||
free(fake);
|
||||
rmdir(tmp);
|
||||
free(dir);
|
||||
free(canon);
|
||||
}
|
||||
|
||||
/* 写一个临时配置文件并加载 */
|
||||
static int load_temp_config(LigentConfig *c, const char *json)
|
||||
{
|
||||
char tmpl[] = "/tmp/ligent_prec_XXXXXX";
|
||||
int fd = mkstemp(tmpl);
|
||||
if (fd < 0) return -1;
|
||||
ssize_t w = write(fd, json, strlen(json));
|
||||
close(fd);
|
||||
if (w != (ssize_t)strlen(json)) { unlink(tmpl); return -1; }
|
||||
char err[256];
|
||||
int rc = config_load_file(c, tmpl, err, sizeof err);
|
||||
unlink(tmpl);
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* 厂商专属的环境变量不能越权覆盖配置文件里写好的密钥。
|
||||
* 这正是用户踩到的坑:配置写了 OpenRouter 的 key,
|
||||
* shell 里残留的 DEEPSEEK_API_KEY 把它顶掉,于是 A 家的密钥发给了 B 家。 */
|
||||
static void test_env_key_precedence(void)
|
||||
{
|
||||
puts("api_key 优先级:厂商环境变量不得越权");
|
||||
unset_all();
|
||||
setenv("DEEPSEEK_API_KEY", "sk-leftover-in-shell", 1);
|
||||
|
||||
/* 1) 配置文件写了 key => 它就是最终值,环境变量顶不掉 */
|
||||
LigentConfig c;
|
||||
config_defaults(&c);
|
||||
CHECK(load_temp_config(&c,
|
||||
"{\"base_url\":\"https://openrouter.ai/api/v1\","
|
||||
"\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败");
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-or-v1-from-file") == 0,
|
||||
"配置文件里的 key 不应被残留环境变量顶掉,实际 %s", c.api_key);
|
||||
config_free(&c);
|
||||
|
||||
/* 2) 配置文件没写 key + 端点就是 DeepSeek => 用 DEEPSEEK_API_KEY 兜底 */
|
||||
config_defaults(&c);
|
||||
CHECK(load_temp_config(&c, "{\"base_url\":\"https://api.deepseek.com/v1\"}") == 0,
|
||||
"加载配置失败");
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-leftover-in-shell") == 0,
|
||||
"DeepSeek 端点应能用 DEEPSEEK_API_KEY 兜底,实际 %s", c.api_key);
|
||||
config_free(&c);
|
||||
|
||||
/* 3) 配置文件没写 key + 端点是别家 => 不猜,宁可空着让用户看到明确提示 */
|
||||
config_defaults(&c);
|
||||
CHECK(load_temp_config(&c, "{\"base_url\":\"https://openrouter.ai/api/v1\"}") == 0,
|
||||
"加载配置失败");
|
||||
config_apply_env(&c);
|
||||
CHECK(c.api_key[0] == '\0',
|
||||
"base_url 指向别家时不应拿 DeepSeek 的密钥去猜,实际 %s", c.api_key);
|
||||
config_free(&c);
|
||||
|
||||
/* 4) LIGENT_API_KEY 是显式覆盖,任何时候都最高 */
|
||||
setenv("LIGENT_API_KEY", "sk-explicit", 1);
|
||||
config_defaults(&c);
|
||||
CHECK(load_temp_config(&c,
|
||||
"{\"base_url\":\"https://openrouter.ai/api/v1\","
|
||||
"\"api_key\":\"sk-or-v1-from-file\"}") == 0, "加载配置失败");
|
||||
config_apply_env(&c);
|
||||
CHECK(strcmp(c.api_key, "sk-explicit") == 0,
|
||||
"LIGENT_API_KEY 应最高优先,实际 %s", c.api_key);
|
||||
config_free(&c);
|
||||
|
||||
unset_all();
|
||||
}
|
||||
|
||||
/* 软链接:配置目录要跟着"用户实际调用那个文件"走。
|
||||
* 否则把 ligent 软链到 ~/bin 之后,配置会被悄悄挪到源文件所在的目录,
|
||||
* 用户放在 ~/bin 旁边的 ligent.json 就永远读不到。 */
|
||||
static void test_symlink_dir(void)
|
||||
{
|
||||
puts("软链接:配置目录跟着软链接走");
|
||||
|
||||
char self[PATH_MAX];
|
||||
ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
|
||||
if (n <= 0) { puts(" (没有 /proc/self/exe,跳过)"); return; }
|
||||
self[n] = '\0';
|
||||
|
||||
char tmpl[] = "/tmp/ligent_link_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
if (!dir) { puts(" (mkdtemp 失败,跳过)"); return; }
|
||||
|
||||
char *link = xasprintf("%s/ligent", dir);
|
||||
if (symlink(self, link) != 0) {
|
||||
puts(" (symlink 失败,跳过)");
|
||||
free(link); rmdir(dir);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 核心:指向软链接时取软链接所在目录,而不是源文件所在目录 */
|
||||
char *got = config_binary_dir(link);
|
||||
CHECK(got && strcmp(got, dir) == 0,
|
||||
"应取软链接所在目录 %s,实际 %s", dir, got ? got : "(null)");
|
||||
free(got);
|
||||
|
||||
/* 路径里的 . 应被规范化,但最后一段仍不解析 */
|
||||
char *dotted = xasprintf("%s/./ligent", dir);
|
||||
got = config_binary_dir(dotted);
|
||||
CHECK(got && strcmp(got, dir) == 0,
|
||||
"带 . 的路径应规范化,实际 %s", got ? got : "(null)");
|
||||
free(got); free(dotted);
|
||||
|
||||
/* argv[0] 只是个名字时经 $PATH 找到的仍应是软链接本身 */
|
||||
char *saved = getenv("PATH") ? xstrdup(getenv("PATH")) : NULL;
|
||||
char *newpath = xasprintf("%s:%s", dir, saved ? saved : "");
|
||||
setenv("PATH", newpath, 1);
|
||||
got = config_binary_dir("ligent");
|
||||
CHECK(got && strcmp(got, dir) == 0,
|
||||
"$PATH 找到软链接时应取它所在目录,实际 %s", got ? got : "(null)");
|
||||
free(got);
|
||||
if (saved) setenv("PATH", saved, 1); else unsetenv("PATH");
|
||||
free(saved); free(newpath);
|
||||
|
||||
/* 直接用真实文件时,取的还是真实目录 */
|
||||
char *selfdir = xstrdup(self);
|
||||
char *slash = strrchr(selfdir, '/');
|
||||
if (slash) *slash = '\0';
|
||||
got = config_binary_dir(self);
|
||||
CHECK(got && strcmp(got, selfdir) == 0,
|
||||
"真实文件应取真实目录 %s,实际 %s", selfdir, got ? got : "(null)");
|
||||
free(got); free(selfdir);
|
||||
|
||||
unlink(link);
|
||||
free(link);
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_defaults();
|
||||
test_env_key();
|
||||
test_env_others();
|
||||
test_thinking_parse();
|
||||
test_file_thinking();
|
||||
test_file_override();
|
||||
test_binary_dir_lookup();
|
||||
test_env_key_precedence();
|
||||
test_symlink_dir();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* test_http.c - HTTP 层纯逻辑测试(不联网) */
|
||||
#include "http.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
int main(void)
|
||||
{
|
||||
puts("重试判定:只重试服务端明确报错的情况");
|
||||
|
||||
/* 应当重试 */
|
||||
long retryable[] = { 408, 409, 429, 500, 502, 503, 504 };
|
||||
for (size_t i = 0; i < sizeof retryable / sizeof retryable[0]; i++)
|
||||
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++)
|
||||
CHECK(http_status_retryable(permanent[i]) == 0,
|
||||
"HTTP %ld 不应重试", permanent[i]);
|
||||
|
||||
/* 默认策略与上限的合理性 */
|
||||
HttpRetry def = HTTP_RETRY_DEFAULT;
|
||||
HttpRetry none = HTTP_RETRY_NONE;
|
||||
CHECK(def.max_retries > 0, "默认应当会重试");
|
||||
CHECK(none.max_retries == 0, "HTTP_RETRY_NONE 应当不重试");
|
||||
CHECK(HTTP_MAX_BODY >= 1024 * 1024, "响应体上限不该小到影响正常使用");
|
||||
CHECK(HTTP_MAX_ERRBODY < HTTP_MAX_BODY, "错误体上限应远小于正文上限");
|
||||
|
||||
/* ---- 调试输出的密钥脱敏 ---- */
|
||||
puts("--verbose 输出脱敏");
|
||||
{
|
||||
const char *key = "sk-0f114e3dac8249f0a36fd8fd5c517360";
|
||||
char *buf = NULL;
|
||||
size_t sz = 0;
|
||||
FILE *f = open_memstream(&buf, &sz);
|
||||
|
||||
/* 这条正是曾经漏出去的:libcurl 的 HTTP/2 调试行走的是 CURLINFO_TEXT,
|
||||
* 不是 HEADER_OUT,所以只处理 HEADER_OUT 的做法根本挡不住 */
|
||||
const char *h2 = "== Info: [HTTP/2] [1] [authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360]\n";
|
||||
http_write_redacted(f, h2, strlen(h2), key);
|
||||
|
||||
/* 整块请求头(HEADER_OUT 是整块传来的,不是逐行) */
|
||||
const char *hdrs = "POST /v1/chat/completions HTTP/2\r\n"
|
||||
"Authorization: Bearer sk-0f114e3dac8249f0a36fd8fd5c517360\r\n"
|
||||
"Content-Type: application/json\r\n";
|
||||
http_write_redacted(f, hdrs, strlen(hdrs), key);
|
||||
|
||||
/* 其它内容不能被改动 */
|
||||
const char *plain = "== Info: TLSv1.3 (IN), TLS handshake, Finished (20):\n";
|
||||
http_write_redacted(f, plain, strlen(plain), key);
|
||||
|
||||
/* 没有密钥时原样输出 */
|
||||
http_write_redacted(f, "no key here\n", 12, "");
|
||||
|
||||
fclose(f);
|
||||
CHECK(strstr(buf, key) == NULL, "输出里不应残留完整密钥");
|
||||
CHECK(strstr(buf, "<已隐藏>") != NULL, "应替换为占位符");
|
||||
CHECK(strstr(buf, "[HTTP/2]") != NULL, "HTTP/2 调试行本身应保留");
|
||||
CHECK(strstr(buf, "Authorization: Bearer <已隐藏>") != NULL,
|
||||
"请求头里的密钥应脱敏: %.200s", buf);
|
||||
CHECK(strstr(buf, "TLS handshake") != NULL, "其它调试信息不能被误改");
|
||||
CHECK(strstr(buf, "no key here") != NULL, "无密钥时原样输出");
|
||||
/* 一次替换必须整段吃掉,不能留下半个密钥 */
|
||||
CHECK(strstr(buf, "0f114e3dac") == NULL, "不应留下密钥片段");
|
||||
free(buf);
|
||||
}
|
||||
|
||||
puts("\n空请求体:POST 分支也要扛得住 body == NULL");
|
||||
{
|
||||
/* body 允许为 NULL(http_probe 就是这么调的),但 POST 分支以前写的是
|
||||
* 无条件的 strlen(body) —— 传 NULL 会在 curl_easy_setopt 期间就段错误。
|
||||
*
|
||||
* 这里故意用一个格式非法的 URL:libcurl 会在真正联网之前就拒绝它,
|
||||
* 所以本测试仍然不碰网络。而 strlen(NULL) 发生在设置 POSTFIELDS 期间、
|
||||
* curl_easy_perform 之前,也就是说旧代码在这里必崩 —— 这个断言对旧
|
||||
* 代码是有效的回归守卫,不是走过场。 */
|
||||
static const HttpRetry no_retry = HTTP_RETRY_NONE;
|
||||
char errbuf[256] = "";
|
||||
int attempts = 0;
|
||||
long status = 0;
|
||||
|
||||
http_global_init();
|
||||
int rc = http_post(":::", "sk-test", NULL, 5, 0, &no_retry,
|
||||
NULL, &status, &attempts, errbuf, sizeof errbuf);
|
||||
http_global_cleanup();
|
||||
|
||||
CHECK(rc == -1, "非法 URL 应当返回 -1(实际返回 %d)", rc);
|
||||
CHECK(errbuf[0] != '\0', "失败时必须给出错误说明");
|
||||
}
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/* test_i18n.c - 界面文案表
|
||||
*
|
||||
* 这张表是"每种语言一条"。它最容易出的错不是逻辑,而是漏:加了键忘了翻,
|
||||
* 或者译文里少了个占位符({path} 写成 {paths})—— 用户看到的就是一句缺字的话。
|
||||
* 所以这里的检查全部是机械的:遍历表,逐条比对。
|
||||
*
|
||||
* 文案本身好不好不在这里管;"英文界面是不是每句都有着落"才是。
|
||||
*/
|
||||
#include "i18n.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* 收一份占位符名单({name} 里的 name),按出现顺序放进 out */
|
||||
static int placeholders(const char *s, char (*out)[32], int max)
|
||||
{
|
||||
int n = 0;
|
||||
for (const char *p = s; *p; p++) {
|
||||
if (*p != '{') continue;
|
||||
const char *e = strchr(p + 1, '}');
|
||||
if (!e) break;
|
||||
size_t len = (size_t)(e - p - 1);
|
||||
if (len == 0 || len >= 32 || n >= max) break;
|
||||
memcpy(out[n], p + 1, len);
|
||||
out[n][len] = '\0';
|
||||
n++;
|
||||
p = e;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 收一份 printf 转换符名单(%zu、%c、%.20s …),原样带走标志/宽度/长度。
|
||||
* json.* 那几条是 printf 模板而不是 {name},两种语言只要转换符顺序或类型
|
||||
* 对不上,snprintf 就会读错参数 —— 比少字更糟,得由机器盯住。 */
|
||||
static int printf_specs(const char *s, char (*out)[16], int max)
|
||||
{
|
||||
int n = 0;
|
||||
for (const char *p = s; p && *p; p++) {
|
||||
if (*p != '%' || n >= max) continue;
|
||||
const char *q = p + 1;
|
||||
while (*q && strchr("-+ #0", *q)) q++; /* 标志 */
|
||||
while (*q >= '0' && *q <= '9') q++; /* 宽度 */
|
||||
if (*q == '.') { q++; while (*q >= '0' && *q <= '9') q++; }
|
||||
while (*q == 'h' || *q == 'l' || *q == 'L' ||
|
||||
*q == 'z' || *q == 'j' || *q == 't') q++; /* 长度修饰 */
|
||||
if (strchr("diouxXcspfgeE", *q)) {
|
||||
size_t len = (size_t)(q + 1 - p);
|
||||
if (len < 16) {
|
||||
memcpy(out[n], p, len);
|
||||
out[n][len] = '\0';
|
||||
n++;
|
||||
}
|
||||
}
|
||||
if (*q) p = q;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 有没有中日韩字符(含全角标点:〔〕())。英文那一列里出现它,基本就是
|
||||
* "照抄了中文"或者"美化用的框线忘了换" —— 用户看到的是英文句子中间卡着一对
|
||||
* 中文括号。顺带把"英文栏里混进日文假名"这种复制粘贴也一并挡住。 */
|
||||
static int has_cjk(const char *s)
|
||||
{
|
||||
for (const unsigned char *p = (const unsigned char *)s; p && *p; p++) {
|
||||
if (p[0] >= 0xE3 && p[0] <= 0xEF && p[1] >= 0x80 && p[2] >= 0x80) {
|
||||
unsigned cp = (unsigned)((p[0] & 0x0Fu) << 12)
|
||||
| (unsigned)((p[1] & 0x3Fu) << 6)
|
||||
| (unsigned)(p[2] & 0x3Fu);
|
||||
if ((cp >= 0x3000 && cp <= 0x30FF) || /* 标点 / 假名 */
|
||||
(cp >= 0x4E00 && cp <= 0x9FFF) || /* 汉字 */
|
||||
(cp >= 0xFF00 && cp <= 0xFFEF)) /* 全角 */
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
size_t n = 0;
|
||||
const LigentMsg *tab = i18n_table(&n);
|
||||
CHECK(tab != NULL && n > 0, "文案表不能是空的");
|
||||
|
||||
puts("每条两种语言都齐、key 不重复、占位符对得上");
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const LigentMsg *m = &tab[i];
|
||||
CHECK(m->key && *m->key, "第 %zu 条没有 key", i);
|
||||
if (!m->key || !*m->key) continue;
|
||||
CHECK(m->en && *m->en, "%s 缺英文", m->key);
|
||||
CHECK(m->zh && *m->zh, "%s 缺中文", m->key);
|
||||
CHECK(!has_cjk(m->en ? m->en : ""), "%s 的英文里混进了中日韩字符",
|
||||
m->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);
|
||||
|
||||
char pe[16][32], pz[16][32];
|
||||
int ne = placeholders(m->en ? m->en : "", pe, 16);
|
||||
int nz = placeholders(m->zh ? m->zh : "", pz, 16);
|
||||
CHECK(ne == nz, "%s 两种语言的占位符数量不同(en %d / zh %d)",
|
||||
m->key, ne, nz);
|
||||
for (int a = 0; a < ne && a < nz; a++) {
|
||||
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]);
|
||||
}
|
||||
/* 译文里多出一个原文没有的占位符,同样会在运行时漏字 */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/* 表里有一部分键是**当 printf 模板用的**(repl.usage.*、repl.model.row、
|
||||
* json.* …):调用方直接把 t(key) 交给 printf/snprintf。这些条目的
|
||||
* {name} 检查管不到,得单独比转换符 —— 少一个 %s 会读到别的参数,
|
||||
* 类型写错(%d 对 %zu)更是直接踩内存。 */
|
||||
puts("printf 模板:两种语言的转换符必须一模一样");
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const LigentMsg *m = &tab[i];
|
||||
char se[16][16], sz[16][16];
|
||||
int ce = printf_specs(m->en ? m->en : "", se, 16);
|
||||
int cz = printf_specs(m->zh ? m->zh : "", sz, 16);
|
||||
if (ce == 0 && cz == 0) continue; /* 不是模板,跳过 */
|
||||
CHECK(ce == cz, "%s:转换符个数不同(en %d / zh %d)", m->key, ce, cz);
|
||||
for (int a = 0; a < ce && a < cz; a++)
|
||||
CHECK(strcmp(se[a], sz[a]) == 0,
|
||||
"%s:第 %d 个转换符不同(en %s / zh %s)",
|
||||
m->key, a + 1, se[a], sz[a]);
|
||||
}
|
||||
|
||||
puts("查表、切换语言、漏键可见");
|
||||
i18n_set_lang(LIGENT_LANG_ZH);
|
||||
CHECK(strcmp(t("cli.err.unknown_option"),
|
||||
i18n_lookup(LIGENT_LANG_ZH, "cli.err.unknown_option")) == 0,
|
||||
"t() 应取当前语言的那条");
|
||||
CHECK(i18n_lang() == LIGENT_LANG_ZH, "语言状态");
|
||||
CHECK(strstr(t("cli.err.unknown_option"), "未知") != NULL, "中文那条");
|
||||
i18n_set_lang(LIGENT_LANG_EN);
|
||||
CHECK(i18n_lang_name()[0] == 'e', "i18n_lang_name");
|
||||
CHECK(strstr(t("cli.err.unknown_option"), "unknown") != NULL, "英文那条");
|
||||
CHECK(strncmp(t("no.such.key"), "[?", 2) == 0,
|
||||
"漏掉的键要显眼地露出来(实际 %s),不能变成空白", t("no.such.key"));
|
||||
|
||||
puts("占位符替换按名字走");
|
||||
i18n_set_lang(LIGENT_LANG_EN);
|
||||
const char *r = tf("cli.err.unknown_option", "option", "--nope", NULL);
|
||||
CHECK(strstr(r, "--nope") != NULL, "值要填进去");
|
||||
CHECK(strstr(r, "{option}") == NULL, "填过的占位符不该留下");
|
||||
CHECK(strstr(tf("cli.err.unknown_option", "wrong", "x", NULL), "{option}") != NULL,
|
||||
"名字对不上时原样保留,别静默吞掉");
|
||||
i18n_set_lang(LIGENT_LANG_ZH);
|
||||
CHECK(strstr(tf("cli.err.unknown_option", "option", "选项X", NULL), "选项X") != NULL,
|
||||
"中文模板同样能填");
|
||||
/* 同一句里多个占位符,且顺序与调用顺序相反 */
|
||||
i18n_set_lang(LIGENT_LANG_EN);
|
||||
const char *two = tf("doctor.val.match_bad", "guess", "OpenAI",
|
||||
"owner", "DeepSeek", NULL);
|
||||
CHECK(strstr(two, "DeepSeek") && strstr(two, "OpenAI"), "两个值都要在");
|
||||
CHECK(strstr(two, "{") == NULL, "不该有没填上的占位符:%s", two);
|
||||
|
||||
puts("语言名解析");
|
||||
CHECK(i18n_parse_lang("zh") == LIGENT_LANG_ZH, "zh");
|
||||
CHECK(i18n_parse_lang("zh_CN.UTF-8") == LIGENT_LANG_ZH, "zh_CN.UTF-8");
|
||||
CHECK(i18n_parse_lang("zh-Hans") == LIGENT_LANG_ZH, "zh-Hans");
|
||||
CHECK(i18n_parse_lang("en_US") == LIGENT_LANG_EN, "en_US");
|
||||
CHECK(i18n_parse_lang("C") == LIGENT_LANG_EN, "C 就是英文");
|
||||
CHECK(i18n_parse_lang("POSIX") == LIGENT_LANG_EN, "POSIX 也是英文");
|
||||
CHECK(i18n_parse_lang("fr_FR") < 0, "认不出来的返回 -1");
|
||||
CHECK(i18n_parse_lang("") < 0, "空串返回 -1");
|
||||
CHECK(i18n_parse_lang(NULL) < 0, "NULL 返回 -1");
|
||||
|
||||
puts("环境探测:LIGENT_LANG > LC_ALL > LC_MESSAGES > LANG,认不出/没设 -> 英文");
|
||||
unsetenv("LIGENT_LANG");
|
||||
unsetenv("LC_ALL");
|
||||
unsetenv("LC_MESSAGES");
|
||||
unsetenv("LANG");
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "什么都没设 -> 英文");
|
||||
setenv("LANG", "zh_CN.UTF-8", 1);
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_ZH, "LANG=zh_CN -> 中文");
|
||||
setenv("LC_MESSAGES", "en_US.UTF-8", 1);
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LC_MESSAGES 优先于 LANG");
|
||||
setenv("LC_ALL", "zh_TW", 1);
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_ZH, "LC_ALL 优先于 LC_MESSAGES");
|
||||
setenv("LIGENT_LANG", "en", 1);
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 优先级最高");
|
||||
setenv("LIGENT_LANG", "fr", 1);
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "LIGENT_LANG 认不出时回退英文");
|
||||
unsetenv("LIGENT_LANG");
|
||||
unsetenv("LC_ALL");
|
||||
unsetenv("LC_MESSAGES");
|
||||
unsetenv("LANG");
|
||||
CHECK(i18n_lang_from_env() == LIGENT_LANG_EN, "清干净之后仍是英文");
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/* test_image.c - 图片格式嗅探、本地备份、base64、请求前展开 */
|
||||
#include "image.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static const char PNG[] = "\x89PNG\r\n\x1a\n rest";
|
||||
static const char JPEG[] = "\xff\xd8\xff\xe0 rest";
|
||||
static const char GIF87[] = "GIF87a rest";
|
||||
static const char GIF89[] = "GIF89a rest";
|
||||
static const char WEBP[] = "RIFF\x00\x00\x00\x00WEBPVP8 ";
|
||||
|
||||
static void test_sniff(void)
|
||||
{
|
||||
puts("按文件内容认格式(不看扩展名)");
|
||||
const char *ext = NULL, *mime = NULL;
|
||||
|
||||
CHECK(image_sniff((const unsigned char *)PNG, 14, &ext, &mime) == 0 &&
|
||||
strcmp(ext, "png") == 0 && strcmp(mime, "image/png") == 0, "PNG");
|
||||
CHECK(image_sniff((const unsigned char *)JPEG, 7, &ext, &mime) == 0 &&
|
||||
strcmp(ext, "jpg") == 0 && strcmp(mime, "image/jpeg") == 0, "JPEG");
|
||||
CHECK(image_sniff((const unsigned char *)GIF87, 10, &ext, &mime) == 0 &&
|
||||
strcmp(ext, "gif") == 0, "GIF87a");
|
||||
CHECK(image_sniff((const unsigned char *)GIF89, 10, &ext, &mime) == 0 &&
|
||||
strcmp(ext, "gif") == 0, "GIF89a");
|
||||
CHECK(image_sniff((const unsigned char *)WEBP, 16, &ext, &mime) == 0 &&
|
||||
strcmp(ext, "webp") == 0 && strcmp(mime, "image/webp") == 0, "WebP");
|
||||
|
||||
/* 认不出来要明确失败,别猜一个格式报上去 */
|
||||
CHECK(image_sniff((const unsigned char *)"not an image", 12, &ext, &mime) != 0,
|
||||
"杂文本不该被认成图片");
|
||||
CHECK(image_sniff((const unsigned char *)"\x89PNG", 4, &ext, &mime) != 0,
|
||||
"截断的 PNG 头不该被认出来");
|
||||
CHECK(image_sniff((const unsigned char *)"RIFFxxxxNOPE", 12, &ext, &mime) != 0,
|
||||
"RIFF 但不是 WEBP 不该被认成 WebP");
|
||||
CHECK(image_sniff(NULL, 0, &ext, &mime) != 0, "NULL 不该崩");
|
||||
}
|
||||
|
||||
static void test_backup(void)
|
||||
{
|
||||
puts("本地备份:命名、内容、不重复");
|
||||
char tmpl[] = "/tmp/ligent_img_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
CHECK(dir != NULL, "建临时目录失败");
|
||||
if (!dir) return;
|
||||
|
||||
const unsigned char data[] = "\x89PNG\r\n\x1a\n payload";
|
||||
size_t n = sizeof data - 1;
|
||||
|
||||
char *b1 = image_backup_save(dir, data, n, "png");
|
||||
CHECK(b1 != NULL, "备份应成功");
|
||||
if (!b1) { return; }
|
||||
|
||||
/* 名字形如 <上传时间戳>_<4位随机>.<ext> */
|
||||
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);
|
||||
|
||||
char *p1 = image_backup_path(dir, b1);
|
||||
size_t len = 0;
|
||||
char *back = read_entire_file(p1, &len);
|
||||
CHECK(back && len == n && memcmp(back, data, n) == 0, "备份内容应逐字节一致");
|
||||
free(back); free(p1);
|
||||
|
||||
/* 再存一次应该拿到不同的名字(随机后缀),而不是互相覆盖 */
|
||||
char *b2 = image_backup_save(dir, data, n, "png");
|
||||
CHECK(b2 && strcmp(b1, b2) != 0, "两次备份不该同号:%s / %s",
|
||||
b1, b2 ? b2 : "(null)");
|
||||
free(b2);
|
||||
|
||||
free(b1);
|
||||
/* 清场 */
|
||||
char *cmd = xasprintf("rm -rf %s", dir);
|
||||
if (system(cmd) != 0) { /* 清理失败不影响断言 */ }
|
||||
free(cmd);
|
||||
}
|
||||
|
||||
static void test_base64(void)
|
||||
{
|
||||
puts("base64:长度、补位、往返");
|
||||
/* 用几组长度覆盖三种补位情况:3n、3n+1、3n+2 */
|
||||
struct { const char *in; const char *want; } cases[] = {
|
||||
{ "", "" },
|
||||
{ "f", "Zg==" },
|
||||
{ "fo", "Zm8=" },
|
||||
{ "foo", "Zm9v" },
|
||||
{ "foob", "Zm9vYg==" },
|
||||
{ "fooba", "Zm9vYmE=" },
|
||||
{ "foobar", "Zm9vYmFy" },
|
||||
};
|
||||
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) {
|
||||
size_t n = strlen(cases[i].in);
|
||||
char *got = base64_encode((const unsigned char *)cases[i].in, n);
|
||||
CHECK(got && strcmp(got, cases[i].want) == 0,
|
||||
"\"%s\" 应编码成 %s,实际 %s", cases[i].in, cases[i].want,
|
||||
got ? got : "(null)");
|
||||
CHECK(base64_encoded_len(n) == strlen(cases[i].want),
|
||||
"长度函数应与实际一致(%zu)", n);
|
||||
free(got);
|
||||
}
|
||||
/* 二进制字节(0x00 / 0xFF 这类不能被当字符串处理) */
|
||||
const unsigned char bin[] = { 0x00, 0xFF, 0x10, 0x80 };
|
||||
char *b = base64_encode(bin, sizeof bin);
|
||||
CHECK(b && strcmp(b, "AP8QgA==") == 0, "二进制应正确编码,实际 %s",
|
||||
b ? b : "(null)");
|
||||
free(b);
|
||||
}
|
||||
|
||||
/* 造一张内容合法(靠文件头就能嗅探出来)的假图,返回字节数 */
|
||||
static size_t fake_png(unsigned char *out, size_t n)
|
||||
{
|
||||
static const char magic[] = "\x89PNG\r\n\x1a\n";
|
||||
size_t m = sizeof magic - 1;
|
||||
if (n < m) return 0;
|
||||
memcpy(out, magic, m);
|
||||
for (size_t i = m; i < n; i++) out[i] = (unsigned char)(i & 0xFF);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* 把一段备份引用塞进一条 user 消息,模拟 read_image 之后的历史 */
|
||||
static const char *ref_message_type = "ligent_image";
|
||||
|
||||
static JsonValue *history_with_ref(const char *backup)
|
||||
{
|
||||
JsonValue *msgs = json_new_array();
|
||||
|
||||
JsonValue *sys = json_new_object();
|
||||
json_object_set_string(sys, "role", "system");
|
||||
json_object_set_string(sys, "content", "你是助手");
|
||||
json_array_push(msgs, sys);
|
||||
|
||||
JsonValue *part = json_new_object();
|
||||
json_object_set_string(part, "type", ref_message_type);
|
||||
json_object_set_string(part, "backup", backup);
|
||||
JsonValue *content = json_new_array();
|
||||
json_array_push(content, part);
|
||||
|
||||
JsonValue *um = json_new_object();
|
||||
json_object_set_string(um, "role", "user");
|
||||
json_object_set(um, "content", content);
|
||||
json_array_push(msgs, um);
|
||||
return msgs;
|
||||
}
|
||||
|
||||
static void test_expand(void)
|
||||
{
|
||||
puts("请求前展开:引用 -> image_url + base64");
|
||||
char tmpl[] = "/tmp/ligent_imgx_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
CHECK(dir != NULL, "建临时目录失败");
|
||||
if (!dir) return;
|
||||
image_set_bindir(dir);
|
||||
|
||||
unsigned char png[256];
|
||||
size_t n = fake_png(png, sizeof png);
|
||||
char *backup = image_backup_save(dir, png, n, "png");
|
||||
CHECK(backup != NULL, "备份应成功");
|
||||
if (!backup) { image_set_bindir("."); return; }
|
||||
|
||||
/* --- 正常展开 --- */
|
||||
JsonValue *msgs = history_with_ref(backup);
|
||||
int degraded = -1;
|
||||
JsonValue *wire = image_expand_for_request(msgs, °raded);
|
||||
|
||||
CHECK(wire && json_array_size(wire) == 2, "副本消息条数应与原历史一致");
|
||||
CHECK(degraded == 0, "正常展开不该有降级");
|
||||
|
||||
JsonValue *wu = json_array_get(wire, 1);
|
||||
JsonValue *wc = json_object_get(wu, "content");
|
||||
CHECK(wc && wc->type == JSON_ARRAY && json_array_size(wc) == 1,
|
||||
"附件消息应有一个 content 块");
|
||||
JsonValue *wp = json_array_get(wc, 0);
|
||||
CHECK(strcmp(json_get_string(wp, "type"), "image_url") == 0,
|
||||
"发送形态的类型应是 image_url,实际 %s",
|
||||
json_get_string(wp, "type"));
|
||||
JsonValue *iu = json_object_get(wp, "image_url");
|
||||
const char *url = iu ? json_get_string(iu, "url") : NULL;
|
||||
CHECK(url && strncmp(url, "data:image/png;base64,", 22) == 0,
|
||||
"应是 data: URL,实际 %.40s", url ? url : "(null)");
|
||||
|
||||
/* base64 段应能解回原字节 —— 这里只验长度与首尾,避免在测试里再写一个解码器 */
|
||||
CHECK(url && strlen(url) == 22 + base64_encoded_len(n),
|
||||
"data URL 长度应等于前缀 + base64 长度");
|
||||
|
||||
/* 关键不变量:原始历史没被改动,且里面没有图片字节 */
|
||||
char *orig = json_dump(msgs);
|
||||
CHECK(strstr(orig, "ligent_image") != NULL, "原历史里应还是引用");
|
||||
CHECK(strstr(orig, "base64") == NULL, "原历史里不该出现 base64");
|
||||
CHECK(strstr(orig, backup) != NULL, "原历史里应记着备份名");
|
||||
free(orig);
|
||||
json_free(wire);
|
||||
|
||||
/* --- 备份没了:就地降级 --- */
|
||||
char *bpath = image_backup_path(dir, backup);
|
||||
CHECK(unlink(bpath) == 0, "删掉备份文件");
|
||||
free(bpath);
|
||||
|
||||
degraded = 0;
|
||||
wire = image_expand_for_request(msgs, °raded);
|
||||
CHECK(degraded == 1, "读不到备份应降级 1 处");
|
||||
JsonValue *wc2 = json_object_get(json_array_get(wire, 1), "content");
|
||||
JsonValue *wp2 = json_array_get(wc2, 0);
|
||||
CHECK(strcmp(json_get_string(wp2, "type"), "text") == 0,
|
||||
"发送形态应降级成 text 块");
|
||||
const char *dt = json_get_string(wp2, "text");
|
||||
CHECK(dt && strstr(dt, "无法交给模型") != NULL, "应说明原因,实际 %s",
|
||||
dt ? dt : "(null)");
|
||||
json_free(wire);
|
||||
|
||||
/* 降级是**就地**的:原历史里的坏引用应该已经被换掉,否则每轮都会重试 */
|
||||
char *after = json_dump(msgs);
|
||||
CHECK(strstr(after, "ligent_image") == NULL, "原历史里的坏引用应已被清掉");
|
||||
CHECK(strstr(after, "ligent_note") != NULL, "应换成内部说明块");
|
||||
free(after);
|
||||
|
||||
/* 再来一次不该重复降级(历史已经干净了) */
|
||||
degraded = 0;
|
||||
wire = image_expand_for_request(msgs, °raded);
|
||||
CHECK(degraded == 0, "第二次不该再降级");
|
||||
json_free(wire);
|
||||
|
||||
/* image_has_refs:降级之后应为 0,这正是 4xx 兜底的判据 */
|
||||
CHECK(image_has_refs(msgs) == 0, "降级后历史里不该还有图片引用");
|
||||
json_free(msgs);
|
||||
|
||||
/* --- 4xx 兜底:image_degrade_all --- */
|
||||
JsonValue *m2 = history_with_ref(backup);
|
||||
CHECK(image_has_refs(m2) == 1, "有引用时应报 1");
|
||||
int dn = image_degrade_all(m2);
|
||||
CHECK(dn == 1, "应降级 1 处,实际 %d", dn);
|
||||
CHECK(image_has_refs(m2) == 0, "降级后不该还有引用");
|
||||
JsonValue *c2 = json_object_get(json_array_get(m2, 1), "content");
|
||||
const char *t2 = json_get_string(json_array_get(c2, 0), "text");
|
||||
/* 给模型的话必须是能直接照做的中文,而不是服务端的英文报错 */
|
||||
CHECK(t2 && strstr(t2, "不符合 API 所需的格式标准") != NULL,
|
||||
"应说明格式不符,实际 %s", t2 ? t2 : "(null)");
|
||||
CHECK(t2 && strstr(t2, "请规范化图片") != NULL, "应给出下一步动作");
|
||||
CHECK(t2 && strchr(t2, '\n') == NULL, "说明不该带换行");
|
||||
json_free(m2);
|
||||
|
||||
/* 没有引用时不该动历史 */
|
||||
JsonValue *plain = json_new_array();
|
||||
JsonValue *pm = json_new_object();
|
||||
json_object_set_string(pm, "role", "user");
|
||||
json_object_set_string(pm, "content", "你好");
|
||||
json_array_push(plain, pm);
|
||||
CHECK(image_has_refs(plain) == 0, "纯文本历史不该报有引用");
|
||||
CHECK(image_degrade_all(plain) == 0, "纯文本历史不该被改");
|
||||
JsonValue *pw = image_expand_for_request(plain, NULL);
|
||||
CHECK(pw && json_array_size(pw) == 1, "纯文本历史应能原样复制");
|
||||
json_free(pw);
|
||||
json_free(plain);
|
||||
|
||||
free(backup);
|
||||
image_set_bindir(".");
|
||||
char *cmd = xasprintf("rm -rf %s", tmpl);
|
||||
if (system(cmd) != 0) { }
|
||||
free(cmd);
|
||||
}
|
||||
|
||||
static void test_pending(void)
|
||||
{
|
||||
puts("待发附件:取走即清空");
|
||||
image_pending_take(); /* 先清干净 */
|
||||
|
||||
image_pending_add("1700000000_aaaa.png");
|
||||
image_pending_add("1700000001_bbbb.png");
|
||||
JsonValue *arr = image_pending_take();
|
||||
CHECK(arr && json_array_size(arr) == 2, "应取到 2 个块");
|
||||
if (arr && json_array_size(arr) == 2) {
|
||||
JsonValue *p0 = json_array_get(arr, 0);
|
||||
CHECK(strcmp(json_get_string(p0, "type"), "ligent_image") == 0,
|
||||
"类型应是内部引用块,不是协议块");
|
||||
CHECK(strcmp(json_get_string(p0, "backup"), "1700000000_aaaa.png") == 0,
|
||||
"第一个备份名");
|
||||
CHECK(json_get_string(p0, "file_id") == NULL,
|
||||
"不该再有 file_id 这种协议字段");
|
||||
CHECK(strcmp(json_get_string(json_array_get(arr, 1), "backup"),
|
||||
"1700000001_bbbb.png") == 0, "第二个备份名");
|
||||
}
|
||||
json_free(arr);
|
||||
|
||||
CHECK(image_pending_take() == NULL, "取走之后应该空了");
|
||||
image_pending_add(NULL);
|
||||
image_pending_add("");
|
||||
CHECK(image_pending_take() == NULL, "空备份名不该被记下");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_sniff();
|
||||
test_backup();
|
||||
test_base64();
|
||||
test_expand();
|
||||
test_pending();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/* test_json.c - JSON 解析器/序列化器 单元测试 */
|
||||
#include "json.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static void test_basic(void)
|
||||
{
|
||||
puts("test_basic");
|
||||
char err[256];
|
||||
JsonValue *v = json_parse("{\"a\": 1, \"b\": [true, false, null], \"c\": \"hi\"}",
|
||||
err, sizeof err);
|
||||
CHECK(v != NULL, "解析失败: %s", err);
|
||||
if (!v) return;
|
||||
CHECK(json_get_number(v, "a", -1) == 1, "a 应为 1");
|
||||
CHECK(json_array_size(json_object_get(v, "b")) == 3, "b 应有 3 个元素");
|
||||
CHECK(json_bool_value(json_array_get(json_object_get(v, "b"), 0), 0) == 1,
|
||||
"b[0] 应为 true");
|
||||
CHECK(json_array_get(json_object_get(v, "b"), 2)->type == JSON_NULL,
|
||||
"b[2] 应为 null");
|
||||
CHECK(strcmp(json_get_string(v, "c"), "hi") == 0, "c 应为 hi");
|
||||
|
||||
char *dump = json_dump(v);
|
||||
JsonValue *v2 = json_parse(dump, err, sizeof err);
|
||||
CHECK(v2 != NULL, "往返解析失败: %s", err);
|
||||
char *dump2 = json_dump(v2);
|
||||
CHECK(strcmp(dump, dump2) == 0, "往返不一致:\n %s\n %s", dump, dump2);
|
||||
free(dump); free(dump2);
|
||||
json_free(v); json_free(v2);
|
||||
}
|
||||
|
||||
static void test_escapes(void)
|
||||
{
|
||||
puts("test_escapes");
|
||||
char err[256];
|
||||
JsonValue *v = json_parse("\"a\\nb\\t\\\"c\\\\d\\u4e2d\\u6587\\uD83D\\uDE00\"",
|
||||
err, sizeof err);
|
||||
CHECK(v != NULL, "解析失败: %s", err);
|
||||
if (!v) return;
|
||||
const char *s = json_string_value(v);
|
||||
CHECK(strcmp(s, "a\nb\t\"c\\d中文😀") == 0, "转义解码错误: [%s]", s);
|
||||
|
||||
char *q = json_quote_string(s);
|
||||
JsonValue *v2 = json_parse(q, err, sizeof err);
|
||||
CHECK(v2 != NULL, "转义往返失败: %s / %s", q, err);
|
||||
CHECK(v2 && strcmp(json_string_value(v2), s) == 0, "转义往返内容不一致");
|
||||
free(q);
|
||||
json_free(v); json_free(v2);
|
||||
}
|
||||
|
||||
static void test_numbers(void)
|
||||
{
|
||||
puts("test_numbers");
|
||||
char err[256];
|
||||
JsonValue *v = json_parse("[0, -1, 3.5, 1e3, -2.5e-2, 123456789012]", err, sizeof err);
|
||||
CHECK(v != NULL, "解析失败: %s", err);
|
||||
if (!v) return;
|
||||
CHECK(json_number_value(json_array_get(v, 0), -1) == 0, "[0]");
|
||||
CHECK(json_number_value(json_array_get(v, 1), 0) == -1, "[1]");
|
||||
CHECK(json_number_value(json_array_get(v, 2), 0) == 3.5, "[2]");
|
||||
CHECK(json_number_value(json_array_get(v, 3), 0) == 1000, "[3]");
|
||||
CHECK(json_number_value(json_array_get(v, 4), 0) == -0.025, "[4]");
|
||||
CHECK(json_number_value(json_array_get(v, 5), 0) == 123456789012.0, "[5]");
|
||||
json_free(v);
|
||||
|
||||
/* 严格按 RFC 8259 扫描数字:这些以前全都被接受 —— strtod 会把 "1e" 读成 1、
|
||||
* "5." 读成 5,于是一个手写错的数字会被静默当成另一个数用下去(配置文件、
|
||||
* 存档都是手写的,用户只会在很久之后从一个不相干的现象里发现它)。 */
|
||||
const char *bad_nums[] = {
|
||||
"01", "-01", "5.", ".5", "+1", "1e", "1e+", "1e-", "1.2e", "-", "1_000",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof bad_nums / sizeof bad_nums[0]; i++) {
|
||||
JsonValue *b = json_parse(bad_nums[i], err, sizeof err);
|
||||
CHECK(b == NULL, "非法数字应被拒绝: [%s]", bad_nums[i]);
|
||||
json_free(b);
|
||||
}
|
||||
/* 边界上合法的写法不能被误伤 */
|
||||
const char *ok_nums[] = {
|
||||
"0", "-0", "0.0", "10", "-1.5", "1e3", "1E3", "1e+3", "1e-3", "0.1e10",
|
||||
"123456789012345678901234567890", "1e308",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof ok_nums / sizeof ok_nums[0]; i++) {
|
||||
JsonValue *g = json_parse(ok_nums[i], err, sizeof err);
|
||||
CHECK(g != NULL && g->type == JSON_NUMBER,
|
||||
"合法数字被拒: [%s] (%s)", ok_nums[i], err);
|
||||
json_free(g);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_invalid(void)
|
||||
{
|
||||
puts("test_invalid");
|
||||
char err[256];
|
||||
const char *bad[] = {
|
||||
"{", "}", "[1,]", "{\"a\":}", "\"unterminated", "tru", "{\"a\" 1}",
|
||||
"[1 2]", "01abc", "", " ", "{\"a\":1}}", "[1,2] extra",
|
||||
};
|
||||
for (size_t i = 0; i < sizeof bad / sizeof bad[0]; i++) {
|
||||
JsonValue *v = json_parse(bad[i], err, sizeof err);
|
||||
CHECK(v == NULL, "应当解析失败但成功了: [%s]", bad[i]);
|
||||
json_free(v);
|
||||
}
|
||||
/* 空字符串是合法 JSON 字符串 */
|
||||
JsonValue *e = json_parse("\"\"", err, sizeof err);
|
||||
CHECK(e != NULL && strcmp(json_string_value(e), "") == 0, "空字符串应为合法");
|
||||
json_free(e);
|
||||
}
|
||||
|
||||
static void test_builders(void)
|
||||
{
|
||||
puts("test_builders");
|
||||
JsonValue *o = json_new_object();
|
||||
json_object_set_string(o, "model", "gpt-x");
|
||||
json_object_set_number(o, "n", 3);
|
||||
json_object_set_bool(o, "ok", 1);
|
||||
JsonValue *arr = json_new_array();
|
||||
json_array_push(arr, json_new_string("a"));
|
||||
json_array_push(arr, json_new_string("b"));
|
||||
json_object_set(o, "items", arr);
|
||||
json_object_set_string(o, "model", "gpt-y"); /* 覆盖 */
|
||||
|
||||
char *s = json_dump(o);
|
||||
CHECK(strcmp(s, "{\"model\":\"gpt-y\",\"n\":3,\"ok\":true,\"items\":[\"a\",\"b\"]}") == 0,
|
||||
"构造结果不对: %s", s);
|
||||
free(s);
|
||||
|
||||
/* clone 独立性 */
|
||||
JsonValue *c = json_clone(o);
|
||||
json_object_set_string(o, "model", "changed");
|
||||
CHECK(strcmp(json_get_string(c, "model"), "gpt-y") == 0, "clone 应相互独立");
|
||||
json_object_remove(o, "model");
|
||||
CHECK(json_object_get(o, "model") == NULL, "remove 应删除键");
|
||||
json_free(o); json_free(c);
|
||||
}
|
||||
|
||||
static void test_nested_depth(void)
|
||||
{
|
||||
puts("test_nested_depth");
|
||||
char err[256];
|
||||
size_t n = 300;
|
||||
char *deep = xmalloc(n * 2 + 1);
|
||||
for (size_t i = 0; i < n; i++) deep[i] = '[';
|
||||
for (size_t i = 0; i < n; i++) deep[n + i] = ']';
|
||||
deep[n * 2] = '\0';
|
||||
JsonValue *v = json_parse(deep, err, sizeof err);
|
||||
CHECK(v == NULL, "超深嵌套应被拒绝");
|
||||
free(deep);
|
||||
|
||||
JsonValue *ok = json_parse("[[[1]]]", err, sizeof err);
|
||||
CHECK(ok != NULL, "正常嵌套应成功");
|
||||
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);
|
||||
CHECK(strstr(err, "期望对象的键") != NULL, "应说明期望什么: %s", err);
|
||||
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);
|
||||
CHECK(strstr(err, "结束") != NULL, "应说明输入在此结束: %s", err);
|
||||
json_free(v);
|
||||
|
||||
/* 缺冒号 */
|
||||
v = json_parse("{\"a\" 1}", err, sizeof err);
|
||||
CHECK(v == NULL, "缺冒号应报错");
|
||||
CHECK(strstr(err, "第 1 行") && strstr(err, "':'") != NULL, "缺冒号: %s", err);
|
||||
json_free(v);
|
||||
|
||||
/* 尾部多余内容 */
|
||||
v = json_parse("{\"a\": 1} extra", err, sizeof err);
|
||||
CHECK(v == NULL, "尾部多余内容应报错");
|
||||
CHECK(strstr(err, "多余") != NULL, "应说明是多余内容: %s", err);
|
||||
json_free(v);
|
||||
|
||||
/* 非法字符要被回显出来 */
|
||||
v = json_parse("{\n \"a\": @\n}", err, sizeof err);
|
||||
CHECK(v == NULL, "非法字符应报错");
|
||||
CHECK(strstr(err, "@") != NULL, "应回显非法字符: %s", err);
|
||||
CHECK(strstr(err, "第 2 行") != NULL, "行号应为第 2 行: %s", err);
|
||||
json_free(v);
|
||||
|
||||
/* 控制字符不能被原样吐到终端上 */
|
||||
v = json_parse("{\"a\": \x01\x02}", err, sizeof err);
|
||||
CHECK(v == NULL, "控制字符应报错");
|
||||
CHECK(strchr(err, '\x01') == NULL, "错误信息里不应含原始控制字符");
|
||||
json_free(v);
|
||||
|
||||
/* 合法输入不受影响 */
|
||||
v = json_parse("{\"a\": [1, {\"b\": null}]}", err, sizeof err);
|
||||
CHECK(v != NULL, "合法输入不应报错: %s", err);
|
||||
json_free(v);
|
||||
}
|
||||
|
||||
/* 序列化出去的东西必须是**合法 UTF-8**。
|
||||
*
|
||||
* 服务端会因为请求体里有非法 UTF-8 直接回
|
||||
* 400 ... invalid unicode code point
|
||||
* 而那段坏数据一旦进了对话历史,之后每次请求都会失败 —— 除非丢掉整轮对话。
|
||||
* 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
|
||||
* 字节字符、二进制垃圾。所以这条不能只靠"在入口清理",必须在序列化器这层兜住:
|
||||
* 出口是唯一能保证"发出去的永远合法"的地方。
|
||||
*
|
||||
* 这个校验函数故意用 utf8_seq_len 自己走一遍,而不是信任 dump 的实现 ——
|
||||
* 断言的是**产物**的性质,不是实现的自述。 */
|
||||
static int utf8_all_valid(const char *s)
|
||||
{
|
||||
size_t n = strlen(s), i = 0;
|
||||
while (i < n) {
|
||||
size_t adv = utf8_seq_len(s + i, n - i);
|
||||
if (adv == 0) return 0;
|
||||
i += adv;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void test_dump_utf8_safety(void)
|
||||
{
|
||||
puts("test_dump_utf8_safety");
|
||||
|
||||
/* 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 字节");
|
||||
CHECK(utf8_seq_len("\xc2\x80", 2) == 2, "U+0080 的 2 字节编码应合法");
|
||||
|
||||
/* 非法的一律返回 0 */
|
||||
CHECK(utf8_seq_len("\x80", 1) == 0, "孤立续字节应非法");
|
||||
CHECK(utf8_seq_len("\xe4\xb8", 2) == 0, "被截断的序列应非法");
|
||||
CHECK(utf8_seq_len("\xc0\xaf", 2) == 0, "过长编码应非法");
|
||||
CHECK(utf8_seq_len("\xed\xa0\x80", 3) == 0, "UTF-16 代理区应非法");
|
||||
CHECK(utf8_seq_len("\xf5\x80\x80\x80", 4) == 0, "超出 U+10FFFF 应非法");
|
||||
CHECK(utf8_seq_len("\xe4\x41\x42", 3) == 0, "续字节格式不对应非法");
|
||||
|
||||
/* 各种坏字节经 json_dump 之后,整份输出必须是合法 UTF-8 */
|
||||
static const char *bad[] = {
|
||||
"a\x80" "b", /* 孤立续字节 */
|
||||
"a\xe4\xb8" "b", /* 截断的 3 字节序列 */
|
||||
"a\xc0\xaf" "b", /* 过长编码(历史上被用来夹带 '/') */
|
||||
"a\xed\xa0\x80" "b", /* UTF-16 代理区 */
|
||||
"a\xf5\x80\x80\x80" "b", /* 超出 U+10FFFF */
|
||||
"\xff\xfe", /* 二进制垃圾 */
|
||||
"\xe4\xb8\xad\x80\xe6", /* 合法中文里夹一个坏字节 */
|
||||
};
|
||||
for (size_t k = 0; k < sizeof bad / sizeof bad[0]; k++) {
|
||||
JsonValue *o = json_new_object();
|
||||
json_object_set_string(o, "content", bad[k]);
|
||||
char *d = json_dump(o);
|
||||
CHECK(utf8_all_valid(d), "第 %zu 个坏输入序列化后仍含非法 UTF-8: %s", k, d);
|
||||
/* 而且我们的解析器(和服务端一样严格)必须收得下 */
|
||||
char err[256];
|
||||
JsonValue *back = json_parse(d, err, sizeof err);
|
||||
CHECK(back != NULL, "第 %zu 个输出无法解析回来: %s", k, err);
|
||||
if (back) {
|
||||
const char *got = json_get_string(back, "content");
|
||||
CHECK(got && strstr(got, "\xef\xbf\xbd") != NULL,
|
||||
"第 %zu 个输出里应出现 U+FFFD 替换字符", k);
|
||||
json_free(back);
|
||||
}
|
||||
free(d);
|
||||
json_free(o);
|
||||
}
|
||||
|
||||
/* 合法的多字节内容必须**原样保留** —— 别把好数据一起误伤 */
|
||||
JsonValue *o = json_new_object();
|
||||
json_object_set_string(o, "content", "中文 😀 tail");
|
||||
char *d = json_dump(o);
|
||||
CHECK(strstr(d, "中文") != NULL, "合法中文不该被改动: %s", d);
|
||||
CHECK(strstr(d, "\xf0\x9f\x98\x80") != NULL, "合法 emoji 不该被改动: %s", d);
|
||||
CHECK(strstr(d, "\xef\xbf\xbd") == NULL, "合法输入不该出现替换字符: %s", d);
|
||||
free(d);
|
||||
json_free(o);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_basic();
|
||||
test_escapes();
|
||||
test_numbers();
|
||||
test_invalid();
|
||||
test_builders();
|
||||
test_nested_depth();
|
||||
test_error_location();
|
||||
test_dump_utf8_safety();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/* test_lineedit.c - 行编辑器的缓冲区逻辑与历史记录(不需要终端) */
|
||||
#include "lineedit.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define BUFEQ(lb, want) \
|
||||
CHECK(strcmp((lb).buf, (want)) == 0 && (lb).len == strlen(want), \
|
||||
"内容应为 [%s],实际 [%s]", want, (lb).buf)
|
||||
|
||||
/* 读一个文本文件的前 n 字节到 buf(用于检查落盘格式) */
|
||||
static void slurp(const char *path, char *buf, size_t buflen)
|
||||
{
|
||||
buf[0] = '\0';
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
size_t n = fread(buf, 1, buflen - 1, f);
|
||||
buf[n] = '\0';
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void test_history_file(void)
|
||||
{
|
||||
puts("输入历史的持久化");
|
||||
char path[] = "/tmp/ligent_hist_XXXXXX";
|
||||
int fd = mkstemp(path);
|
||||
CHECK(fd >= 0, "mkstemp 失败");
|
||||
if (fd < 0) return;
|
||||
close(fd);
|
||||
unlink(path); /* 故意从"文件不存在"开始 */
|
||||
|
||||
line_history_clear();
|
||||
CHECK(line_history_load(path, 100) == 0, "文件不存在不算错误");
|
||||
CHECK(line_history_len() == 0, "没有文件时历史应为空");
|
||||
CHECK(line_history_append(path, "") == 0, "空串不写文件");
|
||||
CHECK(access(path, F_OK) != 0, "空串不该把文件创建出来");
|
||||
|
||||
/* 追加两条,再读回来 */
|
||||
CHECK(line_history_add("echo hello") == 1, "第一次应记下");
|
||||
line_history_append(path, "echo hello");
|
||||
CHECK(line_history_add("echo hello") == 0, "与上一条重复应被忽略");
|
||||
CHECK(line_history_add("/think high") == 1, "不同内容应记下");
|
||||
line_history_append(path, "/think high");
|
||||
|
||||
char raw[512];
|
||||
slurp(path, raw, sizeof raw);
|
||||
CHECK(strcmp(raw, "echo hello\n/think high\n") == 0,
|
||||
"普通命令应原样一行,实际 %s", raw);
|
||||
|
||||
line_history_clear();
|
||||
CHECK(line_history_load(path, 100) == 0, "加载应成功");
|
||||
CHECK(line_history_len() == 2, "应读回 2 条,实际 %zu", line_history_len());
|
||||
CHECK(strcmp(line_history_get(0), "echo hello") == 0, "第 0 条应还原");
|
||||
CHECK(strcmp(line_history_get(1), "/think high") == 0, "第 1 条应还原");
|
||||
line_history_clear();
|
||||
|
||||
/* 反斜杠和换行必须能原样往返:一条历史在文件里只占一行 */
|
||||
unlink(path);
|
||||
line_history_add("echo C:\\Users\\me");
|
||||
line_history_append(path, "echo C:\\Users\\me");
|
||||
line_history_add("第一行\n第二行"); /* 反斜杠续行拼出来的多行输入 */
|
||||
line_history_append(path, "第一行\n第二行");
|
||||
line_history_add("中文历史");
|
||||
line_history_append(path, "中文历史");
|
||||
|
||||
slurp(path, raw, sizeof raw);
|
||||
CHECK(strchr(raw, '\n') != NULL && strstr(raw, "\n\n") == NULL,
|
||||
"每条只占一行,实际 %s", raw);
|
||||
int newlines = 0;
|
||||
for (const char *p = raw; *p; p++) if (*p == '\n') newlines++;
|
||||
CHECK(newlines == 3, "3 条历史应占 3 行,实际 %d 行", newlines);
|
||||
|
||||
line_history_clear();
|
||||
line_history_load(path, 100);
|
||||
CHECK(line_history_len() == 3, "应读回 3 条,实际 %zu", line_history_len());
|
||||
CHECK(strcmp(line_history_get(0), "echo C:\\Users\\me") == 0,
|
||||
"反斜杠应原样还原,实际 [%s]", line_history_get(0));
|
||||
CHECK(line_history_get(1) && strcmp(line_history_get(1), "第一行\n第二行") == 0,
|
||||
"多行条目应整条还原,实际 [%s]", line_history_get(1) ? line_history_get(1) : "(null)");
|
||||
CHECK(strcmp(line_history_get(2), "中文历史") == 0, "中文应原样还原");
|
||||
line_history_clear();
|
||||
|
||||
/* 超过上限:只留最新的 keep 条,并且文件被就地截短 */
|
||||
unlink(path);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
char *s = xasprintf("cmd-%d", i);
|
||||
line_history_add(s);
|
||||
line_history_append(path, s);
|
||||
free(s);
|
||||
}
|
||||
int lines = 0;
|
||||
slurp(path, raw, sizeof raw);
|
||||
for (const char *p = raw; *p; p++) if (*p == '\n') lines++;
|
||||
CHECK(lines == 10, "截短前应有 10 行,实际 %d", lines);
|
||||
|
||||
line_history_clear();
|
||||
line_history_load(path, 4);
|
||||
CHECK(line_history_len() == 4, "keep=4 时应只剩 4 条,实际 %zu", line_history_len());
|
||||
CHECK(line_history_get(0) && strcmp(line_history_get(0), "cmd-6") == 0,
|
||||
"应保留最新的 4 条,第一条应是 cmd-6,实际 [%s]",
|
||||
line_history_get(0) ? line_history_get(0) : "(null)");
|
||||
CHECK(line_history_get(3) && strcmp(line_history_get(3), "cmd-9") == 0,
|
||||
"最后一条应是 cmd-9");
|
||||
CHECK(line_history_get(4) == NULL, "第 5 条应不存在");
|
||||
|
||||
slurp(path, raw, sizeof raw);
|
||||
lines = 0;
|
||||
for (const char *p = raw; *p; p++) if (*p == '\n') lines++;
|
||||
CHECK(lines == 4, "文件应被就地截短成 4 行,实际 %d", lines);
|
||||
|
||||
/* 截短后的文件读回来还是那 4 条 */
|
||||
line_history_clear();
|
||||
line_history_load(path, 100);
|
||||
CHECK(line_history_len() == 4, "截短后应只读到 4 条,实际 %zu", line_history_len());
|
||||
line_history_clear();
|
||||
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
puts("插入与删除");
|
||||
LineBuf lb;
|
||||
linebuf_init(&lb);
|
||||
linebuf_set(&lb, "");
|
||||
BUFEQ(lb, "");
|
||||
|
||||
linebuf_insert(&lb, "hello", 5);
|
||||
BUFEQ(lb, "hello");
|
||||
CHECK(lb.pos == 5, "插入后光标应在末尾,实际 %zu", lb.pos);
|
||||
|
||||
/* 在中间插入 */
|
||||
linebuf_home(&lb);
|
||||
CHECK(lb.pos == 0, "Home 应回到行首");
|
||||
linebuf_right(&lb);
|
||||
linebuf_right(&lb);
|
||||
linebuf_insert(&lb, "XY", 2);
|
||||
BUFEQ(lb, "heXYllo");
|
||||
CHECK(lb.pos == 4, "插入后光标应后移,实际 %zu", lb.pos);
|
||||
|
||||
/* 退格删的是光标前的字符 */
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "heXllo");
|
||||
|
||||
/* Delete 删的是光标处的字符 */
|
||||
linebuf_home(&lb);
|
||||
linebuf_delete(&lb);
|
||||
BUFEQ(lb, "eXllo");
|
||||
linebuf_end(&lb);
|
||||
linebuf_delete(&lb); /* 末尾 Delete 应无动作 */
|
||||
BUFEQ(lb, "eXllo");
|
||||
linebuf_free(&lb);
|
||||
|
||||
puts("UTF-8:按字符而非字节移动");
|
||||
linebuf_init(&lb);
|
||||
linebuf_set(&lb, "中文abc"); /* 中(3) 文(3) a b c = 9 字节 */
|
||||
CHECK(lb.len == 9, "应为 9 字节,实际 %zu", lb.len);
|
||||
linebuf_left(&lb);
|
||||
CHECK(lb.pos == 8, "左移一格应为 8,实际 %zu", lb.pos);
|
||||
linebuf_left(&lb);
|
||||
linebuf_left(&lb);
|
||||
linebuf_left(&lb);
|
||||
CHECK(lb.pos == 3, "再左移三格应停在第二个汉字之后,实际 %zu", lb.pos);
|
||||
linebuf_left(&lb);
|
||||
linebuf_left(&lb);
|
||||
CHECK(lb.pos == 0, "应能移到行首,实际 %zu", lb.pos);
|
||||
linebuf_left(&lb);
|
||||
CHECK(lb.pos == 0, "行首再左移不应越界");
|
||||
|
||||
/* 退格必须整字删除,不能留下半个字符 */
|
||||
linebuf_end(&lb);
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "中文ab");
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "中文a");
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "中文");
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "中");
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "");
|
||||
linebuf_backspace(&lb);
|
||||
BUFEQ(lb, "");
|
||||
|
||||
/* 中间删除多字节字符 */
|
||||
linebuf_set(&lb, "a中b");
|
||||
linebuf_home(&lb);
|
||||
linebuf_right(&lb); /* 光标到 'a' 之后 */
|
||||
linebuf_delete(&lb); /* 删掉 '中' */
|
||||
BUFEQ(lb, "ab");
|
||||
CHECK(lb.pos == 1, "删除后光标不动,实际 %zu", lb.pos);
|
||||
linebuf_free(&lb);
|
||||
|
||||
puts("Home / End 走的是逻辑行,不是整个缓冲区");
|
||||
linebuf_init(&lb);
|
||||
linebuf_set(&lb, "one two three");
|
||||
linebuf_home(&lb);
|
||||
CHECK(lb.pos == 0, "单行时 Home 到行首");
|
||||
linebuf_end(&lb);
|
||||
CHECK(lb.pos == lb.len, "单行时 End 到行尾");
|
||||
|
||||
/* 多行(粘贴进来的那种):Home/End 只在本行内走 */
|
||||
linebuf_set(&lb, "aaa\nbbb\nccc"); /* 长度 11 */
|
||||
CHECK(lb.len == 11, "应为 11 字节,实际 %zu", lb.len);
|
||||
linebuf_home(&lb);
|
||||
CHECK(lb.pos == 8, "末尾按 Home 应到最后一行的行首(8),实际 %zu", lb.pos);
|
||||
linebuf_end(&lb);
|
||||
CHECK(lb.pos == 11, "End 应到本行行尾(11),实际 %zu", lb.pos);
|
||||
|
||||
lb.pos = 5; /* 落在 "bbb" 中间 */
|
||||
linebuf_home(&lb);
|
||||
CHECK(lb.pos == 4, "中间行按 Home 应到该行行首(4),实际 %zu", lb.pos);
|
||||
linebuf_end(&lb);
|
||||
CHECK(lb.pos == 7, "中间行按 End 应到该行行尾(7),实际 %zu", lb.pos);
|
||||
|
||||
lb.pos = 4; /* 正好在行首 */
|
||||
linebuf_home(&lb);
|
||||
CHECK(lb.pos == 4, "已在行首时 Home 不应跳到整个缓冲区开头");
|
||||
lb.pos = 7; /* 正好在行尾(换行符前) */
|
||||
linebuf_end(&lb);
|
||||
CHECK(lb.pos == 7, "已在行尾时 End 不应跳过换行符");
|
||||
|
||||
linebuf_set(&lb, "one two three");
|
||||
lb.pos = 7; /* "one two" 之后 */
|
||||
linebuf_kill_to_end(&lb);
|
||||
BUFEQ(lb, "one two");
|
||||
|
||||
linebuf_set(&lb, "one two three");
|
||||
lb.pos = 8; /* "one two " 之后 */
|
||||
linebuf_kill_to_start(&lb);
|
||||
BUFEQ(lb, "three");
|
||||
CHECK(lb.pos == 0, "删到行首后光标应在行首");
|
||||
|
||||
linebuf_set(&lb, "one two three");
|
||||
linebuf_kill_word(&lb); /* 从末尾回删一个词 */
|
||||
BUFEQ(lb, "one two ");
|
||||
linebuf_kill_word(&lb); /* 先跳过空格再删词 */
|
||||
BUFEQ(lb, "one ");
|
||||
linebuf_free(&lb);
|
||||
|
||||
puts("历史记录");
|
||||
line_history_clear();
|
||||
CHECK(line_history_len() == 0, "初始应为空");
|
||||
line_history_add("first");
|
||||
line_history_add("second");
|
||||
line_history_add("second"); /* 与上一条重复,应忽略 */
|
||||
line_history_add(""); /* 空行不入历史 */
|
||||
CHECK(line_history_len() == 2, "应有 2 条,实际 %zu", line_history_len());
|
||||
CHECK(strcmp(line_history_get(0), "first") == 0, "第 0 条");
|
||||
CHECK(strcmp(line_history_get(1), "second") == 0, "第 1 条");
|
||||
CHECK(line_history_get(2) == NULL, "越界应返回 NULL");
|
||||
line_history_clear();
|
||||
CHECK(line_history_len() == 0, "清空后应为空");
|
||||
line_history_add("中文历史");
|
||||
CHECK(strcmp(line_history_get(0), "中文历史") == 0, "应支持非 ASCII");
|
||||
line_history_clear();
|
||||
|
||||
test_history_file();
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
/* test_markdown.c - 流式 Markdown 渲染器测试
|
||||
*
|
||||
* 最关键的不变量:把同一段文本任意切分后逐片喂入,输出必须与一次性喂入
|
||||
* 完全一致。SSE 的分片位置是随机的,这条不成立就会漏出"半个星号"。
|
||||
*/
|
||||
#include "markdown.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* 去掉 ANSI 转义,得到"肉眼可见"的文本 */
|
||||
static char *strip_ansi(const char *s)
|
||||
{
|
||||
StrBuf out;
|
||||
sb_init(&out);
|
||||
for (const char *p = s; *p; ) {
|
||||
if (*p == 0x1b && p[1] == '[') {
|
||||
p += 2;
|
||||
while (*p && !(*p >= '@' && *p <= '~')) p++;
|
||||
if (*p) p++;
|
||||
continue;
|
||||
}
|
||||
sb_putc(&out, *p++);
|
||||
}
|
||||
return sb_detach(&out);
|
||||
}
|
||||
|
||||
static void show_escaped(const char *s)
|
||||
{
|
||||
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
|
||||
if (*p == 0x1b) printf("<ESC>");
|
||||
else if (*p == '\n') printf("\\n");
|
||||
else putchar(*p);
|
||||
}
|
||||
}
|
||||
|
||||
/* 渲染到内存流;chunk == 0 表示一次性喂入 */
|
||||
static char *do_render(const char *input, int enabled, size_t chunk)
|
||||
{
|
||||
char *buf = NULL;
|
||||
size_t size = 0;
|
||||
FILE *f = open_memstream(&buf, &size);
|
||||
if (!f) return NULL;
|
||||
MdRenderer *r = md_new(enabled);
|
||||
md_set_out(r, f);
|
||||
|
||||
if (chunk == 0) {
|
||||
md_feed(r, input);
|
||||
} else {
|
||||
size_t len = strlen(input);
|
||||
for (size_t i = 0; i < len; i += chunk) {
|
||||
size_t n = len - i < chunk ? len - i : chunk;
|
||||
char tmp[64];
|
||||
if (n >= sizeof tmp) n = sizeof tmp - 1;
|
||||
memcpy(tmp, input + i, n);
|
||||
tmp[n] = '\0';
|
||||
md_feed(r, tmp);
|
||||
}
|
||||
}
|
||||
md_finish(r);
|
||||
md_free(r);
|
||||
fclose(f);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static char *render(const char *in, int enabled)
|
||||
{
|
||||
return do_render(in, enabled, 0);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void test_split_invariance(void)
|
||||
{
|
||||
puts("分片不变性(一次性 / 3 字节 / 逐字节 必须一致)");
|
||||
static const char *samples[] = {
|
||||
"**粗体**\n",
|
||||
"普通 *斜体* 文本\n",
|
||||
"这是 `code` 片段\n",
|
||||
"# 一级标题\n## 二级标题\n",
|
||||
"```c\nint main(void) { return 0; }\n```\n",
|
||||
"- 项目一\n- 项目二\n",
|
||||
"> 引用内容\n",
|
||||
"2 * 3 * 4 = 24\n",
|
||||
"**a** 和 *b* 和 `c`\n",
|
||||
"---\n",
|
||||
"----\n",
|
||||
"-x\n",
|
||||
"--x\n",
|
||||
"混合 **粗** 与 `代` 与 *斜* 一行\n",
|
||||
"# 标题里有 **粗体**\n",
|
||||
"```\n**这不是粗体**\n```\n",
|
||||
"```\n没有闭合的代码块\n",
|
||||
"未闭合 **粗体\n下一行\n",
|
||||
"*a\nb*\n",
|
||||
"**\n",
|
||||
"***\n",
|
||||
"\n\n**\n\n",
|
||||
"行内 `代码` 跨分片被切断\n",
|
||||
"| a | b |\n|---|---|\n| 1 | 22 |\n",
|
||||
"| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n| 香蕉 | 12 |\n",
|
||||
"| 左 | 中 | 右 |\n|:---|:---:| ---:|\n| a | b | c |\n",
|
||||
"见 [文档](https://example.com) 说明\n",
|
||||
"这是 _斜体_ 与 foo_bar_baz\n",
|
||||
"```\n# 不是标题\n[x](y)\n```\n",
|
||||
"ls -la | grep foo\n",
|
||||
"a | b\nc | d\n",
|
||||
/* 以下三个是模糊测试找到并最小化出来的历史缺陷,
|
||||
* 每个都曾让"一次性喂"与"分片喂"结果不一致,留作回归见证 */
|
||||
"1|`", /* 表格探测挂起整行 -> 行内代码被漏渲染 */
|
||||
"- |", /* try_block 提前 return 跳过了列表规则 */
|
||||
"s|\n-", /* 表格判断依赖分片边界 */
|
||||
"| a |\n|---|\n", /* 表头+分隔行后紧跟 EOF */
|
||||
"| a |\n|---|", /* 末尾无换行的表格 */
|
||||
"| a | b |\n|---|---|\n| 1 | 2 |\n- 列表\n", /* 表格后紧跟列表 */
|
||||
"| a |\n| b |\n| c |\n", /* 有续行但缺分隔行 */
|
||||
};
|
||||
for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) {
|
||||
char *whole = render(samples[k], 1);
|
||||
char *by1 = do_render(samples[k], 1, 1);
|
||||
char *by3 = do_render(samples[k], 1, 3);
|
||||
int ok = whole && by1 && by3 &&
|
||||
strcmp(whole, by1) == 0 && strcmp(whole, by3) == 0;
|
||||
checks++;
|
||||
if (!ok) {
|
||||
failures++;
|
||||
printf(" ✗ 分片不一致: [%s]\n", samples[k]);
|
||||
printf(" 一次性: "); show_escaped(whole ? whole : "(null)"); printf("\n");
|
||||
printf(" 逐字节: "); show_escaped(by1 ? by1 : "(null)"); printf("\n");
|
||||
printf(" 3 字节: "); show_escaped(by3 ? by3 : "(null)"); printf("\n");
|
||||
}
|
||||
free(whole); free(by1); free(by3);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_headings(void)
|
||||
{
|
||||
puts("标题");
|
||||
char *o = render("# 一\n## 二\n### 三\n#### 四\n", 1);
|
||||
CHECK(strstr(o, "\033[1;36m# 一\033[0m") != NULL, "H1 应为粗体青");
|
||||
CHECK(strstr(o, "\033[1;35m## 二\033[0m") != NULL, "H2 应为粗体品红");
|
||||
CHECK(strstr(o, "\033[1;34m### 三") != NULL, "H3 应为粗体蓝");
|
||||
CHECK(strstr(o, "\033[1m#### 四") != NULL, "H4 应为粗体");
|
||||
char *vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "# 一\n## 二\n### 三\n#### 四\n") == 0,
|
||||
"可见文本应保留 # 标记: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 不是标题的情况必须原样保留 */
|
||||
o = render("#没有空格\n", 1);
|
||||
CHECK(strcmp(o, "#没有空格\n") == 0, "无空格不应识别为标题: [%s]", o);
|
||||
free(o);
|
||||
|
||||
o = render("####### 七个\n", 1);
|
||||
CHECK(strstr(o, "####### 七个") != NULL, "7 个 # 不应识别为标题: [%s]", o);
|
||||
free(o);
|
||||
|
||||
o = render("a # b\n", 1);
|
||||
CHECK(strcmp(o, "a # b\n") == 0, "行中 # 应原样: [%s]", o);
|
||||
free(o);
|
||||
}
|
||||
|
||||
static void test_bold_italic(void)
|
||||
{
|
||||
puts("粗体 / 斜体");
|
||||
char *o = render("**粗**\n", 1);
|
||||
CHECK(strstr(o, "\033[1m粗\033[0m") != NULL, "粗体样式: [%s]", o);
|
||||
char *vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "粗\n") == 0, "粗体标记应被吃掉: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
o = render("*斜*\n", 1);
|
||||
CHECK(strstr(o, "\033[3m斜\033[0m") != NULL, "斜体样式: [%s]", o);
|
||||
vis = strip_ansi(o);
|
||||
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);
|
||||
|
||||
/* snake_case 不能被误伤(这是不实现 _斜体_ 的原因) */
|
||||
o = render("foo_bar_baz\n", 1);
|
||||
CHECK(strcmp(o, "foo_bar_baz\n") == 0, "下划线标识符不应被改动: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 样式不应跨行泄漏 */
|
||||
o = render("*a\nb\n", 1);
|
||||
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);
|
||||
free(vis); free(o);
|
||||
}
|
||||
|
||||
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,
|
||||
"行内代码应保留反引号: [%s]", vis);
|
||||
CHECK(strstr(o, "\033[36m") != NULL, "内容应着色: [%s]", o);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 围栏代码块内部不做行内解析 */
|
||||
o = render("```\n**原样**\n```\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "```\n**原样**\n```\n") == 0,
|
||||
"代码块内不应渲染粗体: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 带语言标识 */
|
||||
o = render("```c\nx;\n```\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "```c\nx;\n```\n") == 0,
|
||||
"语言标识与内容应完整保留: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 未闭合的代码块:不能吞掉后面的内容 */
|
||||
o = render("```\ncode\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "```\ncode\n") == 0, "未闭合代码块: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
}
|
||||
|
||||
static void test_blocks(void)
|
||||
{
|
||||
puts("列表 / 引用 / 分隔线");
|
||||
/* 列表标记必须保留模型输出的原始字符,只上色,不换成 • */
|
||||
char *o = render("- 甲\n- 乙\n", 1);
|
||||
char *vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "- 甲\n- 乙\n") == 0, "列表应保留原始 - : [%s]", vis);
|
||||
CHECK(strstr(o, "\033[36m-\033[0m") != NULL, "列表标记 - 应着色: [%s]", o);
|
||||
CHECK(strstr(o, "•") == NULL, "不应出现非 ASCII 圆点: [%s]", o);
|
||||
free(vis); free(o);
|
||||
|
||||
o = render("* 甲\n+ 乙\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "* 甲\n+ 乙\n") == 0, "列表应保留原始 * 和 + : [%s]", vis);
|
||||
CHECK(strstr(o, "\033[36m*\033[0m") != NULL, "列表标记 * 应着色: [%s]", o);
|
||||
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, "└"),
|
||||
"表格仍用框线字符: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
o = render("> 引用\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "│ 引用\n") == 0, "引用: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
o = render("---\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "---\n") == 0, "分隔线可见文本: [%s]", vis);
|
||||
CHECK(strstr(o, "\033[2m") != NULL, "分隔线应暗色: [%s]", o);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 非分隔线、非列表应原样 */
|
||||
o = render("--x\n", 1);
|
||||
CHECK(strcmp(o, "--x\n") == 0, "非分隔线应原样: [%s]", o);
|
||||
free(o);
|
||||
}
|
||||
|
||||
static void test_passthrough(void)
|
||||
{
|
||||
puts("关闭渲染时逐字节透传");
|
||||
static const char *samples[] = {
|
||||
"# 标题 **粗体** `code`\n",
|
||||
"- 列表\n```\ncode\n```\n",
|
||||
"\033 转义 中文 😀\n",
|
||||
"没有换行的最后一行",
|
||||
"",
|
||||
};
|
||||
for (size_t k = 0; k < sizeof samples / sizeof samples[0]; k++) {
|
||||
char *whole = render(samples[k], 0);
|
||||
char *by1 = do_render(samples[k], 0, 1);
|
||||
CHECK(strcmp(whole, samples[k]) == 0,
|
||||
"应完全透传: 期望 [%s] 实际 [%s]", samples[k], whole);
|
||||
CHECK(strcmp(by1, samples[k]) == 0, "分片透传也应一致");
|
||||
free(whole); free(by1);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_fence_verbatim(void)
|
||||
{
|
||||
puts("围栏代码块:全部不渲染");
|
||||
/* 纯代码块:输出里不应出现任何 ANSI 转义 */
|
||||
char *o = render("```c\nint x = 1; // **不是粗体**\n```\n", 1);
|
||||
CHECK(strstr(o, "\033") == NULL,
|
||||
"代码块内不应有任何 ANSI 转义: [%s]", o);
|
||||
CHECK(strcmp(o, "```c\nint x = 1; // **不是粗体**\n```\n") == 0,
|
||||
"代码块应逐字节原样: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 代码块里的 # - > [ 都不能被当成语法 */
|
||||
o = render("```\n# 不是标题\n- 不是列表\n> 不是引用\n[x](y)\n```\n", 1);
|
||||
CHECK(strstr(o, "\033") == NULL, "代码块内不应有转义: [%s]", o);
|
||||
CHECK(strstr(o, "# 不是标题") && strstr(o, "[x](y)") &&
|
||||
strstr(o, "- 不是列表") && strstr(o, "> 不是引用"),
|
||||
"代码块内容应完整: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 代码块结束之后,普通文本要恢复渲染 */
|
||||
o = render("```\nx\n```\n**加粗**\n", 1);
|
||||
CHECK(strstr(o, "\033[1m加粗\033[0m") != NULL,
|
||||
"代码块之后应恢复渲染: [%s]", o);
|
||||
free(o);
|
||||
}
|
||||
|
||||
static void test_inline_code_protected(void)
|
||||
{
|
||||
puts("行内代码内容不受行内语法影响");
|
||||
char *o = render("形如 `a**b**c` 的写法\n", 1);
|
||||
CHECK(strstr(o, "\033[1m") == NULL,
|
||||
"行内代码内不应渲染粗体: [%s]", o);
|
||||
char *vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "形如 `a**b**c` 的写法\n") == 0,
|
||||
"行内代码可见文本应原样: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
o = render("`[不是链接](x)` 和 `_不是斜体_`\n", 1);
|
||||
CHECK(strstr(o, "\033[94m") == NULL, "行内代码内不应高亮 []: [%s]", o);
|
||||
CHECK(strstr(o, "\033[3m") == NULL, "行内代码内不应渲染斜体: [%s]", o);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "`[不是链接](x)` 和 `_不是斜体_`\n") == 0,
|
||||
"可见文本应原样: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
}
|
||||
|
||||
static void test_underscore_italic(void)
|
||||
{
|
||||
puts("_斜体_ 与词内下划线保护");
|
||||
char *o = render("这是 _斜体_ 文字\n", 1);
|
||||
CHECK(strstr(o, "\033[3m斜体\033[0m") != NULL, "_斜体_ 应渲染: [%s]", o);
|
||||
char *vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "这是 斜体 文字\n") == 0, "可见文本: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 中文紧邻下划线也应生效(非 ASCII 不算词字符) */
|
||||
o = render("中文_斜体_中文\n", 1);
|
||||
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);
|
||||
CHECK(strcmp(o, "foo_bar_baz 和 __init__ 和 a_b_c\n") == 0,
|
||||
"标识符应逐字节保留: [%s]", o);
|
||||
free(o);
|
||||
}
|
||||
|
||||
static void test_link_bracket(void)
|
||||
{
|
||||
puts("链接:[] 下划线+青色高亮,其余原样");
|
||||
char *o = render("见 [文档](https://example.com/a) 说明\n", 1);
|
||||
/* 这里是下划线 + 青(SGR 4;36),不是亮蓝。整个项目只用 16 色范围内的
|
||||
* 码,理由见 src/markdown.c 顶部和 tools/check-colors.sh。 */
|
||||
CHECK(strstr(o, "\033[4;36m[文档]") != NULL,
|
||||
"[链接文字] 应下划线+青色高亮: [%s]", o);
|
||||
char *vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "见 [文档](https://example.com/a) 说明\n") == 0,
|
||||
"可见文本应逐字节保留(含 URL): [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 图片也是 [...],同样只高亮方括号部分,整段保留 */
|
||||
o = render("\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "\n") == 0, "图片应保留原文: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 下标写法不应被当成链接 */
|
||||
o = render("arr[i] = br[j]\n", 1);
|
||||
CHECK(strstr(o, "\033[94m") == NULL, "下标不应高亮: [%s]", o);
|
||||
CHECK(strcmp(o, "arr[i] = br[j]\n") == 0, "下标应原样: [%s]", o);
|
||||
free(o);
|
||||
}
|
||||
|
||||
static void test_tables(void)
|
||||
{
|
||||
puts("表格");
|
||||
/* ASCII 表格:逐字节比对完整边框输出 */
|
||||
char *o = render("| a | b |\n|---|---|\n| 1 | 22 |\n", 1);
|
||||
char *vis = strip_ansi(o);
|
||||
const char *want =
|
||||
"┌───┬────┐\n"
|
||||
"│ a │ b │\n"
|
||||
"├───┼────┤\n"
|
||||
"│ 1 │ 22 │\n"
|
||||
"└───┴────┘\n";
|
||||
CHECK(strcmp(vis, want) == 0, "ASCII 表格:\n期望:\n%s\n实际:\n%s", want, vis);
|
||||
CHECK(strstr(o, "\033[1m") != NULL, "表头应加粗: [%s]", o);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 中文列宽按 2 列计算,边框必须对齐 */
|
||||
o = render("| 名称 | 值 |\n|---|---|\n| 苹果 | 3 |\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
const char *want2 =
|
||||
"┌──────┬────┐\n"
|
||||
"│ 名称 │ 值 │\n"
|
||||
"├──────┼────┤\n"
|
||||
"│ 苹果 │ 3 │\n"
|
||||
"└──────┴────┘\n";
|
||||
CHECK(strcmp(vis, want2) == 0, "中文表格:\n期望:\n%s\n实际:\n%s", want2, vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 对齐标记 */
|
||||
o = render("| 左 | 中 | 右 |\n|:---|:---:|---:|\n| a | b | c |\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
const char *want3 =
|
||||
"┌────┬────┬────┐\n"
|
||||
"│ 左 │ 中 │ 右 │\n"
|
||||
"├────┼────┼────┤\n"
|
||||
"│ a │ b │ c │\n"
|
||||
"└────┴────┴────┘\n";
|
||||
CHECK(strcmp(vis, want3) == 0, "对齐表格:\n期望:\n%s\n实际:\n%s", want3, vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 顶格必须写 '|'。不带首竖线的写法按普通文本处理 ——
|
||||
* 这是为保证"输出与分片方式无关"而做的取舍:
|
||||
* 判断必须在行的第 0 个字节就能做出,否则分片落在竖线之前时,
|
||||
* 行的前半段已被当作普通文本输出,结果会随分片方式而变。 */
|
||||
o = render("a | b\n--- | ---\n1 | 2\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strcmp(vis, "a | b\n--- | ---\n1 | 2\n") == 0,
|
||||
"无首竖线应原样输出: [%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);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 单列极简表 */
|
||||
o = render("| x |\n|---|\n| 1 |\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strstr(vis, "┌───┐") != NULL, "单列表格: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
|
||||
/* 没有分隔行就不是表格 */
|
||||
o = render("a | b\nc | d\n", 1);
|
||||
CHECK(strcmp(o, "a | b\nc | d\n") == 0, "无分隔行不应成表: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 单竖线的普通命令行不能被误判为表格 */
|
||||
o = render("ls -la | grep foo\n", 1);
|
||||
CHECK(strcmp(o, "ls -la | grep foo\n") == 0, "管道命令不应成表: [%s]", o);
|
||||
free(o);
|
||||
|
||||
/* 表格后面紧跟普通文本,两者都要正常 */
|
||||
o = render("| a | b |\n|---|---|\n| 1 | 2 |\n后续段落 **粗体**\n", 1);
|
||||
vis = strip_ansi(o);
|
||||
CHECK(strstr(vis, "└───┴───┘\n后续段落 粗体\n") != NULL,
|
||||
"表格后应恢复普通渲染: [%s]", vis);
|
||||
free(vis); free(o);
|
||||
}
|
||||
|
||||
static void test_long_line_bounded(void)
|
||||
{
|
||||
puts("超长待处理缓冲应有界");
|
||||
/* 一个没有换行的超长 fence 信息行:渲染器不能无限缓存 */
|
||||
StrBuf sb;
|
||||
sb_init(&sb);
|
||||
sb_puts(&sb, "```");
|
||||
for (int i = 0; i < 200000; i++) sb_putc(&sb, 'x');
|
||||
char *o = render(sb.data, 1);
|
||||
size_t n = strlen(o);
|
||||
CHECK(n > 100000, "应该已经把内容吐出,而不是无限缓存(实际 %zu 字节)", n);
|
||||
free(o);
|
||||
sb_free(&sb);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_split_invariance();
|
||||
test_headings();
|
||||
test_bold_italic();
|
||||
test_code();
|
||||
test_blocks();
|
||||
test_fence_verbatim();
|
||||
test_inline_code_protected();
|
||||
test_underscore_italic();
|
||||
test_link_bracket();
|
||||
test_tables();
|
||||
test_passthrough();
|
||||
test_long_line_bounded();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
/* test_meta.c - meta 历史编辑:轮次切分、结构安全、各工具的行为 */
|
||||
#include "meta.h"
|
||||
#include "json.h"
|
||||
#include "util.h"
|
||||
#include "agent.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* 造一条消息 */
|
||||
static JsonValue *msg(const char *role, const char *content)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", role);
|
||||
json_object_set_string(m, "content", content);
|
||||
return m;
|
||||
}
|
||||
|
||||
/* assistant 带一个 tool_call */
|
||||
static JsonValue *assistant_call(const char *id, const char *name, const char *args)
|
||||
{
|
||||
JsonValue *m = json_new_object();
|
||||
json_object_set_string(m, "role", "assistant");
|
||||
json_object_set_string(m, "content", "");
|
||||
JsonValue *arr = json_new_array();
|
||||
JsonValue *tc = json_new_object();
|
||||
json_object_set_string(tc, "id", id);
|
||||
json_object_set_string(tc, "type", "function");
|
||||
JsonValue *fn = json_new_object();
|
||||
json_object_set_string(fn, "name", name);
|
||||
json_object_set_string(fn, "arguments", args);
|
||||
json_object_set(tc, "function", fn);
|
||||
json_array_push(arr, tc);
|
||||
json_object_set(m, "tool_calls", arr);
|
||||
return m;
|
||||
}
|
||||
|
||||
/* 造一段典型历史:
|
||||
* #0 system
|
||||
* #1 user A ← 轮 1
|
||||
* #2 assistant(tool_calls)
|
||||
* #3 tool
|
||||
* #4 assistant 回答
|
||||
* #5 user B ← 轮 2
|
||||
* #6 assistant 回答
|
||||
* #7 user C ← 轮 3
|
||||
* #8 assistant 回答
|
||||
*/
|
||||
static JsonValue *sample(void)
|
||||
{
|
||||
JsonValue *a = json_new_array();
|
||||
json_array_push(a, msg("system", "你是助手"));
|
||||
json_array_push(a, msg("user", "帮我用 Python 写"));
|
||||
json_array_push(a, assistant_call("call_1", "run_command", "{\"command\":\"ls\"}"));
|
||||
json_array_push(a, msg("tool", "file1\nfile2"));
|
||||
json_array_push(a, msg("assistant", "写好了"));
|
||||
json_array_push(a, msg("user", "改用 C"));
|
||||
json_array_push(a, msg("assistant", "好的,改成 C"));
|
||||
json_array_push(a, msg("user", "加个注释"));
|
||||
json_array_push(a, msg("assistant", "加好了"));
|
||||
return a;
|
||||
}
|
||||
|
||||
static void test_rounds(void)
|
||||
{
|
||||
puts("轮次切分");
|
||||
JsonValue *m = sample();
|
||||
MetaRounds r;
|
||||
CHECK(meta_rounds_build(m, &r) == 1, "应该能切出轮次");
|
||||
CHECK(r.head == 1, "system 之后的 #1 才是第一条可编辑消息,实际 %zu", r.head);
|
||||
CHECK(r.count == 3, "应切出 3 轮,实际 %zu", r.count);
|
||||
CHECK(r.starts[0] == 1, "轮 1 从 #1 开始,实际 %zu", r.starts[0]);
|
||||
CHECK(r.starts[1] == 5, "轮 2 从 #5 开始,实际 %zu", r.starts[1]);
|
||||
CHECK(r.starts[2] == 7, "轮 3 从 #7 开始,实际 %zu", r.starts[2]);
|
||||
meta_rounds_free(&r);
|
||||
|
||||
/* 没有 system 也要能切 */
|
||||
JsonValue *n = json_new_array();
|
||||
json_array_push(n, msg("user", "x"));
|
||||
json_array_push(n, msg("assistant", "y"));
|
||||
CHECK(meta_rounds_build(n, &r) == 1, "没有 system 也能切");
|
||||
CHECK(r.head == 0 && r.count == 1, "1 轮,从头开始");
|
||||
meta_rounds_free(&r);
|
||||
json_free(n);
|
||||
|
||||
/* 只有 system:没有可编辑的东西 */
|
||||
JsonValue *only = json_new_array();
|
||||
json_array_push(only, msg("system", "s"));
|
||||
CHECK(meta_rounds_build(only, &r) == 0, "只有 system 时不该切出轮次");
|
||||
meta_rounds_free(&r);
|
||||
json_free(only);
|
||||
|
||||
/* read_image 补的那条"只有图片附件、没有文字"的 user 消息**不算新的一轮** ——
|
||||
* 判据必须与主循环(agent_opens_round)一致,否则 /meta 报的轮号会和主循环的
|
||||
* 口径对不上,模型照轮号删下去,删掉的不是它以为的那一轮。
|
||||
* 结构:system / user(text) / assistant(tool_calls) / tool / user(只有图) / assistant */
|
||||
JsonValue *withimg = json_new_array();
|
||||
json_array_push(withimg, msg("system", "s"));
|
||||
json_array_push(withimg, msg("user", "看图"));
|
||||
json_array_push(withimg, msg("assistant", "ok")); /* 占位,模拟带 tool_calls 的轮内消息 */
|
||||
json_array_push(withimg, msg("tool", "结果"));
|
||||
{
|
||||
JsonValue *att = json_new_object();
|
||||
json_object_set_string(att, "role", "user");
|
||||
JsonValue *c = json_new_array();
|
||||
JsonValue *blk = json_new_object();
|
||||
json_object_set_string(blk, "type", "image_url");
|
||||
json_array_push(c, blk);
|
||||
json_object_set(att, "content", c);
|
||||
json_array_push(withimg, att);
|
||||
}
|
||||
json_array_push(withimg, msg("assistant", "看到图了"));
|
||||
CHECK(meta_rounds_build(withimg, &r) == 1, "带附件的历史也应该能切");
|
||||
CHECK(r.count == 1, "只有附件的那条 user 不该算新的一轮,实际 %zu 轮", r.count);
|
||||
meta_rounds_free(&r);
|
||||
json_free(withimg);
|
||||
|
||||
json_free(m);
|
||||
}
|
||||
|
||||
static void test_deletable(void)
|
||||
{
|
||||
puts("结构安全:哪些消息能单独删");
|
||||
JsonValue *m = sample();
|
||||
CHECK(meta_deletable(m, 0) == 0, "system 不可删");
|
||||
CHECK(meta_deletable(m, 1) == 1, "user 可删");
|
||||
CHECK(meta_deletable(m, 2) == 0, "带 tool_calls 的 assistant 不可删");
|
||||
CHECK(meta_deletable(m, 3) == 0, "tool 结果不可删");
|
||||
CHECK(meta_deletable(m, 4) == 1, "普通 assistant 可删");
|
||||
CHECK(meta_deletable(m, 99) == 0, "越界不可删");
|
||||
json_free(m);
|
||||
}
|
||||
|
||||
/* ---- 工具驱动 ---- */
|
||||
|
||||
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(""); }
|
||||
int is_err = 0;
|
||||
char *r = meta_tools_call(name, args, &is_err);
|
||||
json_free(args);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* 把一段历史挂上,跑 body,再摘下来 */
|
||||
#define WITH_TOOLS(a) \
|
||||
do { \
|
||||
CHECK(meta_tools_attach(a) == 0, "attach 应成功"); \
|
||||
CHECK(meta_tools_attach(a) != 0, "重复 attach 应被拒绝"); \
|
||||
} while (0)
|
||||
|
||||
static void test_replace(void)
|
||||
{
|
||||
puts("replace_message");
|
||||
Agent a; agent_init(&a);
|
||||
json_free(a.messages); a.messages = sample();
|
||||
meta_tools_attach(&a);
|
||||
|
||||
int err = 0;
|
||||
char *r = call(&a, "replace_message", "{\"index\":1,\"content\":\"帮我用 C 写\"}");
|
||||
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, "角色不该变");
|
||||
free(r);
|
||||
|
||||
/* 改 assistant 说的话 —— 这是 meta 存在的理由 */
|
||||
r = call(&a, "replace_message", "{\"index\":4,\"content\":\"改好了\"}");
|
||||
CHECK(r && strstr(r, "已改写") != NULL, "应能改写 assistant 消息");
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 4), "content"), "改好了") == 0,
|
||||
"assistant 内容应被替换");
|
||||
free(r);
|
||||
|
||||
/* tool 结果和 system 都不该被动 */
|
||||
r = call(&a, "replace_message", "{\"index\":3,\"content\":\"x\"}");
|
||||
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 应被拒绝");
|
||||
free(r);
|
||||
|
||||
/* 越界 */
|
||||
r = call(&a, "replace_message", "{\"index\":99,\"content\":\"x\"}");
|
||||
CHECK(r && strstr(r, "越界") != NULL, "越界应报错");
|
||||
free(r);
|
||||
|
||||
meta_tools_detach();
|
||||
agent_free(&a);
|
||||
}
|
||||
|
||||
static void test_delete_safety(void)
|
||||
{
|
||||
puts("delete_messages 的结构安全");
|
||||
Agent a; agent_init(&a);
|
||||
json_free(a.messages); a.messages = sample();
|
||||
meta_tools_attach(&a);
|
||||
|
||||
size_t before = json_array_size(a.messages);
|
||||
char *r = call(&a, "delete_messages", "{\"indices\":[2]}");
|
||||
CHECK(r && strstr(r, "不可拆") != NULL, "带 tool_calls 的 assistant 应被拒绝");
|
||||
CHECK(json_array_size(a.messages) == before, "被拒绝时不该改动任何东西");
|
||||
free(r);
|
||||
|
||||
r = call(&a, "delete_messages", "{\"indices\":[3]}");
|
||||
CHECK(r && strstr(r, "不可拆") != NULL, "tool 结果应被拒绝");
|
||||
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,并给上下文提示 */
|
||||
r = call(&a, "delete_messages", "{\"indices\":[1]}");
|
||||
CHECK(r && strstr(r, "已删除 1 条") != NULL, "合法删除应成功:%s", r ? r : "");
|
||||
CHECK(strstr(r, "没有对应的提问") != NULL, "删掉提问后应提示后续回答失去上下文");
|
||||
CHECK(json_array_size(a.messages) == before - 1, "条数应减一");
|
||||
free(r);
|
||||
|
||||
/* 多选 + 越界 */
|
||||
r = call(&a, "delete_messages", "{\"indices\":[0]}");
|
||||
CHECK(r && strstr(r, "不可拆") != NULL, "system 也在结构消息之列");
|
||||
free(r);
|
||||
|
||||
meta_tools_detach();
|
||||
agent_free(&a);
|
||||
}
|
||||
|
||||
static void test_delete_rounds(void)
|
||||
{
|
||||
puts("delete_rounds");
|
||||
Agent a; agent_init(&a);
|
||||
json_free(a.messages); a.messages = sample();
|
||||
meta_tools_attach(&a);
|
||||
|
||||
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(json_array_size(a.messages) == before - 2, "第 2 轮是 2 条消息");
|
||||
/* 剩下的结构必须还是自洽的:#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 结果应仍紧跟它的调用");
|
||||
free(r);
|
||||
|
||||
/* 整段删光要被拒 */
|
||||
r = call(&a, "delete_rounds", "{\"from\":1,\"to\":2}");
|
||||
CHECK(r && strstr(r, "/reset") != NULL, "删光整段应被拒绝并建议 /reset");
|
||||
free(r);
|
||||
|
||||
/* 越界 */
|
||||
r = call(&a, "delete_rounds", "{\"from\":99,\"to\":99}");
|
||||
CHECK(r && (strstr(r, "不合法") != NULL), "越界轮号应报错:%s", r ? r : "");
|
||||
free(r);
|
||||
|
||||
meta_tools_detach();
|
||||
agent_free(&a);
|
||||
}
|
||||
|
||||
static void test_insert_and_undo(void)
|
||||
{
|
||||
puts("insert_message 与 undo");
|
||||
Agent a; agent_init(&a);
|
||||
json_free(a.messages); a.messages = sample();
|
||||
meta_tools_attach(&a);
|
||||
|
||||
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(json_array_size(a.messages) == before + 1, "条数应加一");
|
||||
CHECK(strcmp(json_get_string(json_array_get(a.messages, 9), "content"),
|
||||
"这个项目必须用 C99") == 0, "应插在 #8 之后");
|
||||
free(r);
|
||||
|
||||
/* tool 角色不能凭空插 —— 没有配对的 tool_calls */
|
||||
r = call(&a, "insert_message", "{\"after\":0,\"role\":\"tool\",\"content\":\"x\"}");
|
||||
CHECK(r && strstr(r, "role 只能是") != NULL, "插 tool 消息应被拒绝");
|
||||
free(r);
|
||||
|
||||
/* 撤销应回到插入前 */
|
||||
CHECK(meta_tools_ops() == 1, "应有 1 次改动,实际 %zu", meta_tools_ops());
|
||||
r = call(&a, "undo_last_edit", "{}");
|
||||
CHECK(r && strstr(r, "已撤销") != NULL, "撤销应成功");
|
||||
CHECK(json_array_size(a.messages) == before, "撤销后条数应还原");
|
||||
free(r);
|
||||
|
||||
/* 没有更多可撤销的了 */
|
||||
r = call(&a, "undo_last_edit", "{}");
|
||||
CHECK(r && strstr(r, "没有可撤销") != NULL, "栈空时应明确说没有");
|
||||
free(r);
|
||||
|
||||
meta_tools_detach();
|
||||
agent_free(&a);
|
||||
}
|
||||
|
||||
static void test_read_tools(void)
|
||||
{
|
||||
puts("list_rounds / read_messages(只读)");
|
||||
Agent a; agent_init(&a);
|
||||
json_free(a.messages); a.messages = sample();
|
||||
meta_tools_attach(&a);
|
||||
|
||||
char *r = call(&a, "list_rounds", "{}");
|
||||
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);
|
||||
|
||||
r = call(&a, "list_rounds", "{\"from\":2,\"to\":2}");
|
||||
CHECK(r && strstr(r, "轮 2") != NULL && strstr(r, "轮 3") == NULL, "应只列第 2 轮");
|
||||
free(r);
|
||||
|
||||
r = call(&a, "read_messages", "{\"from\":2,\"to\":3}");
|
||||
CHECK(r && strstr(r, "run_command") != NULL, "应能看到工具调用");
|
||||
CHECK(r && strstr(r, "file1") != NULL, "应能看到工具结果");
|
||||
CHECK(r && strstr(r, "结构消息,不可单删") != NULL, "结构消息应有标注");
|
||||
free(r);
|
||||
|
||||
CHECK(meta_tools_mutating("list_rounds") == 0, "list_rounds 不是写操作");
|
||||
CHECK(meta_tools_mutating("read_messages") == 0, "read_messages 不是写操作");
|
||||
CHECK(meta_tools_mutating("delete_rounds") == 1, "delete_rounds 是写操作");
|
||||
CHECK(meta_tools_mutating("replace_message") == 1, "replace_message 是写操作");
|
||||
CHECK(meta_tools_mutating("不存在的工具") == 0, "未知工具不该被当成写操作");
|
||||
|
||||
r = call(&a, "delete_rounds_but_typo", "{}");
|
||||
CHECK(r && strstr(r, "未知工具") != NULL, "未知工具名应被拒绝");
|
||||
free(r);
|
||||
|
||||
meta_tools_detach();
|
||||
agent_free(&a);
|
||||
}
|
||||
|
||||
static void test_reroot_flag(void)
|
||||
{
|
||||
puts("重新存根标记");
|
||||
Agent a; agent_init(&a);
|
||||
json_free(a.messages); a.messages = sample();
|
||||
meta_reroot_clear();
|
||||
CHECK(meta_reroot_pending() == 0, "初始不该有标记");
|
||||
|
||||
meta_tools_attach(&a);
|
||||
char *r = call(&a, "list_rounds", "{}"); /* 只读,不该打标记 */
|
||||
free(r);
|
||||
CHECK(meta_reroot_pending() == 0, "只读操作不该打标记");
|
||||
|
||||
r = call(&a, "replace_message", "{\"index\":1,\"content\":\"x\"}");
|
||||
free(r);
|
||||
CHECK(meta_reroot_pending() == 1, "改动之后应打上标记");
|
||||
|
||||
meta_tools_detach();
|
||||
agent_free(&a);
|
||||
meta_reroot_clear();
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_rounds();
|
||||
test_deletable();
|
||||
test_read_tools();
|
||||
test_replace();
|
||||
test_delete_safety();
|
||||
test_delete_rounds();
|
||||
test_insert_and_undo();
|
||||
test_reroot_flag();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/* test_models.c - /models 响应体的解析
|
||||
*
|
||||
* 只测纯解析。拉取(GET {base_url}/models)要走网络,由 e2e 第 52 段对着
|
||||
* mock 覆盖,那里还顺带验了交互式选择本身(方向键、确认、取消)。
|
||||
*
|
||||
* 为什么要容忍这么多种形状:OpenAI 兼容是个很大的家庭,/models 的返回花样
|
||||
* 比文档里写的多 —— 见过裸数组、见过重复列同一模型、见过元素里没有 id。
|
||||
* 解析器要么跳过,要么去重,但**不能**因此整个失败:列表里少一个模型远好过
|
||||
* 一个都选不了。
|
||||
*/
|
||||
#include "models.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static void test_standard(void)
|
||||
{
|
||||
puts("标准形状:{\"data\":[{\"id\":...}]}");
|
||||
const char *json =
|
||||
"{\"object\":\"list\",\"data\":["
|
||||
"{\"id\":\"alpha\",\"object\":\"model\",\"owned_by\":\"x\"},"
|
||||
"{\"id\":\"beta\",\"object\":\"model\"}"
|
||||
"]}";
|
||||
size_t n = 0;
|
||||
char **l = models_parse(json, &n);
|
||||
CHECK(l && n == 2, "应解析出 2 个,实际 %zu", n);
|
||||
if (l && n == 2) {
|
||||
CHECK(strcmp(l[0], "alpha") == 0, "顺序应保持服务端给的(第一个 alpha)");
|
||||
CHECK(strcmp(l[1], "beta") == 0, "第二个 beta");
|
||||
}
|
||||
models_free(l, n);
|
||||
}
|
||||
|
||||
static void test_dedupe_and_skip(void)
|
||||
{
|
||||
puts("重复项要去重,缺 id 的要跳过");
|
||||
const char *json =
|
||||
"{\"data\":["
|
||||
"{\"id\":\"a\"},{\"id\":\"a\"},{\"object\":\"model\"},"
|
||||
"{\"id\":\"\"},{\"id\":\"b\"},{\"id\":42}"
|
||||
"]}";
|
||||
size_t n = 0;
|
||||
char **l = models_parse(json, &n);
|
||||
CHECK(l && n == 2, "去重 + 跳过后应剩 2 个,实际 %zu", n);
|
||||
if (l && n == 2) {
|
||||
CHECK(strcmp(l[0], "a") == 0, "第一个是 a");
|
||||
CHECK(strcmp(l[1], "b") == 0, "第二个是 b");
|
||||
}
|
||||
models_free(l, n);
|
||||
}
|
||||
|
||||
static void test_bare_array(void)
|
||||
{
|
||||
puts("兼容裸数组(字符串 / 对象都认)");
|
||||
size_t n = 0;
|
||||
char **l = models_parse("[\"alpha\",\"beta\"]", &n);
|
||||
CHECK(l && n == 2 && strcmp(l[1], "beta") == 0, "字符串数组应能解析");
|
||||
models_free(l, n);
|
||||
|
||||
n = 0;
|
||||
l = models_parse("[{\"id\":\"gamma\"}]", &n);
|
||||
CHECK(l && n == 1 && strcmp(l[0], "gamma") == 0, "对象数组应能解析");
|
||||
models_free(l, n);
|
||||
}
|
||||
|
||||
static void test_bad_input(void)
|
||||
{
|
||||
puts("拿不到模型时返回 NULL,而不是空数组或崩溃");
|
||||
size_t n = 123;
|
||||
CHECK(models_parse(NULL, &n) == NULL && n == 0, "NULL 输入");
|
||||
n = 123;
|
||||
CHECK(models_parse("", &n) == NULL && n == 0, "空串");
|
||||
n = 123;
|
||||
CHECK(models_parse("<html>not json</html>", &n) == NULL && n == 0,
|
||||
"不是 JSON");
|
||||
n = 123;
|
||||
CHECK(models_parse("{\"data\":[]}", &n) == NULL && n == 0, "空列表");
|
||||
n = 123;
|
||||
CHECK(models_parse("{\"object\":\"list\"}", &n) == NULL && n == 0,
|
||||
"没有 data 字段");
|
||||
n = 123;
|
||||
CHECK(models_parse("{\"data\":\"nope\"}", &n) == NULL && n == 0,
|
||||
"data 不是数组");
|
||||
n = 123;
|
||||
CHECK(models_parse("{\"data\":[{\"object\":\"model\"}]}", &n) == NULL && n == 0,
|
||||
"全是坏条目");
|
||||
|
||||
/* out_n 也可为 NULL(调用方只关心数组) */
|
||||
char **l = models_parse("{\"data\":[{\"id\":\"x\"}]}", NULL);
|
||||
CHECK(l != NULL, "out_n 为 NULL 也要能工作");
|
||||
models_free(l, 1);
|
||||
|
||||
puts("空列表要与\"解析不了\"分开");
|
||||
CHECK(models_is_empty_list("{\"object\":\"list\",\"data\":[]}") == 1,
|
||||
"空的 data 应判为空列表");
|
||||
CHECK(models_is_empty_list("[]") == 1, "空的裸数组也是空列表");
|
||||
CHECK(models_is_empty_list("<html>not json</html>") == 0,
|
||||
"解析不了不算空列表(是两回事)");
|
||||
CHECK(models_is_empty_list("{\"data\":\"nope\"}") == 0,
|
||||
"data 不是数组不算空列表");
|
||||
CHECK(models_is_empty_list("{\"data\":[{\"id\":\"a\"}]}") == 0,
|
||||
"有模型就不是空列表");
|
||||
CHECK(models_is_empty_list(NULL) == 0, "NULL 不算空列表");
|
||||
CHECK(models_is_empty_list("") == 0, "空串不算空列表");
|
||||
|
||||
/* 释放 NULL 不该崩 */
|
||||
models_free(NULL, 0);
|
||||
CHECK(1, "释放 NULL 没崩");
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_standard();
|
||||
test_dedupe_and_skip();
|
||||
test_bare_array();
|
||||
test_bad_input();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* test_priv.c - 提权模块里那几条"不能搞错"的不变量
|
||||
*
|
||||
* 这个模块摔过一跤:上一版把认证路径也 setsid + 接管道,于是 sudo 拿不到
|
||||
* 控制终端,真机上认证 100% 失败(而替身 sudo 无条件成功,给了个假绿)。
|
||||
* 所以这里把两条硬不变量钉住:
|
||||
*
|
||||
* 1. **提权调用里不能有 -n**。带 -n 就等于"不许提问",需要密码时直接失败,
|
||||
* 而用户要的恰恰是"提权要有身份验证"。
|
||||
* 2. **没有控制终端时能识别出来**。识别不出来就会起一个等不到输入的进程。
|
||||
*/
|
||||
#include "priv.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int argv_has(char *const *a, const char *want)
|
||||
{
|
||||
for (size_t i = 0; a[i]; i++)
|
||||
if (strcmp(a[i], want) == 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
puts("包装成 sudo -u <用户> -- <argv>");
|
||||
/* 显式转换,和 priv.c 里 w[0] = (char *)"sudo" 是同一个理由:
|
||||
* priv_sudo_argv 的签名要 char *const[](execvp 的要求),而这里放进去的
|
||||
* 字面量没有任何人会改写。把整条链路改成非 const 才是真的丢 const 正确性。 */
|
||||
char *plain[] = { (char *)"/bin/sh", (char *)"-c", (char *)"rm -rf /tmp/x", NULL };
|
||||
char **w = priv_sudo_argv("root", plain);
|
||||
CHECK(w != NULL, "应当返回数组");
|
||||
CHECK(strcmp(w[0], "sudo") == 0, "第一个是 sudo,实际 %s", w[0]);
|
||||
CHECK(strcmp(w[1], "-u") == 0 && strcmp(w[2], "root") == 0,
|
||||
"-u <用户> 紧跟其后");
|
||||
CHECK(strcmp(w[3], "--") == 0,
|
||||
"必须有 -- :否则命令里的选项会被 sudo 当成自己的");
|
||||
CHECK(strcmp(w[4], "/bin/sh") == 0 && strcmp(w[5], "-c") == 0 &&
|
||||
strcmp(w[6], "rm -rf /tmp/x") == 0,
|
||||
"原 argv 原样接在后面,不被拆开");
|
||||
CHECK(w[7] == NULL, "数组要 NULL 结尾");
|
||||
/* 命门外的那条不变量 */
|
||||
CHECK(!argv_has(w, "-n"),
|
||||
"**不能带 -n**:带 -n 就不许提问,需要密码时直接失败 —— "
|
||||
"而提权的要点就是让 sudo 能问密码");
|
||||
CHECK(!argv_has(w, "-S"),
|
||||
"也不能带 -S:那要求把密码从 stdin 喂进去,等于让 ligent 经手密码");
|
||||
free(w);
|
||||
|
||||
puts("用户名为空时按 root");
|
||||
w = priv_sudo_argv(NULL, plain);
|
||||
CHECK(strcmp(w[2], "root") == 0, "空用户应落到 root,实际 %s", w[2]);
|
||||
free(w);
|
||||
w = priv_sudo_argv("", plain);
|
||||
CHECK(strcmp(w[2], "root") == 0, "空串也应落到 root");
|
||||
free(w);
|
||||
|
||||
puts("sudo 的失败原话要换成能照做的中文");
|
||||
char err[512];
|
||||
memset(err, 0, sizeof err);
|
||||
priv_explain(1, "sudo: no tty present and no askpass program specified", err, sizeof err);
|
||||
CHECK(strstr(err, "终端") != NULL, "缺终端要说终端,实际 %s", err);
|
||||
priv_explain(1, "Sorry, try again.", err, sizeof err);
|
||||
CHECK(strstr(err, "认证") != NULL, "认证失败要说认证,实际 %s", err);
|
||||
priv_explain(1, "pluv is not in the sudoers file.", err, sizeof err);
|
||||
CHECK(strstr(err, "sudo") != NULL && strstr(err, "权限") != NULL,
|
||||
"没有 sudo 权限要说清楚,实际 %s", err);
|
||||
/* 认不出来就原样透传:宁可给原文,也不编一句可能不对的解释 */
|
||||
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);
|
||||
CHECK(strstr(err, "没有 sudo") != NULL, "没有 sudo 时要说清楚,实际 %s", err);
|
||||
|
||||
puts("没有控制终端时能识别出来");
|
||||
int fds[2];
|
||||
if (pipe(fds) != 0) { perror("pipe"); return 2; }
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) { perror("fork"); return 2; }
|
||||
if (pid == 0) {
|
||||
close(fds[0]);
|
||||
int rc = -1;
|
||||
if (setsid() >= 0) { /* 摘掉控制终端 */
|
||||
rc = priv_have_tty(); /* 必须是 0 */
|
||||
}
|
||||
ssize_t wr = write(fds[1], &rc, sizeof rc);
|
||||
(void)wr;
|
||||
close(fds[1]);
|
||||
_exit(0);
|
||||
}
|
||||
close(fds[1]);
|
||||
int got = -1;
|
||||
ssize_t rd = read(fds[0], &got, sizeof got);
|
||||
close(fds[0]);
|
||||
while (waitpid(pid, NULL, 0) < 0) { }
|
||||
CHECK(rd == (ssize_t)sizeof got && got == 0,
|
||||
"setsid() 之后不该认为有终端(识别不出来就会卡住)");
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/* test_spinner.c - 等待动画的两段式标签
|
||||
*
|
||||
* 动画写的是 stderr,所以这里把 stderr 挪到一个临时文件,驱动几轮之后
|
||||
* 再把内容读回来断言。spinner_init(1) 是显式开关,测试里不依赖 isatty。
|
||||
*/
|
||||
#include "spinner.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* 把 stderr 接到一个临时文件上,返回文件描述符;*saved 收原来的 stderr。
|
||||
* 用完必须调用 capture_end 还原,否则后面的失败信息也会被吞掉。 */
|
||||
static int capture_begin(char *path, size_t pathlen, int *saved)
|
||||
{
|
||||
snprintf(path, pathlen, "/tmp/ligent_spin_XXXXXX");
|
||||
int fd = mkstemp(path);
|
||||
if (fd < 0) return -1;
|
||||
*saved = dup(STDERR_FILENO);
|
||||
if (*saved < 0) { close(fd); return -1; }
|
||||
dup2(fd, STDERR_FILENO);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static void capture_end(int fd, int saved, char *buf, size_t buflen)
|
||||
{
|
||||
fflush(stderr);
|
||||
dup2(saved, STDERR_FILENO);
|
||||
close(saved);
|
||||
lseek(fd, 0, SEEK_SET);
|
||||
ssize_t n = read(fd, buf, buflen - 1);
|
||||
buf[n > 0 ? (size_t)n : 0] = '\0';
|
||||
close(fd);
|
||||
}
|
||||
|
||||
static void test_two_phase(void)
|
||||
{
|
||||
puts("两段式标签:等待响应 → 思考中");
|
||||
char path[64];
|
||||
int saved = -1;
|
||||
int fd = capture_begin(path, sizeof path, &saved);
|
||||
CHECK(fd >= 0, "无法重定向 stderr");
|
||||
if (fd < 0) return;
|
||||
|
||||
spinner_init(1);
|
||||
spinner_start("等待响应");
|
||||
usleep(400 * 1000);
|
||||
/* 每来一片思维链都会调一次:第一次生效,后面几次都是同一标签 */
|
||||
spinner_set_label("思考中");
|
||||
usleep(120 * 1000);
|
||||
spinner_set_label("思考中");
|
||||
usleep(120 * 1000);
|
||||
spinner_set_label("思考中");
|
||||
usleep(200 * 1000);
|
||||
spinner_stop();
|
||||
|
||||
char buf[8192];
|
||||
capture_end(fd, saved, buf, sizeof buf);
|
||||
unlink(path);
|
||||
|
||||
const char *a = strstr(buf, "等待响应");
|
||||
const char *b = strstr(buf, "思考中");
|
||||
CHECK(a != NULL, "应打印\"等待响应\",实际 [%.120s]", buf);
|
||||
CHECK(b != NULL, "应打印\"思考中\",实际 [%.120s]", buf);
|
||||
CHECK(a && b && a < b, "先\"等待响应\"后\"思考中\",顺序反了");
|
||||
CHECK(strstr(buf, "\r\033[K") != NULL, "每帧应先回车清行再打印");
|
||||
/* 停在"思考中"之后就不该再有"等待响应"冒出来 */
|
||||
if (b) CHECK(strstr(b, "等待响应") == NULL, "换标签后不应回退到\"等待响应\"");
|
||||
}
|
||||
|
||||
static void test_idempotent(void)
|
||||
{
|
||||
puts("未开始/未启用时的行为");
|
||||
char path[64];
|
||||
int saved = -1;
|
||||
int fd = capture_begin(path, sizeof path, &saved);
|
||||
CHECK(fd >= 0, "无法重定向 stderr");
|
||||
if (fd < 0) return;
|
||||
|
||||
/* 没 start 就 set:只记标签,不该打印任何东西,也不该崩 */
|
||||
spinner_init(1);
|
||||
spinner_set_label("思考中");
|
||||
usleep(50 * 1000);
|
||||
/* 没 start 就 stop:幂等 */
|
||||
spinner_stop();
|
||||
/* start 两次:第二次忽略,不该起第二个线程 */
|
||||
spinner_start("等待响应");
|
||||
spinner_start("思考中");
|
||||
usleep(300 * 1000);
|
||||
spinner_stop();
|
||||
spinner_stop();
|
||||
|
||||
char buf[8192];
|
||||
capture_end(fd, saved, buf, sizeof buf);
|
||||
unlink(path);
|
||||
CHECK(strstr(buf, "等待响应") != NULL, "start 后应开始转");
|
||||
CHECK(strstr(buf, "思考中") == NULL,
|
||||
"已在转时再次 start 应被忽略,标签不该被改掉");
|
||||
|
||||
/* enabled = 0 时全部是空操作:连重定向都不需要 */
|
||||
spinner_init(0);
|
||||
spinner_start("等待响应");
|
||||
spinner_set_label("思考中");
|
||||
spinner_stop();
|
||||
CHECK(1, "关闭状态下调用不崩溃");
|
||||
}
|
||||
|
||||
static double now_sec(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
|
||||
}
|
||||
|
||||
/* spinner_stop 要 pthread_join worker。如果 worker 是 nanosleep 一整帧,
|
||||
* 这里就得干等最多 250ms —— 而这笔账会加在每一轮模型往返、每一次工具
|
||||
* 来回的用户感知延迟上。 */
|
||||
static void test_stop_is_prompt(void)
|
||||
{
|
||||
puts("stop 不该等满一帧(250ms)");
|
||||
char path[64];
|
||||
int saved = -1;
|
||||
int fd = capture_begin(path, sizeof path, &saved);
|
||||
CHECK(fd >= 0, "无法重定向 stderr");
|
||||
if (fd < 0) return;
|
||||
|
||||
spinner_init(1);
|
||||
double worst = 0.0;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
double t0 = now_sec();
|
||||
spinner_start("等待响应");
|
||||
spinner_stop();
|
||||
double dt = now_sec() - t0;
|
||||
if (dt > worst) worst = dt;
|
||||
}
|
||||
|
||||
char buf[4096];
|
||||
capture_end(fd, saved, buf, sizeof buf);
|
||||
unlink(path);
|
||||
CHECK(worst < 0.10, "stop 应立即返回,最慢一次 %.3fs(帧间隔是 0.25s)", worst);
|
||||
}
|
||||
|
||||
/* 帧率必须受 250ms 限制。曾经踩过一次:pthread_cond_timedwait 默认按
|
||||
* CLOCK_REALTIME 解释超时,而传进去的是 CLOCK_MONOTONIC 的绝对时间
|
||||
* (开机时长永远小于 epoch 时间),于是超时永远"已经过了" —— worker
|
||||
* 变成疯狂重绘的忙循环,把 pty 刷屏。 */
|
||||
static void test_frame_rate_is_bounded(void)
|
||||
{
|
||||
puts("帧率受 250ms 限制,不能狂刷");
|
||||
char path[64];
|
||||
int saved = -1;
|
||||
int fd = capture_begin(path, sizeof path, &saved);
|
||||
CHECK(fd >= 0, "无法重定向 stderr");
|
||||
if (fd < 0) return;
|
||||
|
||||
spinner_init(1);
|
||||
spinner_start("等待响应");
|
||||
usleep(600 * 1000); /* 0.6 秒 ≈ 2~3 帧 */
|
||||
spinner_stop();
|
||||
|
||||
char buf[65536];
|
||||
capture_end(fd, saved, buf, sizeof buf);
|
||||
unlink(path);
|
||||
|
||||
int frames = 0;
|
||||
for (const char *p = buf; (p = strstr(p, "等待响应")); p++) frames++;
|
||||
CHECK(frames >= 1, "至少该画一帧,实际 %d", frames);
|
||||
CHECK(frames <= 5, "0.6 秒内不该超过 5 帧,实际 %d(忙循环?)", frames);
|
||||
}
|
||||
|
||||
/* spinner_notice 会先停、打印一条提示、再**接着转**。恢复时它传的是 g_label
|
||||
* 自己 —— 而 spinner_start 曾经先执行 g_label[0] = '\0' 再读那个 label,于是
|
||||
* 标签被自己清空、退化成默认的"思考中"。症状:每次 HTTP 重试退避之后,屏幕上
|
||||
* 的"等待响应"都会变成"思考中",而这两段等待的含义恰恰是它要区分的。 */
|
||||
static void test_notice_keeps_label(void)
|
||||
{
|
||||
puts("notice 之后标签保持不变");
|
||||
char path[64];
|
||||
int saved = -1;
|
||||
int fd = capture_begin(path, sizeof path, &saved);
|
||||
CHECK(fd >= 0, "无法重定向 stderr");
|
||||
if (fd < 0) return;
|
||||
|
||||
spinner_init(1);
|
||||
spinner_start("自定义标签A");
|
||||
usleep(300 * 1000); /* 让它画至少一帧 */
|
||||
spinner_notice("一条插进来的提示");
|
||||
usleep(300 * 1000);
|
||||
spinner_stop();
|
||||
|
||||
char buf[8192];
|
||||
capture_end(fd, saved, buf, sizeof buf);
|
||||
unlink(path);
|
||||
|
||||
const char *noticed = strstr(buf, "一条插进来的提示");
|
||||
CHECK(noticed != NULL, "提示本身应该打出来,实际 [%.200s]", buf);
|
||||
/* 提示之后剩下的帧里必须还是原来那个标签 */
|
||||
if (noticed)
|
||||
CHECK(strstr(noticed, "自定义标签A") != NULL,
|
||||
"notice 之后标签被改写了(应保持\"自定义标签A\"):\n%.300s",
|
||||
noticed);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_two_phase();
|
||||
test_idempotent();
|
||||
test_stop_is_prompt();
|
||||
test_frame_rate_is_bounded();
|
||||
test_notice_keeps_label();
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
/* test_tools.c - 工具层测试 */
|
||||
#include "tools.h"
|
||||
#include "image.h"
|
||||
#include "json.h"
|
||||
#include "util.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
static int failures = 0;
|
||||
static int checks = 0;
|
||||
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
checks++; \
|
||||
if (!(cond)) { \
|
||||
failures++; \
|
||||
printf(" ✗ %s:%d: ", __FILE__, __LINE__); \
|
||||
printf(__VA_ARGS__); \
|
||||
printf("\n"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static char *call(const char *name, const char *args_json, int *is_error)
|
||||
{
|
||||
char err[256];
|
||||
JsonValue *args = json_parse(args_json, err, sizeof err);
|
||||
if (!args) {
|
||||
printf(" 测试用例 args JSON 非法: %s\n", err);
|
||||
exit(2);
|
||||
}
|
||||
char *r = tool_execute(name, args, is_error);
|
||||
json_free(args);
|
||||
return r;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
char tmpl[] = "/tmp/ligent_test_XXXXXX";
|
||||
char *dir = mkdtemp(tmpl);
|
||||
if (!dir) { perror("mkdtemp"); return 2; }
|
||||
if (chdir(dir) != 0) { perror("chdir"); return 2; }
|
||||
|
||||
/* ---- 工具 schema 合法性 ---- */
|
||||
puts("tools_schema");
|
||||
JsonValue *schema = tools_schema();
|
||||
CHECK(json_array_size(schema) == 6, "应有 6 个工具,实际 %zu",
|
||||
json_array_size(schema));
|
||||
for (size_t i = 0; i < json_array_size(schema); i++) {
|
||||
JsonValue *t = json_array_get(schema, i);
|
||||
CHECK(strcmp(json_get_string(t, "type"), "function") == 0, "type");
|
||||
JsonValue *fn = json_object_get(t, "function");
|
||||
CHECK(json_get_string(fn, "name") != NULL, "缺 name");
|
||||
JsonValue *params = json_object_get(fn, "parameters");
|
||||
CHECK(json_get_string(params, "type") != NULL, "缺 parameters.type");
|
||||
CHECK(json_object_get(params, "properties") != NULL, "缺 properties");
|
||||
CHECK(json_object_get(params, "required") != NULL, "缺 required");
|
||||
}
|
||||
{
|
||||
int has_image = 0;
|
||||
for (size_t i = 0; i < json_array_size(schema); i++) {
|
||||
JsonValue *fn = json_object_get(json_array_get(schema, i), "function");
|
||||
const char *nm = json_get_string(fn, "name");
|
||||
if (nm && strcmp(nm, "read_image") == 0) has_image = 1;
|
||||
}
|
||||
CHECK(has_image, "schema 里应有 read_image");
|
||||
}
|
||||
{
|
||||
/* line 是集合型参数:schema 里必须是 array(元素 integer),
|
||||
* 否则模型根本不会想到可以给数组 —— 参数表的 "integer[]" 写法
|
||||
* 只有 schema 展开对了才作数 */
|
||||
JsonValue *props = NULL, *reqd = NULL;
|
||||
for (size_t i = 0; i < json_array_size(schema); i++) {
|
||||
JsonValue *fn = json_object_get(json_array_get(schema, i), "function");
|
||||
const char *nm = json_get_string(fn, "name");
|
||||
if (nm && strcmp(nm, "edit_file") == 0) {
|
||||
JsonValue *par = json_object_get(fn, "parameters");
|
||||
props = json_object_get(par, "properties");
|
||||
reqd = json_object_get(par, "required");
|
||||
}
|
||||
}
|
||||
/* line 是可选的(省略 = 全文件替换),不该出现在 required 里 */
|
||||
int line_required = 0;
|
||||
for (size_t i = 0; i < json_array_size(reqd); i++) {
|
||||
const char *nm = json_string_value(json_array_get(reqd, i));
|
||||
if (nm && strcmp(nm, "line") == 0) line_required = 1;
|
||||
}
|
||||
CHECK(!line_required, "line 可以省略(= 全文件替换),不该是 required");
|
||||
|
||||
JsonValue *line = props ? json_object_get(props, "line") : NULL;
|
||||
const char *lt = line ? json_get_string(line, "type") : NULL;
|
||||
CHECK(lt && strcmp(lt, "array") == 0, "edit_file 的 line 应是 array,实际 %s",
|
||||
lt ? lt : "(缺)");
|
||||
JsonValue *items = line ? json_object_get(line, "items") : NULL;
|
||||
const char *it = items ? json_get_string(items, "type") : NULL;
|
||||
CHECK(it && strcmp(it, "integer") == 0, "line 的元素应是 integer,实际 %s",
|
||||
it ? it : "(缺)");
|
||||
}
|
||||
|
||||
/* 序列化一遍确保可发给服务端 */
|
||||
char *schema_json = json_dump(schema);
|
||||
JsonValue *re = json_parse(schema_json, NULL, 0);
|
||||
CHECK(re != NULL, "schema 无法被自身解析器往返");
|
||||
free(schema_json);
|
||||
json_free(re);
|
||||
json_free(schema);
|
||||
|
||||
/* ---- run_command ---- */
|
||||
puts("run_command");
|
||||
int err = 0;
|
||||
char *r = call("run_command", "{\"command\":\"echo hello\"}", &err);
|
||||
CHECK(err == 0, "exit 0 不应算错误: %s", r);
|
||||
CHECK(strstr(r, "hello") != NULL, "应含 hello: %s", r);
|
||||
CHECK(strstr(r, "[exit code: 0]") != NULL, "应含退出码: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("run_command", "{\"command\":\"exit 3\"}", &err);
|
||||
CHECK(err == 1, "非零退出应标记为错误");
|
||||
CHECK(strstr(r, "[exit code: 3]") != NULL, "应报告退出码 3: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("run_command", "{\"command\":\"echo err >&2; exit 1\"}", &err);
|
||||
CHECK(strstr(r, "err") != NULL, "stderr 应被合并捕获: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 超时 */
|
||||
r = call("run_command", "{\"command\":\"sleep 5\",\"timeout\":1}", &err);
|
||||
CHECK(err == 1 && strstr(r, "超时") != NULL, "应超时: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("run_command", "{}", &err);
|
||||
CHECK(err == 1 && strstr(r, "command") != NULL, "缺参数应报错: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- write_file / read_file ---- */
|
||||
puts("write_file / read_file");
|
||||
r = call("write_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"content\":\"line1\\nline2\\nline3\\n\"}", &err);
|
||||
CHECK(err == 0, "写入应成功: %s", r);
|
||||
CHECK(access("sub/dir/a.txt", R_OK) == 0, "父目录应被自动创建");
|
||||
free(r);
|
||||
|
||||
r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err);
|
||||
CHECK(err == 0, "读取应成功: %s", r);
|
||||
CHECK(strstr(r, "line1") && strstr(r, "line3"), "内容应完整: %s", r);
|
||||
CHECK(strstr(r, "1\t") != NULL, "应带行号: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("read_file", "{\"path\":\"sub/dir/a.txt\",\"offset\":2,\"limit\":1}", &err);
|
||||
CHECK(strstr(r, "line2") && !strstr(r, "line1") && !strstr(r, "line3"),
|
||||
"offset/limit 应生效: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("read_file", "{\"path\":\"does_not_exist\"}", &err);
|
||||
CHECK(err == 1, "读不存在的文件应报错: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- edit_file:替换由行号钉住 ---- */
|
||||
puts("edit_file");
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":2,\"old_string\":\"line2\","
|
||||
"\"new_string\":\"LINE-TWO\"}", &err);
|
||||
CHECK(err == 0, "按行号替换应成功: %s", r);
|
||||
CHECK(strstr(r, "第 2 行") != NULL, "回执里应带上行号: %s", r);
|
||||
free(r);
|
||||
r = call("read_file", "{\"path\":\"sub/dir/a.txt\"}", &err);
|
||||
CHECK(strstr(r, "LINE-TWO") != NULL, "替换结果应落盘: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 省略 line 不再是错误:那是"全文件替换"这条逃生门(见下面那组用例) */
|
||||
|
||||
/* 行号对不上:必须报出它**真正在哪一行**,否则模型只能反复猜 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":3,\"old_string\":\"LINE-TWO\","
|
||||
"\"new_string\":\"x\"}", &err);
|
||||
CHECK(err == 1, "行号不对应报错: %s", r);
|
||||
CHECK(strstr(r, "第 2 行") != NULL, "应指出它真正的行号: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 越界行号:要连文件总行数一起说清楚 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":99,\"old_string\":\"x\","
|
||||
"\"new_string\":\"y\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "只有 3 行") != NULL,
|
||||
"越界行号应报错并给出总行数: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 唯一性只要求**在这一行里**唯一:同一个词出现在别的行不该拦住替换
|
||||
* (这正是"全文唯一"最烦人的地方——文件一长,随便一个短串都不唯一) */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"sub/dir/a.txt\",\"line\":1,\"old_string\":\"line\","
|
||||
"\"new_string\":\"LINE\"}", &err);
|
||||
CHECK(err == 0, "同一行里唯一即可,别的行的重复不该拦: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 同一行里出现两处 → 仍然拒绝(行内也要定得下来) */
|
||||
r = call("write_file", "{\"path\":\"dup.txt\",\"content\":\"foo foo\\n\"}", &err);
|
||||
CHECK(err == 0, "写入 dup.txt: %s", r);
|
||||
free(r);
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\"}",
|
||||
&err);
|
||||
CHECK(err == 1 && strstr(r, "第 1 行里有 2 处") != NULL,
|
||||
"同一行里多匹配应拒绝: %s", r);
|
||||
free(r);
|
||||
|
||||
/* replace_all 仍然换全文件,但 line 必须指向其中真实的一处 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"dup.txt\",\"line\":1,\"old_string\":\"foo\",\"new_string\":\"bar\","
|
||||
"\"replace_all\":true}", &err);
|
||||
CHECK(err == 0, "replace_all 应成功: %s", r);
|
||||
free(r);
|
||||
r = call("read_file", "{\"path\":\"dup.txt\"}", &err);
|
||||
CHECK(strstr(r, "bar bar") != NULL && !strstr(r, "foo"),
|
||||
"replace_all 应换掉文件里全部匹配: %s", r);
|
||||
free(r);
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"dup.txt\",\"line\":7,\"old_string\":\"bar\",\"new_string\":\"x\","
|
||||
"\"replace_all\":true}", &err);
|
||||
CHECK(err == 1, "replace_all 也必须锚定在真实的一行上: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- edit_file:line 是集合型 ----
|
||||
* 集合是**搜索范围**(old_string 的起点落在其中任一行即可),不是
|
||||
* "每行都改一次":真改动的仍是唯一那一处。 */
|
||||
puts("edit_file:line 集合");
|
||||
r = call("write_file",
|
||||
"{\"path\":\"set.txt\",\"content\":\"aaa\\nbbb\\nccc\\nddd\\n\"}", &err);
|
||||
CHECK(err == 0, "写入 set.txt: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 数组写法:起点落在集合里的哪一行都算命中;回执要带上整个集合 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"set.txt\",\"line\":[1,2],\"old_string\":\"aaa\","
|
||||
"\"new_string\":\"AAA\"}", &err);
|
||||
CHECK(err == 0, "集合圈住的替换应成功: %s", r);
|
||||
CHECK(strstr(r, "第 1、2 行") != NULL, "回执里应带上整个集合: %s", r);
|
||||
free(r);
|
||||
r = call("read_file", "{\"path\":\"set.txt\"}", &err);
|
||||
CHECK(strstr(r, "AAA") != NULL && !strstr(r, "aaa"), "替换应落盘: %s", r);
|
||||
free(r);
|
||||
|
||||
/* old_string 跨行时,把覆盖到的几行都填进集合——不必赌起点是第几行 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"set.txt\",\"line\":[2,3],\"old_string\":\"bbb\\nccc\","
|
||||
"\"new_string\":\"B\\nC\"}", &err);
|
||||
CHECK(err == 0, "跨行替换(集合给两行)应成功: %s", r);
|
||||
free(r);
|
||||
r = call("read_file", "{\"path\":\"set.txt\"}", &err);
|
||||
CHECK(strstr(r, "B") && strstr(r, "C") && !strstr(r, "bbb"), "跨行替换应落盘: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 集合里有多处匹配 → 仍然拒绝(集合不是"每行改一次"的授权) */
|
||||
r = call("write_file", "{\"path\":\"two.txt\",\"content\":\"foo\\nx\\nfoo\\n\"}", &err);
|
||||
CHECK(err == 0, "写入 two.txt: %s", r);
|
||||
free(r);
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"two.txt\",\"line\":[1,3],\"old_string\":\"foo\","
|
||||
"\"new_string\":\"bar\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "第 1、3 行里有 2 处") != NULL,
|
||||
"集合里多处匹配应拒绝: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 重复的行号要去重:不然同一处匹配会被数成两处 */
|
||||
r = call("write_file", "{\"path\":\"dup3.txt\",\"content\":\"foo\\n\"}", &err);
|
||||
CHECK(err == 0, "写入 dup3.txt: %s", r);
|
||||
free(r);
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"dup3.txt\",\"line\":[1,1],\"old_string\":\"foo\","
|
||||
"\"new_string\":\"bar\"}", &err);
|
||||
CHECK(err == 0, "重复行号应去重,而不是当成两处: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 集合里有一行越界 → 和单个行号时一样,报出文件总行数 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"set.txt\",\"line\":[1,99],\"old_string\":\"x\","
|
||||
"\"new_string\":\"y\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "只有 4 行") != NULL,
|
||||
"集合里的越界行号应报错并给出总行数: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 写法本身不对的三种:空数组、非整数元素、一次给太多 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"set.txt\",\"line\":[],\"old_string\":\"x\","
|
||||
"\"new_string\":\"y\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "空数组") != NULL, "空数组应报错: %s", r);
|
||||
CHECK(strstr(r, "省略") != NULL, "空数组的报错要提示可以整个省略 line: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"set.txt\",\"line\":[1,1.5],\"old_string\":\"x\","
|
||||
"\"new_string\":\"y\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "第 2 个") != NULL,
|
||||
"非整数元素应报错并指出是第几个: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- 省略 line = 全文件替换所有匹配 ----
|
||||
* 这是 range 那样的集合参数该有的默认值:不给范围就是不设范围。 */
|
||||
r = call("write_file", "{\"path\":\"all.txt\",\"content\":\"k\\nx\\nk\\n\"}", &err);
|
||||
CHECK(err == 0, "写入 all.txt: %s", r);
|
||||
free(r);
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"all.txt\",\"old_string\":\"k\",\"new_string\":\"K\"}", &err);
|
||||
CHECK(err == 0, "省略 line 应做全文件替换: %s", r);
|
||||
CHECK(strstr(r, "全文件替换") != NULL && strstr(r, "2 处") != NULL,
|
||||
"回执要说清是全文件替换、换了几处: %s", r);
|
||||
free(r);
|
||||
r = call("read_file", "{\"path\":\"all.txt\"}", &err);
|
||||
CHECK(strstr(r, "K") != NULL && !strstr(r, "k"), "两处都该被换掉: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 省略 line 时文件里没有 old_string:只说整个文件里没有(没有"哪几行"可说) */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"all.txt\",\"old_string\":\"zzz\",\"new_string\":\"y\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "整个文件里没有") != NULL, "该报整个文件里没有: %s", r);
|
||||
free(r);
|
||||
|
||||
/* line 给错了类型(不是"想全局"):仍然当场报错,并告诉它可以省略 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"all.txt\",\"line\":\"1\",\"old_string\":\"K\","
|
||||
"\"new_string\":\"z\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "整数或整数数组") != NULL, "line 类型错应报错: %s", r);
|
||||
CHECK(strstr(r, "整个省略") != NULL, "报错要指出可以省略 line: %s", r);
|
||||
free(r);
|
||||
|
||||
{
|
||||
/* 65 个行号:超过一次能圈住的范围,要明说 */
|
||||
char big[600];
|
||||
size_t o = (size_t)snprintf(big, sizeof big, "{\"path\":\"set.txt\",\"line\":[");
|
||||
for (int i = 1; i <= 65; i++)
|
||||
o += (size_t)snprintf(big + o, sizeof big - o, "%s%d", i > 1 ? "," : "", i);
|
||||
snprintf(big + o, sizeof big - o, "],\"old_string\":\"x\",\"new_string\":\"y\"}");
|
||||
r = call("edit_file", big, &err);
|
||||
CHECK(err == 1 && strstr(r, "一次最多") != NULL, "行号过多应报错: %s", r);
|
||||
free(r);
|
||||
}
|
||||
|
||||
/* ---- list_dir ---- */
|
||||
puts("list_dir");
|
||||
r = call("list_dir", "{\"path\":\".\"}", &err);
|
||||
CHECK(err == 0 && strstr(r, "sub/") != NULL, "应列出子目录: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("list_dir", "{\"path\":\"nope\"}", &err);
|
||||
CHECK(err == 1, "不存在的目录应报错: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 这两个工具都已经删掉:搜索交给模型自己用 run_command 里的真 grep,
|
||||
* 子代理(spawn_subagent)整个功能也去掉了。留着断言是为了钉住
|
||||
* "不要再冒出一个半吊子的替代品"。 */
|
||||
r = call("grep", "{\"pattern\":\"x\",\"path\":\".\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有 grep 工具: %s", r);
|
||||
free(r);
|
||||
r = call("spawn_subagent", "{}", &err);
|
||||
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "不该再有子代理工具: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- 超大文件不能把内存吃干 ---- */
|
||||
puts("超大文件防护");
|
||||
{
|
||||
/* 稀疏文件,创建几乎不耗时;关键是客户端要在读之前就发现它太大。
|
||||
* 开头 16 KB 写成 'x':这样它是"文本"(探测的前 8 KB 里没有 NUL),
|
||||
* 否则连 read_file 都会先被二进制检测拦掉 —— 这个用例就测不到
|
||||
* "分段读大文件"了。 */
|
||||
FILE *big = fopen("huge.txt", "wb");
|
||||
CHECK(big != NULL, "创建测试文件失败");
|
||||
if (big) {
|
||||
char head[16 * 1024];
|
||||
memset(head, 'x', sizeof head);
|
||||
fwrite(head, 1, sizeof head, big);
|
||||
fseek(big, 40L * 1024 * 1024, SEEK_SET);
|
||||
fputc('x', big);
|
||||
fclose(big);
|
||||
}
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"huge.txt\",\"line\":1,\"old_string\":\"x\",\"new_string\":\"y\"}",
|
||||
&err);
|
||||
CHECK(err == 1, "超大文件的 edit_file 应被拒绝: %.120s", r);
|
||||
CHECK(strstr(r, "上限") != NULL, "应说明是超上限: %.120s", r);
|
||||
free(r);
|
||||
|
||||
/* read_file 是分段读的,应当照常可用 */
|
||||
r = call("read_file", "{\"path\":\"huge.txt\",\"limit\":1}", &err);
|
||||
CHECK(err == 0, "分段读大文件应当可用: %.120s", r);
|
||||
free(r);
|
||||
unlink("huge.txt");
|
||||
}
|
||||
|
||||
/* ---- 二进制文件:所有处理文本的工具都必须拒绝(而且要说明该用什么) ---- */
|
||||
puts("二进制拒绝");
|
||||
{
|
||||
FILE *bf = fopen("bin.dat", "wb");
|
||||
CHECK(bf != NULL, "创建二进制测试文件失败");
|
||||
if (bf) { fwrite("PNG\0\1\2\3", 1, 8, bf); fclose(bf); }
|
||||
}
|
||||
|
||||
r = call("read_file", "{\"path\":\"bin.dat\"}", &err);
|
||||
CHECK(err == 1, "read_file 读二进制应被拒绝: %s", r);
|
||||
CHECK(strstr(r, "二进制") != NULL && strstr(r, "第 4 字节") != NULL,
|
||||
"拒绝理由要指出 NUL 在哪一字节: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"bin.dat\",\"line\":1,\"old_string\":\"PNG\",\"new_string\":\"JPG\"}",
|
||||
&err);
|
||||
CHECK(err == 1 && strstr(r, "二进制") != NULL, "edit_file 改二进制应被拒绝: %s", r);
|
||||
free(r);
|
||||
|
||||
r = call("write_file", "{\"path\":\"bin.dat\",\"content\":\"text\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "二进制") != NULL,
|
||||
"write_file 覆盖二进制应被拒绝: %s", r);
|
||||
free(r);
|
||||
|
||||
/* 被拒绝的三次都不能动到文件本身 —— 拒绝的意义就在这里 */
|
||||
{
|
||||
FILE *bf = fopen("bin.dat", "rb");
|
||||
char keep[16];
|
||||
memset(keep, 0, sizeof keep);
|
||||
size_t kn = bf ? fread(keep, 1, sizeof keep, bf) : 0;
|
||||
if (bf) fclose(bf);
|
||||
CHECK(kn == 8 && memcmp(keep, "PNG\0\1\2\3", 8) == 0,
|
||||
"被拒绝的读/写不能改动二进制文件的字节");
|
||||
}
|
||||
|
||||
/* 确认提示里要能看出改的是哪一行 */
|
||||
r = call("write_file", "{\"path\":\"confirm.txt\",\"content\":\"aaa\\nbbb\\n\"}", &err);
|
||||
free(r);
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"confirm.txt\",\"line\":2,\"old_string\":\"bbb\",\"new_string\":\"ccc\"}",
|
||||
&err);
|
||||
CHECK(err == 0 && strstr(r, "第 2 行") != NULL, "回执带行号: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- 哪些工具会改动磁盘(决定是否弹确认) ---- */
|
||||
puts("写操作分类");
|
||||
CHECK(tool_mutating("run_command") == 1, "run_command 会执行命令,必须确认");
|
||||
CHECK(tool_mutating("write_file") == 1, "write_file 会写盘,必须确认");
|
||||
CHECK(tool_mutating("edit_file") == 1, "edit_file 会改盘,必须确认");
|
||||
CHECK(tool_mutating("read_file") == 0, "read_file 只读,不该打扰");
|
||||
CHECK(tool_mutating("list_dir") == 0, "list_dir 只读,不该打扰");
|
||||
/* read_image 会在 ligent 自己的目录里留备份(副作用),
|
||||
* 但不改用户的任何东西 —— 它是"读",不该每次都拦一道 */
|
||||
CHECK(tool_mutating("read_image") == 0, "read_image 不改用户的文件,不该打扰");
|
||||
CHECK(tool_mutating("没有这个工具") == 0, "未知工具按只读处理");
|
||||
CHECK(tool_mutating(NULL) == 0, "空名字不应崩");
|
||||
|
||||
/* ---- read_image:只收真的能内联进去的图 ----
|
||||
*
|
||||
* 两条拒绝路径是新的:格式不认识、体积超限。它们都必须**不备份** ——
|
||||
* 一张交不出去的图,留备份只会让 ligent.archive 变成垃圾场。 */
|
||||
puts("read_image:本地就把不合格的图拦掉");
|
||||
{
|
||||
char btmp[] = "/tmp/ligent_tools_img_XXXXXX";
|
||||
char *bdir = mkdtemp(btmp);
|
||||
CHECK(bdir != NULL, "建备份临时目录失败");
|
||||
if (bdir) {
|
||||
image_set_bindir(bdir);
|
||||
image_pending_take(); /* 清干净,免得上一段残留 */
|
||||
|
||||
/* 1) 内容是文本、扩展名骗人 */
|
||||
FILE *f = fopen("notanimage.png", "wb");
|
||||
if (f) { fputs("这不是图片", f); fclose(f); }
|
||||
r = call("read_image", "{\"path\":\"notanimage.png\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "不认识") != NULL,
|
||||
"非图片内容应被拒: %s", r);
|
||||
CHECK(strstr(r, "按文件内容判断") != NULL, "应说明是看内容判断的");
|
||||
free(r);
|
||||
|
||||
/* 2) 体积超限:base64 后超过 IMAGE_MAX_B64。
|
||||
* 4/3 * 原始大小 = 编码后大小,所以原始要 > 3/4 * 上限。 */
|
||||
size_t raw_needed = (size_t)(IMAGE_MAX_B64 / 4 * 3) + 4096;
|
||||
f = fopen("huge.png", "wb");
|
||||
if (f) {
|
||||
fwrite("\x89PNG\r\n\x1a\n", 1, 8, f);
|
||||
char *pad = xcalloc(1, 65536);
|
||||
size_t left = raw_needed - 8;
|
||||
while (left > 0) {
|
||||
size_t chunk = left > 65536 ? 65536 : left;
|
||||
if (fwrite(pad, 1, chunk, f) != chunk) break;
|
||||
left -= chunk;
|
||||
}
|
||||
free(pad);
|
||||
fclose(f);
|
||||
}
|
||||
r = call("read_image", "{\"path\":\"huge.png\"}", &err);
|
||||
CHECK(err == 1 && strstr(r, "太大") != NULL,
|
||||
"超限的图应被当场拒掉: %.120s", r);
|
||||
CHECK(strstr(r, "内联上限") != NULL, "应说清上限是多少");
|
||||
free(r);
|
||||
|
||||
/* 3) 合法的小图:成功,并且挂上待发附件 */
|
||||
f = fopen("ok.png", "wb");
|
||||
if (f) { fwrite("\x89PNG\r\n\x1a\n", 1, 8, f); fwrite("payload", 1, 7, f); fclose(f); }
|
||||
r = call("read_image", "{\"path\":\"ok.png\"}", &err);
|
||||
CHECK(err == 0 && strstr(r, "已读入图片") != NULL,
|
||||
"合法图片应成功: %s", r);
|
||||
CHECK(strstr(r, "base64") != NULL, "应说明会以 base64 内联");
|
||||
free(r);
|
||||
|
||||
JsonValue *att = image_pending_take();
|
||||
CHECK(att && json_array_size(att) == 1, "应挂上 1 个待发附件");
|
||||
if (att && json_array_size(att) == 1)
|
||||
CHECK(strcmp(json_get_string(json_array_get(att, 0), "type"),
|
||||
"ligent_image") == 0, "附件应是内部引用块");
|
||||
json_free(att);
|
||||
|
||||
/* 只有成功那一张留了备份 —— 两条拒绝路径都不该留下东西 */
|
||||
char *ls = xasprintf("ls %s/ligent.archive/images/*.png | wc -l", bdir);
|
||||
FILE *p = popen(ls, "r");
|
||||
int cnt = -1;
|
||||
if (p) { if (fscanf(p, "%d", &cnt) != 1) cnt = -1; pclose(p); }
|
||||
free(ls);
|
||||
CHECK(cnt == 1, "只该留 1 份备份(成功那张),实际 %d", cnt);
|
||||
|
||||
image_set_bindir(".");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 超长单行:read_file 报出的行号必须与 edit_file 的口径一致 ----
|
||||
*
|
||||
* read_file 以前用 fgets + 固定 64 KB 缓冲,一行比缓冲长时会被切成"多行"、
|
||||
* 每块各带一个行号。于是模型按 read_file 看到的行号去 edit_file 改,要么改错
|
||||
* 地方、要么报越界 —— 而它没有任何办法察觉。工具描述和 README 都写着
|
||||
* "read_file 打出来的行号可以直接喂给 edit_file 的 line",这里把它钉住。 */
|
||||
puts("超长单行的行号口径");
|
||||
{
|
||||
FILE *lf = fopen("longline.txt", "wb");
|
||||
for (int i = 0; i < 70000; i++) fputc('a', lf); /* 跨过 64 KB 那块缓冲 */
|
||||
fputc('\n', lf);
|
||||
fputs("second line\nthird line\n", lf);
|
||||
fclose(lf);
|
||||
|
||||
r = call("read_file", "{\"path\":\"longline.txt\"}", &err);
|
||||
CHECK(err == 0, "读超长行文件失败: %s", r);
|
||||
CHECK(strstr(r, " 1\ta") != NULL, "第 1 行应以 a 开头:\n%.120s", r);
|
||||
CHECK(strstr(r, " 2\tsecond line") != NULL,
|
||||
"超长行之后的第 2 行应是 second line(它不能被算成好几行):\n%.200s", r);
|
||||
CHECK(strstr(r, " 3\tthird line") != NULL, "第 3 行应是 third line");
|
||||
CHECK(strstr(r, " 4\t") == NULL, "不应该出现第 4 行");
|
||||
free(r);
|
||||
|
||||
/* 拿 read_file 报出的行号去 edit_file 改,必须命中同一处 */
|
||||
r = call("edit_file",
|
||||
"{\"path\":\"longline.txt\",\"line\":3,"
|
||||
"\"old_string\":\"third line\",\"new_string\":\"THIRD LINE\"}", &err);
|
||||
CHECK(err == 0, "用 read_file 的行号改第 3 行失败: %s", r);
|
||||
CHECK(strstr(r, "第 3 行替换 1 处") != NULL, "回执应说清改的是第 3 行: %s", r);
|
||||
free(r);
|
||||
char *after = read_entire_file("longline.txt", NULL);
|
||||
CHECK(after && strstr(after, "THIRD LINE") != NULL, "改动应落到文件里");
|
||||
free(after);
|
||||
|
||||
/* 单行本身就超过输出上限:必须说清"这一行只给了开头",不能说成
|
||||
* "已截断,从下一行继续" —— 那会让调用方以为拿到的是完整的行。 */
|
||||
FILE *hf = fopen("hugeline.txt", "wb");
|
||||
for (int i = 0; i < 200000; i++) fputc('b', hf);
|
||||
fputc('\n', hf);
|
||||
fputs("tail\n", hf);
|
||||
fclose(hf);
|
||||
r = call("read_file", "{\"path\":\"hugeline.txt\"}", &err);
|
||||
CHECK(strstr(r, "第 1 行本身就超过") != NULL,
|
||||
"应说清是这一行超过上限:%.160s", r);
|
||||
CHECK(strstr(r, " 2\t") == NULL, "不能把剩下的半个行伪装成第 2 行");
|
||||
free(r);
|
||||
remove("hugeline.txt");
|
||||
remove("longline.txt");
|
||||
}
|
||||
|
||||
/* ---- 空目录:qsort 的第一个参数会拿到 NULL ---- */
|
||||
puts("list_dir 空目录");
|
||||
if (mkdir("emptydir", 0755) != 0) { /* 可能已存在 */ }
|
||||
r = call("list_dir", "{\"path\":\"emptydir\"}", &err);
|
||||
CHECK(err == 0 && strstr(r, "0 个条目") != NULL,
|
||||
"空目录应报 0 个条目,实际 %s", r);
|
||||
free(r);
|
||||
rmdir("emptydir");
|
||||
|
||||
/* ---- 未知工具 ---- */
|
||||
r = call("no_such_tool", "{}", &err);
|
||||
CHECK(err == 1 && strstr(r, "未知工具") != NULL, "未知工具应报错: %s", r);
|
||||
free(r);
|
||||
|
||||
/* ---- 输出截断 ---- */
|
||||
puts("output truncation");
|
||||
r = call("run_command",
|
||||
"{\"command\":\"head -c 300000 /dev/zero | tr '\\\\0' 'a'\"}", &err);
|
||||
CHECK(strlen(r) <= LIGENT_TOOL_OUTPUT_LIMIT + 200,
|
||||
"输出应被截断,实际 %zu 字节", strlen(r));
|
||||
CHECK(strstr(r, "截断") != NULL, "应提示已截断");
|
||||
free(r);
|
||||
|
||||
printf("\n%d 项检查,%d 项失败\n", checks, failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Reference in new issue
Block a user