Files
ligent/tests/confirm_check.py
2026-10-03 14:56:56 +08:00

276 lines
8.6 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""confirm_check.py - 确认提示上的每一种回答都必须被正确处理
重点是 Ctrl-C.
`confirm_tool` 用 fgets 读输入, 而 SIGINT 会让 fgets 以 EINTR 失败、返回
NULL; 代码把 NULL 当成"没人能回答, 按默认值放行". 于是**在确认提示
上按 Ctrl-C 等于批准这次操作**. run_command 侥幸自己查了中断标志才没执行,
write_file / edit_file 没查, 文件照写.
这个脚本把三种回答都跑一遍, 直接看磁盘上有没有留下东西:
Ctrl-C -> 必须拒绝
n -> 必须拒绝
y -> 必须执行 (否则就是"修过头"了)
用法:
confirm_check.py <ligent 二进制> <mock base-url>
"""
import os
import pty
import select
import sys
import time
# 探针文件放在 build/ 下, 别弄脏仓库根目录
# WRITE_TEST 写这一个; WRITE2_TEST 写 PROBE1 / PROBE2 (一轮里两次)
PROBE = "build/tmp/confirm_probe.txt"
PROBE1 = "build/tmp/confirm_probe1.txt"
PROBE2 = "build/tmp/confirm_probe2.txt"
# 只认稳定前缀: 选项列表是会变的 (现在是 Y/N/Always/neVer),
# 写死整串会在改提示文案时把测试弄坏.
MARK = "继续?"
PROMPT_READY = b"\x1b[?2004h"
def run_case(binary, base, answer, wait_confirm=6.0):
"""跑一轮: 触发 write_file → 等确认提示出现 → 回答 → 看文件在不在. """
try:
os.unlink(PROBE)
except OSError:
pass
os.environ["LIGENT_API_KEY"] = "sk-test"
pid, fd = pty.fork()
if pid == 0:
os.execv(binary, [binary, "--base-url", base, "--no-color",
"--confirm", "--no-auto-save", "--no-history"])
os._exit(127)
out = bytearray()
def pump(seconds, idle=0.3):
"""读到"安静"或超时为止"""
end = time.time() + seconds
last = time.time()
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.02)
if r:
try:
d = os.read(fd, 65536)
except OSError:
return False
if not d:
return False
out.extend(d)
last = time.time()
elif time.time() - last > idle:
return True
return True
def wait_for(text, seconds):
"""一直读到 text 出现 (或超时). 按标记等, 不靠 sleep 猜. """
end = time.time() + seconds
while time.time() < end:
if text.encode("utf-8") in bytes(out):
return True
r, _, _ = select.select([fd], [], [], 0.05)
if r:
try:
d = os.read(fd, 65536)
except OSError:
return False
if not d:
return False
out.extend(d)
return text.encode("utf-8") in bytes(out)
pump(1.2) # 横幅 + 第一个提示符
os.write(fd, "WRITE_TEST 写个文件\n".encode())
got_prompt = wait_for(MARK, wait_confirm)
if got_prompt:
os.write(fd, answer)
pump(1.5)
os.write(fd, b"\x03")
pump(0.3)
try:
os.close(fd)
except OSError:
pass
try:
os.waitpid(pid, 0)
except OSError:
pass
return got_prompt, os.path.exists(PROBE)
def run_turn_scope(binary, base):
"""Always / neVer 的作用域必须是"这一轮", 不是整个会话.
一轮里让模型连写两个文件: 第一次问、答 Always, 第二次不该再问.
然后开新的一轮 (下一条用户消息), 必须**重新问** —— 会话级的开关是
/yolo, 不是这个.
全程按标记同步 ("继续?"出现 / 回到提示符), 不靠 sleep 猜时间.
"""
for p in (PROBE1, PROBE2):
try:
os.unlink(p)
except OSError:
pass
os.environ["LIGENT_API_KEY"] = "sk-test"
pid, fd = pty.fork()
if pid == 0:
os.execv(binary, [binary, "--base-url", base, "--no-color",
"--confirm", "--no-auto-save", "--no-history"])
os._exit(127)
out = bytearray()
state = {"ready": 0, "tail": b""}
READY = b"\x1b[?2004h" # 行编辑器每次回到提示符都会发它
def note(chunk):
buf = state["tail"] + chunk
state["ready"] += buf.count(READY)
state["tail"] = buf[-(len(READY) - 1):]
def pump(seconds, idle=0.3):
end = time.time() + seconds
last = time.time()
while time.time() < end:
r, _, _ = select.select([fd], [], [], 0.02)
if r:
try:
d = os.read(fd, 65536)
except OSError:
return
if not d:
return
out.extend(d)
note(d)
last = time.time()
elif time.time() - last > idle:
return
def wait_for(pred, seconds):
end = time.time() + seconds
while time.time() < end:
if pred():
return True
r, _, _ = select.select([fd], [], [], 0.03)
if r:
try:
d = os.read(fd, 65536)
except OSError:
return False
if not d:
return False
out.extend(d)
note(d)
return pred()
def has_prompt():
return "继续?".encode("utf-8") in bytes(out)
def prompt_count():
return bytes(out).count("继续?".encode("utf-8"))
pump(1.2)
# ---- 第一轮: 连写两个 ----
base_ready = state["ready"]
os.write(fd, "WRITE2_TEST 写两个文件\n".encode())
wait_for(has_prompt, 8.0)
os.write(fd, b"Always\n")
# 等这一轮彻底跑完 (回到提示符), 别用固定时长猜
wait_for(lambda: state["ready"] > base_ready, 10.0)
pump(0.3)
first_turn_prompts = prompt_count()
both_written = os.path.exists(PROBE1) and os.path.exists(PROBE2)
# ---- 第二轮: 换一条用户消息, 必须重新问 ----
base_ready = state["ready"]
before = prompt_count()
os.write(fd, "WRITE_LAST_TEST 再来一轮\n".encode())
got_again = wait_for(lambda: prompt_count() > before, 8.0)
second_prompts = prompt_count() - before
os.write(fd, b"N\n")
pump(1.0)
os.write(fd, b"\x03")
pump(0.3)
try:
os.close(fd)
except OSError:
pass
try:
os.waitpid(pid, 0)
except OSError:
pass
for p in (PROBE1, PROBE2):
try:
os.unlink(p)
except OSError:
pass
return first_turn_prompts, both_written, got_again, second_prompts
def main():
if len(sys.argv) < 3:
print("用法: confirm_check.py <二进制> <base-url>")
return 2
binary, base = sys.argv[1], sys.argv[2]
os.makedirs(os.path.dirname(PROBE), exist_ok=True)
fails = 0
checks = 0
def check(name, ok, detail=""):
nonlocal fails, checks
checks += 1
if ok:
print(" ✓ %s" % name)
else:
fails += 1
print(" ✗ %s%s" % (name, (" " + detail) if detail else ""))
# 三种回答: (说明, 发送的字节, 期望文件是否被写)
cases = [
("在确认提示上按 Ctrl-C", b"\x03", False),
("答 n", b"n\n", False),
("答 y", b"y\n", True),
]
for label, answer, expect_written in cases:
got, written = run_case(binary, base, answer)
check("%s: 等到了确认提示" % label, got, "没等到确认提示")
check("%s: 文件%s" % (label, "被写入" if expect_written else "没被写入"),
written == expect_written,
"实际 %s" % ("写了" if written else "没写"))
# 作用域: Always 只活这一轮
n1, both, again, n2 = run_turn_scope(binary, base)
check("Always: 同一轮里只问一次", n1 == 1, "实际问了 %d 次" % n1)
check("Always: 同轮的第二次改动被放行", both, "第二个文件没写")
check("新一轮会重新询问 (作用域仅限本轮)", again and n2 >= 1,
"新一轮没有询问")
for p in (PROBE, PROBE1, PROBE2):
try:
os.unlink(p)
except OSError:
pass
# 这里**不要** rmtree("build/tmp"): 那是 e2e 各段共用的临时目录, 删掉它
# 会连带清掉别人放进去的东西 (我就因此丢过两次日志), 而它自己的三个文件
# 上面已经删干净了. 测试辅助脚本只该收拾自己造的东西.
print("\n%d 项检查, %d 项失败" % (checks, fails))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())