1545 lines
80 KiB
Bash
Executable File
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 ]
|