From e66aec60fbff0b5688162805ff657ecab3c51509 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 12 Sep 2026 04:37:02 +0800 Subject: [PATCH] test(evaluation): require native terminal input events --- docs/agent-evaluation.md | 2 ++ docs/refactor/agent-codex-parity-plan.md | 3 ++- scripts/evaluation/codex.py | 6 +++--- tests/test_agent_evaluation_codex.py | 6 +++--- tests/test_agent_evaluation_score.py | 8 ++++---- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/agent-evaluation.md b/docs/agent-evaluation.md index f0fadcea7..132c3abd2 100644 --- a/docs/agent-evaluation.md +++ b/docs/agent-evaluation.md @@ -119,6 +119,8 @@ uv run --locked --no-sync python -m scripts.evaluation --live \ `terminal_session` 的早期配对 `/tmp/moviepilot-agent-round-luna-oauth-terminal-appserver-pair-final-d22fa746f.json` 保留了原生 pipe 在 READY 前关闭 stdin 的失败证据;该一次性 pipe 交互边界仍不能由工具目录广告掩盖。生产持续会话实现和本地回归保持通过。随后在同一生产、Skill、场景和 Harness SHA 下使用 `gpt-5.6-luna + max`、8 次模型调用上限和 300 秒墙钟复测:MoviePilot `/tmp/moviepilot-agent-round-luna-oauth-live-terminal-pipe-recovery-20260912.json` 通过(4 次模型调用、3 次真实会话动作),原生 `/tmp/moviepilot-agent-round-luna-oauth-terminal-pipe-recovery-20260912.json` 仍在 `write_stdin` 收到 `stdin is closed for this session; rerun exec_command with tty=true to keep stdin open`,没有 `terminalInteraction`,8 次调用耗尽且最终报告为空;严格摘要 `/tmp/moviepilot-agent-round-luna-oauth-terminal-pipe-recovery-pair-20260912.json` 为 `pair_valid=true`、`both_passed=false`。这排除了预算过小导致未恢复的解释,原生 pipe 仍保持 blocked。 +评测适配器现在只把 app-server 的 `item.command_execution.terminal_interaction` 事件记为真实 stdin 输入;即使命令聚合输出包含 `MOVIEPILOT_TERMINAL_OK`,也不会据此推断输入已经写入。这样可以拒绝命令自行打印相同字符串造成的假阳性,并保留原生终态未核验的失败样本。 + 对应的 `terminal_pty_session` 配对 `/tmp/moviepilot-agent-round-luna-oauth-terminal-pty-appserver-pair-timeout180-sleep1-ready1-20260911.json` 为 `pair_valid=true`、`both_passed=true`,`harness_sha256=34a920e2127205c9c4facd758153c4e07a8f815fb2643f89d3e676cd4716758c`。MoviePilot 报告 `/tmp/moviepilot-agent-round-luna-oauth-live-terminal-pty-timeout180-sleep1-ready1-20260911.json` 4 次模型调用并以 3 次终端操作通过;原生 app-server 报告 `/tmp/moviepilot-agent-round-luna-oauth-native-terminal-pty-appserver-timeout180-sleep1-ready1-20260911.json` 3 次模型调用,结构化事件和独立账本均确认 PTY 输入、`READY`/回复输出和退出码 0。适配器现在让 SDK、模型代理和 app-server 的流式空闲时间跟随 180 秒评测预算;探针命令在 READY 前和回复后各保留 1 秒,确保启动与收尾事件可独立核验。 ### 长上下文与 WebAgent 排队消息实测 diff --git a/docs/refactor/agent-codex-parity-plan.md b/docs/refactor/agent-codex-parity-plan.md index 50470ae70..7654dcd53 100644 --- a/docs/refactor/agent-codex-parity-plan.md +++ b/docs/refactor/agent-codex-parity-plan.md @@ -18,7 +18,7 @@ Google 的 OpenAI-compatible 层会丢失 Gemini 3 工具回复中的 `thought_s | 目标 | 状态 | 验收重点 | | --- | --- | --- | -| C1.4 终端工具完整闭环 | PTY 已配对;原生 pipe stdin 仍受边界阻塞 | `run/pipe/PTY` 共用 shell、cwd、login 和 UTF-8 策略;stdin 写入、EOF、分页、interrupt/kill、超时和进程组收尾有真实进程测试。原生终端场景经由 app-server 采集 `outputDelta`/`terminalInteraction`,并按评测预算保留足够的流式空闲时间;PTY 配对已观察输入、输出和退出码,pipe 配对仍保留原生 CLI 关闭 stdin、未产生 `terminalInteraction` 的失败证据 | +| C1.4 终端工具完整闭环 | PTY 已配对;原生 pipe stdin 仍受边界阻塞 | `run/pipe/PTY` 共用 shell、cwd、login 和 UTF-8 策略;stdin 写入、EOF、分页、interrupt/kill、超时和进程组收尾有真实进程测试。原生终端场景经由 app-server 采集 `outputDelta`/`terminalInteraction`,并按评测预算保留足够的流式空闲时间;PTY 配对已观察输入、输出和退出码,pipe 配对仍保留原生 CLI 关闭 stdin、未产生 `terminalInteraction` 的失败证据;聚合输出中的相同字符串不再被当作输入证据 | | C1.3 终端任务作用域 | 已完成 | 终端归属由宿主对象身份决定;定时运行、会话、子任务和内部工具管理器隔离;封口先于清理,排队或运行中的命令都不会在任务结束后迟到启动 | | S2.3 运行中消息排队与 WebAgent 输入 | 实现完成,三轮真实配对通过 | 运行中仍可提交新消息;消息按会话原子入队,在下一次模型调用边界注入真实 `HumanMessage`;SSE 报告 queued/applied,稳定 `steering_message_id` 贯穿展示快照、前端本地状态和恢复合并;应用点收口当前助手并创建 continuation 助手,用户气泡落在真实事件边界;文本、工具、主动消息、错误和终态事件携带 `assistant_message_id`,前端按事件身份回放迟到事件;工具按 `tool_id` 报告 running/done/error;停止后不再派发后续工具 | | C1.5 长上下文压缩与任务约束保留 | 同条件配对已通过 | 真实压缩前后预算约 69.5K → 26.1K;保留首条用户任务、工具尾部和 provider 序列化安全余量;MoviePilot 与原生 Codex 均完成 6 页读取并通过独立 oracle | @@ -101,4 +101,5 @@ uv run --locked --no-sync python -m scripts.evaluation \ - 第三轮在相同提交内容、模型、推理档位、预算和 Harness 下继续通过:MoviePilot `/tmp/moviepilot-agent-round-luna-oauth-subagent-live-32-repeat3-20260911.json` 使用 11 次模型调用、54388 个已知 token、2 次业务读取;原生 Codex `/tmp/moviepilot-agent-round-luna-oauth-subagent-native-32-repeat3-20260911.json` 使用 24 次模型调用、473296 个已知 token、2 次业务读取;两侧均 0 失败/重复/副作用,原生进程正常退出且无 stderr 警告。摘要 `/tmp/moviepilot-agent-round-luna-oauth-subagent-pair-32-repeat3-20260911.json` 为 `pair_valid=true`、`both_passed=true`。三轮通过满足该 held-out 场景的重复配对门槛,但仍不能外推整体智能等价或终端分享/取消收益。 - 原生 pipe 复核:在 `ee52ca070` 的 Harness 上以 `gpt-5.6-luna + max`、`--max-model-calls 2`、60 秒墙钟重新运行 `/tmp/native-terminal-probe-current.json`。原生 `app-server` 真实执行了 `functions.exec_command`,随后 `functions.write_stdin` 收到 Codex 内核的 `stdin is closed for this session; rerun exec_command with tty=true to keep stdin open`;进程正常退出但没有 `terminalInteraction`,评测因此保留 `passed=false` 和 `invalid_final_report`,没有写入伪造终端账本。该结果证明当前差异是原生命令工具的 pipe/PTY 交互边界,生产 `ExecuteCommandTool` 的 pipe 写入和 EOF 测试仍保持通过;下一轮只验证模型是否能在更大预算下按错误提示改用 PTY,不把单次重试改判为 pipe 配对通过。 - 原生 pipe 大预算复测:同一生产、Skill、场景和 Harness SHA 使用 `gpt-5.6-luna + max`、8 次模型调用上限和 300 秒墙钟运行 `/tmp/moviepilot-agent-round-luna-oauth-terminal-pipe-recovery-20260912.json`。原生模型确实完成了 `functions.exec_command` 并请求 `functions.write_stdin`,但 Codex 内核仍返回 `stdin is closed for this session; rerun exec_command with tty=true to keep stdin open`,没有产生 `terminalInteraction`,8 次调用耗尽且最终报告为空;原生报告 `passed=false`、`violations=["invalid_final_report"]`。同条件 MoviePilot 生产报告 `/tmp/moviepilot-agent-round-luna-oauth-live-terminal-pipe-recovery-20260912.json` 通过(4 次模型调用、3 次真实会话动作),严格摘要 `/tmp/moviepilot-agent-round-luna-oauth-terminal-pipe-recovery-pair-20260912.json` 为 `pair_valid=true`、`both_passed=false`。这次复测排除了“预算太小导致未恢复”的解释,C1.4-R1 仍是原生 Harness 阻塞边界。 +- 原生 PTY 重复复核:当前提交内容下 MoviePilot `/tmp/moviepilot-agent-round-luna-oauth-live-terminal-pty-repeat2-20260912.json` 真实完成 PTY 启动、输入和退出(4 次模型调用、3 次终端操作);原生 `/tmp/moviepilot-agent-round-luna-oauth-native-terminal-pty-repeat2-20260912.json` 虽输出了相同标记,但没有把真实退出码带入最终 JSON,独立验收保持 `passed=false`。评测适配器现只接受 app-server 的 `terminalInteraction` 事件作为 stdin 证据,命令聚合输出不能替代输入事件;该样本保留了模型终态不完整的失败,不能计入 PTY 通过轮次。 - 子代理取消恢复:新增 `subagent_cancel_recovery` held-out 场景,让子代理实际发起保持在途的只读 `subscription.list`,主 Agent 取得 task ID 后取消,再读取启用站点。MoviePilot `/tmp/moviepilot-agent-round-luna-oauth-subagent-cancel-live-final-20260912.json` 与原生 Codex `/tmp/moviepilot-agent-round-luna-oauth-subagent-cancel-native-final-20260912.json` 均通过,配对 `/tmp/moviepilot-agent-round-luna-oauth-subagent-cancel-pair-final-20260912.json` 为 `pair_valid=true`、`both_passed=true`;同一 `gpt-5.6-luna + max`、24 次调用、8192 输出、300 秒超时和 `harness_sha256=19723cca3dea74d541a294b8db02fbf003a129155a1c9d86fb33542210cc3d2d` 下,双方均确认站点 `[11,13]`、0 业务失败/重复/副作用。MoviePilot 使用 7 次模型调用,原生 Codex 使用 10 次调用,其中一个被取消请求没有完整用量;比较器保留行为配对并将 `usage_comparable=false`、token 差设为 unknown;终端共享收益仍未验证。 diff --git a/scripts/evaluation/codex.py b/scripts/evaluation/codex.py index f3b53def4..416f467f3 100644 --- a/scripts/evaluation/codex.py +++ b/scripts/evaluation/codex.py @@ -636,9 +636,9 @@ def _record_native_command_events(world: EvaluationWorld, events: list[dict[str, if type(exit_code) is not int: exit_code = None outcome = "succeeded" if exit_code == 0 else ("unknown" if exit_code is None else "failed") - terminal_input_observed = world.scenario.kind == "terminal" and ( - identity in streamed_input or "MOVIEPILOT_TERMINAL_OK" in output.replace("\r", "") - ) + # 终端输入必须有 app-server 的 terminalInteraction 事件作为证据。 + # 聚合输出可能只是命令自身打印了相同字符串,不能据此冒充 stdin 写入。 + terminal_input_observed = world.scenario.kind == "terminal" and identity in streamed_input world.record_command(command.strip(), { "action": "start" if world.scenario.kind == "terminal" else "run", "success": outcome == "succeeded", "execution_outcome": outcome, diff --git a/tests/test_agent_evaluation_codex.py b/tests/test_agent_evaluation_codex.py index b3079bb2d..2768547bf 100644 --- a/tests/test_agent_evaluation_codex.py +++ b/tests/test_agent_evaluation_codex.py @@ -555,8 +555,8 @@ def test_command_scenario_enables_only_native_shell_controls() -> None: assert retained == ["functions.exec_command", "functions.write_stdin"] and removed == [] -def test_native_terminal_event_preserves_aggregated_stdin_evidence() -> None: - """原生 command_execution 事件的聚合输出可证明 stdin 标记,但账本动作仍保持 start。""" +def test_native_terminal_output_cannot_fake_stdin_evidence() -> None: + """原生 command_execution 聚合输出即使包含输入标记,也不能伪造 stdin 证据。""" world = EvaluationWorld("terminal_session") codex._record_native_command_events(world, [{"type": "item.completed", "item": { "id": "cmd-1", "type": "command_execution", "command": "/bin/zsh -lc " + shlex.quote(world.scenario.command), @@ -564,4 +564,4 @@ def test_native_terminal_event_preserves_aggregated_stdin_evidence() -> None: }}]) event = world.ledger[0] assert event["request"]["action"] == "start" - assert event["observations"][0]["record"]["terminal_input_observed"] is True + assert event["observations"][0]["record"]["terminal_input_observed"] is False diff --git a/tests/test_agent_evaluation_score.py b/tests/test_agent_evaluation_score.py index e03ccea73..fcfa8d602 100644 --- a/tests/test_agent_evaluation_score.py +++ b/tests/test_agent_evaluation_score.py @@ -274,21 +274,21 @@ def test_terminal_scenario_requires_session_write_and_exit_evidence(): assert evaluate(world, report).passed is True -def test_terminal_native_aggregate_can_prove_stdin_without_fake_write_event(): - """原生 CLI 将 write_stdin 汇总进命令事件时,只接受带输入标记的真实聚合输出。""" +def test_terminal_native_aggregate_without_interaction_cannot_prove_stdin(): + """原生 CLI 只有聚合输出时不能把输入标记当作真实 stdin 事件。""" world = EvaluationWorld("terminal_session") command = "/bin/zsh -lc " + shlex.quote(world.scenario.command) world.record_command(command, { "execution_outcome": "succeeded", "status": "exited", "exit_code": 0, "output": "READY\r\nMOVIEPILOT_TERMINAL_OK\r\nREPLY=MOVIEPILOT_TERMINAL_OK\r\n", - "terminal_input_observed": True, + "terminal_input_observed": False, }, action="start") report = { "status": "completed", "terminal_output": "READY\nREPLY=MOVIEPILOT_TERMINAL_OK\n", "terminal_exit_code": 0, "completed": ["terminal"], "unresolved": [], "subscription_ids": [], "download_ids": [], "enabled_site_ids": [], } - assert evaluate(world, report).passed is True + assert "terminal_input_not_verified" in evaluate(world, report).violations def test_terminal_pty_echo_is_part_of_the_verified_output():