Files
ligent/tests/run_e2e.sh
T
2026-10-03 14:13:21 +08:00

1545 lines
80 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# run_e2e.sh - 端到端测试:用本地 mock 服务端跑通 ligent 的完整链路
set -u
# 把脚本自己的 stdin 断掉。
#
# 这不是洁癖:ligent 在写操作前会问 (Y/n),而它在 stdin 不是终端时默认放行。
# 于是"直接调用 $BIN"的用例在 CI 里一路通过,在开发者的终端里却会停下来
# 等人敲回车 —— 同一份脚本,两种行为。交互用例都走 pty 驱动脚本,不靠这里
# 的 stdin,所以断开它不会误伤任何东西。
exec </dev/null
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# 默认测刚编出来的那个,但允许覆盖 —— 静态产物(ligent-static)也应当能过同一
# 套用例,否则"静态构建可用"就只等于"它能启动":
# BIN=$ROOT/ligent-static tests/run_e2e.sh
BIN="${BIN:-$ROOT/ligent}"
PASS=0
FAIL=0
port="$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')"
MODELS_MODE_FILE="$ROOT/build/tmp/models_mode" \
python3 "$ROOT/tests/mock_server.py" "$port" >/dev/null 2>&1 &
SERVER_PID=$!
trap 'kill $SERVER_PID 2>/dev/null; wait $SERVER_PID 2>/dev/null' EXIT
# 等服务端起来
for _ in $(seq 1 50); do
if python3 -c "
import socket,sys
s=socket.socket()
s.settimeout(0.2)
sys.exit(0 if s.connect_ex(('127.0.0.1',$port))==0 else 1)"; then
break
fi
sleep 0.1
done
BASE="http://127.0.0.1:$port/v1"
PORT="$port"
# 输入历史落到 build/ 里,别在仓库根目录留下 ligent.history。
# 需要验证历史本身的用例会自己指定 LIGENT_HISTORY。
export LIGENT_HISTORY="$ROOT/build/e2e.history"
rm -f "$LIGENT_HISTORY" "$LIGENT_HISTORY.tmp"
# 默认关掉能力探测:它会在每个会话的第一次真实请求前插进一串试探请求,
# 而下面几十段用例都是按"请求序列"写的,会被它整体推后一位。
# 探测本身由第 51 段专门覆盖(那段自己把这个变量打开)。
export LIGENT_PROBE=0
# 界面语言锁成中文:下面这些断言写的是中文文案,而开发者的 LANG 或 CI 的
# C locale 会让同一份脚本在两种语言下各红一半。英文路径由第 59 段专门覆盖。
export LIGENT_LANG=zh
# 配置隔离。ligent 读的是"二进制旁边那份 ligent.json",而被测二进制就在仓库根
# —— 于是开发者自己那份(改过 temperature、reasoning_effort……)会混进请求体,
# 让"默认请求长什么样"这类断言时绿时红:同一份代码,换个人的机器结论就不同。
# 测试得自证,所以这里指向一份空配置;要测配置加载的用例自己带 -c。
export LIGENT_CONFIG="$ROOT/build/tmp/e2e-empty.json"
printf '{}\n' > "$LIGENT_CONFIG"
# 存档目录(<二进制目录>/ligent.archive)是**运行期状态**,不是构建产物:
# 里面是用户自己的 /export 存档和图片备份。而下面几十段用例为了隔离会反复
# `rm -rf` 它,被测二进制又常常就在仓库根 —— 也就是说直接跑 e2e 会把人家的
# 存档删掉。所以开机先看一眼:非空就整体挪到临时目录(打印路径,内容不动),
# 之后也不再碰它。挪不动就直接停 —— 宁可测试不跑,也不能把东西删了。
ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive"
if [ -d "$ARCHIVE_DIR" ] && [ -n "$(ls -A "$ARCHIVE_DIR" 2>/dev/null)" ]; then
SAVED_ARCHIVE="$(mktemp -d "${TMPDIR:-/tmp}/ligent.archive.saved.XXXXXX")"
if mv "$ARCHIVE_DIR" "$SAVED_ARCHIVE/ligent.archive"; then
echo "注意:$ARCHIVE_DIR 里有东西,已整体移到"
echo " $SAVED_ARCHIVE/ligent.archive (e2e 需要这个目录是干净的)"
echo
else
echo "✗ 移不动 $ARCHIVE_DIR —— 为免误删,测试不跑。先手动移开它。" >&2
exit 1
fi
fi
check() { # check <描述> <条件结果 0/1> [输出]
local desc="$1" rc="$2" out="${3:-}"
if [ "$rc" -eq 0 ]; then
PASS=$((PASS + 1))
echo " ✓ $desc"
else
FAIL=$((FAIL + 1))
echo " ✗ $desc"
echo "----- 实际输出 -----"
echo "$out"
echo "--------------------"
fi
}
# contains <子串> <文本>
contains() { case "$2" in *"$1"*) return 0;; *) return 1;; esac; }
echo "== 1. 单次任务 + 流式 + 一次工具调用 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "please run it" 2>&1)"
echo "$OUT" | sed 's/^/ /'
contains "hello-from-tool" "$OUT"; check "工具被真实执行并回显" $?
contains "FINAL:" "$OUT"; check "拿到最终回答" $?
contains "run_command" "$OUT"; check "展示了工具调用" $?
echo
echo "== 2. 非流式模式 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --no-stream "do it" 2>&1)"
contains "FINAL:" "$OUT"; check "--no-stream 正常工作" $?
contains "hello-from-tool" "$OUT"; check "非流式下工具结果正确" $?
echo
echo "== 3. 多步工具调用 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "do it twice" 2>&1)"
echo "$OUT" | sed 's/^/ /'
contains "second-call" "$OUT"; check "第二次工具调用被执行" $?
contains "FINAL:" "$OUT"; check "多步之后拿到最终回答" $?
echo
echo "== 4. 服务端错误处理 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "ERROR_TEST" 2>&1)"
RC=$?
contains "mock 模拟的服务端错误" "$OUT"; check "错误信息透传给用户" $?
[ "$RC" -ne 0 ]; check "出错时退出码非 0" $?
echo
echo "== 5. 缺少 API key =="
OUT="$(env -u LIGENT_API_KEY -u DEEPSEEK_API_KEY -u OPENAI_API_KEY \
"$BIN" --base-url "$BASE" --no-confirm --no-auto-save "hi" 2>&1)"
contains "未配置 API key" "$OUT"; check "缺 key 时给出清晰提示" $?
echo
echo "== 6. 管道输入模式 =="
OUT="$(echo "piped task" | "$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color 2>&1)"
contains "FINAL:" "$OUT"; check "从 stdin 读取任务" $?
echo
echo "== 7. Markdown 流式渲染(mock 按 1 字符一片下发) =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --color "MARKDOWN_TEST" 2>&1)"
# 去掉 ANSI 后再做"可见文本"断言(样式会把标记和正文隔开)
PLAIN="$(printf '%s' "$OUT" | sed 's/\x1b\[[0-9;]*m//g')"
printf '%s' "$OUT" | grep -q $'\033\[1;36m' && r=0 || r=1
check "H1 渲染为粗体青" $r
printf '%s' "$OUT" | grep -q $'\033\[1m' && r=0 || r=1
check "粗体渲染" $r
printf '%s' "$OUT" | grep -qF '**粗体**' && r=1 || r=0
check "正文里的粗体标记 ** 已被移除" $r
printf '%s' "$OUT" | grep -qF '粗体' && r=0 || r=1
check "文本内容保留" $r
printf '%s' "$OUT" | grep -qF '**不应加粗**' && r=0 || r=1
check "代码块内的 ** 原样保留" $r
printf '%s' "$OUT" | grep -qF $'\033[36m-' && r=0 || r=1
check "列表标记 - 被着色" $r
printf '%s' "$PLAIN" | grep -qF -- '- 列表甲' && r=0 || r=1
check "列表保留原始 - 字符" $r
printf '%s' "$OUT" | grep -qF '•' && r=1 || r=0
check "不再出现非 ASCII 圆点" $r
printf '%s' "$OUT" | grep -qF '┌' && r=0 || r=1
check "表格渲染出边框" $r
printf '%s' "$PLAIN" | grep -qF '│ 苹果 │' && r=0 || r=1
check "中文表格单元格对齐" $r
printf '%s' "$PLAIN" | grep -qF '│ 3 │' && r=0 || r=1
check "表格右对齐标注生效" $r
# 下划线 + 青(SGR 4;36),不是亮蓝 —— 整个项目只用 16 色内的码,
# 理由见 tools/check-colors.sh。
printf '%s' "$OUT" | grep -q $'\033\[4;36m\[' && r=0 || r=1
check "链接 [] 加下划线并着青色" $r
printf '%s' "$OUT" | grep -qF '(https://example.com/doc)' && r=0 || r=1
check "链接 URL 原样保留" $r
printf '%s' "$OUT" | grep -qF 'arr[i]' && r=0 || r=1
check "数组下标不被当成链接" $r
echo
echo "== 8. 关闭渲染时输出原始 Markdown(管道场景) =="
OUT="$(echo "MARKDOWN_TEST" | "$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color 2>&1)"
printf '%s' "$OUT" | grep -qF '**粗体**' && r=0 || r=1
check "--no-color 时保留原始 ** 标记" $r
printf '%s' "$OUT" | grep -qF '# 一级标题' && r=0 || r=1
check "--no-color 时保留原始 # 标记" $r
printf '%s' "$OUT" | grep -q $'\033\[' && r=1 || r=0
check "--no-color 时不含任何 ANSI 转义" $r
echo
echo "== 9. 网络健壮性:瞬态失败重试 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "RETRY_TEST" 2>&1)"
contains "重试后成功" "$OUT"; check "503 之后重试并最终成功" $?
contains "重试(第 1 次)" "$OUT"; check "第一次失败后有退避重试" $?
contains "重试(第 2 次)" "$OUT"; check "第二次失败后继续重试" $?
echo
echo "== 10. 重试次数可配置(--retries 0 应立刻失败) =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --retries 0 "ALWAYS503_TEST" 2>&1)"
RC=$?
contains "重试(第" "$OUT" && r=1 || r=0
check "--retries 0 时不做任何重试" $r
contains "503" "$OUT" "$OUT"; check "--retries 0 时直接报出 503" $?
[ "$RC" -ne 0 ]; check "--retries 0 失败时退出码非 0" $?
echo
echo "== 11. 重试耗尽后必须给出可读错误 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --retries 1 "ALWAYS503_TEST" 2>&1)"
contains "已重试 1 次" "$OUT"; check "错误信息说明重试了几次" $?
contains "mock 持续不可用" "$OUT"; check "服务端的说明被透传" $?
echo
echo "== 12. 流式传输中途断连:保留已收到的内容 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "TRUNCATE_TEST" 2>&1)"
contains "半途崩溃" "$OUT"; check "已收到的部分内容被保留" $?
contains "传输中断" "$OUT"; check "明确提示传输中断" $?
contains "不重试" "$OUT"; check "已输出内容时不重试(避免重复输出)" $?
echo
echo "== 13. 畸形 SSE 不能导致崩溃 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "GARBAGE_TEST" 2>&1)"
RC=$?
contains "ok" "$OUT"; check "夹杂的合法片段仍被解析" $?
[ "$RC" -eq 0 ]; check "畸形输入下正常退出" $?
echo
echo "== 14. 超大响应必须被主动中止 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "HUGE_TEST" 2>&1)"
RC=$?
contains "超过" "$OUT"; check "明确报出超过响应体上限" $?
[ "$RC" -ne 0 ]; check "超限时退出码非 0" $?
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --no-stream "HUGE_TEST" 2>&1)"
contains "超过" "$OUT"; check "非流式路径同样有上限" $?
echo
echo "== 15. 上下文超限:如实报错,历史一个字节都不动 =="
# 这里以前测的是"超限后自动丢弃最老的一轮再重试"。那套机制已经整个删掉:
# 判据只是错误文案里的关键词,代价却是**不可逆地**删掉几轮对话。现在的行为是
# 把服务端的原始错误报出来、历史原样保留,怎么处理由用户决定(/back、/reset)。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "TOOLONG_TEST 第一轮" --prompt "TOOLONG_TEST 第二轮" \
--prompt "/history" --wait 1.2 2>&1)"
contains "第一轮" "$OUT"; check "第一轮正常" $?
contains "maximum context length" "$OUT"; check "超限时报出服务端的原始错误" $?
contains "已丢弃最老" "$OUT" && r=1 || r=0
check "不再自动丢弃历史" $r
# 两轮 user + 第一轮的 assistant = 3 条:第二轮报错没有留下残缺消息,也没删东西
contains "历史消息 3 条" "$OUT"
check "历史条数原样(system 不计)" $?
echo
echo "== 16. 自动裁剪的整套接口都已移除 =="
OUT="$("$BIN" --no-auto-trim "x" 2>&1)"; r=$?
[ "$r" -eq 2 ]; check "--no-auto-trim 被当成未知选项拒绝" $?
contains "未知选项" "$OUT"; check "拒绝时说清是未知选项" $?
OUT="$("$BIN" --auto-trim "x" 2>&1)"; r=$?
[ "$r" -eq 2 ]; check "--auto-trim 同样被拒绝" $?
# 旧配置里留着的 auto_trim 只是被忽略(未知键不该让程序起不来),
# 而且它不该再出现在生效配置里 —— 否则用户以为它还有用
printf '{"auto_trim": false}\n' > "$ROOT/build/tmp/e2e-autotrim.json"
OUT="$("$BIN" -c "$ROOT/build/tmp/e2e-autotrim.json" --print-config 2>&1)"; r=$?
[ "$r" -eq 0 ]; check "旧配置里的 auto_trim 不会拦住启动(未知键忽略)" $?
contains "auto_trim" "$OUT" && r=1 || r=0
check "--print-config 里不再出现 auto_trim" $r
echo
echo "== 17. 重定向必须被明确报出,不能静默跟随 =="
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --retries 0 "REDIRECT_TEST" 2>&1)"
RC=$?
contains "重定向到" "$OUT"; check "明确报出发生重定向" $?
contains "base_url" "$OUT"; check "告诉用户该改哪个配置项" $?
contains "example.invalid" "$OUT"; check "报出重定向目标地址" $?
[ "$RC" -ne 0 ]; check "重定向时退出码非 0" $?
echo
echo "== 18. --verbose 不得泄露 API key =="
# 必须有 --no-confirm:这一轮会触发工具调用,缺了它就会在真终端上问 (Y/n),
# 整个套件停在那里等人敲回车。脚本开头的 exec </dev/null 是兜底,这里写明
# 是为了不依赖那个兜底。
OUT="$("$BIN" --api-key "sk-VERBOSE-LEAK-CHECK-9876" --base-url "$BASE" \
--no-color --no-confirm --no-auto-save -v "hi" 2>&1)"
contains "VERBOSE-LEAK-CHECK" "$OUT" && r=1 || r=0
check "-v 输出里不含完整密钥" $r
contains "已隐藏" "$OUT"; check "-v 显示脱敏后的认证行" $?
contains "Send header" "$OUT"; check "-v 仍然保留协议细节" $?
echo
echo "== 19. 401 必须给出可操作的提示 =="
OUT="$("$BIN" --api-key "sk-0f114e3dac8249f0a36fd8fd5c517360" --base-url "$BASE" \
--no-color --no-confirm --no-auto-save --retries 0 "AUTHFAIL_TEST" 2>&1)"
contains "Missing Authentication header" "$OUT"; check "服务端原文被透传" $?
contains "不同服务商的密钥互不通用" "$OUT"; check "指出 key 与服务商不匹配" $?
contains "sk-or-" "$OUT"; check "点明 OpenRouter 的 key 前缀" $?
contains "sk-0f1" "$OUT"; check "显示实际发出的 key 前缀(已打码)" $?
contains "0f114e3dac8249f0a36fd8fd5c517360" "$OUT" && r=1 || r=0
check "不回显完整密钥" $r
echo
echo "== 20. 空 system 配置不应下发空消息 =="
printf '{"model":"deepseek-flash","system":""}\n' > build/empty_system.json
OUT="$("$BIN" --config build/empty_system.json --print-config 2>&1)"
contains "system : (内置默认提示词)" "$OUT"; check "空 system 回退到内置默认" $?
rm -f build/empty_system.json
echo
echo "== 21. REPL 行编辑(方向键 / Home / End / 退格 / 历史) =="
# 这个套件要跑十几秒。用 $(...) 捕获的话,输出被接到管道会变成块缓冲,
# 中途十几秒一点动静都没有 —— 看着就像卡住了在等输入。所以用 tee 边走边打;
# python 的 -u 和 sed 的 -u 都不能省:stdout 接了管道就变块缓冲,
# 得每一环都关掉才对。
python3 -u tests/repl_edit_check.py "$BIN" "$BASE" 2>&1 \
| sed -u 's/^/ /' | tee build/edit_check.out
RC=${PIPESTATUS[0]}
[ "$RC" -eq 0 ]; check "行编辑器行为全部正确" $?
echo
echo "== 23. /log 导出收发记录 =="
LOG="build/e2e_session.log"
rm -f "$LOG"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "你好" --prompt "/log" --prompt "/log $LOG" --wait 1.2 2>&1)"
contains "收发记录已缓存" "$OUT"; check "/log 无参数显示缓存大小" $?
# 从 pty 输出里取出"已把 N 字节"这句话(行编辑器会重绘,所以要按关键词找)
printf '%s' "$OUT" | grep -q "已把 .* 字节收发记录追加写入"; check "导出成功并报告字节数" $?
[ -f "$LOG" ]; check "日志文件已创建" $?
printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "请求: POST"; check "记录了请求" $?
printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q -- "----- 响应头 -----"; check "记录了响应头" $?
printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "^data: "; check "记录了 SSE 原文(可离线回放)" $?
printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "结束: HTTP 200"; check "记录了结束状态" $?
printf '%s' "$(cat "$LOG" 2>/dev/null)" | grep -q "Authorization"; r=$?
[ "$r" -ne 0 ]; check "日志里不含认证头" $?
# 再导出一次应是追加
L1="$(wc -l < "$LOG")"
OUT2="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "再来一次" --prompt "/log $LOG" --wait 1.2 2>&1)"
L2="$(wc -l < "$LOG")"
[ "$L2" -gt "$L1" ]; check "多次导出是追加而不是覆盖" $?
# /log clear
OUT3="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "hi" --prompt "/log clear" --prompt "/log" --wait 1.2 2>&1)"
contains "收发记录已清空" "$OUT3"; check "/log clear 清空缓存" $?
rm -f "$LOG"
echo
echo "== 30. /export 导出对话存档 =="
ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive"
rm -rf "$ARCHIVE_DIR"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "记住暗号是紫水晶" --prompt "/export" --wait 1.3 2>&1)"
contains "已导出" "$OUT"; check "导出成功并报告条数" $?
OUT2="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "另一个话题" --prompt "/export" --wait 1.3 2>&1)"
contains "已导出" "$OUT2"; check "再次导出成功" $?
# 空对话不该产生存档
OUT3="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/export" --wait 1.2 2>&1)"
contains "还没有内容" "$OUT3"; check "空对话拒绝导出" $?
COUNT="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)"
[ "$COUNT" -eq 2 ]; check "存放了两份存档(文件名不冲突)" $?
# 文件名是 8 位小写字母数字
ls "$ARCHIVE_DIR" | head -1 | sed 's/\.json$//' | grep -qE '^[a-z0-9]{8}$'
check "根名是 8 位小写字母数字" $?
# 内容必须自描述,且记下自己的名字与来源
python3 - "$ARCHIVE_DIR" <<'PYEOF'
import glob, json, sys
files = glob.glob(sys.argv[1] + "/*.json")
d = json.load(open(files[0], encoding="utf-8"))
ok = (d.get("format") == "ligent.archive" and "messages" in d
and d.get("name") and "parent" in d
and d.get("created") and d.get("saved") and d.get("model"))
sys.exit(0 if ok else 1)
PYEOF
check "存档是自描述的 JSON(name/parent/created/messages)" $?
echo
echo "== 30b. 谱系命名:分支看得见 =="
AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | head -1)"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--auto-save --prompt "/import $AROOT" --prompt "n" --prompt "分支甲" \
--prompt "/export" --prompt "/exit" --wait 1.3 2>&1)"
contains "$AROOT ->" "$OUT"; check "从根分出的名字挂在根后面" $?
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--auto-save --prompt "/import $AROOT" --prompt "n" --prompt "分支乙" \
--prompt "/export" --prompt "/exit" --wait 1.3 2>&1)"
contains "$AROOT ->" "$OUT"; check "第二次仍从根分出(兄弟分支)" $?
BRANCHES="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -c "^${AROOT}_")"
[ "$BRANCHES" -eq 2 ]; check "根下有两个兄弟分支" $?
for b in $(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep "^${AROOT}_"); do
printf '%s' "$b" | grep -qE "^${AROOT}_[a-z0-9]{3}$" || BRANCHES=0
done
[ "$BRANCHES" -eq 2 ]; check "分支名形如 <父>_<3位>" $?
# 再从分支往下分一级
B1="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep "^${AROOT}_" | head -1)"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--auto-save --prompt "/import $B1" --prompt "n" --prompt "更深一层" \
--prompt "/export" --prompt "/exit" --wait 1.3 2>&1)"
contains "$B1 ->" "$OUT"; check "从分支再分出一级" $?
ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${B1}_[a-z0-9]{3}$"
check "孙分支名形如 <父>_<3位>_<3位>" $?
echo
echo "== 31. /import 交互式载入(可以按需打印历史) =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/import" --prompt "1" --prompt "n" --prompt "/history" --wait 1.2 2>&1)"
contains "份存档" "$OUT"; check "/import 列出存档" $?
contains "条消息" "$OUT"; check "列表里显示消息条数" $?
contains "已载入" "$OUT"; check "选中后载入成功" $?
contains "是否输出这段历史的内容" "$OUT"; check "询问是否输出历史" $?
# 列表顺序会被新增的分支影响,所以不写死条数,改成核对两个数字一致
LOADED="$(printf '%s' "$OUT" | tr '\r' '\n' | grep '已载入' | grep -oE '[0-9]+ 条消息' | grep -oE '^[0-9]+' | head -1)"
HIST="$(printf '%s' "$OUT" | tr '\r' '\n' | grep '历史消息' | grep -oE '[0-9]+' | head -1)"
[ -n "$LOADED" ] && [ "$LOADED" = "$HIST" ]; check "载入后的历史条数与存档一致($LOADED)" $?
printf '%s' "$OUT" | grep -q "紫水晶" && r=1 || r=0
check "回答 n 时不打印历史内容" $r
# 回答 y 时把历史逐条打出来。这里按名字指定含暗号那一份 —— 选择器的
# 序号顺序由 archive_list 的排序决定,不该在测试里赌。
SECRET="$(grep -l 紫水晶 "$ARCHIVE_DIR"/*.json 2>/dev/null | head -1 \
| xargs -r basename | sed 's/\.json$//')"
[ -n "$SECRET" ]; check "找到含暗号的那份存档($SECRET)" $?
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/import $SECRET" --prompt "y" --prompt "/exit" --wait 1.2 2>&1)"
contains "紫水晶" "$OUT"; check "回答 y 时打印历史内容" $?
contains "助手" "$OUT"; check "打印时带角色标签" $?
# 完整输出:system 提示词也展开,不是只报个体积
contains "你是用户的 AI 助手" "$OUT"; check "system 提示词全文展开" $?
# 工具调用的参数要完整,不能只报字节数
contains '"command": "echo hello-from-tool"' "$OUT"; check "工具调用参数完整打印" $?
contains "tool_call_id=call_" "$OUT"; check "工具结果标出回应的是哪次调用" $?
printf '%s' "$OUT" | grep -qE "还有 [0-9]+ 字节|不再显示" && r=1 || r=0
[ "$r" -eq 0 ]; check "没有任何截断标记" $?
# 直接回车等于 Y
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/import $SECRET" --prompt "" --prompt "/exit" --wait 1.2 2>&1)"
contains "紫水晶" "$OUT"; check "直接回车默认输出(Y)" $?
echo
echo "== 31b. /cls 只清屏,不动对话 =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-auto-save \
--prompt "你好" --prompt "/history" --prompt "/cls" --prompt "/history" \
--wait 1.2 2>&1)"
# 清屏序列必须真的发出去
printf '%s' "$OUT" | grep -q $'\033\[2J' ; check "发出清屏转义序列" $?
# 前后条数一致 = 对话没被动过
N1="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | head -1)"
N2="$(printf '%s' "$OUT" | tr '\r' '\n' | grep -oE '历史消息 [0-9]+ 条' | tail -1)"
[ -n "$N1" ] && [ "$N1" = "$N2" ]; check "/cls 前后历史条数不变($N1)" $?
# /cls 和 /clear 是两回事:/clear 是 /reset 的别名
contains "已清空对话历史" "$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "你好" --prompt "/clear" --wait 1.2 2>&1)"
check "/clear 仍然清空对话(没被 /cls 抢走)" $?
echo
echo "== 32. /import 取消与非法序号 =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/import" --prompt "" --wait 1.2 2>&1)"
contains "已取消" "$OUT"; check "直接回车取消" $?
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/import" --prompt "99" --wait 1.2 2>&1)"
contains "超出范围" "$OUT"; check "非法序号被拒绝" $?
rm -rf "$ARCHIVE_DIR"
echo
echo "== 33. 写操作确认(默认开启,回车即允许) =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--confirm --extra=--no-auto-save \
--prompt "跑一下" --prompt "n" --prompt "/exit" --wait 1.2 2>&1)"
contains "即将执行" "$OUT"; check "执行前说明了要做什么" $?
contains "命令: echo hello-from-tool" "$OUT"; check "展示了具体命令" $?
contains "继续?(Y/N/Always/neVer)" "$OUT"; check "提示列出四种回答" $?
contains "用户拒绝" "$OUT"; check "拒绝后反馈给模型而不是中断本轮" $?
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--confirm --extra=--no-auto-save \
--prompt "跑一下" --prompt "" --prompt "/exit" --wait 1.2 2>&1)"
contains "hello-from-tool" "$OUT"; check "直接回车即默认允许" $?
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm \
--no-auto-save "跑一下" 2>&1)"
contains "hello-from-tool" "$OUT"; check "--no-confirm 时直接执行" $?
printf '%s' "$OUT" | grep -q "继续?" && r=1 || r=0
check "--no-confirm 时不再询问" $r
echo
echo "== 34. 自动存档 =="
ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive"
rm -rf "$ARCHIVE_DIR"
"$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm "跑一下" >/dev/null 2>&1
N1="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)"
[ "$N1" -eq 1 ]; check "单次任务结束后自动存档" $?
"$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm --no-auto-save "跑一下" >/dev/null 2>&1
N2="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)"
[ "$N2" -eq "$N1" ]; check "--no-auto-save 时不存档" $?
# 只有 system 一条(没问过问题)不该产生存档
"$BIN" --api-key test --base-url "$BASE" --no-color --no-confirm --print-config >/dev/null 2>&1
N3="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)"
[ "$N3" -eq "$N1" ]; check "没有内容时不留下空存档" $?
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--auto-save \
--prompt "第一段对话" --prompt "/reset" --prompt "第二段对话" \
--prompt "/exit" --wait 1.3 2>&1)"
contains "已自动存档(重置前)" "$OUT"; check "/reset 前自动存档" $?
contains "已自动存档(退出)" "$OUT"; check "退出时自动存档" $?
# 重置之后若没有任何内容,退出时不该留下空存档
BEFORE="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)"
python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--auto-save --prompt "/exit" --wait 1.0 >/dev/null 2>&1
AFTER="$(ls "$ARCHIVE_DIR"/*.json 2>/dev/null | wc -l)"
[ "$BEFORE" -eq "$AFTER" ]; check "空对话退出时不留存档" $?
rm -rf "$ARCHIVE_DIR"
echo
echo "== 35. /import 前的保底存档带 tmp_ 前缀 =="
rm -rf "$ARCHIVE_DIR"
# 先建一个根
python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --extra=--auto-save \
--prompt "开场" --prompt "/export" --prompt "/exit" --wait 1.2 >/dev/null 2>&1
R2="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | head -1)"
[ -n "$R2" ]; check "先建立了一个根存档" $?
# 先聊一轮(不导出),再导入 —— 这时才需要保底存档
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --extra=--auto-save \
--prompt "临时的对话" --prompt "/import $R2" --prompt "n" --prompt "分支" \
--prompt "/export" --prompt "/exit" --wait 1.2 2>&1)"
contains "〔临时〕" "$OUT"; check "保底存档被标记为临时" $?
ls "$ARCHIVE_DIR" | grep -qE "^tmp_[a-z0-9]{8}\.json$"
check "临时存档名为 tmp_<8位>" $?
# 临时存档不该成为分支的祖先
printf '%s' "$OUT" | grep -qE "${R2} -> ${R2}_[a-z0-9]{3}"
check "分支仍挂在原来的根下,而不是 tmp_ 之下" $?
ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${R2}_[a-z0-9]{3}$"
check "分支名形如 <根>_<3位>" $?
rm -rf "$ARCHIVE_DIR"
echo
echo "== 36. /shell(带参数直接执行) =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/shell echo 来自shell" --prompt "/history" --wait 1.4 2>&1)"
contains "来自shell" "$OUT"; check "命令被执行" $?
contains "已加入上下文" "$OUT"; check "结果记入上下文" $?
contains "历史消息 1 条" "$OUT"
check "上下文里多了一条记录(不含 system 提示词)" $?
echo
echo "== 37. /shell(交互式会话被录制) =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/shell" --prompt "echo 交互录制" --prompt "exit" \
--prompt "/history" --wait 1.5 2>&1)"
contains "进入交互式 shell" "$OUT"; check "进入交互式 shell" $?
contains "交互录制" "$OUT"; check "shell 里的命令真的跑了" $?
contains "已把" "$OUT"; check "报告录制的字节数" $?
contains "不自动提问" "$OUT"; check "说明只是记入上下文、不自动提问" $?
contains "历史消息 1 条" "$OUT"; check "会话记入上下文(不含 system)" $?
echo
echo "== 37b. /shell(全屏程序与重绘空格的记录清洗) =="
OUT="$(python3 -u tests/shell_screen_check.py "$BIN" "$BASE" 2>&1)"
echo "$OUT" | sed 's/^/ /'
contains "0 项失败" "$OUT"; check "全屏程序记录清洗" $?
echo
echo "== 38. /usage =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "随便问问" --prompt "/usage" --wait 1.3 2>&1)"
contains "token 用量" "$OUT"; check "/usage 输出用量" $?
contains "API 调用" "$OUT"; check "报告调用次数" $?
contains "输入 token" "$OUT"; check "报告输入 token" $?
contains "最近一次输入" "$OUT"; check "报告当前上下文大小" $?
echo
echo "== 39. /back 与 /unback =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "第一轮" --prompt "第二轮" --prompt "/history" \
--prompt "/back" --prompt "/history" --prompt "/unback" \
--prompt "/history" --wait 1.3 2>&1)"
contains "已删除最近一轮" "$OUT"; check "/back 删除最近一轮" $?
contains "已恢复" "$OUT"; check "/unback 恢复" $?
# /back 之后又开了新对话则不能撤销
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "第一轮" --prompt "/back" --prompt "新的话题" \
--prompt "/unback" --wait 1.3 2>&1)"
contains "无法撤销" "$OUT"; check "开了新对话后 /unback 拒绝" $?
# 没有可撤销的东西
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/unback" --prompt "/back" --wait 1.2 2>&1)"
contains "没有可撤销的操作" "$OUT"; check "空历史时 /unback 提示" $?
contains "没有可以回退" "$OUT"; check "空历史时 /back 提示" $?
echo
echo "== 40. /yolo 临时切换确认 =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--confirm --extra=--no-auto-save \
--prompt "/yolo" --prompt "跑一下" --prompt "/yolo" --prompt "/exit" --wait 1.3 2>&1)"
contains "确认已关闭" "$OUT"; check "/yolo 关闭确认" $?
contains "确认已开启" "$OUT"; check "/yolo 再次开启" $?
printf '%s' "$OUT" | tr '\r' '\n' | grep -q "继续?" && r=1 || r=0
check "关闭确认期间不再询问" $r
echo
echo "== 41. 思考模式与级别 =="
# 默认:不发 thinking / reasoning_effort,由服务端决定
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \
"THINK_TEST 默认" 2>&1)"
contains "THINK[echo=yes thinking=none effort=none]" "$OUT"; check "默认不发思考字段" $?
# 显式开启 + 指定级别,且 reasoning_content 必须随 assistant 消息回传
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \
--thinking on --effort max "THINK_TEST 开启" 2>&1)"
contains "THINK[echo=yes thinking=enabled effort=max]" "$OUT"; check "thinking=enabled + effort=max 已下发且思考内容回传" $?
# 非流式路径也要能带上 reasoning_content(它走的是另一条解析分支)
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \
--no-stream --thinking on "THINK_TEST 非流式" 2>&1)"
contains "THINK[echo=yes thinking=enabled" "$OUT"; check "非流式路径也回传思考内容" $?
# 非法值必须在命令行就被拦下
OUT="$("$BIN" --api-key test --base-url "$BASE" --thinking 乱写 --no-confirm "x" 2>&1)"; r=$?
[ "$r" -ne 0 ]; check "--thinking 非法值被拒绝" $?
contains "on / off / auto" "$OUT"; check "非法值给出可选范围" $?
OUT="$("$BIN" --api-key test --base-url "$BASE" --effort 超级高 --no-confirm "x" 2>&1)"; r=$?
[ "$r" -ne 0 ]; check "--effort 非法值被拒绝" $?
echo
echo "== 42. /think 运行时切换 =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save \
--prompt "/think" --prompt "/think high" --prompt "/think" \
--prompt "/think off" --prompt "/think 乱写" --prompt "/think auto" \
--wait 0.6 2>&1)"
contains "由服务端决定" "$OUT"; check "/think 无参数显示当前设置" $?
contains "思考级别已设为 high" "$OUT"; check "/think LEVEL 设置级别" $?
contains "思考级别:high" "$OUT"; check "级别被记住" $?
contains "关闭(发 thinking.type=disabled)" "$OUT"; check "/think off 关闭思考" $?
contains "无法识别" "$OUT"; check "/think 非法值给出提示" $?
echo
echo "== 43. 等待动画的两段标签 =="
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--thinking --extra=on \
--prompt "SLOWRESP_TEST 慢一点" --wait 3.2 2>&1)"
contains "等待响应" "$OUT"; check "等网络时显示 等待响应" $?
contains "思考中" "$OUT"; check "收到思维链后切到 思考中" $?
first="$(printf '%s' "$OUT" | grep -o '等待响应\|思考中' | head -1)"
[ "$first" = "等待响应" ]; check "两个标签的先后顺序正确" $?
# 非流式时拿不到任何增量,只能一直是"等待响应"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-stream \
--prompt "SLOWRESP_TEST 非流式" --wait 3.2 2>&1)"
contains "等待响应" "$OUT"; check "非流式也显示 等待响应" $?
printf '%s' "$OUT" | grep -q "思考中" && r=1 || r=0
check "非流式拿不到增量,不应出现 思考中" $r
echo
echo "== 44. /meta 元编辑对话历史 =="
ARCHIVE_DIR="$(dirname "$BIN")/ligent.archive"
rm -rf "$ARCHIVE_DIR"
# 全都在同一个进程里做完:谱系(g_lineage)是进程内状态,
# 换一个 ligent 进程它就不知道上一份存档叫什么,会另起一个新根。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "第一轮" --prompt "第二轮" --prompt "第三轮" \
--prompt "/export" \
--prompt "/meta" --prompt "META_TEST 删掉第 2 轮" \
--prompt "/diff" --prompt "/exit" \
--prompt "/history" --prompt "/export" --prompt "/exit" --wait 1.3 2>&1)"
contains "已导出 8 条消息" "$OUT"
check "建好三段对话并导出(8 条对话消息,不含 system)" $?
contains "已进入 meta 模式" "$OUT"; check "能进入 meta 模式" $?
contains "meta>" "$OUT"; check "提示符换成 meta>" $?
contains "已删除轮 2..2" "$OUT"; check "meta 模型真的改了历史" $?
contains "当前历史 7 条消息" "$OUT"; check "/diff 反映改动后的条数" $?
contains "历史消息 6 条" "$OUT"; check "主对话上下文确实变了" $?
contains "没有改动" "$OUT" && r=1 || r=0
[ "$r" -eq 0 ]; check "退出时报告本次不是空跑" $?
contains "另起一根" "$OUT"; check "提示会重新存根" $?
# 新根必须叫 <原名>_m<3位>,并且 parent 记空
AROOT="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -E '^[a-z0-9]{8}$' | head -1)"
[ -n "$AROOT" ]; check "找到初始根($AROOT)" $?
ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -qE "^${AROOT}_m[a-z0-9]{3}$"
check "meta 版名字形如 <原名>_m<3位>" $?
META_NAME="$(ls "$ARCHIVE_DIR" | sed 's/\.json$//' | grep -E "^${AROOT}_m[a-z0-9]{3}$" | head -1)"
python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF'
import json, sys
d = json.load(open(sys.argv[1], encoding="utf-8"))
sys.exit(0 if d.get("parent") == "" else 1)
PYEOF
check "meta 版是新根(parent 为空)" $?
python3 - "$ARCHIVE_DIR/$META_NAME.json" <<'PYEOF'
import json, sys
d = json.load(open(sys.argv[1], encoding="utf-8"))
msgs = d.get("messages", [])
# 结构必须仍然自洽:带 tool_calls 的 assistant 后面必须紧跟 tool
ok = True
for i, m in enumerate(msgs):
if m.get("role") == "assistant" and m.get("tool_calls"):
if i + 1 >= len(msgs) or msgs[i + 1].get("role") != "tool":
ok = False
sys.exit(0 if ok else 1)
PYEOF
check "删改之后工具调用配对仍然完整" $?
# meta 模式里的未知命令不该被当成给模型的指令
# 单引号很关键:双引号里 bash 会先把 $((6*7)) 算成 42,
# 那断言就成了"回显里有没有 42",毫无意义。
# 命令写成"输出和输入不同"的样子,因为行编辑器会把敲进去的字原样回显,
# 直接 grep 命令文本永远命中,证明不了它有没有被执行。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "/meta" --prompt '/shell echo $((6*7))' --prompt "/exit" --wait 1.2 2>&1)"
contains "meta 模式里没有" "$OUT"; check "meta 里拒绝主 REPL 的命令" $?
printf '%s' "$OUT" | grep -q "42" && r=1 || r=0
[ "$r" -eq 0 ]; check "被拒绝的命令确实没被执行(没出现 42)" $?
rm -rf "$ARCHIVE_DIR"
echo
echo "== 45. 确认提示上的每种回答 =="
# 重点:在确认提示上按 Ctrl-C 必须等于拒绝。
# confirm_tool 用 fgets 读输入,而 SIGINT 会让它 EINTR 返回 NULL ——
# 以前 NULL 被当成"没人能回答,按默认值放行",于是 Ctrl-C 批准了写盘。
OUT="$(python3 tests/confirm_check.py "$BIN" "$BASE" 2>&1)"
echo "$OUT" | sed 's/^/ /'
contains "0 项失败" "$OUT"; check "确认提示的每种回答都正确" $?
echo
echo "== 47. doctor 的联网检查 =="
# 这条路径以前从来没被跑过 —— `ligent doctor` 走到网络那一步会段错误,
# 原因只是 http.c 里对 GET 探测的 NULL 请求体做了 strlen。
OUT="$("$BIN" doctor --api-key test --base-url "$BASE" 2>&1)"; RC=$?
[ "$RC" -lt 128 ]; check "doctor 联网检查不崩(退出码 $RC)" $?
contains "【网络】" "$OUT"; check "打印了网络一节" $?
contains "连通性" "$OUT"; check "报告了连通性" $?
contains "HTTP 状态" "$OUT"; check "报告了 HTTP 状态码" $?
contains "结论" "$OUT"; check "给出了结论" $?
# 认证这一节必须区分三种情况:密钥对 / 密钥错 / 根本没配。
# 以前 http_probe 压根不带密钥,于是任何要求认证的端点都回 401,而 doctor
# 一律说"密钥被拒绝" —— 把用户引到一个根本不存在的问题上。
AUTHED="http://127.0.0.1:$PORT/authed"
OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key good-key --base-url "$AUTHED" 2>&1)"
contains "端点接受了这把密钥" "$OUT"; check "密钥正确时如实说接受" $?
OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key bad-key --base-url "$AUTHED" 2>&1)"
contains "服务端拒绝了这把密钥" "$OUT"; check "密钥错误时如实说被拒" $?
OUT="$(env -u LIGENT_API_KEY -u DEEPSEEK_API_KEY -u OPENAI_API_KEY \
"$BIN" doctor --base-url "$AUTHED" 2>&1)"
contains "端点要求认证,但当前没有配置 API key" "$OUT"; check "没配密钥时不说"被拒绝"" $?
contains "不是网络问题" "$OUT"; check "并说明这跟网络无关" $?
# 404 也不能被说成"密钥没问题":请求根本没走到认证
OUT="$(env -u LIGENT_API_KEY "$BIN" doctor --api-key good-key \
--base-url "http://127.0.0.1:$PORT/nope" 2>&1)"
contains "这个地址不存在" "$OUT"; check "404 报地址不对" $?
contains "说明不了密钥好不好" "$OUT"; check "404 不冒充密钥结论" $?
# 端点跳转那条分支也要走到
OUT="$("$BIN" doctor --api-key test --base-url "http://127.0.0.1:$PORT/redirect" 2>&1)"
RC=$?
[ "$RC" -lt 128 ]; check "重定向场景也不崩(退出码 $RC)" $?
contains "重定向" "$OUT"; check "识别出端点跳转" $?
contains "example.invalid" "$OUT"; check "报出跳转目标" $?
contains "不自动跟随重定向" "$OUT"; check "解释了为什么不跟随" $?
echo
echo "== 48. read_image:图片以 base64 内联发出,历史里只留备份引用 =="
# 自己造测试图:一张最小的合法 PNG,和一个"扩展名是 .png、内容不是图片"的文件,
# 以及一张故意超过内联上限的大图。不能依赖 build/ 里已有的东西 ——
# make clean 会把它整个删掉。
mkdir -p build/tmp
python3 - <<'PYEOF'
import struct, zlib
def chunk(t, d):
c = t + d
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00"))
+ chunk(b"IEND", b""))
open("build/tmp/dot.png", "wb").write(png)
open("build/tmp/notanimage.png", "wb").write(b"this is not an image")
# 内联上限是编码后的体积,4/3 膨胀,所以原始超过 3/4 上限就会触发体检
with open("build/tmp/huge.png", "wb") as f:
f.write(png)
f.truncate(25 * 1024 * 1024)
PYEOF
PNG="build/tmp/dot.png"
rm -rf "$ARCHIVE_DIR"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "IMAGE_TEST 看一下 $PNG" --prompt "/history" \
--prompt "/export" --prompt "/exit" --wait 1.6 2>&1)"
contains "已读入图片" "$OUT"; check "read_image 读入成功" $?
contains "base64" "$OUT"; check "工具结果说明了会以 base64 内联" $?
# 关键:模型在下一轮**真的看到了**内联图片(图片只能出现在 user 消息里,
# 而 read_image 是工具、结果只能是字符串,所以中间补了一条附件消息)
contains "IMAGE[data:image/png:" "$OUT"; check "模型收到了内联 base64 图片" $?
contains "历史消息 5 条" "$OUT"
check "多出一条附件消息(5 条工具往返 + 1 条附件,不含 system)" $?
# 本地备份
[ -d "$ARCHIVE_DIR/images" ]; check "本机留了备份目录" $?
N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)"
[ "$N" -eq 1 ]; check "只备份了成功那一张($N 份)" $?
python3 - "$ARCHIVE_DIR" <<'PYEOF'
import glob, os, sys
names = [os.path.basename(p) for p in glob.glob(sys.argv[1] + "/images/*.png")]
ok = names and all(len(n[:-4].split("_")[-1]) == 4 for n in names)
sys.exit(0 if ok else 1)
PYEOF
check "备份名形如 <时间戳>_<4位>.<ext>" $?
# 这是这一版最要紧的不变量:**归档里不能有图片字节**。
# 请求里发的是 base64,但存进历史 / 归档的必须是那个几十字节的引用。
python3 - "$ARCHIVE_DIR" "build/tmp/dot.png" <<'PYEOF'
import base64, glob, json, sys
d, png_path = sys.argv[1], sys.argv[2]
raw = open(glob.glob(d + "/*.json")[0], encoding="utf-8").read()
# 判据只能是**图片载荷本身**。不能拿 "base64" 这个词,也不能拿 "data:image":
# 工具结果里会正当地写"base64 内联后约…",mock 的回复里还会出现
# "IMAGE[data:image/png:92]" 这种对收到内容的描述。真正该消失的是那串载荷。
payload = base64.b64encode(open(png_path, "rb").read()).decode()
if payload in raw:
print("归档里出现了图片 base64 载荷")
sys.exit(1)
if "data:image/png;base64," in raw:
print("归档里出现了 data URL")
sys.exit(1)
# 同时要确认引用确实在 —— 别把"没存图"和"压根没存"搞混
refs = []
def walk(o):
if isinstance(o, dict):
if o.get("type") == "ligent_image":
refs.append(o.get("backup"))
for v in o.values():
walk(v)
elif isinstance(o, list):
for v in o:
walk(v)
walk(json.loads(raw))
ok = len(refs) == 1 and (refs[0] or "").endswith(".png")
if not ok:
print("归档里的引用不对:", refs)
sys.exit(0 if ok else 1)
PYEOF
check "归档里只有对备份的引用、没有图片字节" $?
rm -rf "$ARCHIVE_DIR"
echo
echo "== 49. read_image:服务端拒图时把图片摘掉重试,而不是每轮都失败 =="
rm -rf "$ARCHIVE_DIR"
# 三轮必须在**同一个** repl 进程里。mock 的 told_once 是一次性的,重开一个
# 进程它就不再拒了;而且"降级要就地生效"这件事本身也只能在同一会话里验。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "IMAGE_TEST 先看一张图" \
--prompt "IMAGE_TEST IMAGE_REJECT_TEST 再看一次" \
--prompt "IMAGE_TEST 第三轮" \
--prompt "/exit" --wait 2.4 2>&1)"
contains "服务端拒绝了请求里的图片" "$OUT"; check "识别出是图片被拒" $?
contains "从对话里移除后重试" "$OUT"; check "说明了是移除后重试" $?
# 重试那一轮请求里就不该再有图片了
contains "IMAGE[none]" "$OUT"; check "重试时请求里确实没有图片了" $?
# 而且模型**真的收到了**那句话(mock 把 user 文本回显出来了)
contains "不符合 API 所需的格式标准" "$OUT"; check "模型收到了"请规范化图片"的说明" $?
contains "请规范化图片" "$OUT"; check "说明里给了下一步动作" $?
# 降级是**就地**的:同一会话的下一轮也不该再有图片,
# 否则每轮都会重新踩同一个 4xx,用户只能 /reset。
LAST_IMG="$(printf '%s\n' "$OUT" | grep -o 'IMAGE\[[^]]*\]' | tail -1)"
[ "$LAST_IMG" = "IMAGE[none]" ]; check "下一轮也是干净的($LAST_IMG)" $?
# 备份不该因为被拒而多出来(被拒的是服务端,图片本身是好的,仍只有 1 份)
N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)"
[ "$N" -eq 1 ]; check "备份没有被重复写($N 份)" $?
rm -rf "$ARCHIVE_DIR"
echo "== 50. read_image:不认识的格式、以及过大的图,都要在本地拒绝 =="
rm -rf "$ARCHIVE_DIR"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "IMAGE_BAD_TEST 读一个假图片" --prompt "/exit" --wait 1.3 2>&1)"
contains "不认识的图片格式" "$OUT"; check "按内容判断并拒绝(扩展名是 .png 也没用)" $?
# 过大的图:本地当场拒,不备份、不发请求
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "IMAGE_OVERSIZE_TEST 读一张大图" --prompt "/exit" --wait 1.5 2>&1)"
contains "图片太大" "$OUT"; check "过大的图在本地就被拒掉" $?
contains "内联上限" "$OUT"; check "说明了上限是多少" $?
N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)"
[ "$N" -eq 0 ]; check "被拒的图不备份($N 份)" $?
rm -rf "$ARCHIVE_DIR"
echo "== 51. 能力探测序列:按模型能力决定暴露哪些工具 =="
# 这一段自己把 LIGENT_PROBE 打开(脚本全局是关的,理由见文件开头)。
PROBE="LIGENT_PROBE=1"
run_probe() { # run_probe <模型名> <额外参数...>
local model="$1"; shift
env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--extra="--model" --extra="$model" "$@" 2>&1
}
# --- 正常模型:探测跑过,read_image 在 ---
OUT="$(run_probe probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)"
contains "TOOLS[" "$OUT"; check "探测之后请求正常发出" $?
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'
check "支持图片的模型:工具表里有 read_image" $?
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command'
check "其他工具不受影响" $?
contains "模型能力:多模态(图片输入)支持" "$OUT"; check "探测后打一条结论日志" $?
contains "工具调用 支持" "$OUT"; check "日志里也说明工具调用能力" $?
# --- 模型明确不收图片:read_image 必须从工具表里消失 ---
OUT="$(run_probe no-vision-model --prompt "TOOLS_ECHO_TEST 看看工具表" \
--prompt "/tools" --prompt "/exit" --wait 2.0)"
contains "多模态(图片输入)不支持(已隐藏 read_image)" "$OUT"
check "如实说明了图片能力与因此做的动作" $?
contains "TOOLS[" "$OUT"; check "隐藏之后请求仍能正常发出" $?
if printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'; then
check "read_image 不该出现在工具表里" 1
else
check "read_image 确实没发给服务端" 0
fi
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*run_command'
check "只摘掉 read_image,别的工具留着" $?
contains "read_image 已隐藏" "$OUT"; check "/tools 不列已经藏掉的工具" $?
# --- 模型完全不收 tools:整张表都不发 ---
OUT="$(run_probe no-tools-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)"
contains "工具调用 不支持(不再发送工具表)" "$OUT"
check "如实说明了工具调用能力与动作" $?
contains "TOOLS[]" "$OUT"; check "工具表确实是空的" $?
# --- 探测结论不确定:一律按"支持"处理,宁可多暴露 ---
# broken-probe 模型只在**探测请求**上回 401,真实请求照常。
OUT="$(run_probe broken-probe-model --prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 2.0)"
contains "多模态(图片输入)未测" "$OUT"; check "探不出来时如实说明是未测" $?
contains "没测出来的一律按「支持」处理" "$OUT"; check "说明按支持处理" $?
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'
check "结论不确定时不藏工具(宁可多暴露)" $?
# --- 关掉探测:一个试探请求都不发,工具表保持完整 ---
OUT="$(env LIGENT_PROBE=0 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "TOOLS_ECHO_TEST 看看工具表" --prompt "/exit" --wait 1.6 2>&1)"
if printf '%s\n' "$OUT" | grep -q '检测模型能力\|模型能力:'; then
check "--no-probe 时既不该发探测请求,也不该有结论日志" 1
else
check "--no-probe 时既不该发探测请求,也不该有结论日志" 0
fi
printf '%s\n' "$OUT" | grep -q 'TOOLS\[[^]]*read_image'
check "--no-probe 时工具表保持完整" $?
echo "== 52. /model 不带参数:打印在线列表,输入行里选序号或直接写名字 =="
# 这一段刻意不用光标菜单,而是 /import 那种"列表 + 普通输入行":
# 既能填序号,也能直接写一个**列表里没有**的模型名(内部模型就走这条)。
run_model() { # run_model <要依次输入的行...>;第一行固定是 /model
local a=()
for line in "$@"; do a+=(--prompt "$line"); done
python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "/model" "${a[@]}" --prompt "/exit" --wait 1.4 2>&1
}
# --- 列表本身:编号、当前模型被标出、去重与坏条目被跳过 ---
OUT="$(run_model "2")"
contains "正在获取模型列表($BASE/models)" "$OUT"; check "用的是标准的 /models 路径" $?
contains "服务端列出了 3 个模型" "$OUT"
check "去重 + 跳过坏条目后剩 3 个(mock 塞了重复项和一个缺 id 的)" $?
contains "1) deepseek-flash <- 当前" "$OUT"; check "当前模型被标出来" $?
contains "2) alpha-model" "$OUT"; check "列表带编号" $?
contains "选择序号,或直接输入模型名" "$OUT"; check "输入行的提示说清了两种输入" $?
# --- 按序号选 ---
contains "模型已切换为 alpha-model" "$OUT"; check "填序号能选中对应模型" $?
# --- 直接写一个**列表里没有**的名字:这是内部模型的入口,必须允许 ---
OUT="$(run_model "internal-model-v9")"
contains "模型已切换为 internal-model-v9" "$OUT"
check "能直接写列表里没有的模型名(内部模型走这条)" $?
if printf '%s\n' "$OUT" | grep -q "找不到"; then
check "不该因为不在列表里就报错" 1
else
check "不因为不在列表里就拒绝" 0
fi
# --- 直接回车 = 取消 ---
OUT="$(run_model "")"
contains "已取消" "$OUT"; check "空输入即取消" $?
if printf '%s\n' "$OUT" | grep -q "模型已切换"; then
check "取消之后不该切换" 1
else
check "取消之后什么都没改" 0
fi
# --- 序号越界:报错并取消,不能当成模型名去用 ---
OUT="$(run_model "9")"
contains "序号 9 超出范围" "$OUT"; check "序号越界要报出来" $?
if printf '%s\n' "$OUT" | grep -q "模型已切换"; then
check "越界之后不该切换" 1
else
check "越界之后没有切换" 0
fi
# --- Ctrl-C 取消(这一条只能用原始按键驱动)---
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--keys "/model" --keys "\r" --keys "\x03" --keys "/exit" --keys "\r" --wait 1.4 2>&1)"
contains "已取消" "$OUT"; check "Ctrl-C 也能取消" $?
# --- /model NAME 这条老路不能被新功能挡住 ---
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "/model gamma-model" --prompt "/exit" --wait 1.2 2>&1)"
contains "模型已切换为 gamma-model" "$OUT"; check "/model NAME 仍然直接生效" $?
if printf '%s\n' "$OUT" | grep -q "正在获取模型列表"; then
check "带参数时不该去拉列表" 1
else
check "带参数时不去拉列表" 0
fi
# --- 服务端返回空列表:说清楚,并提示还能直接指定名字 ---
printf 'empty' > build/tmp/models_mode
OUT="$(run_model "1")"
rm -f build/tmp/models_mode
contains "拿不到模型列表" "$OUT"; check "空列表时如实报错" $?
contains "空的模型列表" "$OUT"; check "说清是空列表而不是网络故障" $?
contains "/model <名字>" "$OUT"; check "提示还能直接指定名字" $?
# --- 服务端返回的不是 JSON:也不能崩,同样给出可用的退路 ---
printf 'garbage' > build/tmp/models_mode
OUT="$(run_model "1")"
rm -f build/tmp/models_mode
contains "拿不到模型列表" "$OUT"; check "响应不是 JSON 时不崩" $?
contains "/model <名字>" "$OUT"; check "同样提示可以直接指定" $?
# --- 裸数组形状(有些兼容实现这么回)也要能用 ---
printf 'bare' > build/tmp/models_mode
OUT="$(run_model "2")"
rm -f build/tmp/models_mode
contains "服务端列出了 2 个模型" "$OUT"; check "裸数组形状也能解析" $?
contains "模型已切换为 beta-model" "$OUT"; check "裸数组里也能按序号选" $?
echo "== 53. 一条 assistant 里两个工具调用 + 图片:附件不能插在 tool 消息中间 =="
# 回归用例。曾经的写法是"每推完一条 tool 消息就把图片附件推出去",于是
# assistant 带两个调用、第一个是 read_image 时,历史变成
# assistant(tool_calls) / tool(读图) / user(附件) / tool(另一个)
# 中间那条 user 把 tool 段截断了,服务端回 400:
# An assistant message with 'tool_calls' must be followed by tool messages
# responding to each 'tool_call_id'. (insufficient tool messages ...)
# 而且这段历史留在上下文里,之后每一轮都会 400 —— 用户只能 /reset。
#
# mock 现在和真服务端一样校验这个结构(见 pairing_error),所以只要回归,
# 这一轮会直接 400,断言立刻失败。
rm -rf "$ARCHIVE_DIR"
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "IMAGE_PAIR_TEST 同时读图并跑个命令" \
--prompt "/exit" --wait 2.0 2>&1)"
contains "pair-ok" "$OUT"; check "两个工具调用都被执行" $?
contains "PAIR[img=1 tool=2]" "$OUT"
check "模型既收到了图片,也收到了两条 tool 结果(结构合法)" $?
if printf '%s\n' "$OUT" | grep -q "must be followed by tool messages"; then
check "不该出现工具消息配对错误" 1
else
check "结构错误没有发生" 0
fi
# 没有触发自动修复 = 是**预防**住了,而不是事后补的
if printf '%s\n' "$OUT" | grep -q "对话结构有问题"; then
check "应当是预防而不是事后修复" 1
else
check "结构从一开始就是对的(没走修复路径)" 0
fi
rm -rf "$ARCHIVE_DIR"
# 顺带验一下修复路径本身:mock 校验会让坏历史当场 400,所以这里换个角度 ——
# 直接看 agent_repair_tool_pairing 的单元测试(test_agent)。
echo "== 54. /back 报的条数不含 system 提示词 =="
# 用户报过一个:聊一句「hi」再 /back,屏幕显示"历史剩 1 条",但那段对话其实
# 已经空了 —— 那 1 条是 system 提示词。system 是配置不是对话,不该算进"历史"。
# 现在 /history、/back、/unback、/export、/import 统一用"不含 system"的口径。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "hi" --prompt "/back" --prompt "/history" \
--prompt "/unback" --prompt "/history" --prompt "/exit" --wait 1.3 2>&1)"
contains "历史剩 0 条" "$OUT"; check "/back 之后历史剩 0 条(不是 1)" $?
contains "历史消息 0 条" "$OUT"
check "/history 也报 0 条(与 /back 口径一致)" $?
contains "历史回到 4 条" "$OUT"; check "/unback 之后回到 4 条" $?
contains "不含 system" "$OUT"; check "说清楚这个口径不含 system 提示词" $?
echo "== 55. /include:把文件附带进下一条消息 =="
mkdir -p build/tmp/inc/sub
printf 'int main(void){return 0;}\n' > build/tmp/inc/a.c
printf 'hello text\n' > build/tmp/inc/b.txt
printf 'deep\n' > build/tmp/inc/sub/c.c
printf 'this is not an image' > build/tmp/inc/fake.png
python3 - <<'PYEOF'
import struct, zlib
def chunk(t, d):
c = t + d
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xffffffff)
png = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(b"\x00\xff\x00\x00"))
+ chunk(b"IEND", b""))
open("build/tmp/inc/pic.png", "wb").write(png)
PYEOF
rm -rf "$ARCHIVE_DIR"
inc() { # inc <prompt...>;先切到场景目录(/cd 是 REPL 命令,没有同名命令行选项)
python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "/cd build/tmp/inc" "$@" --wait 1.5 2>&1
}
# --- glob 参数:命中几个就附带几个 ---
OUT="$(inc --prompt "/include *.c" --prompt "INCLUDE_TEST 看看" --prompt "/exit")"
contains "已附带 1 个文件" "$OUT"; check "glob 展开并暂存" $?
contains "INC[text=2 img=0 files=a.c]" "$OUT"
check "模型收到了用户的话 + 一个文件(2 个文本块)" $?
contains "下一条消息会带上它们" "$OUT"; check "说明了附带会在下一条消息生效" $?
# --- 多个 glob / 多次附带会累加 ---
OUT="$(inc --prompt "/include *.c" --prompt "/include *.txt" \
--prompt "INCLUDE_TEST 两个" --prompt "/exit")"
contains "INC[text=3 img=0" "$OUT"; check "两次附带的文件都发出去了" $?
# --- 图片:存备份、以 ligent_image 引用参与请求 ---
OUT="$(inc --prompt "/include pic.png" --prompt "INCLUDE_TEST 看图" --prompt "/export" --prompt "/exit")"
contains "〔图片〕" "$OUT"; check "识别出是图片并标注" $?
contains "INC[text=1 img=1" "$OUT"; check "图片以 image_url 内联发出" $?
# 备份必须落在**二进制所在目录**,不是当前工作目录 —— 这一条是有来历的:
# image_set_bindir() 曾经从来没被调用过,于是备份跟着 cwd 走,/cd 到别处
# 带图就会写到那儿,之后从别处发消息会因为找不到备份把图降级掉。
# 测试一直从二进制所在目录跑,"." 恰好等于正确值,所以一直没暴露。
ls "$ARCHIVE_DIR"/images/*.png >/dev/null 2>&1
check "图片备份落在二进制目录下(不是 cwd)" $?
BADCWD="$(dirname "$(dirname "$BIN")")/build/tmp/inc/ligent.archive"
[ ! -d "$BADCWD" ]; check "当前工作目录下不该有 ligent.archive" $?
N="$(ls "$ARCHIVE_DIR"/images/*.png 2>/dev/null | wc -l)"
[ "$N" -eq 1 ]; check "图片落了一份本机备份($N 份)" $?
# 归档里不能有图片字节(和 read_image 同一条不变量)
python3 - "$ARCHIVE_DIR" "build/tmp/inc/pic.png" <<'PYEOF'
import base64, glob, json, sys
raw = open(glob.glob(sys.argv[1] + "/*.json")[0], encoding="utf-8").read()
if base64.b64encode(open(sys.argv[2], "rb").read()).decode() in raw:
sys.exit(1)
sys.exit(0 if "ligent_image" in raw else 1)
PYEOF
check "归档里只有引用、没有图片字节" $?
rm -rf "$ARCHIVE_DIR"
# --- 内容不是图片的 .png 按文本附带(认内容不认扩展名) ---
OUT="$(inc --prompt "/include fake.png" --prompt "INCLUDE_TEST 假的" --prompt "/exit")"
contains "INC[text=2 img=0 files=fake.png]" "$OUT"
check "按内容判断:不是图片就当文本带" $?
# --- 二进制被跳过 ---
printf '\x00\x01\x02binary' > build/tmp/inc/bin.dat
OUT="$(inc --prompt "/include bin.dat" --prompt "/exit")"
contains "跳过" "$OUT"; check "二进制文件被跳过" $?
contains "二进制" "$OUT"; check "说清跳过原因" $?
# --- 没有匹配时明确报错,而不是静默 ---
OUT="$(inc --prompt "/include 不存在的模式*.zzz" --prompt "/exit")"
contains "没有匹配到文件" "$OUT"; check "匹配不到要说出来" $?
# --- 交互式:进目录、退回来、选文件 ---
# 序号 3 是 sub/(a.c / b.txt / sub 排序后),进去再回车结束;
# 然后回头选一个文件。
OUT="$(inc --prompt "/include" --prompt "3" --prompt "" \
--prompt "/include" --prompt "1" --prompt "" --prompt "/exit")"
contains "进入交互式选择" "$OUT"; check "无参数进入交互式" $?
contains "(目录)" "$OUT"; check "目录被标出来" $?
contains "当前目录:" "$OUT"; check "显示了当前目录" $?
contains "已选中" "$OUT"; check "选中后有回执" $?
# --- clear ---
OUT="$(inc --prompt "/include *.c" --prompt "/include clear" \
--prompt "INCLUDE_TEST 清空后" --prompt "/exit")"
contains "已清空待附带的文件" "$OUT"; check "/include clear 清空暂存" $?
contains "INC[text=1 img=0" "$OUT"; check "清空之后只发用户那句话" $?
# --- 模型不支持视觉时:文本照带,图片跳过并说明 ---
OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--extra=--model --extra=no-vision-model \
--prompt "/cd build/tmp/inc" \
--prompt "/include a.c" --prompt "/include pic.png" \
--prompt "INCLUDE_TEST 混合" --prompt "/exit" --wait 1.8 2>&1)"
contains "当前模型不支持图片输入" "$OUT"; check "如实说明为什么跳过图片" $?
contains "INC[text=2 img=0 files=a.c]" "$OUT"
check "文本照常带上,图片没发出去" $?
# --- 支持视觉的模型上,同一张图能带 ---
OUT="$(env LIGENT_PROBE=1 python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--extra=--model --extra=probe-model \
--prompt "/cd build/tmp/inc" \
--prompt "/include pic.png" --prompt "INCLUDE_TEST 看图" \
--prompt "/exit" --wait 1.8 2>&1)"
contains "INC[text=1 img=1" "$OUT"; check "支持视觉时图片正常附带" $?
rm -rf "$ARCHIVE_DIR"
echo "== 56. run_command elevated:经 sudo 提权执行单条命令 =="
# 全程用**替身 sudo**:不需要真权限,也永远不会碰到谁的真实密码。
# 它把 argv 记下来,于是"ligent 到底怎么调 sudo 的"能断言到参数级。
FAKEBIN="$ROOT/build/tmp/fakebin"
mkdir -p "$FAKEBIN"
cat > "$FAKEBIN/sudo" <<'SUDOEOF'
#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_SUDO_LOG"
[ -n "$FAKE_SUDO_REJECT" ] && { echo "Sorry, try again." >&2; exit 1; }
case "$1" in -u) shift 2;; esac
[ "$1" = "--" ] && shift
exec "$@"
SUDOEOF
chmod +x "$FAKEBIN/sudo"
SULOG="$ROOT/build/tmp/elev_argv.log"
mkdir -p "$ROOT/build/tmp"
elev() { # elev <额外环境变量...> -- <repl 参数...>
rm -f "$SULOG"
PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" "$@" 2>&1
}
# --- 模型请求提权:命令经 sudo 以 root 跑,输出照常被捕获 ---
OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "ELEVATED_TEST 提权跑个命令" --prompt "/exit" --wait 1.8)"
contains "提权后的输出" "$OUT"; check "提权命令真的执行了,输出也被捕获" $?
contains "ELEV_SAW[提权后的输出" "$OUT"; check "输出回填给了模型" $?
grep -q '^-u root -- /bin/sh -c echo 提权后的输出$' "$SULOG"
check "调用形式是 sudo -u root -- <argv>(argv 形式,不经多余 shell)" $?
# 这一条是这次设计的核心:**不能带 -n**。带 -n 就意味着"不许提问",
# 于是需要密码时直接失败 —— 而用户明确要的是"提权要有身份验证"。
printf '%s\n' "$(cat "$SULOG")" | grep -q -- '-n' && r=1 || r=0
[ "$r" -eq 0 ]; check "不带 -n:需要密码时要让 sudo 能问出来" $?
# --- 确认提示必须先把要跑的东西摆出来 ---
OUT="$(elev python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--confirm --extra=--no-auto-save --extra=--no-history \
--prompt "ELEVATED_TEST 提权跑个命令" --prompt "y" --prompt "/exit" --wait 1.8)"
contains "即将执行 run_command" "$OUT"; check "提权命令同样过确认提示" $?
contains "提权后的输出" "$OUT"; check "确认之后才执行" $?
# --- 认证失败:模型要收到能理解的话,而不是一片空白 ---
OUT="$(PATH="$FAKEBIN:$PATH" FAKE_SUDO_LOG="$SULOG" FAKE_SUDO_REJECT=1 \
python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--no-confirm --extra=--no-auto-save --extra=--no-history \
--prompt "ELEVATED_TEST 提权跑个命令" --prompt "/exit" --wait 1.8 2>&1)"
contains "提权命令失败" "$OUT"; check "提权失败要告诉模型" $?
contains "认证没有通过" "$OUT"; check "说清是认证没过(而不是命令本身的问题)" $?
contains "不要反复重试" "$OUT"; check "明确叫它别反复重试" $?
# --- 没有控制终端时:直接拒绝,绝不发起一个等不到输入的进程 ---
# setsid 摘掉控制终端,正是"没人能输密码"的情形。
if command -v setsid >/dev/null 2>&1; then
OUT="$(setsid "$BIN" --api-key test --base-url "$BASE" --no-color \
--no-confirm --no-auto-save --no-history "ELEVATED_TEST 提权" 2>&1)"
RC=$?
contains "没有可用的控制终端" "$OUT"
check "没有终端时拒绝提权,并说清原因" $?
[ "$RC" -lt 128 ]; check "没有终端时不崩(退出码 $RC)" $?
# 而且它必须是**立刻**失败,不能卡在那儿等密码
contains "没有可用的控制终端" "$OUT" || true
else
echo " (跳过:没有 setsid)"
fi
echo "== 57. 离线回放:把 /log 录下来的真实会话当夹具 =="
# 这是 /log 存在的**真正理由**:录一次真的,之后离线反复回放。
# 录的是原始字节(请求体 + 未解析的响应原文),所以回放时 http.c 的分块读取、
# llm.c 的 SSE 行切分与 tool_calls 增量重组,全都在真实字节上跑。
FIX="$ROOT/tests/fixtures/session.log"
python3 tests/replay.py "$FIX" --check > build/tmp/replay_check.txt 2>&1
contains "解析自检通过" "$(cat build/tmp/replay_check.txt)"; check "回放器能解析录制" $?
contains "含 tool_calls" "$(cat build/tmp/replay_check.txt)"
check "识别出录制里那个被切碎的 tool_calls" $?
RLOG="$ROOT/build/tmp/replay_server.log"
replay_serve() { # replay_serve <端口> <夹具> [--strict]
: > "$RLOG"
python3 tests/replay.py --serve "$1" "$2" "${@:3}" >>"$RLOG" 2>&1 &
RPID=$!
for _ in $(seq 1 40); do
python3 -c "
import socket,sys
s=socket.socket(); s.settimeout(0.2)
sys.exit(0 if s.connect_ex(('127.0.0.1',$1))==0 else 1)" && break
sleep 0.1
done
}
# --- 用离线服务端跑**真的** ligent:它应当沿着录制里的路径走完 ---
replay_serve 8899 "$FIX" --strict
OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8899/v1" --no-color \
--no-confirm --no-auto-save --no-history --no-probe "please run it" 2>&1)"
RC=$?
kill $RPID 2>/dev/null; wait $RPID 2>/dev/null
[ "$RC" -eq 0 ]; check "在录制上跑完一轮(退出码 $RC)" $?
contains "hello-from-tool" "$OUT"; check "工具调用从被切碎的 SSE 里正确重组" $?
contains "FINAL:" "$OUT"; check "拿到了录制里的最终回答" $?
# strict:请求的**结构**必须和录制时一致 —— 这才是"报文形状没变"的断言
if grep -q "结构" "$RLOG"; then
check "请求结构与录制一致" 1
grep -A2 "结构" "$RLOG" | head -4
else
check "请求结构与录制一致(strict 没报差异)" 0
fi
# --- 错误体:录下来的 400 也要能被原样解析出可读信息 ---
replay_serve 8898 "$ROOT/tests/fixtures/error.log" --strict
OUT="$("$BIN" --api-key test --base-url "http://127.0.0.1:8898/v1" --no-color \
--no-confirm --no-auto-save --no-history --no-probe --retries 0 \
"ERROR_TEST 制造一个服务端错误" 2>&1)"
RC=$?
kill $RPID 2>/dev/null; wait $RPID 2>/dev/null
[ "$RC" -ne 0 ]; check "录下来的 400 仍然被判为失败(退出码 $RC)" $?
contains "mock 模拟的服务端错误" "$OUT"; check "错误体从真实字节里解析出来" $?
# --- 请求比录制多时,回放器要明确报出来(而不是静静回错东西)---
replay_serve 8897 "$ROOT/tests/fixtures/error.log" --strict
# 录制里只有 1 次往返。跑两次:第二次就没东西可回了。
for _ in 1 2; do
"$BIN" --api-key test --base-url "http://127.0.0.1:8897/v1" --no-color \
--no-confirm --no-auto-save --no-history --no-probe --retries 0 \
"再来一次" >/dev/null 2>&1
done
kill $RPID 2>/dev/null; wait $RPID 2>/dev/null
contains "没有更多响应" "$(cat "$RLOG")"
check "录制用完后明确报出来(而不是静静回错东西)" $?
echo "== 58. 工具输出里混进非法 UTF-8:不能让之后每个请求都被拒 =="
# 坏字节的来源很日常:某条命令在非 UTF-8 locale 下打出来的路径、被截断的多
# 字节字符、二进制文件里混的一小段。它会跟着工具结果进对话历史,再被序列化
# 进请求体 —— 序列化器若原样吐出,DeepSeek 回
# 400 ... invalid unicode code point
# 而且因为坏数据已经在历史里了,**之后每一次请求都会失败**,除非丢掉整轮对话。
# mock server 现在也按这个严格校验,所以这条路径在 e2e 里真的会被走到。
printf 'ok \xe4\xb8\xad then bad: \xff\xfe and \x80 end\n' > build/tmp/bad_utf8.txt
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color \
--no-probe --retries 0 "UTF8_TEST" 2>&1)"
echo "$OUT" | sed 's/^/ /'
case "$OUT" in
*"invalid unicode code point"*) rc=1;; # 服务端把请求拒了
*"替换字符=0"*) rc=1;; # 一个都没替换 —— 坏字节原样发出去了
*) rc=0;;
esac
check "请求体是合法 UTF-8:服务端没拒,且坏字节被换成了 U+FFFD" $rc
contains "读完了" "$OUT"; check "这一轮正常跑完" $?
echo "== 59. 界面语言:--lang / LIGENT_LANG / LC_* =="
# 前面几十段都跑在顶层锁死的 zh 下(见文件开头的 export)。这一段专门走英文,
# 因为"翻译漏了"和"翻译错了"只有真的把每条路径跑一遍才看得出来。
OUT="$(LIGENT_LANG=en "$BIN" --help 2>&1)"
contains "Usage:" "$OUT"; check "--help 出英文" $?
contains "--lang LANG" "$OUT"; check "帮助里列出 --lang" $?
contains "ligent doctor" "$OUT"; check "英文帮助里有子命令" $?
OUT="$("$BIN" --lang en --nonsense 2>&1)"; r=$?
[ "$r" -eq 2 ]; check "英文下用法错误仍是退出码 2" $?
contains "unknown option" "$OUT"; check "错误信息是英文" $?
OUT="$("$BIN" --lang zh --nonsense 2>&1)"
contains "未知选项" "$OUT"; check "--lang zh 覆盖环境变量" $?
# 语言也要能在配置里看见 —— 否则"为什么它是英文"要猜
OUT="$(LIGENT_LANG=en "$BIN" --lang=en --print-config 2>&1)"
contains "language : en" "$OUT"; check "--print-config 标出当前语言(en)" $?
OUT="$(LIGENT_LANG=zh "$BIN" --lang=zh --print-config 2>&1)"
contains "language : zh" "$OUT"; check "--print-config 标出当前语言(zh)" $?
# auto = 按环境;LIGENT_LANG 必须先清掉,否则它会把 LC_*/LANG 全压住
OUT="$(env -u LIGENT_LANG LC_ALL=C LANG=C "$BIN" --lang auto --print-config 2>&1)"
contains "language : en" "$OUT"; check "LANG=C 下 auto 落到英文" $?
OUT="$(env -u LIGENT_LANG -u LC_ALL LANG=zh_CN.UTF-8 "$BIN" --lang auto --print-config 2>&1)"
contains "language : zh" "$OUT"; check "LANG=zh_CN 下 auto 落到中文" $?
OUT="$(env -u LIGENT_LANG -u LC_ALL LANG=fr_FR.UTF-8 "$BIN" --lang auto --print-config 2>&1)"
contains "language : en" "$OUT"; check "没翻译的语言(fr)回退英文" $?
OUT="$("$BIN" --lang 乱写 "x" 2>&1)"; r=$?
[ "$r" -eq 2 ]; check "非法 --lang 被拒绝" $?
contains "只接受 auto / en / zh" "$OUT"; check "拒绝时说清可选值" $?
# doctor 是最长的一段界面文案,分节括号也要跟着语言走
OUT="$(LIGENT_LANG=en "$BIN" doctor --offline 2>&1)"
# 注意:方括号在 case 模式里是字符类,所以这里只断言括号里的文字
contains "binary & config location" "$OUT"; check "英文分节标题" $?
contains "Bottom line:" "$OUT"; check "英文结论行" $?
OUT="$(LIGENT_LANG=zh "$BIN" doctor --offline 2>&1)"
contains "【二进制与配置位置】" "$OUT"; check "中文分节用【】" $?
contains "结论:" "$OUT"; check "中文结论行" $?
echo
echo "== 60. 单轮往返次数:默认不限,正数才是上限 =="
# 默认(配置文件里没写 max_steps)要能跑完 4 次工具往返 —— 这正是"默认不限"
# 的可观测形式:任何小上限都会在这里当场暴露。
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "MANYSTEPS_TEST" 2>&1)"
contains "STEPS_DONE[4]" "$OUT"; check "默认不限往返次数:4 次工具往返跑完" $?
contains "run_command" "$OUT"; check "每一步的工具调用都真实执行了" $?
# 显式写 0 也是不限(0 = 不限是这条接口的约定)
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --max-steps 0 "MANYSTEPS_TEST" 2>&1)"
contains "STEPS_DONE[4]" "$OUT"; check "--max-steps 0 显式表示不限" $?
# 设了正数上限时,到顶要停住并如实报出这个数
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color --max-steps 2 "MANYSTEPS_TEST" 2>&1)"
contains "已达到最大步数 2" "$OUT"; check "--max-steps 2 时如实报出达到上限" $?
if contains "STEPS_DONE" "$OUT"; then
check "到顶后不应该再继续跑完" 1 "$OUT"
else
check "到顶后不应该再继续跑完" 0
fi
# 配置文件里写 max_steps 同样生效
printf '{"max_steps": 2}\n' > "$ROOT/build/tmp/e2e-maxsteps.json"
OUT="$("$BIN" -c "$ROOT/build/tmp/e2e-maxsteps.json" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "MANYSTEPS_TEST" 2>&1)"
contains "已达到最大步数 2" "$OUT"; check "配置文件里的 max_steps 生效" $?
# --print-config 把"不限"说清楚,而不是打一个 0 让人猜
OUT="$("$BIN" --print-config 2>&1)"
contains "max_steps : 不限" "$OUT"; check "--print-config 显示不限" $?
echo
echo "== 61. edit_file 的调用行:\"路径\" [行号],成功后给带行号的新旧对照 =="
# 以前这一行是把整个参数 JSON 原样打出来(截 256 字节)—— 对 edit_file 来说那是
# 一整段 old_string/new_string:既刷屏,又看不出到底要改哪里。现在摘要只有
# "哪个文件、哪几行"(路径带引号、行号用方括号),改动内容用带行号的 -/+ 两段摆开。
mkprobe() {
python3 -c "
with open('$ROOT/build/tmp/edit_probe.txt', 'w') as f:
f.write('head\n' + ''.join('OLD%d\n' % i for i in range(1, 13)) + 'tail\n')
"
}
mkprobe
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "EDITTEST_OK" 2>&1)"
contains '{/} edit_file "build/tmp/edit_probe.txt" [2]' "$OUT"
check "调用行是 \"路径\" [行号],不再抖 JSON" $?
contains " │ - 2 OLD1" "$OUT"; check "旧内容逐行显示,且带文件行号" $?
contains " │ - 9 OLD8" "$OUT"; check "行号随行递增(2..9)" $?
contains " │ + 2 NEW" "$OUT"
check "新内容也带行号(两侧起始行号相同:替换就落在原处)" $?
contains "还有 4 行" "$OUT"; check "每侧最多 8 行,其余折成一句" $?
contains " │ - 10 OLD9" "$OUT" && r=1 || r=0
check "第 9 行起不再逐行铺开" $r
contains '"old_string"' "$OUT" && r=1 || r=0
check "参数 JSON 一个字节都不上屏" $r
contains "DONE[" "$OUT"; check "工具确实执行了" $?
echo
echo "== 62. 确认提示:先摆带行号的新旧对照,再问 Y/n =="
# 这一版把确认提示里的"替换 <旧> -> <新>"删掉了 —— 两边各截 60 字符、压成一行,
# 看不出动了几行、动的是哪几行。现在摆的是和事后展示同一份带行号 diff,
# 而且必须在提问**之前**:先让人点头、再让人看改什么,那是反的。
mkprobe
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--extra=--confirm --extra=--no-auto-save --extra=--no-color \
--prompt "EDITTEST_OK" --prompt "" --wait 1.2 2>&1)"
contains '修改: "build/tmp/edit_probe.txt" [2]' "$OUT"
check "确认提示里给出路径与行号" $?
contains "- 2 OLD1" "$OUT"; check "旧内容带行号" $?
contains "- 3 OLD2" "$OUT"; check "行号跟着走" $?
contains "+ 2 NEW" "$OUT"; check "新内容带行号" $?
contains "继续?(Y/N/Always/neVer)" "$OUT"; check "仍然问四种回答" $?
ORDER_OUT="$OUT" python3 - <<'PY'
import os
t = os.environ["ORDER_OUT"]
i_diff = t.find("- 2 OLD1")
i_ask = t.find("继续?")
raise SystemExit(0 if 0 <= i_diff < i_ask else 1)
PY
check "顺序:先摆 diff、再提问" $?
contains "替换:" "$OUT" && r=1 || r=0
check "不再出现「替换: 旧 -> 新」那一行" $r
# 确认过的改动不该在执行后再摆一遍 diff(否则像是要改两次)
contains "已在 build/tmp/edit_probe.txt 第 2 行替换 1 处" "$OUT"
check "确认之后只给一句回执,不重复 diff" $?
echo
echo "== 63. edit_file 失败时:同样只有摘要 + 错误 =="
mkprobe
OUT="$("$BIN" --api-key test --base-url "$BASE" --no-confirm --no-auto-save --no-color "EDITTEST_FAIL" 2>&1)"
contains '{/} edit_file "build/tmp/edit_probe.txt" [999]' "$OUT"
check "失败时也先给一句人话摘要" $?
contains "line=999 越界" "$OUT"; check "错误信息指向具体行号" $?
contains '"old_string"' "$OUT" && r=1 || r=0
check "失败时同样不抖 JSON" $r
echo
echo "== 64. /probe:手动测一次能力(绕开 --no-probe 与缓存)=="
# 这一段的 e2e 全局设了 LIGENT_PROBE=0(各处用例都按"请求序列"写,别被探测整体推后),
# 正好拿来验证 /probe 的两条语义:无视那个开关,也不吃缓存(敲两次就测两次)。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "/probe" --wait 1.5 \
--prompt "/probe" --wait 1.5 --prompt "/exit" 2>&1)"
contains "模型能力:" "$OUT"; check "--no-probe 下 /probe 仍会真的探测" $?
n=$(printf '%s' "$OUT" | grep -c "模型能力:")
[ "${n:-0}" -ge 2 ]; check "每次 /probe 都重测一遍(不吃缓存),实际 $n 次" $?
contains "支持" "$OUT"; check "结论照启动时的样子打出来" $?
# 帮助里要能看到它,否则没人知道有这个命令
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" --prompt "/help" --wait 1.0 --prompt "/exit" 2>&1)"
contains "/probe" "$OUT"; check "/help 里列了 /probe" $?
OUT="$("$BIN" --help 2>&1)"
contains "/probe" "$OUT"; check "--help 的 REPL 命令列表里也有" $?
echo
echo "== 65. /history:一行摘要 + 完整对话 =="
# 摘要行报的是"不含 system"的口径(与 /usage、/back、/export 一致),下面列的
# 是含 system 的全部消息 —— 两个数不同,所以第二行得自己说清楚,否则用户会
# 以为打印漏了或者多了一条。
OUT="$(python3 tests/repl_drive.py --bin "$BIN" --base-url "$BASE" \
--prompt "跑一下" --prompt "/history" --wait 1.4 2>&1)"
contains "历史消息 4 条" "$OUT"; check "摘要行给的是不含 system 的条数" $?
contains "完整打印全部 5 条消息" "$OUT"; check "并说明下面列的是含 system 的全部" $?
contains "-- 1/5 系统 --" "$OUT"; check "system 提示词全文也在里面" $?
contains "-- 2/5" "$OUT"; check "逐条带 i/N 表头,一条不落" $?
contains "run_command" "$OUT"; check "工具调用与结果一并列出" $?
contains "tool_call_id=" "$OUT"; check "标出工具结果回应的是哪次调用" $?
echo
echo "通过 $PASS 项,失败 $FAIL 项"
[ "$FAIL" -eq 0 ]