diff --git a/strix/core/agents.py b/strix/core/agents.py index c96204df..edd7863b 100644 --- a/strix/core/agents.py +++ b/strix/core/agents.py @@ -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 diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index 9c24da92..da9ee34f 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -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]: diff --git a/strix/interface/tui/backend/live_view.py b/strix/interface/tui/backend/live_view.py index bc12c034..549f8bdd 100644 --- a/strix/interface/tui/backend/live_view.py +++ b/strix/interface/tui/backend/live_view.py @@ -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 diff --git a/strix/interface/tui/backend/projection.py b/strix/interface/tui/backend/projection.py index aefb945b..2a9a323a 100644 --- a/strix/interface/tui/backend/projection.py +++ b/strix/interface/tui/backend/projection.py @@ -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, diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go index 8f9e6a11..e7cc8975 100644 --- a/strix/interface/tui/internal/app/model.go +++ b/strix/interface/tui/internal/app/model.go @@ -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" diff --git a/strix/interface/tui/internal/app/model_test.go b/strix/interface/tui/internal/app/model_test.go index e8143621..59a3ad7c 100644 --- a/strix/interface/tui/internal/app/model_test.go +++ b/strix/interface/tui/internal/app/model_test.go @@ -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, diff --git a/strix/interface/tui/internal/app/wire.go b/strix/interface/tui/internal/app/wire.go index b1bb7337..1536110b 100644 --- a/strix/interface/tui/internal/app/wire.go +++ b/strix/interface/tui/internal/app/wire.go @@ -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" diff --git a/strix/interface/tui/live_view.py b/strix/interface/tui/live_view.py index 7e8f2534..dbcf4028 100644 --- a/strix/interface/tui/live_view.py +++ b/strix/interface/tui/live_view.py @@ -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() diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index 451e6ddc..12b3602f 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -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" diff --git a/tests/test_execution.py b/tests/test_execution.py index d389bde3..8fbf18ff 100644 --- a/tests/test_execution.py +++ b/tests/test_execution.py @@ -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() diff --git a/tests/test_go_tui_runtime.py b/tests/test_go_tui_runtime.py index ee5c0a23..a1cb6369 100644 --- a/tests/test_go_tui_runtime.py +++ b/tests/test_go_tui_runtime.py @@ -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 diff --git a/tests/test_tui_backend_controller.py b/tests/test_tui_backend_controller.py index 5b8048c9..078a0324 100644 --- a/tests/test_tui_backend_controller.py +++ b/tests/test_tui_backend_controller.py @@ -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 diff --git a/tests/test_tui_backend_server.py b/tests/test_tui_backend_server.py index eb4e3239..957b9b5c 100644 --- a/tests/test_tui_backend_server.py +++ b/tests/test_tui_backend_server.py @@ -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 diff --git a/tests/test_tui_resume_history.py b/tests/test_tui_resume_history.py index 20c010e5..2d780f38 100644 --- a/tests/test_tui_resume_history.py +++ b/tests/test_tui_resume_history.py @@ -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(