mirror of
https://github.com/usestrix/strix.git
synced 2026-09-05 08:06:08 +00:00
Fix user message retry lifecycle and TUI sync
This commit is contained in:
parent
1f3f9b31ae
commit
0a6e8b01bf
14 changed files with 236 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue