diff --git a/.coderabbit.yaml b/.coderabbit.yaml index ebd0077..127e31c 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -27,7 +27,7 @@ reviews: path_instructions: - path: "crates/**/*.rs" instructions: >- - Rust, edition 2024 (MSRV 1.85). In runtime (non-test) paths, flag `unwrap`, `expect`, + Rust, edition 2024 (MSRV 1.88). In runtime (non-test) paths, flag `unwrap`, `expect`, `panic!`, `todo!`, or `unreachable!` unless the condition is proven infallible in local context; prefer typed errors, logging, or a structured browser error. Failures must be visible at the right boundary: browser-action failures over HTTP/WebSocket, server/operator diff --git a/AGENTS.md b/AGENTS.md index 67eacff..eb4c87e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Cargo workspace with 7 crates under `crates/`: - `giskard-server` — Axum backend + the embedded vanilla static web UI (`static/`) ## Conventions -- Edition 2024, MSRV 1.85. +- Edition 2024, MSRV 1.88. - All Codex-specific types confined to `giskard-harness-codex`. - Atomic writes for all persistence (temp file + fsync + rename). - IDs are ULIDs. diff --git a/Cargo.toml b/Cargo.toml index 8f5d6a0..164fe32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ default-members = [ version = "0.1.0" edition = "2024" license = "MIT" -rust-version = "1.85" +rust-version = "1.88" [workspace.dependencies] giskard-core = { path = "crates/giskard-core" } diff --git a/README.md b/README.md index 8b6f801..768e1e1 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The agent harness is a replaceable component behind a neutral `AgentHarness` tra ## Prerequisites -- **Rust** — edition 2024, MSRV **1.85+** (`rustup` recommended). +- **Rust** — edition 2024, MSRV **1.88+** (`rustup` recommended). - **Codex CLI**, already installed and authenticated on the machine. Giskard does **not** manage Codex's credentials — it inherits `~/.codex` (ChatGPT login or an API key / custom provider) when it spawns the app-server. If Codex isn't configured, turns will fail with an "unauthenticated" diff --git a/crates/giskard-harness-codex/src/lib.rs b/crates/giskard-harness-codex/src/lib.rs index 09148ab..21c7b02 100644 --- a/crates/giskard-harness-codex/src/lib.rs +++ b/crates/giskard-harness-codex/src/lib.rs @@ -1270,11 +1270,11 @@ async fn handle_background_server_message( remaining_active_turns = active_turns.len(), "Codex turn completion observed" ); - } else if let Some((turn, message)) = fatal_completion { - if emit_fatal_turn_completion(senders, thread, turn, message).await { - active_turns.remove(&thread); - mapper.clear_active_turn(thread); - } + } else if let Some((turn, message)) = fatal_completion + && emit_fatal_turn_completion(senders, thread, turn, message).await + { + active_turns.remove(&thread); + mapper.clear_active_turn(thread); } if let Some(elapsed_ms) = completed_compaction { return StreamOutcome::CompactionCompleted { thread, elapsed_ms }; diff --git a/crates/giskard-harness-codex/src/mapping.rs b/crates/giskard-harness-codex/src/mapping.rs index ce6ccb7..7088b15 100644 --- a/crates/giskard-harness-codex/src/mapping.rs +++ b/crates/giskard-harness-codex/src/mapping.rs @@ -502,10 +502,10 @@ impl CodexMapper { let thread = self.resolve_thread(&n.thread_id, fallback_thread)?; let turn = self.resolve_turn(thread, &n.turn_id); let mut lines = Vec::new(); - if let Some(explanation) = &n.explanation { - if !explanation.trim().is_empty() { - lines.push(explanation.clone()); - } + if let Some(explanation) = &n.explanation + && !explanation.trim().is_empty() + { + lines.push(explanation.clone()); } for step in &n.plan { lines.push(format!("{}: {}", enum_string(&step.status), step.step)); @@ -1642,14 +1642,14 @@ fn add_permission_profile_metadata( if let Some(file_system) = &permissions.file_system { add_file_system_permissions_metadata(metadata, workspace_root, file_system); } - if let Some(network) = &permissions.network { - if let Some(enabled) = network.enabled { - add_text_metadata( - metadata, - "Network access", - if enabled { "enabled" } else { "disabled" }, - ); - } + if let Some(network) = &permissions.network + && let Some(enabled) = network.enabled + { + add_text_metadata( + metadata, + "Network access", + if enabled { "enabled" } else { "disabled" }, + ); } } diff --git a/crates/giskard-harness-replay/src/lib.rs b/crates/giskard-harness-replay/src/lib.rs index 9a9a50b..99aa3eb 100644 --- a/crates/giskard-harness-replay/src/lib.rs +++ b/crates/giskard-harness-replay/src/lib.rs @@ -265,10 +265,10 @@ impl AgentHarness for ReplayHarness { fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { // We need to get the sender synchronously. Use try_lock. let threads = self.threads.try_lock(); - if let Ok(threads) = threads { - if let Some((_, state)) = threads.iter().find(|(id, _)| *id == thread.thread) { - return AgentEventStream::new(state.sender.subscribe()); - } + if let Ok(threads) = threads + && let Some((_, state)) = threads.iter().find(|(id, _)| *id == thread.thread) + { + return AgentEventStream::new(state.sender.subscribe()); } // Fallback: create a dummy channel. let (_, rx) = broadcast::channel(1); diff --git a/crates/giskard-persist/src/config.rs b/crates/giskard-persist/src/config.rs index 724ef8e..c714d15 100644 --- a/crates/giskard-persist/src/config.rs +++ b/crates/giskard-persist/src/config.rs @@ -159,17 +159,16 @@ impl ProviderConfig { /// Resolve the discovery API key: the inline `api_key`, else the value of the env var named by /// `api_key_env`. Empty values are treated as unset. pub fn resolve_api_key(&self) -> Option { - if let Some(key) = self.api_key.as_deref() { - if !key.is_empty() { - return Some(key.to_string()); - } + if let Some(key) = self.api_key.as_deref() + && !key.is_empty() + { + return Some(key.to_string()); } - if let Some(var) = self.api_key_env.as_deref() { - if let Ok(val) = std::env::var(var) { - if !val.is_empty() { - return Some(val); - } - } + if let Some(var) = self.api_key_env.as_deref() + && let Ok(val) = std::env::var(var) + && !val.is_empty() + { + return Some(val); } None } diff --git a/crates/giskard-persist/src/store.rs b/crates/giskard-persist/src/store.rs index 9d20afd..594c82b 100644 --- a/crates/giskard-persist/src/store.rs +++ b/crates/giskard-persist/src/store.rs @@ -717,10 +717,10 @@ impl PersistStore { { let name = entry.file_name(); let name = name.to_string_lossy(); - if let Some(stem) = name.strip_suffix(".json") { - if let Ok(ulid) = stem.parse::() { - ids.push(ThreadId(ulid)); - } + if let Some(stem) = name.strip_suffix(".json") + && let Ok(ulid) = stem.parse::() + { + ids.push(ThreadId(ulid)); } } Ok(ids) diff --git a/crates/giskard-server/src/bin/giskard-server-replay.rs b/crates/giskard-server/src/bin/giskard-server-replay.rs index bc6f6f1..511ac77 100644 --- a/crates/giskard-server/src/bin/giskard-server-replay.rs +++ b/crates/giskard-server/src/bin/giskard-server-replay.rs @@ -419,10 +419,10 @@ impl AgentHarness for ScriptedHarness { } fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { - if let Ok(threads) = self.threads.try_lock() { - if let Some((_, tx)) = threads.iter().find(|(id, _)| *id == thread.thread) { - return AgentEventStream::new(tx.subscribe()); - } + if let Ok(threads) = self.threads.try_lock() + && let Some((_, tx)) = threads.iter().find(|(id, _)| *id == thread.thread) + { + return AgentEventStream::new(tx.subscribe()); } let (_, rx) = broadcast::channel(1); AgentEventStream::new(rx) diff --git a/crates/giskard-server/src/highlight.rs b/crates/giskard-server/src/highlight.rs index 846fe39..5009c09 100644 --- a/crates/giskard-server/src/highlight.rs +++ b/crates/giskard-server/src/highlight.rs @@ -148,10 +148,10 @@ impl Highlighter { let cache_key = path.to_path_buf(); { let cache = self.cache.lock().await; - if let Some((_, entry)) = cache.iter().find(|(k, _)| *k == cache_key) { - if entry.mtime == mtime { - return Ok(apply_range(&entry.cached, start_line, end_line)); - } + if let Some((_, entry)) = cache.iter().find(|(k, _)| *k == cache_key) + && entry.mtime == mtime + { + return Ok(apply_range(&entry.cached, start_line, end_line)); } } diff --git a/crates/giskard-server/src/ledger.rs b/crates/giskard-server/src/ledger.rs index 9b1cd5e..411c7a5 100644 --- a/crates/giskard-server/src/ledger.rs +++ b/crates/giskard-server/src/ledger.rs @@ -90,10 +90,10 @@ async fn actor(store: Arc, mut rx: mpsc::Receiver) { warn!(%e, "failed to persist global token ledger"); } for pid in dirty { - if let Some(ledger) = projects.get(&pid) { - if let Err(e) = store.save_project_tokens(pid, ledger).await { - warn!(%pid, %e, "failed to persist project token ledger"); - } + if let Some(ledger) = projects.get(&pid) + && let Err(e) = store.save_project_tokens(pid, ledger).await + { + warn!(%pid, %e, "failed to persist project token ledger"); } } } diff --git a/crates/giskard-server/src/linkify.rs b/crates/giskard-server/src/linkify.rs index 826f672..4ea19b2 100644 --- a/crates/giskard-server/src/linkify.rs +++ b/crates/giskard-server/src/linkify.rs @@ -88,20 +88,21 @@ pub fn linkify_text(text: &str, workspace_root: &Path) -> Vec { } let resolved = resolve_path(path_str, workspace_root); - if let Some(full_path) = resolved { - if full_path.exists() && full_path.is_file() { - let relative = full_path - .strip_prefix(workspace_root) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| full_path.to_string_lossy().to_string()); - - spans.push(LinkSpan { - start: mat.start(), - end, - path: relative, - line, - }); - } + if let Some(full_path) = resolved + && full_path.exists() + && full_path.is_file() + { + let relative = full_path + .strip_prefix(workspace_root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| full_path.to_string_lossy().to_string()); + + spans.push(LinkSpan { + start: mat.start(), + end, + path: relative, + line, + }); } } spans @@ -127,10 +128,10 @@ fn split_line_fragment(candidate: &str) -> (&str, Option) { return (candidate, None); }; - if let Some((path, maybe_line)) = before_last_colon.rsplit_once(':') { - if let Some(line) = parse_positive_usize(maybe_line) { - return (path, Some(line)); - } + if let Some((path, maybe_line)) = before_last_colon.rsplit_once(':') + && let Some(line) = parse_positive_usize(maybe_line) + { + return (path, Some(line)); } (before_last_colon, Some(last_number)) diff --git a/crates/giskard-server/src/live_buffer.rs b/crates/giskard-server/src/live_buffer.rs index d9c929f..c9699b1 100644 --- a/crates/giskard-server/src/live_buffer.rs +++ b/crates/giskard-server/src/live_buffer.rs @@ -200,10 +200,10 @@ fn command_output_item_id(event: &AgentEvent) -> Option { } fn compact_completed_command_output(mut event: AgentEvent) -> AgentEvent { - if let AgentEvent::ItemCompleted { item, .. } = &mut event { - if let ItemPayload::CommandExecution { output, .. } = &mut item.payload { - *output = compact_command_output(output); - } + if let AgentEvent::ItemCompleted { item, .. } = &mut event + && let ItemPayload::CommandExecution { output, .. } = &mut item.payload + { + *output = compact_command_output(output); } event } diff --git a/crates/giskard-server/src/registry.rs b/crates/giskard-server/src/registry.rs index 8c6b0ca..f36e2d3 100644 --- a/crates/giskard-server/src/registry.rs +++ b/crates/giskard-server/src/registry.rs @@ -2681,41 +2681,39 @@ async fn forward_events( } else if owned_turn_completed { continue; } - } else if let Some(turn) = event_turn { - if !seen_turn_ids.contains(&turn) { - owned_turn = Some(turn); - if !matches!(event, AgentEvent::TurnStarted { .. }) { - debug!( - %thread_id, - %turn, - "event forwarder attached to turn before seeing turn start" - ); - } + } else if let Some(turn) = event_turn + && !seen_turn_ids.contains(&turn) + { + owned_turn = Some(turn); + if !matches!(event, AgentEvent::TurnStarted { .. }) { + debug!( + %thread_id, + %turn, + "event forwarder attached to turn before seeing turn start" + ); } } - if let Some(turn) = event_turn { - if seen_turn_ids.contains(&turn) { - let command_state_changed = - apply_seen_turn_running_command_event(&running_commands, &event).await; - if command_state_changed { - if is_terminal_command_completion(&event) { - hub.broadcast_event(thread_id, event).await; - } - broadcast_running_commands(&hub, &running_commands, thread_id).await; - } - if owned_turn_completed { - if let Some(owned) = owned_turn { - if !running_commands - .has_running_for_turn(thread_id, owned) - .await - { - break ForwarderExitReason::AfterTurnCommandsDrained; - } - } + if let Some(turn) = event_turn + && seen_turn_ids.contains(&turn) + { + let command_state_changed = + apply_seen_turn_running_command_event(&running_commands, &event).await; + if command_state_changed { + if is_terminal_command_completion(&event) { + hub.broadcast_event(thread_id, event).await; } - continue; + broadcast_running_commands(&hub, &running_commands, thread_id).await; } + if owned_turn_completed + && let Some(owned) = owned_turn + && !running_commands + .has_running_for_turn(thread_id, owned) + .await + { + break ForwarderExitReason::AfterTurnCommandsDrained; + } + continue; } if owned_turn.is_none() && event_turn.is_none() { @@ -2780,19 +2778,19 @@ async fn forward_events( if ctx.kind == TurnContextKind::PassiveSubagent { refresh_passive_subagent_context(thread_id, &mut ctx).await; - if let Some(turn) = event_turn { - if !matches!(event, AgentEvent::TurnStarted { .. }) { - synthesize_passive_subagent_prompt_item( - thread_id, - turn, - &ctx, - &mut current_turn_items, - &mut synthetic_subagent_prompt, - &hub, - &live_buffers, - ) - .await; - } + if let Some(turn) = event_turn + && !matches!(event, AgentEvent::TurnStarted { .. }) + { + synthesize_passive_subagent_prompt_item( + thread_id, + turn, + &ctx, + &mut current_turn_items, + &mut synthetic_subagent_prompt, + &hub, + &live_buffers, + ) + .await; } } @@ -2957,41 +2955,40 @@ async fn forward_events( // reconnect buffer from the first turn-scoped event and reuse it when the delayed // start arrives, otherwise a reload in that window loses the already-visible item. let mut append_to_live_buffer = true; - if let Some(buffer_turn) = event_turn { - if let Err(existing_turn) = live_buffers + if let Some(buffer_turn) = event_turn + && let Err(existing_turn) = live_buffers .ensure_turn_with_user_input( thread_id, buffer_turn, live_turn_user_input(&ctx), ) .await - { - if matches!(event, AgentEvent::TurnStarted { .. }) { - warn!( - %project_id, - %thread_id, - %buffer_turn, - %existing_turn, - "replacing a stale live buffer when a new turn started" - ); - live_buffers - .replace_turn_with_user_input( - thread_id, - buffer_turn, - live_turn_user_input(&ctx), - ) - .await; - } else { - error!( - %project_id, - %thread_id, - %buffer_turn, - %existing_turn, - event_kind = event_kind(&event), - "not buffering an event for a different turn; live delivery and persistence continue" - ); - append_to_live_buffer = false; - } + { + if matches!(event, AgentEvent::TurnStarted { .. }) { + warn!( + %project_id, + %thread_id, + %buffer_turn, + %existing_turn, + "replacing a stale live buffer when a new turn started" + ); + live_buffers + .replace_turn_with_user_input( + thread_id, + buffer_turn, + live_turn_user_input(&ctx), + ) + .await; + } else { + error!( + %project_id, + %thread_id, + %buffer_turn, + %existing_turn, + event_kind = event_kind(&event), + "not buffering an event for a different turn; live delivery and persistence continue" + ); + append_to_live_buffer = false; } } if append_to_live_buffer && live_buffers.is_active(thread_id).await { @@ -3061,19 +3058,17 @@ async fn forward_events( broadcast_thread_activity(&hub, thread_id, &event, true).await; broadcast_event_with_context(&hub, thread_id, event, &ctx).await; - if is_turn_start { - if let Some(turn) = event_turn { - synthesize_passive_subagent_prompt_item( - thread_id, - turn, - &ctx, - &mut current_turn_items, - &mut synthetic_subagent_prompt, - &hub, - &live_buffers, - ) - .await; - } + if is_turn_start && let Some(turn) = event_turn { + synthesize_passive_subagent_prompt_item( + thread_id, + turn, + &ctx, + &mut current_turn_items, + &mut synthetic_subagent_prompt, + &hub, + &live_buffers, + ) + .await; } if command_state_changed { @@ -4460,14 +4455,14 @@ mod tests { let mut saw_item = false; while let Ok(message) = client_rx.try_recv() { - if let ServerMessage::Event { agent_event, .. } = message { - if let WireAgentEvent::ItemCompleted { item, .. } = *agent_event { - saw_item = matches!( - item.payload, - giskard_proto::WireItemPayload::AgentMessage { ref text } - if text == "Completed child work" - ); - } + if let ServerMessage::Event { agent_event, .. } = message + && let WireAgentEvent::ItemCompleted { item, .. } = *agent_event + { + saw_item = matches!( + item.payload, + giskard_proto::WireItemPayload::AgentMessage { ref text } + if text == "Completed child work" + ); } } assert!(saw_item, "fallback transcript should be broadcast live"); @@ -4986,20 +4981,19 @@ mod tests { let mut matching_updates = 0; while let Ok(message) = client_rx.try_recv() { - if let ServerMessage::Event { agent_event, .. } = message { - if let WireAgentEvent::ContextWindowUpdated { + if let ServerMessage::Event { agent_event, .. } = message + && let WireAgentEvent::ContextWindowUpdated { thread, turn, model: event_model, context_window, } = *agent_event - { - matching_updates += 1; - assert_eq!(thread, thread_id); - assert_eq!(turn, turn_id); - assert_eq!(event_model, model); - assert_eq!(context_window, 258_400); - } + { + matching_updates += 1; + assert_eq!(thread, thread_id); + assert_eq!(turn, turn_id); + assert_eq!(event_model, model); + assert_eq!(context_window, 258_400); } } assert_eq!( @@ -6592,13 +6586,13 @@ mod tests { "earlier turn item must remain untouched" ); while let Ok(message) = client_rx.try_recv() { - if let ServerMessage::Event { agent_event, .. } = message { - if let WireAgentEvent::ItemCompleted { item, .. } = *agent_event { - assert_ne!( - item.id, conflicting_item_id, - "conflicting native identity must not be broadcast" - ); - } + if let ServerMessage::Event { agent_event, .. } = message + && let WireAgentEvent::ItemCompleted { item, .. } = *agent_event + { + assert_ne!( + item.id, conflicting_item_id, + "conflicting native identity must not be broadcast" + ); } } } @@ -6951,14 +6945,12 @@ mod tests { if let Ok(Some(ServerMessage::Event { agent_event, .. })) = tokio::time::timeout(tokio::time::Duration::from_millis(100), client_rx.recv()) .await - { - if let WireAgentEvent::ItemDelta { + && let WireAgentEvent::ItemDelta { delta: giskard_proto::ItemDelta::Text { text }, .. } = *agent_event - { - delta_texts.push(text); - } + { + delta_texts.push(text); } } assert_eq!( diff --git a/crates/giskard-server/src/routes.rs b/crates/giskard-server/src/routes.rs index 63e177c..20f2d38 100644 --- a/crates/giskard-server/src/routes.rs +++ b/crates/giskard-server/src/routes.rs @@ -1271,20 +1271,19 @@ async fn cleanup_new_thread_after_start_failure( } } - if remove_local_thread { - if let Err(error) = state + if remove_local_thread + && let Err(error) = state .store .delete_thread(project_config.id, thread_id) .await - { - warn!( - project_id = %project_config.id, - %thread_id, - %failed_action, - error = %error, - "failed to delete local thread after failed new-thread startup" - ); - } + { + warn!( + project_id = %project_config.id, + %thread_id, + %failed_action, + error = %error, + "failed to delete local thread after failed new-thread startup" + ); } } @@ -2935,12 +2934,11 @@ async fn handle_client_msg( })? .current_model .provider; - if stored_provider != model_ref.provider { - if let Some(warning) = + if stored_provider != model_ref.provider + && let Some(warning) = switch_provider_cold(state, project_id, thread_id, &model_ref).await? - { - let _ = tx.send(ServerMessage::Error { error: warning }).await; - } + { + let _ = tx.send(ServerMessage::Error { error: warning }).await; } } @@ -2954,10 +2952,10 @@ async fn handle_client_msg( &config, &tf.current_model, ); - if old.supports_reasoning_effort { - if let Some(effort) = tf.current_model.reasoning_effort.clone() { - tf.model_efforts.insert(tf.current_model.key(), effort); - } + if old.supports_reasoning_effort + && let Some(effort) = tf.current_model.reasoning_effort.clone() + { + tf.model_efforts.insert(tf.current_model.key(), effort); } let new_descriptor = diff --git a/crates/giskard-server/static/app.js b/crates/giskard-server/static/app.js index dbb8e24..0f06c00 100644 --- a/crates/giskard-server/static/app.js +++ b/crates/giskard-server/static/app.js @@ -673,11 +673,18 @@ function appendThreadRows(box, pid, threads) { roots.push(t); } } + const rendered = new Set(); const appendOne = (t) => { + const id = String(t.id); + if (rendered.has(id)) return; + rendered.add(id); box.append(threadRow(pid, t)); - for (const child of byParent.get(String(t.id)) || []) appendOne(child); + for (const child of byParent.get(id) || []) appendOne(child); }; for (const t of roots) appendOne(t); + // A corrupted parent cycle has no root and would otherwise vanish; keep every visible thread + // rendered so malformed records stay reachable for repair or deletion. + for (const t of threads) appendOne(t); } // Hide only sub-agents whose ownership chain is complete and terminates at a primary root. @@ -1119,9 +1126,22 @@ async function deleteThread(pid, tid, title) { if (!confirm(`Permanently delete thread "${title}"${cascade}? This cannot be undone.`)) return; try { await api("DELETE", `/api/projects/${pid}/threads/${tid}`); + // The server cascades to descendants it discovered itself, which can include children this + // client never listed. Decide from the refreshed authoritative list whether the active view + // was deleted; keep the pre-request set as a fallback in case the sidebar reload fails + // (loadThreads swallows its own errors and leaves the cached list stale). const deletedIds = new Set([String(tid), ...descendants]); - if (state.threadId && deletedIds.has(String(state.threadId))) clearThreadView(state.threadId); await loadThreads(pid); + // Read the live view only after the awaits: the user may have navigated to another thread + // or another project meanwhile, and an unrelated active view must never be cleared. + const activeThread = state.threadId ? String(state.threadId) : null; + const sameProject = String(state.projectId || "") === String(pid); + if ( + activeThread && sameProject && + (deletedIds.has(activeThread) || !knownThreadForId(pid, activeThread)) + ) { + clearThreadView(activeThread); + } } catch (e) { notice("Delete thread failed: " + e.message, "error"); } diff --git a/crates/giskard-server/tests/diff_accumulation.rs b/crates/giskard-server/tests/diff_accumulation.rs index d44749c..7d7b664 100644 --- a/crates/giskard-server/tests/diff_accumulation.rs +++ b/crates/giskard-server/tests/diff_accumulation.rs @@ -285,35 +285,35 @@ session_days = 30 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10); loop { - if let Ok(turns) = state.store.load_all_turns(pid, thread_id).await { - if !turns.is_empty() { - let turn = &turns[0]; - assert_eq!( - turn.diffs.len(), - 2, - "two distinct file paths should have diffs (dedup by path)" - ); + if let Ok(turns) = state.store.load_all_turns(pid, thread_id).await + && !turns.is_empty() + { + let turn = &turns[0]; + assert_eq!( + turn.diffs.len(), + 2, + "two distinct file paths should have diffs (dedup by path)" + ); - let main_rs_diff = turn - .diffs - .iter() - .find(|d| d.path.to_string_lossy() == "src/main.rs") - .expect("src/main.rs diff should exist"); - assert_eq!(main_rs_diff.change, FileChangeKind::Modified); - assert!( - main_rs_diff.new_text.as_ref().unwrap().contains("hello"), - "should contain the latest diff (hello, not hi)" - ); + let main_rs_diff = turn + .diffs + .iter() + .find(|d| d.path.to_string_lossy() == "src/main.rs") + .expect("src/main.rs diff should exist"); + assert_eq!(main_rs_diff.change, FileChangeKind::Modified); + assert!( + main_rs_diff.new_text.as_ref().unwrap().contains("hello"), + "should contain the latest diff (hello, not hi)" + ); - let lib_rs_diff = turn - .diffs - .iter() - .find(|d| d.path.to_string_lossy() == "src/lib.rs") - .expect("src/lib.rs diff should exist"); - assert_eq!(lib_rs_diff.change, FileChangeKind::Created); + let lib_rs_diff = turn + .diffs + .iter() + .find(|d| d.path.to_string_lossy() == "src/lib.rs") + .expect("src/lib.rs diff should exist"); + assert_eq!(lib_rs_diff.change, FileChangeKind::Created); - return; - } + return; } if tokio::time::Instant::now() >= deadline { panic!("turn was not persisted within 10 seconds"); diff --git a/crates/giskard-server/tests/e2e_smoke.rs b/crates/giskard-server/tests/e2e_smoke.rs index 521f8a5..67c7d1d 100644 --- a/crates/giskard-server/tests/e2e_smoke.rs +++ b/crates/giskard-server/tests/e2e_smoke.rs @@ -264,10 +264,10 @@ impl ActivityHarness { async fn wait_for_subscribers(&self, thread: ThreadId, expected: usize) { let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5); loop { - if let Some(sender) = self.threads.lock().await.get(&thread) { - if sender.receiver_count() >= expected { - return; - } + if let Some(sender) = self.threads.lock().await.get(&thread) + && sender.receiver_count() >= expected + { + return; } if tokio::time::Instant::now() >= deadline { panic!("timed out waiting for {expected} subscribers on {thread}"); @@ -501,10 +501,10 @@ impl AgentHarness for UnsupportedCompactionHarness { } fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { - if let Ok(threads) = self.threads.try_lock() { - if let Some(sender) = threads.get(&thread.thread) { - return AgentEventStream::new(sender.subscribe()); - } + if let Ok(threads) = self.threads.try_lock() + && let Some(sender) = threads.get(&thread.thread) + { + return AgentEventStream::new(sender.subscribe()); } let (_, rx) = tokio::sync::broadcast::channel(1); AgentEventStream::new(rx) @@ -623,10 +623,10 @@ impl AgentHarness for SlowCompactionHarness { } fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { - if let Ok(threads) = self.threads.try_lock() { - if let Some(sender) = threads.get(&thread.thread) { - return AgentEventStream::new(sender.subscribe()); - } + if let Ok(threads) = self.threads.try_lock() + && let Some(sender) = threads.get(&thread.thread) + { + return AgentEventStream::new(sender.subscribe()); } let (_, rx) = tokio::sync::broadcast::channel(1); AgentEventStream::new(rx) @@ -1134,10 +1134,10 @@ impl AgentHarness for ActivityHarness { } fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { - if let Ok(threads) = self.threads.try_lock() { - if let Some(sender) = threads.get(&thread.thread) { - return AgentEventStream::new(sender.subscribe()); - } + if let Ok(threads) = self.threads.try_lock() + && let Some(sender) = threads.get(&thread.thread) + { + return AgentEventStream::new(sender.subscribe()); } let (_, rx) = tokio::sync::broadcast::channel(1); AgentEventStream::new(rx) @@ -1283,10 +1283,10 @@ impl AgentHarness for SlowStartHarness { } fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { - if let Ok(threads) = self.threads.try_lock() { - if let Some(sender) = threads.get(&thread.thread) { - return AgentEventStream::new(sender.subscribe()); - } + if let Ok(threads) = self.threads.try_lock() + && let Some(sender) = threads.get(&thread.thread) + { + return AgentEventStream::new(sender.subscribe()); } let (_, rx) = tokio::sync::broadcast::channel(1); AgentEventStream::new(rx) @@ -1396,10 +1396,10 @@ impl AgentHarness for CountingOpenHarness { } fn subscribe(&self, thread: &ThreadHandle) -> AgentEventStream { - if let Ok(threads) = self.threads.try_lock() { - if let Some(sender) = threads.get(&thread.thread) { - return AgentEventStream::new(sender.subscribe()); - } + if let Ok(threads) = self.threads.try_lock() + && let Some(sender) = threads.get(&thread.thread) + { + return AgentEventStream::new(sender.subscribe()); } let (_, rx) = tokio::sync::broadcast::channel(1); AgentEventStream::new(rx) @@ -2209,10 +2209,11 @@ async fn wait_for_ws_error( while tokio::time::Instant::now() < deadline { match tokio::time::timeout(tokio::time::Duration::from_secs(1), ws.next()).await { Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text)))) => { - if let Ok(ServerMessage::Error { error }) = serde_json::from_str(&text) { - if error.action.as_deref() == Some(action) && error.code == code { - return error; - } + if let Ok(ServerMessage::Error { error }) = serde_json::from_str(&text) + && error.action.as_deref() == Some(action) + && error.code == code + { + return error; } } Ok(Some(Ok(_))) => {} @@ -2236,12 +2237,10 @@ async fn wait_for_turn_completed( thread_id: event_thread, agent_event, }) = serde_json::from_str(&text) + && event_thread == thread_id + && matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) { - if event_thread == thread_id - && matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) - { - return; - } + return; } } Ok(Some(Ok(_))) => {} @@ -2267,12 +2266,10 @@ async fn wait_for_turn_started( thread_id: event_thread, agent_event, }) = serde_json::from_str(&text) + && event_thread == thread_id + && let WireAgentEvent::TurnStarted { turn, .. } = *agent_event { - if event_thread == thread_id { - if let WireAgentEvent::TurnStarted { turn, .. } = *agent_event { - return turn; - } - } + return turn; } } Ok(Some(Ok(_))) => {} @@ -2296,15 +2293,12 @@ async fn wait_for_turn_started_with_input( thread_id: event_thread, agent_event, }) = serde_json::from_str(&text) + && event_thread == thread_id + && let WireAgentEvent::TurnStarted { + turn, user_input, .. + } = *agent_event { - if event_thread == thread_id { - if let WireAgentEvent::TurnStarted { - turn, user_input, .. - } = *agent_event - { - return (turn, user_input); - } - } + return (turn, user_input); } } Ok(Some(Ok(_))) => {} @@ -2324,10 +2318,10 @@ async fn wait_for_thread_state( while tokio::time::Instant::now() < deadline { match tokio::time::timeout(tokio::time::Duration::from_secs(1), ws.next()).await { Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text)))) => { - if let Ok(ServerMessage::ThreadState(state)) = serde_json::from_str(&text) { - if state.thread_id == thread_id { - return; - } + if let Ok(ServerMessage::ThreadState(state)) = serde_json::from_str(&text) + && state.thread_id == thread_id + { + return; } } Ok(Some(Ok(_))) => {} @@ -2352,18 +2346,15 @@ async fn wait_for_agent_message_item( thread_id: event_thread, agent_event, }) = serde_json::from_str(&text) + && event_thread == thread_id + && let WireAgentEvent::ItemCompleted { turn, item, .. } = *agent_event + && matches!( + item.payload, + giskard_proto::WireItemPayload::AgentMessage { ref text } + if text == expected_text + ) { - if event_thread == thread_id { - if let WireAgentEvent::ItemCompleted { turn, item, .. } = *agent_event { - if matches!( - item.payload, - giskard_proto::WireItemPayload::AgentMessage { ref text } - if text == expected_text - ) { - return turn; - } - } - } + return turn; } } Ok(Some(Ok(_))) => {} @@ -2388,19 +2379,15 @@ async fn wait_for_command_started( thread_id: event_thread, agent_event, }) = serde_json::from_str(&text) + && event_thread == thread_id + && let WireAgentEvent::ItemStarted { turn, item, .. } = *agent_event + && item.kind == ItemKind::CommandExecution + && matches!( + item.command, + Some(command) if command.command == expected_command + ) { - if event_thread == thread_id { - if let WireAgentEvent::ItemStarted { turn, item, .. } = *agent_event { - if item.kind == ItemKind::CommandExecution - && matches!( - item.command, - Some(command) if command.command == expected_command - ) - { - return turn; - } - } - } + return turn; } } Ok(Some(Ok(_))) => {} @@ -2424,12 +2411,10 @@ async fn wait_for_approval_request( thread_id: event_thread, agent_event, }) = serde_json::from_str(&text) + && event_thread == thread_id + && let WireAgentEvent::ApprovalRequested { request, .. } = *agent_event { - if event_thread == thread_id { - if let WireAgentEvent::ApprovalRequested { request, .. } = *agent_event { - return request.id.to_string(); - } - } + return request.id.to_string(); } } Ok(Some(Ok(_))) => {} @@ -2455,10 +2440,10 @@ async fn wait_for_approval_resolved( request_id: resolved_request, decision, }) = serde_json::from_str(&text) + && resolved_thread == thread_id + && resolved_request == request_id { - if resolved_thread == thread_id && resolved_request == request_id { - return decision; - } + return decision; } } Ok(Some(Ok(_))) => {} @@ -2959,13 +2944,12 @@ async fn compact_context_does_not_block_turns_on_other_threads_or_projects() { thread_id, agent_event, } = server_msg + && matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) { - if matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) { - if thread_id == other_thread { - other_completed = true; - } else if thread_id == other_project_thread { - other_project_completed = true; - } + if thread_id == other_thread { + other_completed = true; + } else if thread_id == other_project_thread { + other_project_completed = true; } } } @@ -6330,11 +6314,11 @@ async fn persisted_thread_can_be_reopened_before_ws_send() { match tokio::time::timeout(tokio::time::Duration::from_secs(5), ws.next()).await { Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(t)))) => { let server_msg: ServerMessage = serde_json::from_str(&t).unwrap(); - if let ServerMessage::Event { agent_event, .. } = server_msg { - if matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) { - saw_completed = true; - break; - } + if let ServerMessage::Event { agent_event, .. } = server_msg + && matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) + { + saw_completed = true; + break; } } Ok(Some(Ok(_))) => {} @@ -6745,12 +6729,11 @@ async fn replayed_persisted_turns_keep_reused_item_ids_separate() { match tokio::time::timeout(tokio::time::Duration::from_secs(5), ws.next()).await { Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(t)))) => { let server_msg: ServerMessage = serde_json::from_str(&t).unwrap(); - if let ServerMessage::Event { agent_event, .. } = server_msg { - if let WireAgentEvent::TurnCompleted { turn, .. } = *agent_event { - if turn == new_turn { - saw_new_turn_complete = true; - } - } + if let ServerMessage::Event { agent_event, .. } = server_msg + && let WireAgentEvent::TurnCompleted { turn, .. } = *agent_event + && turn == new_turn + { + saw_new_turn_complete = true; } } Ok(Some(Ok(_))) => {} diff --git a/crates/giskard-server/tests/interrupt.rs b/crates/giskard-server/tests/interrupt.rs index c39b8e0..47f288c 100644 --- a/crates/giskard-server/tests/interrupt.rs +++ b/crates/giskard-server/tests/interrupt.rs @@ -754,15 +754,14 @@ async fn wait_for_running_command(ws: &mut TestWs) -> RunningTask { continue; }; let server_msg: ServerMessage = serde_json::from_str(&text).unwrap(); - if let ServerMessage::RunningTasks { tasks, .. } = server_msg { - if let Some(cmd) = tasks + if let ServerMessage::RunningTasks { tasks, .. } = server_msg + && let Some(cmd) = tasks .iter() .find(|cmd| cmd.process_id.as_deref() == Some("proc_1")) - { - assert_eq!(cmd.command, "sleep 60"); - if cmd.output == "started" { - return cmd.clone(); - } + { + assert_eq!(cmd.command, "sleep 60"); + if cmd.output == "started" { + return cmd.clone(); } } } @@ -788,10 +787,9 @@ async fn wait_for_empty_running_commands(ws: &mut TestWs) { }; if let ServerMessage::RunningTasks { tasks, .. } = serde_json::from_str::(&text).unwrap() + && tasks.is_empty() { - if tasks.is_empty() { - return; - } + return; } } } @@ -815,13 +813,12 @@ async fn wait_for_terminating_command(ws: &mut TestWs) -> RunningTask { continue; }; let server_msg: ServerMessage = serde_json::from_str(&text).unwrap(); - if let ServerMessage::RunningTasks { tasks, .. } = server_msg { - if let Some(cmd) = tasks + if let ServerMessage::RunningTasks { tasks, .. } = server_msg + && let Some(cmd) = tasks .iter() .find(|cmd| cmd.process_id.as_deref() == Some("proc_1") && cmd.terminating) - { - return cmd.clone(); - } + { + return cmd.clone(); } } } @@ -846,10 +843,10 @@ async fn wait_for_error(ws: &mut TestWs, action: &str, code: &str) -> ErrorInfo }; if let ServerMessage::Error { error } = serde_json::from_str::(&text).unwrap() + && error.action.as_deref() == Some(action) + && error.code == code { - if error.action.as_deref() == Some(action) && error.code == code { - return error; - } + return error; } } } @@ -880,18 +877,17 @@ async fn wait_for_completed_command_after_interrupted_turn(ws: &mut TestWs) { }; match serde_json::from_str::(&text).unwrap() { ServerMessage::Event { agent_event, .. } => { - if let WireAgentEvent::ItemCompleted { item, .. } = *agent_event { - if let giskard_proto::WireItemPayload::CommandExecution { + if let WireAgentEvent::ItemCompleted { item, .. } = *agent_event + && let giskard_proto::WireItemPayload::CommandExecution { status, exit_code, duration_ms, .. } = item.payload - { - saw_completed_command = status.as_deref() == Some("completed") - && exit_code == Some(0) - && duration_ms == Some(60_000); - } + { + saw_completed_command = status.as_deref() == Some("completed") + && exit_code == Some(0) + && duration_ms == Some(60_000); } } ServerMessage::RunningTasks { tasks, .. } => { @@ -921,11 +917,11 @@ async fn wait_for_interrupted_turn(ws: &mut TestWs) { continue; }; let server_msg: ServerMessage = serde_json::from_str(&text).unwrap(); - if let ServerMessage::Event { agent_event, .. } = server_msg { - if let WireAgentEvent::TurnCompleted { status, .. } = *agent_event { - assert_eq!(status.kind, TurnStatusKind::Interrupted); - return; - } + if let ServerMessage::Event { agent_event, .. } = server_msg + && let WireAgentEvent::TurnCompleted { status, .. } = *agent_event + { + assert_eq!(status.kind, TurnStatusKind::Interrupted); + return; } } } diff --git a/crates/giskard-server/tests/running_tasks.rs b/crates/giskard-server/tests/running_tasks.rs index c689f49..f36397a 100644 --- a/crates/giskard-server/tests/running_tasks.rs +++ b/crates/giskard-server/tests/running_tasks.rs @@ -290,14 +290,13 @@ async fn running_tool_call_surfaces_in_running_tasks_snapshot() { }; if let ServerMessage::RunningTasks { tasks, .. } = serde_json::from_str::(&text).unwrap() + && let Some(task) = tasks.iter().find(|t| t.kind == TaskKind::Tool) { - if let Some(task) = tasks.iter().find(|t| t.kind == TaskKind::Tool) { - assert_eq!(task.command, "search"); - assert_eq!(task.server.as_deref(), Some("wiki")); - assert_eq!(task.process_id, None); - assert_eq!(task.started_at_ms, 1_785_000_000_000); - break task.item_id; - } + assert_eq!(task.command, "search"); + assert_eq!(task.server.as_deref(), Some("wiki")); + assert_eq!(task.process_id, None); + assert_eq!(task.started_at_ms, 1_785_000_000_000); + break task.item_id; } }; @@ -323,11 +322,10 @@ async fn running_tool_call_surfaces_in_running_tasks_snapshot() { }; if let ServerMessage::RunningTasks { tasks, .. } = serde_json::from_str::(&text).unwrap() + && tasks.iter().all(|task| task.item_id != tool_item_id) { - if tasks.iter().all(|task| task.item_id != tool_item_id) { - assert!(state.running_commands.snapshot(thread_id).await.is_empty()); - return; - } + assert!(state.running_commands.snapshot(thread_id).await.is_empty()); + return; } } } diff --git a/crates/giskard-server/tests/server_requests.rs b/crates/giskard-server/tests/server_requests.rs index 82f1870..79d8437 100644 --- a/crates/giskard-server/tests/server_requests.rs +++ b/crates/giskard-server/tests/server_requests.rs @@ -515,10 +515,10 @@ async fn wait_for_server_request(ws: &mut TestWs) { while Instant::now() < deadline { match tokio::time::timeout(Duration::from_secs(1), ws.next()).await { Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text)))) => { - if let Ok(ServerMessage::Event { agent_event, .. }) = serde_json::from_str(&text) { - if matches!(*agent_event, WireAgentEvent::ServerRequestReceived { .. }) { - return; - } + if let Ok(ServerMessage::Event { agent_event, .. }) = serde_json::from_str(&text) + && matches!(*agent_event, WireAgentEvent::ServerRequestReceived { .. }) + { + return; } } Ok(Some(Ok(_))) => {} diff --git a/crates/giskard-server/tests/turn_controls.rs b/crates/giskard-server/tests/turn_controls.rs index 15ea68b..22e6a48 100644 --- a/crates/giskard-server/tests/turn_controls.rs +++ b/crates/giskard-server/tests/turn_controls.rs @@ -486,10 +486,10 @@ where { let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5); loop { - if let Ok(Some(tf)) = state.store.load_thread(pid, tid).await { - if pred(&tf) { - return tf; - } + if let Ok(Some(tf)) = state.store.load_thread(pid, tid).await + && pred(&tf) + { + return tf; } if tokio::time::Instant::now() >= deadline { panic!("thread predicate not satisfied in time"); diff --git a/crates/giskard-server/tests/ui.rs b/crates/giskard-server/tests/ui.rs index c4fc8a5..0933f35 100644 --- a/crates/giskard-server/tests/ui.rs +++ b/crates/giskard-server/tests/ui.rs @@ -487,7 +487,7 @@ async fn index_page_is_served_and_public() { && body.contains("linked sub-agent thread") && body.contains("all corresponding Codex threads") && body.contains("This cannot be undone") - && body.contains("deletedIds.has(String(state.threadId))"), + && body.contains("clearThreadView(activeThread)"), "thread deletion warns about and clears recursively deleted sub-agent threads" ); assert!( @@ -591,6 +591,19 @@ async fn index_page_is_served_and_public() { && body.contains("function renderSubagentsButton()"), "sub-agent monitor derives known child threads from project thread summaries" ); + assert!( + body.contains("const rendered = new Set();") + && body.contains("if (rendered.has(id)) return;") + && body.contains("for (const t of threads) appendOne(t);"), + "sidebar keeps rootless (cyclic/malformed) threads visible for repair instead of \ + dropping them" + ); + assert!( + body.contains("deletedIds.has(activeThread) || !knownThreadForId(pid, activeThread)") + && body.contains("String(state.projectId || \"\") === String(pid)"), + "cascade delete clears the active view from the refreshed thread list, scoped to the \ + deleted thread's project so unrelated views survive" + ); assert!( body.contains("function renderSubagentsMenu()") && body.contains("function renderSubagentCard(thread)") diff --git a/tests/e2e/tests/subagents.spec.ts b/tests/e2e/tests/subagents.spec.ts index af6ac5a..a75850e 100644 --- a/tests/e2e/tests/subagents.spec.ts +++ b/tests/e2e/tests/subagents.spec.ts @@ -228,3 +228,107 @@ test.describe("linked sub-agent threads", () => { await expect(runningActivity.getByRole("button", { name: "Open linked thread" })).toBeVisible(); }); }); + +test.describe("cross-project thread deletion", () => { + test.beforeEach(async ({ page }) => { + await login(page); + }); + + // Deleting a thread cascades to descendants the server discovered itself, so the browser clears + // the active transcript when that thread no longer exists in the refreshed list. That decision + // must be scoped to the deleted thread's project: if the user has navigated to a thread in a + // different project, deleting elsewhere must not wipe the unrelated active view. A source-string + // assertion cannot observe this race, so exercise it end to end. + test("deleting a thread in another project keeps the active view", async ({ page }) => { + // Use two freshly created projects instead of the shared "Demo" project, so this test is + // isolated from threads other tests leave behind on the reused replay server. The replay + // server leaves browse roots unrestricted, so projects can be seeded against an existing + // directory instead of driving the folder picker. + const createProject = (name: string): Promise => + page.evaluate(async (projectName) => { + const res = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: projectName, + dir: "/tmp", + default_model: { provider: "replay", model: "replay-model" }, + }), + }); + return (await res.json()).id as string; + }, name); + const deleteProject = (id: string): Promise => + page.evaluate(async (projectId) => { + await fetch(`/api/projects/${projectId}`, { method: "DELETE" }); + }, id); + // Create a persisted thread server-side, avoiding the shared composer and its WebSocket-open + // timing (an unrelated concern that only makes this UI test flaky). + const startThread = (pid: string, text: string): Promise => + page.evaluate( + async ({ pid, text }) => { + const res = await fetch(`/api/projects/${pid}/threads/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text, + model_ref: { provider: "replay", model: "replay-model" }, + mode: "build", + approval_policy: "ask", + }), + }); + return (await res.json()).thread_id as string; + }, + { pid, text }, + ); + + const otherProjectName = "Other project A"; + const activeProjectName = "Active project B"; + const otherProjectId = await createProject(otherProjectName); + const activeProjectId = await createProject(activeProjectName); + + try { + const otherThreadId = await startThread(otherProjectId, "thread in project A"); + const activeThreadId = await startThread(activeProjectId, "thread in project B"); + expect(activeThreadId).not.toBe(otherThreadId); + + // Render both projects and their threads in the sidebar. + await page.evaluate(() => + (window as unknown as { loadProjects: () => Promise }).loadProjects(), + ); + const lastThread = () => + page.evaluate(() => JSON.parse(localStorage.getItem("giskard.lastThread") || "null")); + const otherRow = page.locator(`.thread[data-tid="${otherThreadId}"]`); + const activeRow = page.locator(`.thread[data-tid="${activeThreadId}"]`); + await expect(otherRow).toBeVisible(); + await expect(activeRow).toBeVisible(); + + // Open project B's thread so it is unambiguously the active view. + await activeRow.click(); + await expect + .poll(async () => { + const selection = await lastThread(); + return `${selection?.pid}:${selection?.tid}`; + }) + .toBe(`${activeProjectId}:${activeThreadId}`); + + // Delete project A's thread via its row menu while project B's thread is the active view. + const otherRowContainer = otherRow.locator("xpath=.."); + await otherRowContainer.locator(".thread-menu-btn").click(); + const dialogPromise = page.waitForEvent("dialog"); + const deleteClick = otherRowContainer.locator(".thread-menu .danger").click(); + const dialog = await dialogPromise; + await dialog.accept(); + await deleteClick; + await expect(otherRow).toHaveCount(0); + + // The active view is unaffected: its selection persists (a non-scoped clear would null it + // out), because cascade-delete clearing is scoped to the deleted thread's project. + const afterSelection = await lastThread(); + expect(afterSelection?.pid).toBe(activeProjectId); + expect(afterSelection?.tid).toBe(activeThreadId); + } finally { + await deleteProject(otherProjectId); + await deleteProject(activeProjectId); + } + }); +});