Files
2026-10-03 14:56:56 +08:00

1545 lines
80 KiB
Bash
Executable File

#!/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 ]