A turn's hard interrupt fans out only to `_active_children`; background delegate_task units are detached from the parent at dispatch (`_dispatch_background` / honor_parent_interrupt=False), so /stop left them running to completion and their result arrived minutes later as a parked wake. Every stop surface now also calls `tools.async_delegation.interrupt_for_session` for the session's units: - gateway: `_interrupt_and_clear_session` (busy /stop, /new share it — /new already did this in `_handle_reset_command`, the second request is idempotent) and the idle `_handle_stop_command` tail, which replied "No active task to stop." while a background child was running; it now stops them and replies "Stopped". - tui_gateway `session.interrupt` (Desktop Stop / TUI stop) — own UI sid + spawner id only, so a viewer tab never kills gateway work. - acp_adapter `cancel`. - CLI `/stop` already used `interrupt_all` (process-wide); unchanged. The stop recurses: depth>0 delegations are always synchronous (`_model_background_value`), so the child's hard interrupt reaches its workers through its own `_active_children` fan-out, and each level's interrupted partial result rolls up as that child's completion. An interrupted child's entry now carries what it actually had: the loop's `final_response` is the "Operation interrupted." placeholder (also appended as the closing assistant row), so `_build_result_entry` takes the child's last real assistant text as `summary` and keeps the placeholder as `error`. The unit finalizes normally and re-enters at once as its completion notice (status=interrupted, "Partial output: ...", "Subagent Task Interrupted" on TUI/Desktop) instead of the chat waiting for the child's budget to run out. Docs: delegate_task description, tools/AGENTS.md, delegation.md, gateway-session-lifecycle.md. Part of #114456
29 lines
1.7 KiB
Python
29 lines
1.7 KiB
Python
"""Invariant: a child stopped mid-work reports what it HAD so far. The loop's ``final_response`` for an
|
|
interrupted turn is the placeholder ``"Operation interrupted."`` (also appended as the closing assistant row);
|
|
the parent-visible entry must carry the child's last real assistant text as ``summary`` and keep the
|
|
placeholder as ``error`` — never a partial result that reads as an empty stop. See #114456.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
from tools.delegate_tool_child_run import _SchemaOutcome, _build_result_entry
|
|
|
|
|
|
def test_interrupted_child_entry_carries_its_partial_output():
|
|
messages = [
|
|
{"role": "user", "content": "kickoff"},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "Audited 3 of 7 modules; two findings so far."}],
|
|
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "terminal", "arguments": "{}"}}]},
|
|
{"role": "tool", "tool_call_id": "c1", "content": "[Command interrupted]"},
|
|
{"role": "assistant", "content": "Operation interrupted."},
|
|
]
|
|
result = {"final_response": "Operation interrupted.", "messages": messages, "api_calls": 2,
|
|
"completed": False, "interrupted": True}
|
|
child = SimpleNamespace(model="m", session_estimated_cost_usd=0.0, session_cost_status="unknown",
|
|
session_prompt_tokens=1, session_completion_tokens=1, _delegate_role="leaf")
|
|
|
|
entry = _build_result_entry(child, result, 0, 12.5, _SchemaOutcome(None, None, [], 0))
|
|
|
|
assert entry["status"] == entry["exit_reason"] == "interrupted"
|
|
assert entry["summary"] == "Audited 3 of 7 modules; two findings so far."
|
|
assert entry["error"] == "Operation interrupted."
|