tools: model batch steps as nested executions

This commit is contained in:
2026-11-02 14:21:26 +08:00
parent b897bac52b
commit 5c13d10989
8 changed files with 474 additions and 110 deletions
+16 -5
View File
@@ -456,9 +456,9 @@ async def test_pretty_run_argv_batch_stopped(capsys: pytest.CaptureFixture[str])
result = (
"steps=3 ran=2 stop_on_error=true stopped_early=true\n"
"--- step 1 ---\nexit_code=1\ntimed_out=false\ncwd=.\ncmd=git status\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- step 2 ---\nexit_code=0\ntimed_out=false\ncwd=.\ncmd=git diff\n"
"pipe_out=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"pipe=false\nmix_stderr=false\n--- stdout ---\n--- stderr ---\n"
"--- summary ---\nlast_exit=1\n"
)
await render_events(_aiter([_pretty_call("run_argv_batch", '{"steps": []}'), _result(result)]))
@@ -495,16 +495,27 @@ async def test_pretty_run_argv_batch_lists_commands(capsys: pytest.CaptureFixtur
async def test_pretty_run_argv_batch_joins_pipe_chain(capsys: pytest.CaptureFixture[str]) -> None:
"""``pipe_out`` steps share one line; a step they do not feed keeps its own."""
"""A pipe chain shares one line; a node it does not feed keeps its own."""
args = (
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe_out": true},'
' {"argv": ["wc", "-l"]}, {"argv": ["git", "status"]}]}'
'{"steps": [{"argv": ["grep", "-n", "foo", "a.py"], "pipe": {"argv": ["wc", "-l"]}},'
' {"argv": ["git", "status"]}]}'
)
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
assert "$ grep -n foo a.py | wc -l\n$ git status\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_indents_branches(capsys: pytest.CaptureFixture[str]) -> None:
"""A ``then`` / ``else`` body sits below the node it follows."""
args = (
'{"steps": [{"argv": ["pytest", "-q"], "then": {"argv": ["ruff", "format", "."]},'
' "else": [{"argv": ["git", "status"]}]}]}'
)
await render_events(_aiter([_pretty_call("run_argv_batch", args), _result(_BATCH_DONE_RESULT)]))
out = capsys.readouterr().out
assert "$ pytest -q\n then ruff format .\n else git status\n* Run batch (done)" in out
async def test_pretty_run_argv_batch_elides_a_long_command(capsys: pytest.CaptureFixture[str]) -> None:
long_arg = "x" * 200
args = f'{{"steps": [{{"argv": ["echo", "{long_arg}"]}}]}}'
+28
View File
@@ -108,6 +108,34 @@ def test_classify_run_argv_batch_risky() -> None:
)
def test_classify_run_argv_batch_sees_nested_steps() -> None:
"""A command hiding in a continuation is reviewed like a top-level step."""
reason = _danger(
"run_argv_batch",
{
"steps": [
{
"argv": ["echo", "ok"],
"then": {"argv": ["bash", "-c", "echo risky"]},
"else": [{"argv": ["sh", "-c", "echo risky"]}],
}
]
},
)
assert "run_argv_batch[0].then[0]" in reason.text
assert "run_argv_batch[0].else[0]" in reason.text
# A plain interpreter run is YOLO mode's to drop, wherever it sits.
assert reason.yolo_skip is True
def test_a_nested_step_that_escalates_keeps_the_batch_prompt() -> None:
reason = _danger(
"run_argv_batch",
{"steps": [{"argv": ["echo", "ok"], "then": {"argv": ["sudo", "python", "-c", "print(1)"]}}]},
)
assert reason.yolo_skip is False
def test_classify_shell_and_dash_c() -> None:
r = _reason("run_argv", {"argv": ["bash", "-c", "rm -rf /"]})
assert r is not None
+150 -10
View File
@@ -112,33 +112,31 @@ async def test_run_argv_batch_stop_on_error(workspace: object) -> None:
assert "should-not-run" not in out
async def test_run_argv_batch_pipe_out(workspace: object) -> None:
async def test_run_argv_batch_pipe(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("print('piped-payload', end='')"),
"pipe_out": True,
},
{
"argv": _py("import sys; print(sys.stdin.read())"),
"pipe": {"argv": _py("import sys; print(sys.stdin.read())")},
},
],
)
assert "ran=2" in out
assert "piped-payload" in out
assert "pipe_out=true" in out
assert "pipe=true" in out
async def test_run_argv_batch_last_pipe_out_error(workspace: object) -> None:
async def test_run_argv_batch_pipe_out_is_gone(workspace: object) -> None:
"""The old provider-side flag is refused, pointing at the pipe node."""
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print(1)"), "pipe_out": True}],
)
assert "error:" in out
assert "pipe_out" in out
assert '{"pipe": [...]}' in out
async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
@@ -149,9 +147,8 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
{
"argv": _py("import sys; sys.stdout.write('out'); sys.stderr.write('err')"),
"mix_stderr": True,
"pipe_out": True,
"pipe": {"argv": _py("import sys; print(repr(sys.stdin.read()))")},
},
{"argv": _py("import sys; print(repr(sys.stdin.read()))")},
],
)
assert "ran=2" in out
@@ -159,6 +156,149 @@ async def test_run_argv_batch_mix_stderr(workspace: object) -> None:
assert "out" in out and "err" in out
async def test_run_argv_batch_then_runs_on_success(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print('first')"), "then": {"argv": _py("print('then-body')")}}],
)
assert "then-body" in out
assert "ran=2" in out
assert "depth=1" in out
assert "via=then of step 0" in out
async def test_run_argv_batch_else_handles_the_failure(workspace: object) -> None:
"""A failure with an ``else`` is answered, so the batch keeps going."""
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("import sys; sys.exit(2)"),
"else": {"argv": _py("print('else-body')")},
},
{"argv": _py("print('sibling-ran')")},
],
)
assert "else-body" in out
assert "sibling-ran" in out
assert "via=else of step 0" in out
assert "stopped_early=false" in out
async def test_run_argv_batch_runs_a_then_list_in_order(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("print('first')"),
"then": [{"argv": _py("print('second')")}, {"argv": _py("print('third')")}],
}
],
)
assert "ran=3" in out
assert out.index("first") < out.index("second") < out.index("third")
async def test_run_argv_batch_a_failing_branch_stops_the_batch(workspace: object) -> None:
"""Nobody answered a failure inside a continuation, so the batch stops."""
del workspace
out = await call_async(
run_argv_batch,
[
{"argv": _py("print('first')"), "then": {"argv": _py("import sys; sys.exit(4)")}},
{"argv": _py("print('sibling-ran')")},
],
)
assert "stopped_early=true" in out
assert "sibling-ran" not in out
async def test_run_argv_batch_stop_on_error_false_skips_only_its_continuations(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("import sys; sys.exit(2)"),
"stop_on_error": False,
"then": {"argv": _py("print('then-ran')")},
},
{"argv": _py("print('sibling-ran')")},
],
)
assert "then-ran" not in out
assert "sibling-ran" in out
assert "stopped_early=false" in out
async def test_run_argv_batch_branches_on_the_pipe_result(workspace: object) -> None:
"""A piped node branches on the last command of its chain."""
del workspace
out = await call_async(
run_argv_batch,
[
{
"argv": _py("print('payload', end='')"),
"pipe": {"argv": _py("import sys; sys.stdin.read(); sys.exit(3)")},
"then": {"argv": _py("print('pipe-ok')")},
"else": {"argv": _py("print('pipe-failed')")},
}
],
)
assert "pipe-failed" in out
assert "pipe-ok" not in out
assert "ran=3" in out
async def test_run_argv_batch_pipe_step_takes_no_branch(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "then": {"argv": _py("print(3)")}}}],
)
assert "error:" in out
assert "pipe step takes no then/else" in out
async def test_run_argv_batch_pipe_step_takes_no_stdin(workspace: object) -> None:
del workspace
out = await call_async(
run_argv_batch,
[{"argv": _py("print(1)"), "pipe": {"argv": _py("print(2)"), "stdin": "own"}}],
)
assert "error:" in out
assert "stdin comes from the pipe" in out
async def test_run_argv_batch_rejects_a_deep_tree(workspace: object) -> None:
del workspace
node: dict[str, object] = {"argv": _py("print(1)")}
for _ in range(6):
node = {"argv": _py("print(1)"), "then": node}
out = await call_async(run_argv_batch, [node])
assert "error:" in out
assert "nest at most 4 levels deep" in out
async def test_run_argv_batch_counts_nested_nodes(workspace: object) -> None:
"""Every node of the tree costs one, so a nested tree hits the same cap."""
del workspace
out = await call_async(run_argv_batch, [{"argv": _py("print(1)")} for _ in range(21)])
assert "error:" in out
assert "more than 20 nodes" in out
async def test_run_argv_batch_names_the_node_grammar(workspace: object) -> None:
del workspace
out = await call_async(run_argv_batch, [{"argv": _py("print(1)"), "when": "always"}])
assert "error:" in out
assert "unknown key(s) when" in out
assert "then, else, pipe" in out
async def test_run_argv_denied(workspace: object) -> None:
del workspace
out = await call_async(run_argv, ["rm", "-rf", "."])