Keep a pending question across restarts, and make the restart question answerable
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m18s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m18s
A restart in AWAIT_INPUT replaced the saved pending item with a generic "continue or re-plan?" whose answer nothing handled: the reply cleared it and left the agent in AWAIT_INPUT with nothing to resume, where 'use' was refused too. The first live run hit this after a planning failure and could not be moved by any message. - Waiting on the operator is a committed state (A3 step 8): on boot, ask the saved question again instead of replacing it. _await_input keeps the prompt with the pending item for this. - The restart question (A3 step 7) records the state it interrupted, and 'continue' / 're-plan' now do what they offer: re-run the interrupted planning turn, or return an interrupted milestone to its approved plan. Any other reply asks again rather than being swallowed. - With nothing pending, 'use <project>' starts over, as the reply already advised. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e5ed32bc2b
commit
e5450e3e77
2 files changed
+161
-7
No files matched your search
@@ -10,8 +10,10 @@ from nisse_dev.control import (
|
||||
AWAIT_MILESTONE_SIGNOFF,
|
||||
EXECUTING,
|
||||
IDLE,
|
||||
PLANNING_ARCH,
|
||||
AskHuman,
|
||||
Orchestrator,
|
||||
PlanningError,
|
||||
StubAgent,
|
||||
SubmitPlan,
|
||||
)
|
||||
@@ -153,3 +155,95 @@ def test_ask_human_pauses_and_answer_resumes(tmp_path: Path) -> None:
|
||||
h.transport.receive("don", "monorepo")
|
||||
assert h.state == AWAIT_ARCH_SIGNOFF
|
||||
assert "monorepo" in h.transport.outbound[-1].text
|
||||
|
||||
|
||||
# --- restarts (A3 steps 7 and 8) --------------------------------------------
|
||||
|
||||
|
||||
class FailsOnce:
|
||||
"""A planner whose first turn fails, as Opus 5.5's did on its first live
|
||||
run when asked for forced tool use."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def plan(
|
||||
self,
|
||||
gate: str,
|
||||
project: str,
|
||||
feedback: str | None = None,
|
||||
answer: str | None = None,
|
||||
) -> SubmitPlan | AskHuman:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise PlanningError("planner returned 400")
|
||||
return SubmitPlan(f"[stub] {gate} plan for {project}")
|
||||
|
||||
|
||||
def test_restart_while_awaiting_input_asks_the_same_question(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
agent = FailsOnce()
|
||||
h = Harness(tmp_path, agent=agent)
|
||||
h.transport.receive("don", "use demo")
|
||||
assert h.state == AWAIT_INPUT
|
||||
h = Harness(tmp_path, agent=agent) # restart on the same state
|
||||
question = h.transport.outbound[-1]
|
||||
assert question.kind == "question"
|
||||
assert "restarted while waiting on you" in question.text
|
||||
assert "planning for arch failed" in question.text
|
||||
h.transport.receive("don", "Please retry.")
|
||||
assert h.state == AWAIT_ARCH_SIGNOFF
|
||||
|
||||
|
||||
def test_restart_mid_planning_offers_continue_and_it_replans(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
h = Harness(tmp_path)
|
||||
h.store.transition(PLANNING_ARCH, cause="test", project="demo", data={})
|
||||
h = Harness(tmp_path)
|
||||
assert h.state == AWAIT_INPUT
|
||||
assert "'continue'" in h.outbound_texts()[-1]
|
||||
h.transport.receive("don", "Please retry.") # neither choice: asked again
|
||||
assert h.state == AWAIT_INPUT
|
||||
assert h.transport.outbound[-1].kind == "question"
|
||||
h.transport.receive("don", "continue")
|
||||
assert h.state == AWAIT_ARCH_SIGNOFF
|
||||
|
||||
|
||||
def test_restart_mid_execution_continue_returns_to_the_approved_plan(
|
||||
h: Harness, tmp_path: Path
|
||||
) -> None:
|
||||
h.transport.receive("don", "use demo")
|
||||
h.transport.receive("don", "approve")
|
||||
h.transport.receive("don", "approve")
|
||||
h.transport.receive("don", "start milestone 1")
|
||||
assert h.state == EXECUTING
|
||||
h = Harness(tmp_path)
|
||||
assert h.state == AWAIT_INPUT
|
||||
h.transport.receive("don", "continue")
|
||||
assert h.state == AWAIT_MILESTONE_SIGNOFF
|
||||
assert "start milestone 1" in h.outbound_texts()[-1]
|
||||
h.transport.receive("don", "start milestone 1")
|
||||
assert h.state == EXECUTING
|
||||
|
||||
|
||||
def test_restart_question_without_its_interrupted_state_still_replans(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# What the first live run left behind: a restart question recorded
|
||||
# before the question kept its prompt or the state it interrupted.
|
||||
h = Harness(tmp_path)
|
||||
h.store.transition(
|
||||
AWAIT_INPUT, cause="test", project="demo", data={"pending": {"kind": "boot"}}
|
||||
)
|
||||
h = Harness(tmp_path)
|
||||
assert "my last question still stands" in h.outbound_texts()[-1]
|
||||
h.transport.receive("don", "re-plan")
|
||||
assert h.state == AWAIT_ARCH_SIGNOFF
|
||||
|
||||
|
||||
def test_use_starts_over_when_nothing_is_pending(h: Harness) -> None:
|
||||
h.store.transition(AWAIT_INPUT, cause="forced", project="demo", data={})
|
||||
h.transport.receive("don", "use demo")
|
||||
assert h.state == AWAIT_ARCH_SIGNOFF
|
||||
Reference in new issue
Block a user