Fix user message retry lifecycle and TUI sync

This commit is contained in:
0xallam 2026-08-28 23:41:31 +03:00 committed by Ahmed Allam
parent 1f3f9b31ae
commit 0a6e8b01bf
14 changed files with 236 additions and 3 deletions

View file

@ -291,6 +291,12 @@ class AgentCoordinator:
self.pending_counts[target_agent_id] = self.pending_counts.get(target_agent_id, 0) + 1
if from_user:
runtime.user_wake_required = False
self.errors.pop(target_agent_id, None)
self.wait_kinds.pop(target_agent_id, None)
self.recovery_counts.pop(target_agent_id, None)
self.idle_resume_counts.pop(target_agent_id, None)
self._parent_notified.discard(target_agent_id)
self.statuses[target_agent_id] = "waiting"
runtime.wake.set()
stream = runtime.stream
interrupt_on_message = runtime.interrupt_on_message

View file

@ -411,6 +411,7 @@ class TuiController:
delivered = await asyncio.wrap_future(future)
if not delivered:
raise RuntimeError("Message could not be delivered")
self.live_view.upsert_agent(agent_id, status="waiting", error_message=None)
return {"sent": True}
async def _stop_agent(self, payload: dict[str, Any]) -> dict[str, Any]:

View file

@ -60,6 +60,9 @@ class TuiLiveView(BaseLiveView):
if error_message and current.get("error_message") != error_message:
current["error_message"] = error_message
changed = True
elif error_message is None and "error_message" in current:
current.pop("error_message", None)
changed = True
if changed:
current["updated_at"] = now
return changed

View file

@ -164,13 +164,14 @@ def bounded_state_projection(state: dict[str, Any]) -> dict[str, Any]:
"scan_state": state["scan_state"],
"targets": state["targets"][:4],
"target_count": state["target_count"],
"working_dir": terminal_projection(state.get("working_dir", ""), max_string=256),
"pending_mount": terminal_projection(state.get("pending_mount", ""), max_string=256),
"instruction": terminal_projection(state["instruction"], max_string=128),
"scan_mode": state["scan_mode"],
"max_budget_usd": state["max_budget_usd"],
"max_turns": state["max_turns"],
"scope_mode": state["scope_mode"],
"diff_base": state["diff_base"],
"provider": state["provider"],
"model": state["model"],
"model_warning": "",
"caido_url": None,

View file

@ -359,7 +359,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.resyncRequested[msg.collection] = false
}
} else if msg.command == "collection.resync" && msg.requestID != "" && msg.collection != "" {
m.resyncRequests[msg.requestID] = msg.collection
if m.resyncRequested[msg.collection] {
m.resyncRequests[msg.requestID] = msg.collection
}
}
case selectionCopiedMsg:
text := "Copied to clipboard"

View file

@ -103,6 +103,21 @@ func bootstrapEnvelope(t *testing.T, collection string, revision int, items ...a
return protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap", Payload: rawJSON(t, payload)}
}
func TestStateSnapshotClearsNilError(t *testing.T) {
model := New(nil)
errText := "provider rejected"
model.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "failed", Error: &errText}))
if model.errorText != errText {
t.Fatalf("error was not installed: %q", model.errorText)
}
model.handleEnvelope(stateEnvelope(t, 2, protocol.Snapshot{ScanState: "running"}))
if model.errorText != "" {
t.Fatalf("nil snapshot error did not clear errorText: %q", model.errorText)
}
}
func TestBackendDisconnectBecomesFatalUnlessUserIsQuitting(t *testing.T) {
model := New(nil)
updated, cmd := model.Update(wireErrMsg{err: fmt.Errorf("socket closed")})
@ -160,6 +175,27 @@ func TestCollectionBootstrapChunksAndVersionedDelta(t *testing.T) {
}
}
func TestAgentCollectionDeltaClearsErrorMessage(t *testing.T) {
model := New(nil)
failed := protocol.Agent{ID: "root", Name: "Strix", Status: "failed", ErrorMessage: "provider rejected"}
model.handleEnvelope(bootstrapEnvelope(t, "agents", 1, failed))
resumed := protocol.Agent{ID: "root", Name: "Strix", Status: "waiting"}
delta := protocol.CollectionDelta{
Collection: "agents", BaseRevision: 1, Revision: 2, Cursor: 0, NextCursor: 1, Done: true,
Operations: []protocol.CollectionOperation{{Op: "upsert", Item: rawJSON(t, resumed)}},
}
model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, delta)})
if len(model.snapshot.Agents) != 1 {
t.Fatalf("agents were not retained: %#v", model.snapshot.Agents)
}
agent := model.snapshot.Agents[0]
if agent.Status != "waiting" || agent.ErrorMessage != "" {
t.Fatalf("agent error was not cleared: %#v", agent)
}
}
func TestCollectionMismatchRequestsOneResync(t *testing.T) {
connection := &recordingConn{}
model := New(newClient(connection))
@ -184,6 +220,42 @@ func TestCollectionMismatchRequestsOneResync(t *testing.T) {
}
}
func TestFailedResyncResultBeforeSentMsgRearmsResync(t *testing.T) {
connection := &recordingConn{}
model := New(newClient(connection))
model.collectionRevisions["events"] = 4
bad := protocol.CollectionDelta{
Collection: "events", BaseRevision: 2, Revision: 3, Cursor: 0, NextCursor: 0, Done: true,
}
cmd := model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, bad)})
if cmd == nil {
t.Fatal("revision mismatch did not request a resync")
}
sent, ok := cmd().(sentMsg)
if !ok || sent.err != nil || sent.requestID == "" {
t.Fatalf("resync send = %#v", sent)
}
failed := protocol.CommandResult{
OK: false,
Command: "collection.resync",
Error: &protocol.CommandError{Code: "command_failed", Message: "resync failed"},
}
model.handleEnvelope(protocol.Envelope{
Version: protocol.Version, Type: "command_result", RequestID: sent.requestID, Payload: rawJSON(t, failed),
})
updated, _ := model.Update(sent)
model = updated.(Model)
if model.resyncRequested["events"] {
t.Fatal("failed resync result left resync suppressed")
}
if retry := model.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_delta", Payload: rawJSON(t, bad)}); retry == nil {
t.Fatal("resync was not rearmed after failure")
}
}
func TestAgentsCollectionPreservesSelectedIDAcrossUpsertsAndDeletes(t *testing.T) {
model := New(nil)
model.handleEnvelope(bootstrapEnvelope(t, "agents", 1,

View file

@ -33,6 +33,8 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd {
m.stateRevision = update.Revision
if m.snapshot.Error != nil {
m.errorText = *m.snapshot.Error
} else {
m.errorText = ""
}
if m.snapshot.SetupMode {
// The start screen is its own landing page; never sit on the
@ -79,6 +81,10 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd {
if collection := m.resyncRequests[envelope.RequestID]; collection != "" {
m.resyncRequested[collection] = false
delete(m.resyncRequests, envelope.RequestID)
} else {
for collection := range m.resyncRequested {
m.resyncRequested[collection] = false
}
}
}
message := "Command failed"

View file

@ -103,6 +103,7 @@ class TuiLiveView:
statuses = agents_data.get("statuses") or {}
names = agents_data.get("names") or {}
parent_of = agents_data.get("parent_of") or {}
errors = agents_data.get("errors") or {}
if not isinstance(statuses, dict):
return
for agent_id, status in statuses.items():
@ -113,6 +114,7 @@ class TuiLiveView:
name=names.get(agent_id, agent_id) if isinstance(names, dict) else agent_id,
parent_id=parent_of.get(agent_id) if isinstance(parent_of, dict) else None,
status=str(status),
error_message=errors.get(agent_id) if isinstance(errors, dict) else None,
)
# Ahead of the replayed history, so it opens the transcript.
self.flush_user_instruction()

View file

@ -258,6 +258,9 @@ class GoTuiRuntime:
scan_state = "failed"
if root_id is not None and errors.get(root_id):
self.controller.error = errors[root_id]
elif scan_state == "failed" and root_status in {"running", "waiting", "budget_paused"}:
scan_state = "running"
self.controller.error = None
elif scan_state != "failed":
if report_status == "completed":
scan_state = "completed"

View file

@ -458,6 +458,49 @@ async def test_non_user_send_does_not_resume_budget_pause(tmp_path: Any) -> None
session.close()
@pytest.mark.asyncio
async def test_user_send_starts_fresh_resume_attempt_after_failure() -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
await coordinator.park_waiting("child", wait_kind="stalled")
await coordinator.record_recovery("child")
await coordinator.record_idle_resume("child")
await coordinator.set_status("child", "failed", error="provider rejected request")
assert await coordinator.claim_parent_notice("child") is True
delivered = await coordinator.send("child", {"from": "user", "content": "try again"})
assert delivered is True
assert coordinator.statuses["child"] == "waiting"
assert coordinator.pending_counts["child"] == 1
assert "child" not in coordinator.errors
assert "child" not in coordinator.wait_kinds
assert "child" not in coordinator.recovery_counts
assert "child" not in coordinator.idle_resume_counts
assert await coordinator.claim_parent_notice("child") is True
@pytest.mark.asyncio
async def test_non_user_send_preserves_failed_resume_state() -> None:
coordinator = AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
await coordinator.register("child", "recon", parent_id="root")
await coordinator.park_waiting("child", wait_kind="stalled")
await coordinator.record_recovery("child")
await coordinator.record_idle_resume("child")
await coordinator.set_status("child", "failed", error="provider rejected request")
delivered = await coordinator.send("child", {"from": "root", "content": "status"})
assert delivered is True
assert coordinator.statuses["child"] == "failed"
assert coordinator.errors["child"] == "provider rejected request"
assert coordinator.wait_kinds["child"] == "stalled"
assert coordinator.recovery_counts["child"] == 1
assert coordinator.idle_resume_counts["child"] == 1
@pytest.mark.asyncio
async def test_reset_budget_stops_clears_pause_and_normalizes_statuses() -> None:
coordinator = AgentCoordinator()

View file

@ -857,6 +857,37 @@ async def test_agent_state_sync_does_not_mask_root_failure_with_completed_report
assert runtime.controller.error == "finalization failed"
@pytest.mark.asyncio
async def test_agent_state_sync_clears_root_failure_after_user_resume() -> None:
runtime = GoTuiRuntime(args())
await runtime.coordinator.register("root", "Strix", parent_id=None)
await runtime.coordinator.set_status("root", "failed", error="provider rejected request")
await runtime._sync_agent_state()
assert runtime.controller.scan_state == "failed"
assert runtime.live_view.agents["root"]["error_message"] == "provider rejected request"
await runtime.coordinator.send("root", {"from": "user", "content": "try again"})
await runtime._sync_agent_state()
assert runtime.controller.scan_state == "running"
assert runtime.controller.error is None
root = runtime.live_view.agents["root"]
assert root["status"] == "waiting"
assert "error_message" not in root
@pytest.mark.asyncio
async def test_agent_state_sync_does_not_reopen_stopped_scan_with_active_root() -> None:
runtime = GoTuiRuntime(args())
runtime.controller.scan_state = "stopped"
await runtime.coordinator.register("root", "Strix", parent_id=None)
await runtime._sync_agent_state()
assert runtime.controller.scan_state == "stopped"
def _direct_launch_args() -> argparse.Namespace:
launch_args = args()
launch_args.needs_setup = False

View file

@ -12,6 +12,16 @@ from strix.config.settings import DEFAULT_MAX_TURNS
from strix.interface.tui.backend.controller import TuiController
class _SendingCoordinator:
def __init__(self, delivered: bool = True) -> None:
self.delivered = delivered
self.messages: list[tuple[str, dict[str, object]]] = []
async def send(self, agent_id: str, message: dict[str, object]) -> bool:
self.messages.append((agent_id, message))
return self.delivered
def args() -> argparse.Namespace:
return argparse.Namespace(
needs_setup=True,
@ -313,6 +323,34 @@ def test_snapshot_exposes_working_directory() -> None:
assert controller.snapshot()["pending_mount"] == ""
@pytest.mark.asyncio
async def test_user_message_updates_live_agent_projection_immediately() -> None:
coordinator = _SendingCoordinator()
controller = TuiController(args(), coordinator=coordinator)
controller.setup_mode = False
controller.scan_started = True
controller.scan_loop = asyncio.get_running_loop()
controller.live_view.upsert_agent(
"root",
name="Strix",
status="failed",
error_message="provider rejected request",
)
result = await controller.handle(
"agent.send_message",
{"agent_id": "root", "message": "try again"},
)
assert result == {"sent": True}
assert coordinator.messages == [
("root", {"from": "user", "content": "try again", "type": "instruction"})
]
agent = controller.live_view.agents["root"]
assert agent["status"] == "waiting"
assert "error_message" not in agent
@pytest.mark.asyncio
async def test_start_forwards_verify_flag_by_default() -> None:
seen_verify: bool | None = None

View file

@ -243,13 +243,15 @@ def test_defensive_state_projection_preserves_usage_summary() -> None:
),
)
state = controller.snapshot()
state["provider"] = None
state["pending_mount"] = "current-project"
state["future_oversized_field"] = "x" * 100_000
snapshot = bounded_state_projection(state)
assert snapshot["projection_truncated"] is True
assert snapshot["usage"] == {"total_tokens": 720_400, "cost": 20.0}
assert snapshot["working_dir"] == state["working_dir"]
assert snapshot["pending_mount"] == "current-project"
@pytest.mark.asyncio

View file

@ -104,6 +104,29 @@ def test_resume_hides_system_guidance_injected_as_user_turns(tmp_path: Path) ->
] == ["starting", "continuing"]
def test_resume_hydrates_saved_agent_errors(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
state_dir = runtime_state_dir(run_dir)
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "agents.json").write_text(
json.dumps(
{
"statuses": {"root": "failed"},
"names": {"root": "Strix"},
"parent_of": {"root": None},
"errors": {"root": "provider rejected request"},
}
),
encoding="utf-8",
)
view = GoTuiLiveView()
view.hydrate_from_run_dir(run_dir)
assert view.agents["root"]["status"] == "failed"
assert view.agents["root"]["error_message"] == "provider rejected request"
def test_resume_keeps_messages_the_user_actually_typed(tmp_path: Path) -> None:
run_dir = tmp_path / "run"
_write_run(