diff --git a/.dockerignore b/.dockerignore index 48ebd32..1628acc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ # Keep the Docker build context small and reproducible. The e2e image (tests/e2e/Dockerfile) builds # from the repository root, so these are excluded from every build: target +/target-* .git **/node_modules tests/e2e/test-results diff --git a/README.md b/README.md index 4c2c041..8b6f801 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,12 @@ Then open **http://127.0.0.1:8787**, log in, and: sent, so choose the **Plan/Build** mode, **approval policy**, and **model** first if needed. 3. Type in the composer (Enter to send). The first send creates the Codex thread with the selected provider/model and starts the turn. Existing threads show the **Tasks** menu for running - commands/tools, **MCP** status menu, and **Context** usage button; scrolling the transcript to - the top lazy-loads older history. + commands/tools, **Sub-agents** monitor, **MCP** status menu, and **Context** usage button; + scrolling the transcript to the top lazy-loads older history. +4. Linked child threads appear in the **Sub-agents** monitor and can be opened from their activity + rows; their header **Parent** button returns to the owning thread. See + [Sub-agent threads](docs/subagents.md) for spawning protocols, monitoring, prompts, direct + follow-ups, ownership, and deletion behavior. The header context value is a context-window indicator, not a billing total. Codex currently exposes the latest turn's input tokens rather than a dedicated context-occupancy field, so Giskard uses that @@ -326,7 +330,9 @@ The browser (and any client) drives everything through a small REST surface plus WebSocket. Highlights: `POST /api/login`, `POST /api/logout`, `GET /api/ws-ticket`, `GET /api/ws`, `GET/POST /api/projects`, `GET/DELETE /api/projects/{id}`, `GET/POST /api/projects/{id}/threads`, `POST /api/projects/{id}/threads/start`, `DELETE -/api/projects/{id}/threads/{thread_id}`, `PATCH /api/projects/{id}/threads/{thread_id}/title`, +/api/projects/{id}/threads/{thread_id}`, `POST +/api/projects/{id}/threads/{parent_thread_id}/subagent-links/{item_id}/open`, `PATCH +/api/projects/{id}/threads/{thread_id}/title`, `POST /api/projects/{id}/threads/{thread_id}/archive`, `GET /api/models`, `POST /api/models/refresh`, `GET /api/projects/{id}/models`, `GET /api/tokens`, `GET /api/projects/{id}/tokens`, @@ -340,6 +346,13 @@ WebSocket. Highlights: `POST /api/login`, `POST /api/logout`, `GET /api/ws-ticke persists a deterministic title generated from that prompt, and returns the title with the new thread and turn identifiers. +`POST /api/projects/{id}/threads` opens an existing local thread when `thread_id` is provided, or +imports/resumes a native harness thread when `resume` is provided. Linked transcript items use the +dedicated parent/item endpoint above; the server resolves native routing, ownership, provenance, +prompt, and lifecycle evidence from its authoritative item rather than accepting those fields from +the browser. Thread summaries and browser-facing sub-agent payloads omit native harness thread IDs. +See [Sub-agent threads](docs/subagents.md) for the full contract. + If you open a thread whose agent can no longer be started — most often because its **provider was removed from config** (e.g. you swapped one proxy provider id for another) — the thread still opens **read-only**: its history loads, a persistent banner above the composer names diff --git a/crates/giskard-core/src/item.rs b/crates/giskard-core/src/item.rs index e8ec466..cb6a9d3 100644 --- a/crates/giskard-core/src/item.rs +++ b/crates/giskard-core/src/item.rs @@ -90,9 +90,51 @@ pub struct ToolCallStart { #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subagent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub started_at_ms: Option, } +/// Harness-neutral link from a transcript item to a child sub-agent thread. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SubagentLink { + /// Harness-native thread id to resume/import as a Giskard child thread. + pub harness_thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Initial task prompt used to start the child thread, when the harness exposes it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_prompt: Option, + pub action: SubagentAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubagentAction { + Spawned, + Started, + Interacted, + Interrupted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubagentStatus { + Pending, + Running, + Completed, + Interrupted, + Failed, + Shutdown, + NotFound, +} + /// The finalized item persisted in thread history and sent on `ItemCompleted`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Item { @@ -149,6 +191,10 @@ pub enum ItemPayload { #[serde(default, skip_serializing_if = "Option::is_none")] status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, }, Activity { @@ -157,6 +203,8 @@ pub enum ItemPayload { detail: Option, #[serde(default, skip_serializing_if = "Option::is_none")] metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent: Option, }, } @@ -237,6 +285,8 @@ mod tests { input: serde_json::json!({ "jql": "project = ERE" }), server: Some("cf-tools".into()), status: Some("in_progress".into()), + metadata: None, + subagent: None, started_at_ms: Some(1_700_000_000_000), }), }; diff --git a/crates/giskard-core/src/lib.rs b/crates/giskard-core/src/lib.rs index eb75c69..3aa8e56 100644 --- a/crates/giskard-core/src/lib.rs +++ b/crates/giskard-core/src/lib.rs @@ -12,6 +12,8 @@ pub mod item; pub mod mcp; pub mod model; pub mod server_request; +pub mod text; +pub mod thread; pub mod token; pub mod turn; pub mod user_input; @@ -21,13 +23,17 @@ pub use diff::{DiffHunk, DiffLine, FileDiff}; pub use error::{GiskardError, HarnessError, PersistError}; pub use event::AgentEvent; pub use ids::{ApprovalId, ItemId, ProjectId, ServerRequestId, ThreadId, TurnId}; -pub use item::{FileChangeKind, Item, ItemDelta, ItemKind, ItemPayload, ItemStart}; +pub use item::{ + FileChangeKind, Item, ItemDelta, ItemKind, ItemPayload, ItemStart, SubagentAction, + SubagentLink, SubagentStatus, +}; pub use mcp::{ McpAuthStatus, McpOauthStart, McpResource, McpResourceTemplate, McpServerInfo, McpServerStatus, McpTool, }; pub use model::{Effort, ModelDescriptor, ModelRef}; pub use server_request::{ServerRequest, ServerRequestResponse}; +pub use thread::ThreadKind; pub use token::{ByModel, DailyTokenLedger, TokenLedger, TokenUsage}; pub use turn::{ApprovalPolicy, Mode, Turn, TurnOverrides, TurnStatus, TurnStatusKind}; pub use user_input::UserInput; diff --git a/crates/giskard-core/src/text.rs b/crates/giskard-core/src/text.rs new file mode 100644 index 0000000..042230b --- /dev/null +++ b/crates/giskard-core/src/text.rs @@ -0,0 +1,16 @@ +/// Trim surrounding whitespace and reject an empty result. +pub fn trimmed_non_empty(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +#[cfg(test)] +mod tests { + use super::trimmed_non_empty; + + #[test] + fn trims_and_rejects_empty_text() { + assert_eq!(trimmed_non_empty(" value "), Some("value")); + assert_eq!(trimmed_non_empty(" \n\t "), None); + } +} diff --git a/crates/giskard-core/src/thread.rs b/crates/giskard-core/src/thread.rs new file mode 100644 index 0000000..60db98d --- /dev/null +++ b/crates/giskard-core/src/thread.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// Durable thread origin/type metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadKind { + #[default] + Primary, + Subagent, +} diff --git a/crates/giskard-harness-codex/README.md b/crates/giskard-harness-codex/README.md index cbe39f0..bfe01fd 100644 --- a/crates/giskard-harness-codex/README.md +++ b/crates/giskard-harness-codex/README.md @@ -92,6 +92,53 @@ Some Codex notifications carry an item ID without producing a visible Giskard item. The mapper may seed the scoped item registry from those notifications so that later deltas and completion still resolve to the same `ItemId`. +## Sub-agent links + +Codex collaboration items are mapped into harness-neutral `SubagentLink` values before they leave +the adapter. Both native spawning protocols are supported: + +- legacy `multi_agent_v1` is exposed by the app server as a `collabAgentToolCall` whose tool is + `spawnAgent`; its start event has no receiver, so the adapter links the child on completion and + preserves the supplied prompt as `initial_prompt`. `agentsStates` is keyed by native thread id; + the adapter reads only the linked receiver's state. Single-child `sendInput`, `wait`, + `resumeAgent`, and `closeAgent` calls also carry lifecycle links, while a multi-child `wait` + remains unlinked rather than attributing aggregate state to one child; and +- current collaboration v2 is exposed as a completed `subAgentActivity` with `kind = started`; the + adapter preserves its child thread id and agent path. Its activity title uses the final non-empty + path component as the task name and does not expose the native child id; the complete path and id + remain in link metadata. This event does not contain the delegated prompt, so the server uses its + explicit `Sub-agent turn` fallback rather than misidentifying an inherited parent turn as the + task. + +The server imports the child from either representation and passively monitors only lifecycle +evidence that can denote active work (`spawned`, `started`, `interacted`, `pending`, or `running`). +An explicitly active monitor has a 10-minute no-event pre-turn safety bound; any event restarts it, +and a started turn may run without that bound. Terminal evidence wakes an already-armed idle monitor +and never creates a new one; reopening a persisted child without lifecycle evidence does not monitor +it. The browser addresses links by Giskard parent-thread and item IDs; the server resolves native +routing and lifecycle metadata from its authoritative item, and native thread IDs are redacted from +browser-facing sub-agent payloads. Linked children use strict native resume: Codex can advertise a +newly spawned child milliseconds before its rollout is readable, so the adapter retries only the exact matching +`no rollout found` response for a short bounded window. It never applies the normal fresh-thread +fallback to a linked child, because that would replace the advertised routing identity and miss the +child's early commentary and command-start events. Primary threads retain the existing fresh-session +recovery when their stored native rollout is genuinely gone. Idle child threads accept direct user +follow-ups, while sends are rejected during delegated work. See +[Sub-agent threads](../../docs/subagents.md) for the complete lifecycle and ownership contract. When +opening or resuming a Codex thread, the adapter also maps +`thread.agent_nickname` to +`ThreadHandle.agent_name`; Giskard uses that harness-neutral name to title imported sub-agent +threads and their Sub-agents card entries. It maps `thread.parent_thread_id` to +`ThreadHandle.parent_harness_thread_id` as a validation signal: the server accepts a proposed +Giskard parent only when it agrees with this native parent when Codex supplies one. Reverse +child-to-parent activity therefore remains transcript navigation and cannot reparent the real +parent thread. + +Codex thread deletion is idempotent only for the exact JSON-RPC `-32600` response `no rollout found +for thread id `. That response proves the requested native rollout is already absent, +so the adapter returns success and lets Giskard remove stale local metadata. A different native ID, +JSON-RPC code, timeout, authentication failure, or any other transport error remains an error. + ## Command item ID versus process ID A Codex command execution item can contain both: diff --git a/crates/giskard-harness-codex/src/lib.rs b/crates/giskard-harness-codex/src/lib.rs index 638fb40..09148ab 100644 --- a/crates/giskard-harness-codex/src/lib.rs +++ b/crates/giskard-harness-codex/src/lib.rs @@ -31,18 +31,20 @@ use giskard_core::mcp::{ }; use giskard_core::model::ModelDescriptor; use giskard_core::server_request::ServerRequestResponse; +use giskard_core::text::trimmed_non_empty; use giskard_core::token::TokenUsage; use giskard_core::turn::{TurnOverrides, TurnStatus, TurnStatusKind}; use giskard_core::user_input::UserInput; use giskard_harness::{ AgentEventStream, AgentHarness, HarnessCapabilities, HarnessNotice, OpenThreadOptions, - ThreadHandle, + ResumePolicy, ThreadHandle, }; use mapping::CodexMapper; const BROADCAST_CAPACITY: usize = 256; const TURN_FIRST_EVENT_WARN_AFTER: Duration = Duration::from_secs(15); +const STRICT_RESUME_RETRY_DELAYS_MS: [u64; 7] = [10, 20, 40, 80, 160, 320, 500]; #[cfg(not(test))] const CODEX_JSON_RPC_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(test)] @@ -1166,7 +1168,10 @@ fn should_poll_codex_messages( active_turns: &ActiveTurns, pending_compactions: &HashMap, ) -> bool { - !active_turns.is_empty() || mapper.has_running_commands() || !pending_compactions.is_empty() + !active_turns.is_empty() + || mapper.has_active_turns() + || mapper.has_running_commands() + || !pending_compactions.is_empty() } fn fallback_thread(mapper: &CodexMapper, active_turns: &ActiveTurns) -> ThreadId { @@ -1651,12 +1656,24 @@ async fn handle_open_thread( // warn the caller that agent context was lost while keeping the Giskard-side history. let mut resume_warning = None; - let (harness_thread_id, resumed_model) = if let Some(ref resume_id) = opts.resume { + let opened = if let Some(ref resume_id) = opts.resume { let context = CodexOperationContext::for_project("thread_resume", opts.project) .with_thread_id(thread_id) .with_harness_thread_id(resume_id); - match resume_thread(client, context, resume_id, &cwd, &opts.initial_model).await { + match resume_thread_with_policy( + client, + context, + resume_id, + &cwd, + &opts.initial_model, + opts.resume_policy, + ) + .await + { Ok(opened) => opened, + Err(error) if opts.resume_policy == ResumePolicy::RequireExisting => { + return Err(error); + } Err(e) => { // C5: Codex thread store purged/rotated. Start fresh instead of hard-failing. resume_warning = Some(HarnessNotice { @@ -1681,14 +1698,14 @@ async fn handle_open_thread( }; // B4: bind the (possibly re-established) native id to the durable ThreadId. - mapper.register_thread(harness_thread_id.clone(), thread_id); + mapper.register_thread(opened.harness_thread_id.clone(), thread_id); let (tx, _) = broadcast::channel(BROADCAST_CAPACITY); ensure_thread_sender(senders, thread_id, tx); let _ = broadcast_event(senders, thread_id, || AgentEvent::ThreadOpened { thread: thread_id, - harness_thread_id: harness_thread_id.clone(), + harness_thread_id: opened.harness_thread_id.clone(), }) .await; @@ -1704,12 +1721,69 @@ async fn handle_open_thread( Ok(ThreadHandle { thread: thread_id, - harness_thread_id, + harness_thread_id: opened.harness_thread_id, warning: resume_warning, - resumed_model, + resumed_model: opened.model, + agent_name: opened.agent_name, + parent_harness_thread_id: opened.parent_harness_thread_id, }) } +struct OpenedNativeThread { + harness_thread_id: String, + model: Option, + agent_name: Option, + parent_harness_thread_id: Option, +} + +async fn resume_thread_with_policy( + client: &mut dyn CodexTransport, + context: CodexOperationContext<'_>, + resume_id: &str, + cwd: &str, + model: &giskard_core::model::ModelRef, + policy: ResumePolicy, +) -> Result { + let mut retry = 0usize; + loop { + match resume_thread(client, context, resume_id, cwd, model).await { + Ok(opened) => { + if policy == ResumePolicy::RequireExisting && opened.harness_thread_id != resume_id + { + return Err(HarnessError::Protocol(format!( + "strict resume returned native thread {} instead of {resume_id}", + opened.harness_thread_id + ))); + } + if retry > 0 { + info!( + harness_thread_id = %resume_id, + attempts = retry + 1, + "resumed newly materialized native thread after retry" + ); + } + return Ok(opened); + } + Err(error) + if policy == ResumePolicy::RequireExisting + && codex_reports_missing_rollout(&error, resume_id) + && retry < STRICT_RESUME_RETRY_DELAYS_MS.len() => + { + let delay_ms = STRICT_RESUME_RETRY_DELAYS_MS[retry]; + debug!( + harness_thread_id = %resume_id, + attempt = retry + 1, + delay_ms, + "native thread was advertised before its rollout was materialized; retrying strict resume" + ); + retry += 1; + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } + Err(error) => return Err(error), + } + } +} + /// The model/provider a `thread/start` / `thread/resume` response reports as effective. Codex can /// intentionally ignore resume overrides for an already-loaded thread while still answering /// success, so callers switching providers must compare this against what they requested (see @@ -1736,7 +1810,7 @@ async fn resume_thread( resume_id: &str, cwd: &str, model: &giskard_core::model::ModelRef, -) -> Result<(String, Option), HarnessError> { +) -> Result { let params: codex_codes::ThreadResumeParams = serde_json::from_value(serde_json::json!({ "threadId": resume_id, "cwd": cwd, @@ -1752,7 +1826,22 @@ async fn resume_thread( ) .await?; let resumed = effective_model(&resp.model, &resp.model_provider, model); - Ok((resp.thread.id, resumed)) + Ok(OpenedNativeThread { + harness_thread_id: resp.thread.id, + model: resumed, + agent_name: resp + .thread + .agent_nickname + .as_deref() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned), + parent_harness_thread_id: resp + .thread + .parent_thread_id + .as_deref() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned), + }) } async fn start_thread( @@ -1760,7 +1849,7 @@ async fn start_thread( context: CodexOperationContext<'_>, cwd: &str, initial_model: &giskard_core::model::ModelRef, -) -> Result<(String, Option), HarnessError> { +) -> Result { let params: codex_codes::ThreadStartParams = serde_json::from_value(serde_json::json!({ "cwd": cwd, "model": initial_model.model, @@ -1775,7 +1864,22 @@ async fn start_thread( ) .await?; let started = effective_model(&resp.model, &resp.model_provider, initial_model); - Ok((resp.thread.id, started)) + Ok(OpenedNativeThread { + harness_thread_id: resp.thread.id, + model: started, + agent_name: resp + .thread + .agent_nickname + .as_deref() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned), + parent_harness_thread_id: resp + .thread + .parent_thread_id + .as_deref() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned), + }) } async fn handle_start_turn( @@ -2522,14 +2626,40 @@ async fn handle_delete_thread( let params = codex_codes::ThreadDeleteParams { thread_id: thread.harness_thread_id.clone(), }; - let _: codex_codes::ThreadDeleteResponse = codex_request( + let result: Result = codex_request( client, CodexOperationContext::for_thread("delete_thread", thread), codex_codes::protocol::methods::THREAD_DELETE, ¶ms, ) - .await?; - Ok(()) + .await; + match result { + Ok(_) => Ok(()), + Err(error) if codex_reports_missing_rollout(&error, &thread.harness_thread_id) => { + warn!( + thread_id = %thread.thread, + harness_thread_id = %thread.harness_thread_id, + action = "delete_thread", + "native Codex rollout is already absent; completing thread deletion idempotently" + ); + Ok(()) + } + Err(error) => Err(error), + } +} + +fn codex_reports_missing_rollout(error: &HarnessError, harness_thread_id: &str) -> bool { + // This is intentionally coupled to the pinned Codex/codex-codes protocol chain: Codex emits + // `InvalidRequest("no rollout found for thread id …")`, app-server preserves it as JSON-RPC + // -32600, and codex-codes formats that response with this prefix. Keep the match fail-closed if + // any layer changes; a different "thread not found" error must remain visible to the caller. + const PREFIX: &str = "JSON-RPC error (-32600): no rollout found for thread id "; + let HarnessError::Transport(message) = error else { + return false; + }; + message + .strip_prefix(PREFIX) + .is_some_and(|missing_id| missing_id == harness_thread_id) } async fn handle_interrupt_turn( @@ -2571,6 +2701,8 @@ mod tests { harness_thread_id: "native-thread".into(), warning: None, resumed_model: None, + agent_name: None, + parent_harness_thread_id: None, } } @@ -2622,6 +2754,8 @@ mod tests { hang_methods: HashSet, background_terminal_terminate_result: Option, command_exec_terminate_error: Option, + thread_delete_error: Option, + thread_resume_missing_rollout_failures: usize, model_list_error: Option, hang_response_json: bool, hang_shutdown: bool, @@ -2687,6 +2821,17 @@ mod tests { self.state.lock().await.command_exec_terminate_error = Some(message.into()); } + async fn fail_thread_delete(&self, message: &str) { + self.state.lock().await.thread_delete_error = Some(message.into()); + } + + async fn fail_thread_resume_missing_rollout(&self, failures: usize) { + self.state + .lock() + .await + .thread_resume_missing_rollout_failures = failures; + } + async fn fail_model_list(&self, message: &str) { self.state.lock().await.model_list_error = Some(message.into()); } @@ -2744,11 +2889,19 @@ mod tests { .as_str() .filter(|id| !id.is_empty()) .unwrap_or("native-resumed"); - Ok(thread_open_response( - native_thread_id, - params["model"].as_str().unwrap_or("gpt-5.5"), - params["modelProvider"].as_str().unwrap_or("openai"), - )) + if state.thread_resume_missing_rollout_failures > 0 { + state.thread_resume_missing_rollout_failures -= 1; + Err(HarnessError::Transport(format!( + "JSON-RPC error (-32600): no rollout found for thread id \ + {native_thread_id}" + ))) + } else { + Ok(thread_open_response( + native_thread_id, + params["model"].as_str().unwrap_or("gpt-5.5"), + params["modelProvider"].as_str().unwrap_or("openai"), + )) + } } codex_codes::protocol::methods::TURN_START => { state.turn_counter += 1; @@ -2771,8 +2924,14 @@ mod tests { | codex_codes::protocol::methods::THREAD_UNARCHIVE | codex_codes::protocol::methods::THREAD_NAME_SET | codex_codes::protocol::methods::CONFIG_MCPSERVER_RELOAD - | codex_codes::protocol::methods::THREAD_DELETE | codex_codes::protocol::methods::TURN_INTERRUPT => Ok(json!({})), + codex_codes::protocol::methods::THREAD_DELETE => { + if let Some(message) = state.thread_delete_error.clone() { + Err(HarnessError::Transport(message)) + } else { + Ok(json!({})) + } + } THREAD_BACKGROUND_TERMINALS_TERMINATE => { let terminated = state.background_terminal_terminate_result.unwrap_or(true); Ok(json!({ "terminated": terminated })) @@ -2894,6 +3053,7 @@ mod tests { } fn thread_open_response(native_thread_id: &str, model: &str, provider: &str) -> Value { + let parent_thread_id = (native_thread_id == "native-existing").then_some("native-parent"); json!({ "approvalPolicy": "never", "approvalsReviewer": null, @@ -2902,7 +3062,8 @@ mod tests { "modelProvider": provider, "sandbox": {}, "thread": { - "id": native_thread_id + "id": native_thread_id, + "parentThreadId": parent_thread_id } }) } @@ -2913,6 +3074,7 @@ mod tests { thread, workspace_root: PathBuf::from("/tmp"), resume: resume.map(str::to_owned), + resume_policy: ResumePolicy::AllowFreshFallback, initial_model: test_model(None), } } @@ -3002,6 +3164,7 @@ mod tests { title: "Context compacted".into(), detail: None, metadata: None, + subagent: None, }, created_at: Utc::now(), }, @@ -3088,6 +3251,8 @@ mod tests { harness_thread_id: "native-thread-2".into(), warning: None, resumed_model: None, + agent_name: None, + parent_harness_thread_id: None, }; let first_turn = TurnId::new(); let second_turn = TurnId::new(); @@ -3196,12 +3361,107 @@ mod tests { assert_eq!(resumed.thread, resumed_thread); assert_eq!(resumed.harness_thread_id, "native-existing"); + assert_eq!( + resumed.parent_harness_thread_id.as_deref(), + Some("native-parent") + ); assert!(controller.requests().await.iter().any(|req| { req.method == codex_codes::protocol::methods::THREAD_RESUME && req.params["threadId"] == "native-existing" })); } + #[tokio::test] + async fn strict_resume_retries_materialization_without_starting_a_replacement() { + let (harness, controller) = spawn_fake_harness(); + controller.fail_thread_resume_missing_rollout(1).await; + let mut opts = open_opts(None, Some("native-emerging-child")); + opts.resume_policy = ResumePolicy::RequireExisting; + + let opened = harness.open_thread(opts).await.unwrap(); + + assert_eq!(opened.harness_thread_id, "native-emerging-child"); + let requests = controller.requests().await; + assert_eq!( + requests + .iter() + .filter(|request| { + request.method == codex_codes::protocol::methods::THREAD_RESUME + }) + .count(), + 2 + ); + assert!( + !requests + .iter() + .any(|request| { request.method == codex_codes::protocol::methods::THREAD_START }) + ); + } + + #[tokio::test] + async fn strict_resume_exhaustion_never_starts_a_replacement() { + let (harness, controller) = spawn_fake_harness(); + controller + .fail_thread_resume_missing_rollout(STRICT_RESUME_RETRY_DELAYS_MS.len() + 1) + .await; + let mut opts = open_opts(None, Some("native-unmaterialized-child")); + opts.resume_policy = ResumePolicy::RequireExisting; + + let error = harness.open_thread(opts).await.unwrap_err(); + + assert!(codex_reports_missing_rollout( + &error, + "native-unmaterialized-child" + )); + let requests = controller.requests().await; + assert_eq!( + requests + .iter() + .filter(|request| { + request.method == codex_codes::protocol::methods::THREAD_RESUME + }) + .count(), + STRICT_RESUME_RETRY_DELAYS_MS.len() + 1 + ); + assert!( + !requests + .iter() + .any(|request| { request.method == codex_codes::protocol::methods::THREAD_START }) + ); + } + + #[tokio::test] + async fn normal_resume_keeps_fresh_thread_recovery_after_missing_rollout() { + let (harness, controller) = spawn_fake_harness(); + controller.fail_thread_resume_missing_rollout(1).await; + + let opened = harness + .open_thread(open_opts(None, Some("native-missing"))) + .await + .unwrap(); + + assert_eq!(opened.harness_thread_id, "native-thread-1"); + assert_eq!( + opened.warning.as_ref().map(|warning| warning.code.as_str()), + Some("codex_resume_failed") + ); + let requests = controller.requests().await; + assert_eq!( + requests + .iter() + .filter(|request| request.method == codex_codes::protocol::methods::THREAD_RESUME) + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter(|request| request.method == codex_codes::protocol::methods::THREAD_START) + .count(), + 1 + ); + } + #[tokio::test] async fn codex_worker_starts_other_thread_turn_while_first_turn_is_active() { let (harness, controller) = spawn_fake_harness(); @@ -3471,6 +3731,48 @@ mod tests { } } + #[tokio::test] + async fn codex_delete_is_idempotent_when_matching_rollout_is_missing() { + let (harness, controller) = spawn_fake_harness(); + let thread = harness.open_thread(open_opts(None, None)).await.unwrap(); + controller + .fail_thread_delete(&format!( + "JSON-RPC error (-32600): no rollout found for thread id {}", + thread.harness_thread_id + )) + .await; + + timeout(Duration::from_secs(1), harness.delete_thread(&thread)) + .await + .expect("delete_thread should complete") + .expect("an already-absent matching rollout should be idempotent success"); + + assert!(controller.requests().await.iter().any(|request| { + request.method == codex_codes::protocol::methods::THREAD_DELETE + && request.params["threadId"] == thread.harness_thread_id + })); + } + + #[tokio::test] + async fn codex_delete_preserves_nonmatching_transport_failure() { + let (harness, controller) = spawn_fake_harness(); + let thread = harness.open_thread(open_opts(None, None)).await.unwrap(); + controller + .fail_thread_delete( + "JSON-RPC error (-32600): no rollout found for thread id different-thread", + ) + .await; + + let error = timeout(Duration::from_secs(1), harness.delete_thread(&thread)) + .await + .expect("delete_thread should complete") + .expect_err("a nonmatching missing-rollout error must remain fatal"); + assert!(matches!( + error, + HarnessError::Transport(message) if message.ends_with("different-thread") + )); + } + #[tokio::test] async fn codex_worker_surfaces_process_terminate_failure_without_interrupting_turn() { let (harness, controller) = spawn_fake_harness(); diff --git a/crates/giskard-harness-codex/src/mapping.rs b/crates/giskard-harness-codex/src/mapping.rs index 0492585..ce6ccb7 100644 --- a/crates/giskard-harness-codex/src/mapping.rs +++ b/crates/giskard-harness-codex/src/mapping.rs @@ -14,10 +14,12 @@ use giskard_core::event::AgentEvent; use giskard_core::ids::{ApprovalId, ItemId, ServerRequestId, ThreadId, TurnId}; use giskard_core::item::{ CommandExecutionStart, FileChangeEntry, FileChangeKind, Item, ItemDelta, ItemKind, ItemPayload, - ItemStart, ToolCallStart, command_status_is_running, normalized_command_status, + ItemStart, SubagentAction, SubagentLink, SubagentStatus, ToolCallStart, + command_status_is_running, normalized_command_status, }; use giskard_core::model::ModelRef; use giskard_core::server_request::ServerRequest as GiskardServerRequest; +use giskard_core::text::trimmed_non_empty; use giskard_core::token::TokenUsage; use giskard_core::turn::{ApprovalPolicy, Mode, TurnStatus, TurnStatusKind}; @@ -107,6 +109,10 @@ impl CodexMapper { !self.running_commands.is_empty() } + pub fn has_active_turns(&self) -> bool { + !self.active_turns.is_empty() + } + pub fn running_command_fallback_thread(&self) -> Option { self.running_commands .iter() @@ -561,6 +567,7 @@ impl CodexMapper { title: "Context compacted".into(), detail: None, metadata: serde_json::to_value(n).ok(), + subagent: None, }, created_at: Utc::now(), }, @@ -734,6 +741,7 @@ impl CodexMapper { title: title.into(), detail: (!detail.trim().is_empty()).then_some(detail), metadata: serde_json::to_value(metadata).ok(), + subagent: None, }, created_at: Utc::now(), }, @@ -1861,8 +1869,7 @@ fn mcp_elicitation_meta( fn string_field<'a>(map: &'a serde_json::Map, key: &str) -> Option<&'a str> { map.get(key) .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) + .and_then(trimmed_non_empty) } fn to_json_value(value: &T) -> Value { @@ -2213,6 +2220,8 @@ fn map_thread_item_start( input: arguments.clone(), server: Some(server.clone()), status: Some(tool_status_string(status)), + metadata: None, + subagent: None, started_at_ms, }), codex_codes::ThreadItem::DynamicToolCall { @@ -2226,6 +2235,8 @@ fn map_thread_item_start( input: arguments.clone(), server: namespace.clone(), status: Some(tool_status_string(status)), + metadata: None, + subagent: None, started_at_ms, }), codex_codes::ThreadItem::CollabAgentToolCall { @@ -2233,6 +2244,8 @@ fn map_thread_item_start( status, prompt, model, + agents_states, + receiver_thread_ids, .. } => Some(ToolCallStart { name: json_display(tool), @@ -2242,6 +2255,13 @@ fn map_thread_item_start( }), server: Some("collab-agent".into()), status: Some(json_display(status)), + metadata: None, + subagent: collab_agent_link( + tool, + agents_states, + receiver_thread_ids, + prompt.as_deref(), + ), started_at_ms, }), _ => None, @@ -2334,6 +2354,8 @@ fn map_thread_item_complete( output: result.as_ref().and_then(json_value), server: Some(server.clone()), status: Some(enum_string(status)), + metadata: None, + subagent: None, error: error.as_ref().map(|e| e.message.clone()), }, codex_codes::ThreadItem::DynamicToolCall { @@ -2353,6 +2375,8 @@ fn map_thread_item_complete( .or_else(|| success.map(|s| json!({ "success": s }))), server: namespace.clone(), status: Some(enum_string(status)), + metadata: None, + subagent: None, error: None, }, codex_codes::ThreadItem::CollabAgentToolCall { @@ -2360,6 +2384,8 @@ fn map_thread_item_complete( status, prompt, model, + agents_states, + receiver_thread_ids, .. } => ItemPayload::ToolCall { name: json_display(tool), @@ -2370,6 +2396,13 @@ fn map_thread_item_complete( output: Some(status.clone()), server: Some("collab-agent".into()), status: Some(json_display(status)), + metadata: None, + subagent: collab_agent_link( + tool, + agents_states, + receiver_thread_ids, + prompt.as_deref(), + ), error: None, }, codex_codes::ThreadItem::HookPrompt { fragments, .. } => ItemPayload::Activity { @@ -2382,6 +2415,7 @@ fn map_thread_item_complete( .join("\n"), ), metadata: json_value(fragments), + subagent: None, }, codex_codes::ThreadItem::SubAgentActivity { agent_path, @@ -2389,24 +2423,35 @@ fn map_thread_item_complete( kind, .. } => ItemPayload::Activity { - title: format!("Sub-agent {}", enum_string(kind)), - detail: Some(format!("{agent_path} ({agent_thread_id})")), - metadata: json_value(item), + title: subagent_activity_title(agent_path, kind), + detail: None, + metadata: None, + subagent: Some(SubagentLink { + harness_thread_id: agent_thread_id.clone(), + path: Some(agent_path.clone()), + initial_prompt: None, + action: subagent_activity_action(kind), + status: None, + message: None, + }), }, codex_codes::ThreadItem::WebSearch { query, action, .. } => ItemPayload::Activity { title: "Web search".into(), detail: Some(query.clone()), metadata: action.as_ref().and_then(json_value), + subagent: None, }, codex_codes::ThreadItem::ImageView { path, .. } => ItemPayload::Activity { title: "Image viewed".into(), detail: Some(path.0.clone()), metadata: json_value(item), + subagent: None, }, codex_codes::ThreadItem::Sleep { duration_ms, .. } => ItemPayload::Activity { title: "Sleep".into(), detail: Some(format!("{duration_ms} ms")), metadata: json_value(item), + subagent: None, }, codex_codes::ThreadItem::ImageGeneration { status, @@ -2429,21 +2474,25 @@ fn map_thread_item_complete( .join("\n"), ), metadata: json_value(item), + subagent: None, }, codex_codes::ThreadItem::EnteredReviewMode { review, .. } => ItemPayload::Activity { title: "Entered review mode".into(), detail: Some(review.clone()), metadata: None, + subagent: None, }, codex_codes::ThreadItem::ExitedReviewMode { review, .. } => ItemPayload::Activity { title: "Exited review mode".into(), detail: Some(review.clone()), metadata: None, + subagent: None, }, codex_codes::ThreadItem::ContextCompaction { .. } => ItemPayload::Activity { title: "Context compacted".into(), detail: None, metadata: json_value(item), + subagent: None, }, }; @@ -2469,6 +2518,106 @@ fn json_display(value: &Value) -> String { } } +fn collab_agent_link( + tool: &Value, + agents_states: &impl Serialize, + receiver_thread_ids: &[String], + prompt: Option<&str>, +) -> Option { + let tool = json_display(tool); + let mut receivers = receiver_thread_ids + .iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty()); + let harness_thread_id = receivers.next()?.to_owned(); + // `wait` may target multiple children, while the neutral item schema deliberately carries one + // link. Do not attribute aggregate status or fallback output to an arbitrary receiver. + if tool == "wait" && receivers.next().is_some() { + return None; + } + let action = match tool.as_str() { + "spawnAgent" => SubagentAction::Spawned, + "sendInput" | "wait" => SubagentAction::Interacted, + "resumeAgent" => SubagentAction::Started, + "closeAgent" => SubagentAction::Interrupted, + _ => return None, + }; + let state = serde_json::to_value(agents_states) + .ok() + .and_then(|value| subagent_link_state_from_json(&value, &harness_thread_id)); + let (status, message) = state + .map(|state| (state.status, state.message)) + .unwrap_or_default(); + Some(SubagentLink { + harness_thread_id, + path: None, + initial_prompt: (tool == "spawnAgent") + .then(|| prompt.and_then(trimmed_non_empty).map(ToOwned::to_owned)) + .flatten(), + action, + status, + message, + }) +} + +struct SubagentLinkState { + status: Option, + message: Option, +} + +fn subagent_link_state_from_json( + value: &Value, + harness_thread_id: &str, +) -> Option { + // Codex keys `agentsStates` by native thread id, not by nickname or agent path. Looking up the + // linked receiver is essential when a `wait` result contains states for multiple children. + let state = value.as_object()?.get(harness_thread_id)?; + let status = state + .get("status") + .and_then(Value::as_str) + .and_then(codex_subagent_status); + let message = state + .get("message") + .and_then(Value::as_str) + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned); + Some(SubagentLinkState { status, message }) +} + +fn codex_subagent_status(status: &str) -> Option { + match status { + "pendingInit" => Some(SubagentStatus::Pending), + "running" => Some(SubagentStatus::Running), + "completed" => Some(SubagentStatus::Completed), + "interrupted" => Some(SubagentStatus::Interrupted), + "errored" => Some(SubagentStatus::Failed), + "shutdown" => Some(SubagentStatus::Shutdown), + "notFound" => Some(SubagentStatus::NotFound), + _ => None, + } +} + +fn subagent_activity_action(kind: &codex_codes::SubAgentActivityKind) -> SubagentAction { + match kind { + codex_codes::SubAgentActivityKind::Started => SubagentAction::Started, + codex_codes::SubAgentActivityKind::Interacted => SubagentAction::Interacted, + codex_codes::SubAgentActivityKind::Interrupted => SubagentAction::Interrupted, + } +} + +fn subagent_activity_title(agent_path: &str, kind: &codex_codes::SubAgentActivityKind) -> String { + let action = enum_string(kind); + let task_name = agent_path + .rsplit('/') + .map(str::trim) + .find(|segment| !segment.is_empty()); + + match task_name { + Some(task_name) => format!("Sub-agent {task_name} {action}"), + None => format!("Sub-agent {action}"), + } +} + fn enum_string(value: &T) -> String { json_value(value) .map(|v| match v { @@ -4552,6 +4701,292 @@ mod tests { } } + #[test] + fn collab_agent_spawn_maps_to_subagent_link() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "collabAgentToolCall", + "id": "collab1", + "tool": "spawnAgent", + "status": "completed", + "prompt": "Investigate the issue", + "model": "gpt-5.6-terra", + "senderThreadId": "parent-native", + "receiverThreadIds": ["child-native"], + "agentsStates": { + "child-native": { "status": "completed", "message": "Done" } + } + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::ToolCall { + name, + server, + metadata, + subagent, + .. + } => { + assert_eq!(name, "spawnAgent"); + assert_eq!(server.as_deref(), Some("collab-agent")); + assert_eq!(metadata, None); + let subagent = subagent.expect("spawn link is preserved"); + assert_eq!(subagent.harness_thread_id, "child-native"); + assert_eq!(subagent.path, None); + assert_eq!( + subagent.initial_prompt.as_deref(), + Some("Investigate the issue") + ); + assert_eq!(subagent.action, SubagentAction::Spawned); + assert_eq!(subagent.status, Some(SubagentStatus::Completed)); + assert_eq!(subagent.message.as_deref(), Some("Done")); + } + other => panic!("expected tool call, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + + #[test] + fn legacy_collab_agent_spawn_start_without_receiver_has_no_link() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = started_item(serde_json::json!({ + "type": "collabAgentToolCall", + "id": "collab-start", + "tool": "spawnAgent", + "status": "inProgress", + "prompt": "Investigate while streaming", + "model": "gpt-5.5", + "senderThreadId": "parent-native", + "receiverThreadIds": [], + "agentsStates": {} + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemStarted { item, .. } => { + let tool = item.tool.expect("spawn start metadata is preserved"); + assert_eq!(tool.name, "spawnAgent"); + assert_eq!(tool.subagent, None); + } + other => panic!("expected item start, got {other:?}"), + } + } + + #[test] + fn collab_agent_spawn_does_not_use_receiver_id_as_display_path() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "collabAgentToolCall", + "id": "collab1", + "tool": "spawnAgent", + "status": "completed", + "prompt": "Investigate the issue", + "model": "gpt-5.6-terra", + "senderThreadId": "parent-native", + "receiverThreadIds": ["child-native"], + "agentsStates": { + "child-native": { "status": "completed", "message": "Done" } + } + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::ToolCall { subagent, .. } => { + let subagent = subagent.expect("spawn link is preserved"); + assert_eq!(subagent.harness_thread_id, "child-native"); + assert_eq!(subagent.path, None); + assert_eq!(subagent.status, Some(SubagentStatus::Completed)); + assert_eq!(subagent.message.as_deref(), Some("Done")); + } + other => panic!("expected tool call, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + + #[test] + fn collab_agent_state_is_selected_by_receiver_thread_id() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "collabAgentToolCall", + "id": "collab1", + "tool": "sendInput", + "status": "completed", + "prompt": "Continue", + "model": null, + "senderThreadId": "parent-native", + "receiverThreadIds": ["child-native"], + "agentsStates": { + "aaa-other-child": { "status": "completed", "message": "Wrong child" }, + "child-native": { "status": "running", "message": "Still working" } + } + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::ToolCall { subagent, .. } => { + let subagent = subagent.expect("interaction link is preserved"); + assert_eq!(subagent.harness_thread_id, "child-native"); + assert_eq!(subagent.path, None); + assert_eq!(subagent.initial_prompt, None); + assert_eq!(subagent.action, SubagentAction::Interacted); + assert_eq!(subagent.status, Some(SubagentStatus::Running)); + assert_eq!(subagent.message.as_deref(), Some("Still working")); + } + other => panic!("expected tool call, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + + #[test] + fn legacy_collab_lifecycle_tools_map_single_child_links() { + for (tool, expected_action, status) in [ + ("resumeAgent", SubagentAction::Started, "running"), + ("wait", SubagentAction::Interacted, "completed"), + ("closeAgent", SubagentAction::Interrupted, "shutdown"), + ] { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "collabAgentToolCall", + "id": format!("collab-{tool}"), + "tool": tool, + "status": "completed", + "prompt": null, + "model": null, + "senderThreadId": "parent-native", + "receiverThreadIds": ["child-native"], + "agentsStates": { + "child-native": { "status": status, "message": "Lifecycle result" } + } + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::ToolCall { subagent, .. } => { + let subagent = subagent.expect("lifecycle link is preserved"); + assert_eq!(subagent.harness_thread_id, "child-native"); + assert_eq!(subagent.action, expected_action); + assert_eq!(subagent.message.as_deref(), Some("Lifecycle result")); + } + other => panic!("expected tool call, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + } + + #[test] + fn legacy_wait_with_multiple_receivers_has_no_ambiguous_link() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "collabAgentToolCall", + "id": "collab-wait", + "tool": "wait", + "status": "completed", + "prompt": null, + "model": null, + "senderThreadId": "parent-native", + "receiverThreadIds": ["child-a", "child-b"], + "agentsStates": { + "child-a": { "status": "completed", "message": "A" }, + "child-b": { "status": "completed", "message": "B" } + } + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::ToolCall { subagent, .. } => assert_eq!(subagent, None), + other => panic!("expected tool call, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + + #[test] + fn subagent_activity_maps_to_subagent_link() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "subAgentActivity", + "id": "subagent1", + "kind": "interacted", + "agentThreadId": "child-native", + "agentPath": "explorer" + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::Activity { + title, + detail, + metadata, + subagent, + .. + } => { + assert_eq!(title, "Sub-agent explorer interacted"); + assert_eq!(detail, None); + assert_eq!(metadata, None); + let subagent = subagent.expect("activity link is preserved"); + assert_eq!(subagent.harness_thread_id, "child-native"); + assert_eq!(subagent.path.as_deref(), Some("explorer")); + assert_eq!(subagent.action, SubagentAction::Interacted); + assert_eq!(subagent.status, None); + assert_eq!(subagent.message, None); + } + other => panic!("expected activity, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + + #[test] + fn current_subagent_started_activity_maps_without_inventing_a_prompt() { + let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); + let notif = completed_item(serde_json::json!({ + "type": "subAgentActivity", + "id": "subagent-started", + "kind": "started", + "agentThreadId": "child-native", + "agentPath": "/root/explorer" + })); + + match mapper.map_notification(¬if, ThreadId::new()).unwrap() { + AgentEvent::ItemCompleted { item, .. } => match item.payload { + ItemPayload::Activity { + title, + detail, + subagent, + .. + } => { + assert_eq!(title, "Sub-agent explorer started"); + assert_eq!(detail, None); + let subagent = subagent.expect("started activity exposes the child link"); + assert_eq!(subagent.harness_thread_id, "child-native"); + assert_eq!(subagent.path.as_deref(), Some("/root/explorer")); + assert_eq!(subagent.initial_prompt, None); + assert_eq!(subagent.action, SubagentAction::Started); + } + other => panic!("expected activity, got {other:?}"), + }, + other => panic!("expected item completion, got {other:?}"), + } + } + + #[test] + fn subagent_activity_title_uses_path_leaf_with_a_safe_fallback() { + assert_eq!( + subagent_activity_title( + "/root/nested_reload_parent", + &codex_codes::SubAgentActivityKind::Started, + ), + "Sub-agent nested_reload_parent started" + ); + assert_eq!( + subagent_activity_title("///", &codex_codes::SubAgentActivityKind::Interrupted), + "Sub-agent interrupted" + ); + } + #[test] fn mcp_tool_call_started_preserves_pending_tool_metadata() { let mut mapper = CodexMapper::new(PathBuf::from("/tmp")); @@ -4615,6 +5050,7 @@ mod tests { title, detail, metadata, + .. } => { assert_eq!(title, "Image viewed"); assert_eq!(detail.as_deref(), Some("/tmp/project/screenshot.png")); @@ -4642,6 +5078,7 @@ mod tests { title, detail, metadata, + .. } => { assert_eq!(title, "Context compacted"); assert_eq!(detail, None); @@ -4672,6 +5109,7 @@ mod tests { title, detail, metadata, + .. } => { assert_eq!(title, "Context compacted"); assert_eq!(detail, None); diff --git a/crates/giskard-harness-replay/src/lib.rs b/crates/giskard-harness-replay/src/lib.rs index 6a3b0d2..9a9a50b 100644 --- a/crates/giskard-harness-replay/src/lib.rs +++ b/crates/giskard-harness-replay/src/lib.rs @@ -230,6 +230,8 @@ impl AgentHarness for ReplayHarness { // A deterministic replay applies exactly the requested model, so echo it as // effective — this is what lets server tests exercise verified provider switches. resumed_model: Some(opts.initial_model.clone()), + agent_name: None, + parent_harness_thread_id: None, }) } @@ -320,6 +322,7 @@ impl AgentHarness for ReplayHarness { title: "Context compacted".into(), detail: None, metadata: None, + subagent: None, }, created_at: chrono::Utc::now(), }, @@ -470,6 +473,7 @@ mod tests { thread: None, workspace_root: "/tmp".into(), resume: Some("th_test".into()), + resume_policy: giskard_harness::ResumePolicy::AllowFreshFallback, initial_model: ModelRef { provider: "openai".into(), model: "gpt-5.5".into(), @@ -532,6 +536,7 @@ mod tests { thread: Some(requested_thread), workspace_root: "/tmp".into(), resume: Some("th_test".into()), + resume_policy: giskard_harness::ResumePolicy::AllowFreshFallback, initial_model: ModelRef { provider: "openai".into(), model: "gpt-5.5".into(), diff --git a/crates/giskard-harness-replay/tests/replay_integration.rs b/crates/giskard-harness-replay/tests/replay_integration.rs index a7b70ee..c57dfe4 100644 --- a/crates/giskard-harness-replay/tests/replay_integration.rs +++ b/crates/giskard-harness-replay/tests/replay_integration.rs @@ -114,6 +114,7 @@ async fn open_thread_one_turn_assert_state() { thread: None, workspace_root: "/tmp/test".into(), resume: Some("th_test_001".into()), + resume_policy: giskard_harness::ResumePolicy::AllowFreshFallback, initial_model: ModelRef { provider: "openai".into(), model: "gpt-5.5".into(), @@ -249,6 +250,7 @@ async fn replay_persisted_state_roundtrip() { thread: None, workspace_root: "/tmp/test".into(), resume: Some("th_test_001".into()), + resume_policy: giskard_harness::ResumePolicy::AllowFreshFallback, initial_model: ModelRef { provider: "openai".into(), model: "gpt-5.5".into(), @@ -311,6 +313,9 @@ async fn replay_persisted_state_roundtrip() { project_id: pid, title: "Fix auth".into(), harness_thread_id: handle.harness_thread_id.clone(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Plan, current_model: ModelRef { provider: "openai".into(), diff --git a/crates/giskard-harness/src/lib.rs b/crates/giskard-harness/src/lib.rs index e5a06e4..eaa1c24 100644 --- a/crates/giskard-harness/src/lib.rs +++ b/crates/giskard-harness/src/lib.rs @@ -54,9 +54,22 @@ pub struct OpenThreadOptions { pub workspace_root: PathBuf, /// Some(native id) ⇒ resume; None ⇒ fresh thread. pub resume: Option, + /// Whether a failed native resume may recover by starting a replacement thread. + /// + /// Linked sub-agent imports must use `RequireExisting`: their advertised native id is the + /// ownership and event-routing identity, so silently replacing it would attach Giskard to a + /// different thread. Normal primary-thread reopen keeps the historical fresh-session recovery. + pub resume_policy: ResumePolicy, pub initial_model: ModelRef, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ResumePolicy { + #[default] + AllowFreshFallback, + RequireExisting, +} + /// Handle to an opened thread. #[derive(Debug, Clone)] pub struct ThreadHandle { @@ -70,6 +83,27 @@ pub struct ThreadHandle { /// success (see `specs/model-provider-switching-analysis.md`). `None` ⇒ the harness gave no /// signal and the requested model must be assumed. pub resumed_model: Option, + /// Optional user-facing sub-agent name reported by the harness, such as Codex's random + /// AgentControl nickname. + pub agent_name: Option, + /// Harness-native parent thread id when the native protocol exposes the relationship. + /// Servers use this only to validate a proposed Giskard parent; it never replaces a durable + /// Giskard `ThreadId`. + pub parent_harness_thread_id: Option, +} + +impl ThreadHandle { + /// Build a minimal handle for native operations on a persisted thread that is not attached. + pub fn detached(thread: ThreadId, harness_thread_id: String) -> Self { + Self { + thread, + harness_thread_id, + warning: None, + resumed_model: None, + agent_name: None, + parent_harness_thread_id: None, + } + } } #[derive(Debug, Clone)] diff --git a/crates/giskard-persist/src/store.rs b/crates/giskard-persist/src/store.rs index 4ff5ac5..9d20afd 100644 --- a/crates/giskard-persist/src/store.rs +++ b/crates/giskard-persist/src/store.rs @@ -11,6 +11,7 @@ use tokio::sync::{Mutex, RwLock}; use giskard_core::ids::{ProjectId, ThreadId, TurnId}; use giskard_core::model::{Effort, ModelRef}; +use giskard_core::thread::ThreadKind; use giskard_core::token::{DailyTokenLedger, TokenLedger}; use giskard_core::turn::{ApprovalPolicy, Mode, Turn}; @@ -63,6 +64,12 @@ pub struct ThreadFile { pub project_id: ProjectId, pub title: String, pub harness_thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawned_by_turn_id: Option, + #[serde(default, skip_serializing_if = "is_primary_thread")] + pub kind: ThreadKind, pub mode: Mode, pub current_model: ModelRef, /// Effective context window for `current_model`. This starts from catalog/config metadata and @@ -92,6 +99,10 @@ fn is_false(value: &bool) -> bool { !*value } +fn is_primary_thread(value: &ThreadKind) -> bool { + *value == ThreadKind::Primary +} + fn parse_turn_history(path: &Path, data: &str) -> Result, PersistError> { let lines: Vec<&str> = data.lines().filter(|l| !l.trim().is_empty()).collect(); let mut turns = Vec::with_capacity(lines.len()); @@ -902,6 +913,9 @@ mod tests { project_id: pid, title: "Fix auth".into(), harness_thread_id: "th_abc".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Build, current_model: test_model(), context_window: 262_144, @@ -938,6 +952,9 @@ mod tests { project_id: pid, title: "Fix auth".into(), harness_thread_id: "th_abc".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Build, current_model: test_model(), context_window: 262_144, @@ -984,6 +1001,9 @@ mod tests { project_id: pid, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Plan, current_model: test_model(), context_window: 128_000, @@ -1156,6 +1176,9 @@ mod tests { project_id: pid, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Build, current_model: test_model(), context_window: 0, @@ -1317,6 +1340,9 @@ mod tests { project_id: pid, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Build, current_model: test_model(), context_window: 0, @@ -1356,6 +1382,9 @@ mod tests { project_id: pid, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Build, current_model: test_model(), context_window: 0, diff --git a/crates/giskard-persist/tests/giskard_admin.rs b/crates/giskard-persist/tests/giskard_admin.rs index ccf170d..e86f592 100644 --- a/crates/giskard-persist/tests/giskard_admin.rs +++ b/crates/giskard-persist/tests/giskard_admin.rs @@ -4,6 +4,7 @@ use std::process::Command; use chrono::Utc; use giskard_core::ids::{ProjectId, ThreadId}; use giskard_core::model::ModelRef; +use giskard_core::thread::ThreadKind; use giskard_core::token::TokenLedger; use giskard_core::turn::{ApprovalPolicy, Mode}; use giskard_persist::PersistStore; @@ -31,6 +32,9 @@ fn test_thread( project_id, title: title.into(), harness_thread_id: format!("harness-{thread_id}"), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode, current_model: test_model(), context_window: 262_144, diff --git a/crates/giskard-proto/src/lib.rs b/crates/giskard-proto/src/lib.rs index 8f7b1b2..6b23688 100644 --- a/crates/giskard-proto/src/lib.rs +++ b/crates/giskard-proto/src/lib.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; use giskard_core::ids::{ProjectId, ThreadId, TurnId}; +use giskard_core::thread::ThreadKind; +use giskard_core::user_input::UserInput; pub mod wire; pub use wire::{ @@ -25,6 +27,7 @@ pub use giskard_core::event::AgentEvent; pub use giskard_core::ids::{ApprovalId, ItemId}; pub use giskard_core::item::{ CommandExecutionStart, FileChangeEntry, FileChangeKind, ItemDelta, ItemKind, ItemStart, + SubagentAction, SubagentLink, SubagentStatus, }; pub use giskard_core::mcp::{ McpAuthStatus, McpOauthStart, McpResource, McpResourceTemplate, McpServerInfo, McpServerStatus, @@ -141,6 +144,8 @@ pub enum ThreadActivityKind { pub struct LiveTurnSnapshot { pub thread_id: ThreadId, pub turn_id: TurnId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_input: Option, pub accumulated: Vec, pub pending_approval: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -215,7 +220,7 @@ pub struct ErrorInfo { pub enum ServerMessage { Event { thread_id: ThreadId, - agent_event: WireAgentEvent, + agent_event: Box, }, ThreadActivity(ThreadActivity), ThreadState(ThreadState), @@ -317,23 +322,40 @@ pub struct CreateProjectResponse { pub struct ThreadSummary { pub id: ThreadId, pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawned_by_turn_id: Option, + #[serde(default, skip_serializing_if = "is_primary_thread")] + pub kind: ThreadKind, pub mode: Mode, pub archived: bool, pub created_at: DateTime, pub updated_at: DateTime, } +fn is_primary_thread(value: &ThreadKind) -> bool { + *value == ThreadKind::Primary +} + #[derive(Debug, Clone, Serialize)] pub struct ListThreadsResponse { pub threads: Vec, } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct OpenThreadRequest { pub thread_id: Option, pub resume: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct OpenSubagentLinkResponse { + pub thread_id: ThreadId, + pub title: String, +} + #[derive(Debug, Clone, Serialize)] pub struct OpenThreadResponse { pub thread_id: ThreadId, @@ -538,6 +560,26 @@ mod tests { } } + #[test] + fn open_thread_request_rejects_client_asserted_subagent_metadata() { + let result = serde_json::from_value::(serde_json::json!({ + "thread_id": null, + "resume": "native-child", + "subagent_action": "spawned", + "subagent_status": "completed", + "subagent_message": "done" + })); + assert!(result.is_err()); + + let request: OpenThreadRequest = serde_json::from_value(serde_json::json!({ + "thread_id": null, + "resume": "native-child" + })) + .unwrap(); + assert_eq!(request.thread_id, None); + assert_eq!(request.resume.as_deref(), Some("native-child")); + } + #[test] fn client_message_terminate_command_serde() { let tid = ThreadId::new(); @@ -876,6 +918,7 @@ mod tests { let snapshot = LiveTurnSnapshot { thread_id: tid, turn_id: turn, + user_input: None, accumulated: vec![], pending_approval: None, pending_server_requests: vec![ServerRequest { diff --git a/crates/giskard-proto/src/wire.rs b/crates/giskard-proto/src/wire.rs index 5566224..8fe41c6 100644 --- a/crates/giskard-proto/src/wire.rs +++ b/crates/giskard-proto/src/wire.rs @@ -18,7 +18,8 @@ use giskard_core::error::HarnessError; use giskard_core::event::AgentEvent; use giskard_core::ids::{ApprovalId, ItemId, ServerRequestId, ThreadId, TurnId}; use giskard_core::item::{ - FileChangeEntry, FileChangeKind, Item, ItemDelta, ItemPayload, ItemStart, + CommandExecutionStart, FileChangeEntry, FileChangeKind, Item, ItemDelta, ItemKind, ItemPayload, + ItemStart, SubagentAction, SubagentLink, SubagentStatus, ToolCallStart, }; use giskard_core::model::ModelRef; use giskard_core::server_request::ServerRequest; @@ -41,6 +42,8 @@ pub enum WireAgentEvent { TurnStarted { thread: ThreadId, turn: TurnId, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_input: Option, }, ContextWindowUpdated { thread: ThreadId, @@ -51,7 +54,7 @@ pub enum WireAgentEvent { ItemStarted { thread: ThreadId, turn: TurnId, - item: ItemStart, + item: WireItemStart, }, ItemDelta { thread: ThreadId, @@ -123,6 +126,49 @@ pub struct WireItem { pub created_at: DateTime, } +/// Wire-mirror of [`ItemStart`]. Native routing identifiers stay server-side. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireItemStart { + pub id: ItemId, + pub harness_item_id: String, + pub kind: ItemKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireToolCallStart { + pub name: String, + pub input: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subagent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at_ms: Option, +} + +/// Browser-safe sub-agent metadata. The harness-native thread id is intentionally omitted; link +/// opening is resolved server-side from the owning Giskard thread and item ids. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireSubagentLink { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_prompt: Option, + pub action: SubagentAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + /// Wire-mirror of [`ItemPayload`] (paths as `String`; `serde_json::Value` kept as-is). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] @@ -167,6 +213,10 @@ pub enum WireItemPayload { #[serde(default, skip_serializing_if = "Option::is_none")] status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, }, Activity { @@ -175,6 +225,8 @@ pub enum WireItemPayload { detail: Option, #[serde(default, skip_serializing_if = "Option::is_none")] metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent: Option, }, } @@ -272,7 +324,11 @@ impl From for WireAgentEvent { thread, harness_thread_id, }, - AgentEvent::TurnStarted { thread, turn } => Self::TurnStarted { thread, turn }, + AgentEvent::TurnStarted { thread, turn } => Self::TurnStarted { + thread, + turn, + user_input: None, + }, AgentEvent::ContextWindowUpdated { thread, turn, @@ -284,9 +340,11 @@ impl From for WireAgentEvent { model, context_window, }, - AgentEvent::ItemStarted { thread, turn, item } => { - Self::ItemStarted { thread, turn, item } - } + AgentEvent::ItemStarted { thread, turn, item } => Self::ItemStarted { + thread, + turn, + item: item.into(), + }, AgentEvent::ItemDelta { thread, turn, @@ -401,6 +459,44 @@ impl From for WireItem { } } +impl From for WireItemStart { + fn from(item: ItemStart) -> Self { + Self { + id: item.id, + harness_item_id: item.harness_item_id, + kind: item.kind, + command: item.command, + tool: item.tool.map(Into::into), + } + } +} + +impl From for WireToolCallStart { + fn from(tool: ToolCallStart) -> Self { + Self { + name: tool.name, + input: tool.input, + server: tool.server, + status: tool.status, + metadata: tool.metadata, + subagent: tool.subagent.map(Into::into), + started_at_ms: tool.started_at_ms, + } + } +} + +impl From for WireSubagentLink { + fn from(link: SubagentLink) -> Self { + Self { + path: link.path, + initial_prompt: link.initial_prompt, + action: link.action, + status: link.status, + message: link.message, + } + } +} + impl From for WireItemPayload { fn from(p: ItemPayload) -> Self { match p { @@ -441,6 +537,8 @@ impl From for WireItemPayload { output, server, status, + metadata, + subagent, error, } => Self::ToolCall { name, @@ -448,16 +546,20 @@ impl From for WireItemPayload { output, server, status, + metadata, + subagent: subagent.map(Into::into), error, }, ItemPayload::Activity { title, detail, metadata, + subagent, } => Self::Activity { title, detail, metadata, + subagent: subagent.map(Into::into), }, } } @@ -708,4 +810,67 @@ mod tests { assert_eq!(json["error"]["code"], "harness_protocol_error"); assert_eq!(json["error"]["message"], "protocol error: bad frame"); } + + #[test] + fn subagent_native_thread_id_is_redacted_from_started_and_completed_items() { + let thread = ThreadId::new(); + let turn = TurnId::new(); + let item_id = ItemId::new(); + let native_thread_id = "native-secret-child"; + let link = SubagentLink { + harness_thread_id: native_thread_id.into(), + path: Some("reviewer".into()), + initial_prompt: Some("review this".into()), + action: SubagentAction::Spawned, + status: Some(SubagentStatus::Running), + message: None, + }; + let started: WireAgentEvent = AgentEvent::ItemStarted { + thread, + turn, + item: ItemStart { + id: item_id, + harness_item_id: "spawn-call".into(), + kind: ItemKind::ToolCall, + command: None, + tool: Some(ToolCallStart { + name: "spawn_subagent".into(), + input: serde_json::json!({"prompt": "review this"}), + server: None, + status: Some("in_progress".into()), + metadata: None, + subagent: Some(link.clone()), + started_at_ms: None, + }), + }, + } + .into(); + let completed: WireAgentEvent = AgentEvent::ItemCompleted { + thread, + turn, + item: Item { + id: item_id, + harness_item_id: "spawn-call".into(), + payload: ItemPayload::Activity { + title: "Sub-agent started".into(), + detail: None, + metadata: None, + subagent: Some(link), + }, + created_at: Utc::now(), + }, + } + .into(); + + for event in [started, completed] { + let json = serde_json::to_value(event).unwrap(); + assert!(!json.to_string().contains(native_thread_id)); + let subagent = json + .pointer("/item/tool/subagent") + .or_else(|| json.pointer("/item/payload/subagent")) + .expect("wire item should retain browser-safe sub-agent metadata"); + assert!(subagent.get("harness_thread_id").is_none()); + assert_eq!(subagent["path"], "reviewer"); + } + } } diff --git a/crates/giskard-server/src/bin/giskard-server-replay.rs b/crates/giskard-server/src/bin/giskard-server-replay.rs index f907d95..bc6f6f1 100644 --- a/crates/giskard-server/src/bin/giskard-server-replay.rs +++ b/crates/giskard-server/src/bin/giskard-server-replay.rs @@ -29,7 +29,9 @@ use tracing::info; use giskard_core::error::HarnessError; use giskard_core::event::AgentEvent; use giskard_core::ids::{ItemId, ThreadId, TurnId}; -use giskard_core::item::{Item, ItemDelta, ItemKind, ItemPayload, ItemStart}; +use giskard_core::item::{ + Item, ItemDelta, ItemKind, ItemPayload, ItemStart, SubagentAction, SubagentLink, +}; use giskard_core::model::ModelRef; use giskard_core::token::TokenUsage; use giskard_core::turn::{TurnOverrides, TurnStatus, TurnStatusKind}; @@ -42,6 +44,12 @@ use giskard_server::{AppState, HarnessFactory, build_app}; /// The scripted agent's fixed reply. Tests assert on this exact string, so keep it stable. const SCRIPTED_REPLY: &str = "Hello from the scripted replay harness!"; +const SCRIPTED_SUBAGENT_TRIGGER: &str = "Spawn the scripted linked sub-agent."; +const SCRIPTED_NESTED_SUBAGENT_TRIGGER: &str = "Spawn a scripted nested sub-agent."; +const SCRIPTED_SUBAGENT_PROMPT: &str = "Review the linked child task."; +const SCRIPTED_SUBAGENT_REPLY: &str = "Child replay output"; +const SCRIPTED_SUBAGENT_PREFIX: &str = "scripted-subagent|"; +const SCRIPTED_NESTED_SUBAGENT_PREFIX: &str = "scripted-nested-subagent|"; /// A harness that speaks the neutral protocol but has no backend: every turn streams the same /// canned agent message, so the browser-visible transcript is fully deterministic. @@ -78,6 +86,163 @@ impl ScriptedHarness { .find(|(id, _)| *id == thread) .map(|(_, tx)| tx.clone()) } + + fn subagent_parent(native_thread_id: &str) -> Option { + [SCRIPTED_SUBAGENT_PREFIX, SCRIPTED_NESTED_SUBAGENT_PREFIX] + .into_iter() + .find_map(|prefix| native_thread_id.strip_prefix(prefix)) + .and_then(|value| value.rsplit_once('|')) + .map(|(parent, _)| parent.to_owned()) + } + + fn spawn_nested_subagent_turn( + sender: broadcast::Sender, + thread_id: ThreadId, + parent_harness_thread_id: String, + ) { + tokio::spawn(async move { + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while sender.receiver_count() == 0 && tokio::time::Instant::now() < deadline { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + if sender.receiver_count() == 0 { + return; + } + + let turn = TurnId::new(); + // Mirror the collaboration-v2 race seen from Codex: a turn-scoped sub-agent activity + // can arrive before the corresponding TurnStarted notification. + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + let _ = sender.send(AgentEvent::ItemCompleted { + thread: thread_id, + turn, + item: Item { + id: ItemId::new(), + harness_item_id: format!("scripted_nested_subagent_link_{turn}"), + payload: ItemPayload::Activity { + title: "Sub-agent running".into(), + detail: Some("Nested replay child".into()), + metadata: None, + subagent: Some(SubagentLink { + harness_thread_id: format!( + "{SCRIPTED_SUBAGENT_PREFIX}{parent_harness_thread_id}|{turn}" + ), + path: Some("Nested replay child".into()), + initial_prompt: Some("Run the nested replay task.".into()), + action: SubagentAction::Started, + status: None, + message: None, + }), + }, + created_at: chrono::Utc::now(), + }, + }); + tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; + let _ = sender.send(AgentEvent::TurnStarted { + thread: thread_id, + turn, + }); + tokio::task::yield_now().await; + let wait_item_id = ItemId::new(); + let _ = sender.send(AgentEvent::ItemStarted { + thread: thread_id, + turn, + item: ItemStart { + id: wait_item_id, + harness_item_id: format!("scripted_nested_wait_{turn}"), + kind: ItemKind::ToolCall, + command: None, + tool: Some(giskard_core::item::ToolCallStart { + name: "wait".into(), + input: serde_json::json!({}), + server: Some("collab-agent".into()), + status: Some("in_progress".into()), + metadata: None, + subagent: None, + started_at_ms: None, + }), + }, + }); + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + let _ = sender.send(AgentEvent::TurnCompleted { + thread: thread_id, + turn, + usage: TokenUsage::new(30, 6), + status: TurnStatus { + kind: TurnStatusKind::Completed, + message: None, + }, + }); + }); + } + + fn spawn_subagent_turn( + sender: broadcast::Sender, + thread_id: ThreadId, + parent_harness_thread_id: String, + ) { + tokio::spawn(async move { + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + while sender.receiver_count() == 0 && tokio::time::Instant::now() < deadline { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + if sender.receiver_count() == 0 { + return; + } + + let turn = TurnId::new(); + let _ = sender.send(AgentEvent::TurnStarted { + thread: thread_id, + turn, + }); + tokio::task::yield_now().await; + let _ = sender.send(AgentEvent::ItemCompleted { + thread: thread_id, + turn, + item: Item { + id: ItemId::new(), + harness_item_id: format!("scripted_child_reply_{turn}"), + payload: ItemPayload::AgentMessage { + text: SCRIPTED_SUBAGENT_REPLY.into(), + }, + created_at: chrono::Utc::now(), + }, + }); + tokio::task::yield_now().await; + let _ = sender.send(AgentEvent::ItemCompleted { + thread: thread_id, + turn, + item: Item { + id: ItemId::new(), + harness_item_id: format!("scripted_reverse_link_{turn}"), + payload: ItemPayload::Activity { + title: "Sub-agent interacted".into(), + detail: Some("Sent a result to the parent".into()), + metadata: None, + subagent: Some(SubagentLink { + harness_thread_id: parent_harness_thread_id, + path: Some("/root".into()), + initial_prompt: None, + action: SubagentAction::Interacted, + status: None, + message: None, + }), + }, + created_at: chrono::Utc::now(), + }, + }); + tokio::task::yield_now().await; + let _ = sender.send(AgentEvent::TurnCompleted { + thread: thread_id, + turn, + usage: TokenUsage::new(40, 12), + status: TurnStatus { + kind: TurnStatusKind::Completed, + message: None, + }, + }); + }); + } } #[async_trait] @@ -97,12 +262,24 @@ impl AgentHarness for ScriptedHarness { .clone() .unwrap_or_else(|| format!("scripted_{thread}")); - let (tx, _rx) = broadcast::channel(256); + let (new_sender, _) = broadcast::channel(256); let mut threads = self.threads.lock().await; - if let Some((_, existing)) = threads.iter_mut().find(|(id, _)| *id == thread) { - *existing = tx.clone(); - } else { - threads.push((thread, tx.clone())); + let (sender, is_new) = + if let Some((_, existing)) = threads.iter().find(|(id, _)| *id == thread) { + (existing.clone(), false) + } else { + threads.push((thread, new_sender.clone())); + (new_sender, true) + }; + drop(threads); + + let parent_harness_thread_id = Self::subagent_parent(&harness_thread_id); + if is_new && let Some(parent) = parent_harness_thread_id.clone() { + if harness_thread_id.starts_with(SCRIPTED_NESTED_SUBAGENT_PREFIX) { + Self::spawn_nested_subagent_turn(sender, thread, harness_thread_id.clone()); + } else { + Self::spawn_subagent_turn(sender, thread, parent); + } } Ok(ThreadHandle { @@ -110,13 +287,17 @@ impl AgentHarness for ScriptedHarness { harness_thread_id, warning: None, resumed_model: Some(opts.initial_model), + agent_name: parent_harness_thread_id + .as_ref() + .map(|_| "Replay child".to_string()), + parent_harness_thread_id, }) } async fn start_turn( &self, thread: &ThreadHandle, - _input: UserInput, + input: UserInput, _overrides: TurnOverrides, ) -> Result { let turn = TurnId::new(); @@ -125,10 +306,64 @@ impl AgentHarness for ScriptedHarness { return Err(HarnessError::ThreadNotFound(thread_id)); }; + let input_text = input.as_text(); + let subagent_native_thread_id = match input_text { + Some(SCRIPTED_SUBAGENT_TRIGGER) => Some(format!( + "{SCRIPTED_SUBAGENT_PREFIX}{}|{turn}", + thread.harness_thread_id + )), + Some(SCRIPTED_NESTED_SUBAGENT_TRIGGER) => Some(format!( + "{SCRIPTED_NESTED_SUBAGENT_PREFIX}{}|{turn}", + thread.harness_thread_id + )), + _ => None, + }; + // Stream the canned reply the way a real harness would: start, incremental deltas, then a // completed item and a turn-completed with token usage. Emitted off-task with yields so the // WebSocket layer observes distinct frames (the transcript renders progressively). tokio::spawn(async move { + if let Some(native_thread_id) = subagent_native_thread_id { + let _ = sender.send(AgentEvent::TurnStarted { + thread: thread_id, + turn, + }); + tokio::task::yield_now().await; + let _ = sender.send(AgentEvent::ItemCompleted { + thread: thread_id, + turn, + item: Item { + id: ItemId::new(), + harness_item_id: format!("scripted_subagent_link_{turn}"), + payload: ItemPayload::Activity { + title: "Sub-agent running".into(), + detail: Some("Replay child".into()), + metadata: None, + subagent: Some(SubagentLink { + harness_thread_id: native_thread_id, + path: Some("Replay child".into()), + initial_prompt: Some(SCRIPTED_SUBAGENT_PROMPT.into()), + action: SubagentAction::Started, + status: None, + message: None, + }), + }, + created_at: chrono::Utc::now(), + }, + }); + tokio::task::yield_now().await; + let _ = sender.send(AgentEvent::TurnCompleted { + thread: thread_id, + turn, + usage: TokenUsage::new(25, 5), + status: TurnStatus { + kind: TurnStatusKind::Completed, + message: None, + }, + }); + return; + } + let item_id = ItemId::new(); let _ = sender.send(AgentEvent::TurnStarted { thread: thread_id, @@ -213,6 +448,14 @@ impl AgentHarness for ScriptedHarness { Ok(()) } + async fn delete_thread(&self, thread: &ThreadHandle) -> Result<(), HarnessError> { + self.threads + .lock() + .await + .retain(|(thread_id, _)| *thread_id != thread.thread); + Ok(()) + } + async fn shutdown(&self) -> Result<(), HarnessError> { Ok(()) } diff --git a/crates/giskard-server/src/hub.rs b/crates/giskard-server/src/hub.rs index 79b31ce..cee39cf 100644 --- a/crates/giskard-server/src/hub.rs +++ b/crates/giskard-server/src/hub.rs @@ -131,7 +131,7 @@ impl Hub { thread_id, ServerMessage::Event { thread_id, - agent_event: event.into(), + agent_event: Box::new(event.into()), }, ) .await; diff --git a/crates/giskard-server/src/lib.rs b/crates/giskard-server/src/lib.rs index 908e89c..cde688c 100644 --- a/crates/giskard-server/src/lib.rs +++ b/crates/giskard-server/src/lib.rs @@ -12,6 +12,7 @@ pub mod plan; pub mod registry; pub mod routes; pub mod running_commands; +mod thread_graph; pub mod throttle; pub mod tokens; diff --git a/crates/giskard-server/src/live_buffer.rs b/crates/giskard-server/src/live_buffer.rs index beed2c3..d9c929f 100644 --- a/crates/giskard-server/src/live_buffer.rs +++ b/crates/giskard-server/src/live_buffer.rs @@ -6,6 +6,7 @@ use giskard_core::event::AgentEvent; use giskard_core::ids::{ItemId, ServerRequestId, ThreadId, TurnId}; use giskard_core::item::{ItemDelta, ItemPayload}; use giskard_core::server_request::ServerRequest; +use giskard_core::user_input::UserInput; use giskard_proto::{LiveTurnSnapshot, WireAgentEvent, WireApprovalRequest}; const MAX_LIVE_COMMAND_OUTPUT: usize = 16 * 1024; @@ -14,6 +15,7 @@ const LIVE_COMMAND_OUTPUT_TRUNCATED: &str = "\n\n[... command output truncated i struct LiveTurn { turn_id: TurnId, + user_input: Option, events: Vec, } @@ -29,16 +31,66 @@ impl LiveBufferStore { } pub async fn start_turn(&self, thread_id: ThreadId) { + self.start_turn_with_user_input(thread_id, None).await; + } + + pub async fn start_turn_with_user_input( + &self, + thread_id: ThreadId, + user_input: Option, + ) { + self.replace_turn_with_user_input(thread_id, TurnId::new(), user_input) + .await; + } + + pub async fn replace_turn_with_user_input( + &self, + thread_id: ThreadId, + turn_id: TurnId, + user_input: Option, + ) { let mut buffers = self.buffers.lock().await; buffers.insert( thread_id, LiveTurn { - turn_id: TurnId::new(), + turn_id, + user_input, events: Vec::new(), }, ); } + /// Ensure an exact turn has a reconnect buffer without replacing events already observed for + /// it. Harnesses may publish a turn-scoped item before their delayed `TurnStarted` event; that + /// item is still live state and must survive a browser reload. + pub async fn ensure_turn_with_user_input( + &self, + thread_id: ThreadId, + turn_id: TurnId, + user_input: Option, + ) -> Result<(), TurnId> { + use std::collections::hash_map::Entry; + + let mut buffers = self.buffers.lock().await; + match buffers.entry(thread_id) { + Entry::Vacant(entry) => { + entry.insert(LiveTurn { + turn_id, + user_input, + events: Vec::new(), + }); + Ok(()) + } + Entry::Occupied(mut entry) if entry.get().turn_id == turn_id => { + if entry.get().user_input.is_none() && user_input.is_some() { + entry.get_mut().user_input = user_input; + } + Ok(()) + } + Entry::Occupied(entry) => Err(entry.get().turn_id), + } + } + pub async fn append(&self, thread_id: ThreadId, event: AgentEvent) { let mut buffers = self.buffers.lock().await; if let Some(turn) = buffers.get_mut(&thread_id) { @@ -67,6 +119,24 @@ impl LiveBufferStore { buffers.contains_key(&thread_id) } + /// Return raw server-side lifecycle events for one Giskard item. This is intentionally not a + /// wire snapshot: linked-thread opening needs the native routing id that wire conversion + /// redacts before data reaches the browser. + pub async fn item_events(&self, thread_id: ThreadId, item_id: ItemId) -> Vec { + let buffers = self.buffers.lock().await; + buffers + .get(&thread_id) + .into_iter() + .flat_map(|turn| turn.events.iter()) + .filter(|event| match event { + AgentEvent::ItemStarted { item, .. } => item.id == item_id, + AgentEvent::ItemCompleted { item, .. } => item.id == item_id, + _ => false, + }) + .cloned() + .collect() + } + pub async fn snapshot(&self, thread_id: ThreadId) -> Option { let buffers = self.buffers.lock().await; buffers.get(&thread_id).map(|turn| { @@ -85,6 +155,7 @@ impl LiveBufferStore { LiveTurnSnapshot { thread_id, turn_id: turn.turn_id, + user_input: turn.user_input.clone(), accumulated, pending_approval, pending_server_requests, @@ -254,6 +325,55 @@ mod tests { } } + #[tokio::test] + async fn turn_started_does_not_discard_an_earlier_turn_item() { + let store = LiveBufferStore::new(); + let thread = ThreadId::new(); + let turn = TurnId::new(); + let input = UserInput::text("Sub-agent turn"); + let item = AgentEvent::ItemCompleted { + thread, + turn, + item: Item { + id: ItemId::new(), + harness_item_id: "subagent-started".into(), + payload: ItemPayload::Activity { + title: "Sub-agent started".into(), + detail: None, + metadata: None, + subagent: None, + }, + created_at: Utc::now(), + }, + }; + + store + .ensure_turn_with_user_input(thread, turn, Some(input.clone())) + .await + .expect("first event starts the exact turn buffer"); + store.append(thread, item).await; + store + .ensure_turn_with_user_input(thread, turn, Some(input.clone())) + .await + .expect("late turn start reuses the buffer"); + store + .append(thread, AgentEvent::TurnStarted { thread, turn }) + .await; + + let snapshot = store.snapshot(thread).await.expect("snapshot"); + assert_eq!(snapshot.turn_id, turn); + assert_eq!(snapshot.user_input, Some(input)); + assert_eq!(snapshot.accumulated.len(), 2); + assert!(matches!( + snapshot.accumulated[0], + WireAgentEvent::ItemCompleted { .. } + )); + assert!(matches!( + snapshot.accumulated[1], + WireAgentEvent::TurnStarted { .. } + )); + } + #[tokio::test] async fn command_output_deltas_are_compacted_for_live_snapshot() { let store = LiveBufferStore::new(); diff --git a/crates/giskard-server/src/registry.rs b/crates/giskard-server/src/registry.rs index 76ce05a..8c6b0ca 100644 --- a/crates/giskard-server/src/registry.rs +++ b/crates/giskard-server/src/registry.rs @@ -1,31 +1,45 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}; -use std::time::Instant; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard, Weak}; +use std::time::{Duration, Instant}; use async_trait::async_trait; use chrono::Utc; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, Notify, OwnedMutexGuard, oneshot, watch}; +use tokio::time::timeout; use tracing::{debug, error, info, warn}; use giskard_core::approval::ApprovalDecision; use giskard_core::error::HarnessError; use giskard_core::event::AgentEvent; use giskard_core::ids::{ApprovalId, ItemId, ProjectId, ServerRequestId, ThreadId, TurnId}; -use giskard_core::item::{Item, ItemPayload, command_status_is_running, normalized_command_status}; +use giskard_core::item::{ + Item, ItemPayload, SubagentAction, SubagentStatus, command_status_is_running, + normalized_command_status, +}; use giskard_core::mcp::{McpOauthStart, McpServerStatus}; use giskard_core::model::{ModelDescriptor, ModelRef}; use giskard_core::server_request::ServerRequestResponse; +use giskard_core::text::trimmed_non_empty; +use giskard_core::thread::ThreadKind; use giskard_core::turn::{Mode, Turn, TurnOverrides, TurnStatus, TurnStatusKind}; use giskard_core::user_input::UserInput; -use giskard_harness::{AgentHarness, HarnessCapabilities, OpenThreadOptions, ThreadHandle}; +use giskard_harness::{ + AgentHarness, HarnessCapabilities, OpenThreadOptions, ResumePolicy, ThreadHandle, +}; use giskard_persist::PersistStore; -use giskard_persist::store::ProjectConfig; -use giskard_proto::{RunningTask, ServerMessage, ThreadActivity, ThreadActivityKind, TokenScope}; +use giskard_persist::store::{ProjectConfig, ThreadFile}; +use giskard_proto::{ + RunningTask, ServerMessage, ThreadActivity, ThreadActivityKind, TokenScope, WireAgentEvent, +}; use crate::hub::Hub; use crate::ledger::LedgerHandle; use crate::live_buffer::LiveBufferStore; use crate::running_commands::RunningTaskStore; +use crate::thread_graph::{ + ExistingLinkDisposition, classify_existing_link, load_thread_graph, parent_chain_is_valid, + should_refresh_subagent_title, +}; #[async_trait] pub trait HarnessFactory: Send + Sync { @@ -39,12 +53,17 @@ struct TurnContext { model: ModelRef, mode: Mode, kind: TurnContextKind, + passive_input_is_fallback: bool, + subagent_fallback: Option, + passive_subagent_metadata: Option, + passive_pre_turn_timeout: Option, } #[derive(Clone, Copy, PartialEq, Eq)] enum TurnContextKind { User, ManualCompaction, + PassiveSubagent, } #[derive(Clone, Copy)] @@ -54,6 +73,7 @@ enum ForwarderExitReason { AfterTurnCommandsDrained, StreamEndedRecovered, StreamEndedWithoutTurn, + DuplicateForwarder, } fn forwarder_exit_reason_label(reason: ForwarderExitReason) -> &'static str { @@ -63,6 +83,7 @@ fn forwarder_exit_reason_label(reason: ForwarderExitReason) -> &'static str { ForwarderExitReason::AfterTurnCommandsDrained => "after_turn_commands_drained", ForwarderExitReason::StreamEndedRecovered => "stream_ended_recovered", ForwarderExitReason::StreamEndedWithoutTurn => "stream_ended_without_turn", + ForwarderExitReason::DuplicateForwarder => "duplicate_forwarder", } } @@ -70,13 +91,88 @@ fn turn_context_kind_label(kind: TurnContextKind) -> &'static str { match kind { TurnContextKind::User => "user", TurnContextKind::ManualCompaction => "manual_compaction", + TurnContextKind::PassiveSubagent => "passive_subagent", } } +fn live_turn_user_input(ctx: &TurnContext) -> Option { + if ctx.kind != TurnContextKind::PassiveSubagent { + return None; + } + ctx.user_input + .as_text() + .and_then(trimmed_non_empty) + .map(UserInput::text) +} + +fn passive_subagent_prompt_text(ctx: &TurnContext) -> Option { + if ctx.kind != TurnContextKind::PassiveSubagent || ctx.passive_input_is_fallback { + return None; + } + ctx.user_input + .as_text() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned) +} + /// Shared handle to the pending-approvals map (`ApprovalId -> ThreadId`), cloneable into the /// spawned event forwarder so it can register approvals as they stream in. type ApprovalMap = Arc>>; type ServerRequestMap = Arc>>; +type PassiveSubagentMetadataMap = Arc>>; +type PassiveMonitorTasks = Arc; +type ProjectLifecycleLocks = Arc>>>>; +const ACTIVE_SUBAGENT_PRE_TURN_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const PASSIVE_MONITOR_STOP_TIMEOUT: Duration = Duration::from_secs(5); + +struct PassiveMonitorTaskTracker { + counts: Mutex>, + completion: watch::Sender, +} + +impl Default for PassiveMonitorTaskTracker { + fn default() -> Self { + let (completion, _) = watch::channel(0); + Self { + counts: Mutex::new(HashMap::new()), + completion, + } + } +} + +impl PassiveMonitorTaskTracker { + async fn register(&self, thread_id: ThreadId) { + *self.counts.lock().await.entry(thread_id).or_default() += 1; + } + + async fn contains(&self, thread_id: ThreadId) -> bool { + self.counts.lock().await.contains_key(&thread_id) + } + + async fn finish(&self, thread_id: ThreadId) { + let mut counts = self.counts.lock().await; + match counts.get_mut(&thread_id) { + Some(count) if *count > 1 => *count -= 1, + Some(_) => { + counts.remove(&thread_id); + } + None => { + warn!( + %thread_id, + "passive sub-agent monitor task completed without a registered task" + ); + } + } + drop(counts); + self.completion.send_modify(|generation| { + *generation = generation.wrapping_add(1); + }); + } + + fn subscribe(&self) -> watch::Receiver { + self.completion.subscribe() + } +} #[derive(Clone)] struct ThreadBinding { @@ -85,6 +181,30 @@ struct ThreadBinding { native_model: ModelRef, } +#[derive(Clone, Default)] +struct PassiveSubagentMetadata { + initial_prompt: Option, + fallback: Option, + active_lifecycle_observed: bool, + terminal_observed: bool, + cancelled: bool, + lifecycle_notify: Arc, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PassiveMonitorSignal { + Continue, + Terminal, + Cancelled, +} + +#[derive(Clone, Copy)] +enum LifecycleSignal { + None, + Active, + Terminal, +} + #[derive(Clone, Default)] struct ThreadTurnGate { active: Arc>>, @@ -263,8 +383,13 @@ impl Drop for ThreadTurnLease { } pub struct HarnessRegistry { - harnesses: Mutex>>, - threads: Mutex>, + shared: Arc, + factory: Arc, +} + +struct RegistryShared { + harnesses: Arc>>>, + threads: Arc>>, /// Per-thread turn gate covering both start-in-progress and live turns. `LiveBufferStore` only /// becomes active after `TurnStarted`, so it cannot protect the `start_turn` race itself. turn_gate: ThreadTurnGate, @@ -274,7 +399,16 @@ pub struct HarnessRegistry { /// Which thread a pending non-approval server request belongs to. Browser responses carry only /// the opaque request id, so this mirrors the approval routing map for Codex server requests. server_requests: ServerRequestMap, - factory: Arc, + passive_monitors: Arc>>, + passive_subagent_metadata: PassiveSubagentMetadataMap, + /// Generation count spanning subscription and post-forwarder fallback persistence. A new + /// monitor may start after an old subscription exits, so deletion waits for all generations. + passive_monitor_tasks: PassiveMonitorTasks, + /// Per-parent FIFO for linked lifecycle evidence. Harness events are ordered, so preserving + /// that order here prevents a later terminal observation from racing ahead of an active one. + subagent_materialization_queues: + Arc>>>, + project_lifecycle_locks: ProjectLifecycleLocks, hub: Arc, live_buffers: Arc, running_commands: Arc, @@ -282,9 +416,8 @@ pub struct HarnessRegistry { ledger: LedgerHandle, } -impl HarnessRegistry { - pub fn new( - factory: Arc, +impl RegistryShared { + fn new( hub: Arc, live_buffers: Arc, running_commands: Arc, @@ -292,12 +425,16 @@ impl HarnessRegistry { ledger: LedgerHandle, ) -> Self { Self { - harnesses: Mutex::new(HashMap::new()), - threads: Mutex::new(HashMap::new()), + harnesses: Arc::new(Mutex::new(HashMap::new())), + threads: Arc::new(Mutex::new(HashMap::new())), turn_gate: ThreadTurnGate::default(), approvals: Arc::new(Mutex::new(HashMap::new())), server_requests: Arc::new(Mutex::new(HashMap::new())), - factory, + passive_monitors: Arc::new(Mutex::new(HashSet::new())), + passive_subagent_metadata: Arc::new(Mutex::new(HashMap::new())), + passive_monitor_tasks: Arc::new(PassiveMonitorTaskTracker::default()), + subagent_materialization_queues: Arc::new(Mutex::new(HashMap::new())), + project_lifecycle_locks: Arc::new(Mutex::new(HashMap::new())), hub, live_buffers, running_commands, @@ -305,13 +442,57 @@ impl HarnessRegistry { ledger, } } +} + +impl HarnessRegistry { + pub fn new( + factory: Arc, + hub: Arc, + live_buffers: Arc, + running_commands: Arc, + store: Arc, + ledger: LedgerHandle, + ) -> Self { + Self { + shared: Arc::new(RegistryShared::new( + hub, + live_buffers, + running_commands, + store, + ledger, + )), + factory, + } + } + + /// Serialize persisted thread-graph mutations within one project. Child imports may originate + /// from either an HTTP request or an asynchronously observed harness event, while subtree and + /// project deletion mutate the same graph. One project-scoped lock makes each find/open/save + /// or load/preflight/delete sequence atomic with respect to the others. + pub async fn lock_project_lifecycle(&self, project_id: ProjectId) -> OwnedMutexGuard<()> { + lock_project_lifecycle(&self.shared.project_lifecycle_locks, project_id).await + } + + pub async fn lock_project_lifecycle_with_timeout( + &self, + project_id: ProjectId, + wait: Duration, + ) -> Result, HarnessError> { + timeout(wait, self.lock_project_lifecycle(project_id)) + .await + .map_err(|_| { + HarnessError::Timeout(format!( + "timed out waiting for project {project_id} lifecycle lock" + )) + }) + } async fn get_or_create_harness( &self, project: ProjectId, config: &ProjectConfig, ) -> Result, HarnessError> { - let mut harnesses = self.harnesses.lock().await; + let mut harnesses = self.shared.harnesses.lock().await; if let Some(h) = harnesses.get(&project) { return Ok(h.clone()); } @@ -327,14 +508,55 @@ impl HarnessRegistry { thread: Option, resume: Option, initial_model: ModelRef, + ) -> Result { + self.open_thread_with_resume_policy( + config, + workspace_root, + thread, + resume, + initial_model, + ResumePolicy::AllowFreshFallback, + ) + .await + } + + pub async fn open_linked_thread( + &self, + config: &ProjectConfig, + workspace_root: &str, + thread: Option, + resume: String, + initial_model: ModelRef, + ) -> Result { + self.open_thread_with_resume_policy( + config, + workspace_root, + thread, + Some(resume), + initial_model, + ResumePolicy::RequireExisting, + ) + .await + } + + async fn open_thread_with_resume_policy( + &self, + config: &ProjectConfig, + workspace_root: &str, + thread: Option, + resume: Option, + initial_model: ModelRef, + resume_policy: ResumePolicy, ) -> Result { debug!( project_id = %config.id, thread_id = ?thread, resume = ?resume, + ?resume_policy, "opening harness thread" ); let harness = self.get_or_create_harness(config.id, config).await?; + let requested_native_id = resume.clone(); let handle = harness .open_thread(OpenThreadOptions { @@ -342,10 +564,23 @@ impl HarnessRegistry { thread, workspace_root: workspace_root.into(), resume, + resume_policy, initial_model: initial_model.clone(), }) .await?; + // This is the harness-neutral identity boundary. Individual adapters may enforce the same + // contract internally, but the registry must not rely on adapter-specific validation. + if resume_policy == ResumePolicy::RequireExisting + && requested_native_id.as_deref() != Some(handle.harness_thread_id.as_str()) + { + return Err(HarnessError::Protocol(format!( + "linked-thread resume returned native thread {} instead of {}", + handle.harness_thread_id, + requested_native_id.as_deref().unwrap_or_default() + ))); + } + // Bind the model the harness reports as effective when it says so — Codex can ignore // resume overrides for a loaded thread, and the binding must reflect reality, not the // request (spec: model-provider-switching analysis). @@ -353,7 +588,7 @@ impl HarnessRegistry { .resumed_model .clone() .unwrap_or_else(|| initial_model.clone()); - let mut threads = self.threads.lock().await; + let mut threads = self.shared.threads.lock().await; threads.insert( handle.thread, ThreadBinding { @@ -382,7 +617,14 @@ impl HarnessRegistry { overrides: TurnOverrides, effective_model: ModelRef, ) -> Result { - let threads = self.threads.lock().await; + if self.thread_has_passive_monitor(thread_id).await { + warn!( + %thread_id, + "refusing direct turn while passive sub-agent monitoring owns the thread" + ); + return Err(HarnessError::ThreadBusy { thread: thread_id }); + } + let threads = self.shared.threads.lock().await; let binding = threads .get(&thread_id) .ok_or(HarnessError::ThreadNotFound(thread_id))?; @@ -399,7 +641,7 @@ impl HarnessRegistry { "starting harness turn" ); - let harnesses = self.harnesses.lock().await; + let harnesses = self.shared.harnesses.lock().await; let harness = harnesses .get(&project_id) .ok_or(HarnessError::ThreadNotFound(thread_id))? @@ -411,19 +653,18 @@ impl HarnessRegistry { model: effective_model, mode: overrides.mode, kind: TurnContextKind::User, + passive_input_is_fallback: false, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: None, }; let request_started = Instant::now(); let mut turn_gate = self + .shared .turn_gate .reserve(thread_id, ActiveTurnOwner::new(project_id, &handle, &ctx))?; - let hub = self.hub.clone(); - let live_buffers = self.live_buffers.clone(); - let running_commands = self.running_commands.clone(); - let store = self.store.clone(); - let approvals_map = self.approvals.clone(); - let server_requests_map = self.server_requests.clone(); - let ledger = self.ledger.clone(); + let shared = self.shared.clone(); let stream = harness.subscribe(&handle); let turn_id = match harness.start_turn(&handle, input, overrides).await { @@ -459,21 +700,7 @@ impl HarnessRegistry { }; tokio::spawn(async move { - forward_events( - thread_id, - project_id, - stream, - hub, - live_buffers, - running_commands, - store, - approvals_map, - server_requests_map, - ledger, - ctx, - Some(turn_gate), - ) - .await; + forward_events(shared, thread_id, project_id, stream, ctx, Some(turn_gate)).await; }); Ok(turn_id) @@ -486,6 +713,7 @@ impl HarnessRegistry { decision: ApprovalDecision, ) -> Result { let thread_id = self + .shared .approvals .lock() .await @@ -501,6 +729,7 @@ impl HarnessRegistry { .ok_or(HarnessError::ThreadNotFound(thread_id))?; let harness = self + .shared .harnesses .lock() .await @@ -508,7 +737,7 @@ impl HarnessRegistry { .cloned() .ok_or(HarnessError::ThreadNotFound(thread_id))?; - self.approvals.lock().await.remove(&request_id); + self.shared.approvals.lock().await.remove(&request_id); harness.respond_approval(request_id, decision).await?; Ok(thread_id) } @@ -520,6 +749,7 @@ impl HarnessRegistry { response: ServerRequestResponse, ) -> Result<(), HarnessError> { let thread_id = self + .shared .server_requests .lock() .await @@ -535,6 +765,7 @@ impl HarnessRegistry { .ok_or(HarnessError::ThreadNotFound(thread_id))?; let harness = self + .shared .harnesses .lock() .await @@ -545,7 +776,7 @@ impl HarnessRegistry { harness .respond_server_request(request_id.clone(), response) .await?; - self.server_requests.lock().await.remove(&request_id); + self.shared.server_requests.lock().await.remove(&request_id); Ok(()) } @@ -559,6 +790,7 @@ impl HarnessRegistry { .await .ok_or(HarnessError::ThreadNotFound(thread_id))?; let harness = self + .shared .harnesses .lock() .await @@ -608,6 +840,7 @@ impl HarnessRegistry { .await .ok_or(HarnessError::ThreadNotFound(thread_id))?; let harness = self + .shared .harnesses .lock() .await @@ -630,18 +863,17 @@ impl HarnessRegistry { model: effective_model, mode, kind: TurnContextKind::ManualCompaction, + passive_input_is_fallback: false, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: None, }; let turn_gate = self + .shared .turn_gate .reserve(thread_id, ActiveTurnOwner::new(project_id, &handle, &ctx))?; - let hub = self.hub.clone(); - let live_buffers = self.live_buffers.clone(); - let running_commands = self.running_commands.clone(); - let store = self.store.clone(); - let approvals_map = self.approvals.clone(); - let server_requests_map = self.server_requests.clone(); - let ledger = self.ledger.clone(); + let shared = self.shared.clone(); let stream = harness.subscribe(&handle); harness.compact_thread(&handle).await?; @@ -654,25 +886,54 @@ impl HarnessRegistry { ); tokio::spawn(async move { - forward_events( - thread_id, - project_id, - stream, - hub, - live_buffers, - running_commands, - store, - approvals_map, - server_requests_map, - ledger, - ctx, - Some(turn_gate), - ) - .await; + forward_events(shared, thread_id, project_id, stream, ctx, Some(turn_gate)).await; }); Ok(()) } + pub(crate) async fn open_subagent_link( + &self, + project_id: ProjectId, + parent_thread_id: ThreadId, + item_id: ItemId, + ) -> Result, HarnessError> { + let Some((spawned_by_turn_id, info)) = + resolve_subagent_link_info(&self.shared, project_id, parent_thread_id, item_id).await? + else { + return Ok(None); + }; + if let Some(parent_target) = resolve_reverse_subagent_target( + &self.shared, + project_id, + parent_thread_id, + &info.native_thread_id, + ) + .await? + { + return Ok(Some(parent_target)); + } + + let (result, receiver) = oneshot::channel(); + enqueue_subagent_materialization( + parent_thread_id, + SubagentMaterializationJob { + project_id, + spawned_by_turn_id, + item_id, + origin: "explicit_open", + info, + result: Some(result), + }, + self.shared.clone(), + ) + .await; + receiver.await.map_err(|_| { + HarnessError::Protocol(format!( + "sub-agent materialization queue closed for item {item_id}" + )) + })? + } + pub async fn terminate_command( &self, thread_id: ThreadId, @@ -687,6 +948,7 @@ impl HarnessRegistry { .await .ok_or(HarnessError::ThreadNotFound(thread_id))?; let harness = self + .shared .harnesses .lock() .await @@ -735,12 +997,7 @@ impl HarnessRegistry { let handle = self .get_thread_handle(thread_id) .await - .unwrap_or(ThreadHandle { - thread: thread_id, - harness_thread_id, - warning: None, - resumed_model: None, - }); + .unwrap_or_else(|| ThreadHandle::detached(thread_id, harness_thread_id)); harness.set_thread_archived(&handle, archived).await } @@ -755,12 +1012,7 @@ impl HarnessRegistry { let handle = self .get_thread_handle(thread_id) .await - .unwrap_or(ThreadHandle { - thread: thread_id, - harness_thread_id, - warning: None, - resumed_model: None, - }); + .unwrap_or_else(|| ThreadHandle::detached(thread_id, harness_thread_id)); harness.set_thread_name(&handle, &name).await } @@ -810,122 +1062,1516 @@ impl HarnessRegistry { thread_id: ThreadId, harness_thread_id: String, ) -> Result<(), HarnessError> { + self.stop_passive_subagent_monitor(thread_id).await?; let harness = self.get_or_create_harness(config.id, config).await?; let handle = self .get_thread_handle(thread_id) .await - .unwrap_or(ThreadHandle { - thread: thread_id, - harness_thread_id, - warning: None, - resumed_model: None, - }); + .unwrap_or_else(|| ThreadHandle::detached(thread_id, harness_thread_id)); harness.delete_thread(&handle).await?; self.forget_thread(thread_id).await; Ok(()) } pub async fn get_thread_handle(&self, thread_id: ThreadId) -> Option { - let threads = self.threads.lock().await; + let threads = self.shared.threads.lock().await; threads .get(&thread_id) .map(|binding| binding.handle.clone()) } pub async fn get_thread_native_model(&self, thread_id: ThreadId) -> Option { - let threads = self.threads.lock().await; + let threads = self.shared.threads.lock().await; threads .get(&thread_id) .map(|binding| binding.native_model.clone()) } pub async fn get_project_for_thread(&self, thread_id: ThreadId) -> Option { - let threads = self.threads.lock().await; + let threads = self.shared.threads.lock().await; threads.get(&thread_id).map(|binding| binding.project) } pub async fn thread_has_active_turn(&self, thread_id: ThreadId) -> bool { - self.turn_gate.is_active(thread_id) + self.shared.turn_gate.is_active(thread_id) + } + + pub async fn thread_has_passive_monitor(&self, thread_id: ThreadId) -> bool { + if self + .shared + .passive_monitors + .lock() + .await + .contains(&thread_id) + { + return true; + } + self.shared.passive_monitor_tasks.contains(thread_id).await + } + + pub async fn stop_passive_subagent_monitor( + &self, + thread_id: ThreadId, + ) -> Result<(), HarnessError> { + let monitor_exists = { + let monitors = self.shared.passive_monitors.lock().await; + if !monitors.contains(&thread_id) { + self.shared + .passive_subagent_metadata + .lock() + .await + .remove(&thread_id); + false + } else { + let mut metadata = self.shared.passive_subagent_metadata.lock().await; + let entry = metadata.entry(thread_id).or_default(); + entry.cancelled = true; + entry.lifecycle_notify.notify_one(); + true + } + }; + if !monitor_exists && !self.shared.passive_monitor_tasks.contains(thread_id).await { + return Ok(()); + } + + let deadline = tokio::time::Instant::now() + PASSIVE_MONITOR_STOP_TIMEOUT; + let mut completions = self.shared.passive_monitor_tasks.subscribe(); + loop { + if !self.shared.passive_monitor_tasks.contains(thread_id).await { + return Ok(()); + } + if tokio::time::timeout_at(deadline, completions.changed()) + .await + .is_err() + { + return Err(HarnessError::Protocol(format!( + "timed out stopping passive sub-agent monitor for thread {thread_id}" + ))); + } + } } pub async fn forget_thread(&self, thread_id: ThreadId) { - let mut threads = self.threads.lock().await; + let mut threads = self.shared.threads.lock().await; threads.remove(&thread_id); } pub async fn delete_project(&self, project_id: ProjectId) -> Result<(), HarnessError> { - let harness = self.harnesses.lock().await.get(&project_id).cloned(); + let thread_ids = self + .shared + .threads + .lock() + .await + .iter() + .filter_map(|(thread_id, binding)| { + (binding.project == project_id).then_some(*thread_id) + }) + .collect::>(); + for thread_id in &thread_ids { + self.stop_passive_subagent_monitor(*thread_id).await?; + } + + let harness = self.shared.harnesses.lock().await.get(&project_id).cloned(); if let Some(harness) = harness { harness.shutdown().await?; - self.harnesses.lock().await.remove(&project_id); + self.shared.harnesses.lock().await.remove(&project_id); } - let thread_ids = { - let mut threads = self.threads.lock().await; - let thread_ids = threads + let removed_thread_ids = { + let mut threads = self.shared.threads.lock().await; + let removed_thread_ids = threads .iter() .filter_map(|(thread_id, binding)| { (binding.project == project_id).then_some(*thread_id) }) .collect::>(); threads.retain(|_, binding| binding.project != project_id); - thread_ids + removed_thread_ids }; - if !thread_ids.is_empty() { - let mut approvals = self.approvals.lock().await; - approvals.retain(|_, thread_id| !thread_ids.contains(thread_id)); + if !removed_thread_ids.is_empty() { + let mut approvals = self.shared.approvals.lock().await; + approvals.retain(|_, thread_id| !removed_thread_ids.contains(thread_id)); - let mut server_requests = self.server_requests.lock().await; - server_requests.retain(|_, thread_id| !thread_ids.contains(thread_id)); + let mut server_requests = self.shared.server_requests.lock().await; + server_requests.retain(|_, thread_id| !removed_thread_ids.contains(thread_id)); } Ok(()) } } -#[allow(clippy::too_many_arguments)] -async fn forward_events( - thread_id: ThreadId, +async fn lock_project_lifecycle( + locks: &ProjectLifecycleLocks, project_id: ProjectId, - mut stream: giskard_harness::AgentEventStream, - hub: Arc, - live_buffers: Arc, - running_commands: Arc, - store: Arc, - approvals: ApprovalMap, - server_requests: ServerRequestMap, - ledger: LedgerHandle, - ctx: TurnContext, - mut turn_gate: Option, -) { - let mut turn_id: Option = None; - let mut owned_turn: Option = None; - let mut owned_turn_completed = false; - let mut started_at = Utc::now(); - let mut current_turn_items = CurrentTurnItems::default(); - let mut diffs: Vec = Vec::new(); - let mut seen_turn_ids = persisted_turn_ids(&store, project_id, thread_id).await; - let mut seen_notices = HashSet::new(); - let mut item_ids_by_harness: HashMap<(TurnId, String), ItemId> = HashMap::new(); - let forwarder_started = Instant::now(); - let mut saw_context_compaction_marker = false; - let mut stream_error: Option = None; - debug!( - %project_id, - %thread_id, - context_kind = turn_context_kind_label(ctx.kind), - mode = ?ctx.mode, - provider = %ctx.model.provider, - model = %ctx.model.model, - turn_gate_held = turn_gate.as_ref().is_some_and(|lease| !lease.is_released()), - persisted_turn_count = seen_turn_ids.len(), - "event forwarder started" - ); +) -> OwnedMutexGuard<()> { + let lock = { + let mut locks = locks.lock().await; + locks.retain(|_, lock| lock.strong_count() > 0); + match locks.get(&project_id).and_then(Weak::upgrade) { + Some(lock) => lock, + None => { + let lock = Arc::new(Mutex::new(())); + locks.insert(project_id, Arc::downgrade(&lock)); + lock + } + } + }; + lock.lock_owned().await +} - let exit_reason = loop { - match stream.recv().await { +#[derive(Clone)] +struct SubagentActivityInfo { + native_thread_id: String, + agent_name: Option, + agent_path: Option, + initial_prompt: Option, + title: Option, + action: SubagentAction, + status: Option, + fallback: Option, +} + +type SubagentMaterializationResult = Result, HarnessError>; + +struct SubagentMaterializationJob { + project_id: ProjectId, + spawned_by_turn_id: TurnId, + item_id: ItemId, + origin: &'static str, + info: SubagentActivityInfo, + result: Option>, +} + +#[derive(Clone)] +struct SubagentFallbackTranscript { + message: String, + status: SubagentStatus, +} + +struct FallbackTurnContext { + user_input: UserInput, + model: ModelRef, + mode: Mode, +} + +impl From<&TurnContext> for FallbackTurnContext { + fn from(ctx: &TurnContext) -> Self { + Self { + user_input: ctx.user_input.clone(), + model: ctx.model.clone(), + mode: ctx.mode, + } + } +} + +fn subagent_activity_info(item: &Item) -> Option { + match &item.payload { + ItemPayload::Activity { + title, subagent, .. + } => subagent_link_info(subagent.as_ref(), Some(title.clone()), None), + ItemPayload::ToolCall { + input, subagent, .. + } => subagent_link_info( + subagent.as_ref(), + None, + subagent_prompt_from_tool_input(input), + ), + _ => None, + } +} + +async fn resolve_subagent_link_info( + shared: &RegistryShared, + project_id: ProjectId, + parent_thread_id: ThreadId, + item_id: ItemId, +) -> Result, HarnessError> { + let parent_exists = shared + .store + .load_thread(project_id, parent_thread_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))? + .is_some(); + if !parent_exists { + return Err(HarnessError::ThreadNotFound(parent_thread_id)); + } + + for event in shared + .live_buffers + .item_events(parent_thread_id, item_id) + .await + .into_iter() + .rev() + { + match event { + AgentEvent::ItemCompleted { turn, item, .. } => { + if let Some(info) = subagent_activity_info(&item) { + return Ok(Some((turn, info))); + } + } + AgentEvent::ItemStarted { turn, item, .. } => { + if let Some(info) = subagent_start_info(&item) { + return Ok(Some((turn, info))); + } + } + _ => {} + } + } + + let turns = shared + .store + .load_all_turns(project_id, parent_thread_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?; + for turn in turns.into_iter().rev() { + if let Some(info) = turn + .items + .iter() + .rev() + .find(|item| item.id == item_id) + .and_then(subagent_activity_info) + { + return Ok(Some((turn.id, info))); + } + } + Ok(None) +} + +async fn resolve_reverse_subagent_target( + shared: &RegistryShared, + project_id: ProjectId, + source_thread_id: ThreadId, + native_thread_id: &str, +) -> Result, HarnessError> { + let graph = load_thread_graph(&shared.store, project_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?; + let Some(source) = graph.get(&source_thread_id) else { + return Err(HarnessError::ThreadNotFound(source_thread_id)); + }; + let target = graph + .values() + .find(|thread| thread.harness_thread_id == native_thread_id); + Ok(target + .filter(|target| source.parent_thread_id == Some(target.id)) + .map(|target| target.id)) +} + +fn subagent_start_info(item: &giskard_core::item::ItemStart) -> Option { + let tool = item.tool.as_ref()?; + subagent_link_info( + tool.subagent.as_ref(), + None, + subagent_prompt_from_tool_input(&tool.input), + ) +} + +fn subagent_link_info( + subagent: Option<&giskard_core::item::SubagentLink>, + title: Option, + prompt_fallback: Option, +) -> Option { + let subagent = subagent?; + let native_thread_id = trimmed_non_empty(&subagent.harness_thread_id)?; + let agent_path = subagent + .path + .as_deref() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned); + let initial_prompt = subagent + .initial_prompt + .as_deref() + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned) + .or(prompt_fallback); + Some(SubagentActivityInfo { + native_thread_id: native_thread_id.to_owned(), + agent_name: None, + agent_path, + initial_prompt, + title, + action: subagent.action, + status: subagent.status, + fallback: subagent_fallback_transcript(subagent), + }) +} + +fn subagent_prompt_from_tool_input(input: &serde_json::Value) -> Option { + for key in ["prompt", "message", "task", "instructions"] { + if let Some(prompt) = input + .get(key) + .and_then(serde_json::Value::as_str) + .and_then(trimmed_non_empty) + { + return Some(prompt.to_owned()); + } + } + input + .get("items") + .and_then(serde_json::Value::as_array) + .and_then(|items| { + items.iter().find_map(|item| { + item.get("text") + .and_then(serde_json::Value::as_str) + .and_then(trimmed_non_empty) + .map(ToOwned::to_owned) + }) + }) +} + +fn subagent_fallback_transcript( + subagent: &giskard_core::item::SubagentLink, +) -> Option { + terminal_subagent_fallback(subagent.status, subagent.message.as_deref()) +} + +fn terminal_subagent_fallback( + status: Option, + message: Option<&str>, +) -> Option { + let status = status?; + if !matches!( + status, + SubagentStatus::Completed + | SubagentStatus::Interrupted + | SubagentStatus::Failed + | SubagentStatus::Shutdown + | SubagentStatus::NotFound + ) { + return None; + } + let message = message.and_then(trimmed_non_empty)?.to_owned(); + Some(SubagentFallbackTranscript { message, status }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SubagentMonitorPolicy { + should_monitor: bool, + terminal_observed: bool, + active_observed: bool, + pre_turn_timeout: Option, +} + +struct SubagentObservation { + effective_model: ModelRef, + mode: Mode, + initial_prompt: Option, + policy: SubagentMonitorPolicy, + fallback: Option, +} + +fn subagent_monitor_policy( + action: Option, + status: Option, +) -> SubagentMonitorPolicy { + let terminal_observed = subagent_observation_is_terminal(action, status); + let active_observed = !terminal_observed + && (matches!( + status, + Some(SubagentStatus::Pending | SubagentStatus::Running) + ) || matches!( + action, + Some(SubagentAction::Spawned | SubagentAction::Started | SubagentAction::Interacted) + )); + SubagentMonitorPolicy { + should_monitor: active_observed, + terminal_observed, + active_observed, + // Active evidence gets a generous no-event safety bound so a missed terminal event cannot + // block direct follow-ups forever. Any stream event restarts the bound, and once a native + // turn begins normal turn completion—not this pre-turn timeout—owns the lifecycle. + pre_turn_timeout: active_observed.then_some(ACTIVE_SUBAGENT_PRE_TURN_IDLE_TIMEOUT), + } +} + +fn subagent_observation_is_terminal( + action: Option, + status: Option, +) -> bool { + action == Some(SubagentAction::Interrupted) + || matches!( + status, + Some( + SubagentStatus::Completed + | SubagentStatus::Interrupted + | SubagentStatus::Failed + | SubagentStatus::Shutdown + | SubagentStatus::NotFound + ) + ) +} + +fn subagent_thread_title(info: &SubagentActivityInfo) -> String { + let raw = info + .agent_name + .as_ref() + .map(|name| format!("Sub-agent: {name}")) + .or_else(|| { + info.agent_path + .as_ref() + .map(|path| format!("Sub-agent: {path}")) + }) + .or_else(|| info.title.clone()) + .unwrap_or_else(|| "Sub-agent".to_string()); + normalize_subagent_title(raw) +} + +fn normalize_subagent_title(raw: String) -> String { + let title = raw.split_whitespace().collect::>().join(" "); + let title = if title.is_empty() { + "Sub-agent".to_string() + } else { + title + }; + title.chars().take(120).collect() +} + +fn subagent_info_with_agent_name( + mut info: SubagentActivityInfo, + agent_name: Option, +) -> SubagentActivityInfo { + if let Some(agent_name) = agent_name { + info.agent_name = Some(agent_name); + } + info +} + +async fn update_passive_subagent_metadata( + map: &PassiveSubagentMetadataMap, + thread_id: ThreadId, + initial_prompt: Option, + fallback: Option, + signal: LifecycleSignal, +) { + let mut metadata = map.lock().await; + let entry = metadata.entry(thread_id).or_default(); + merge_passive_subagent_metadata(entry, initial_prompt, fallback, signal); +} + +fn merge_passive_subagent_metadata( + entry: &mut PassiveSubagentMetadata, + initial_prompt: Option, + fallback: Option, + signal: LifecycleSignal, +) { + if let Some(initial_prompt) = initial_prompt { + entry.initial_prompt = Some(initial_prompt); + } + if let Some(fallback) = fallback { + entry.fallback = Some(fallback); + } + match signal { + LifecycleSignal::None => {} + LifecycleSignal::Active => { + entry.active_lifecycle_observed = true; + entry.lifecycle_notify.notify_one(); + } + LifecycleSignal::Terminal => { + entry.terminal_observed = true; + entry.lifecycle_notify.notify_one(); + } + } +} + +async fn register_passive_subagent_monitor( + passive_monitors: &Arc>>, + passive_subagent_metadata: &PassiveSubagentMetadataMap, + passive_monitor_tasks: &PassiveMonitorTasks, + thread_id: ThreadId, + initial_prompt: Option, + fallback: Option, + signal: LifecycleSignal, +) -> bool { + // Monitor ownership and metadata are published atomically under the same lock order used by + // terminal recovery. A terminal observation therefore either updates this monitor or runs + // fallback recovery itself; it cannot slip between metadata creation and monitor insertion. + let mut monitors = passive_monitors.lock().await; + let inserted = monitors.insert(thread_id); + let mut metadata = passive_subagent_metadata.lock().await; + let entry = metadata.entry(thread_id).or_default(); + merge_passive_subagent_metadata(entry, initial_prompt, fallback, signal); + if inserted { + passive_monitor_tasks.register(thread_id).await; + } + inserted +} + +async fn finish_passive_subagent_monitor_task( + passive_monitor_tasks: &PassiveMonitorTasks, + thread_id: ThreadId, +) { + passive_monitor_tasks.finish(thread_id).await; +} + +async fn take_passive_subagent_monitor_metadata( + passive_monitors: &Arc>>, + passive_subagent_metadata: &PassiveSubagentMetadataMap, + thread_id: ThreadId, +) -> Option { + // Keep monitor ownership and metadata removal under one lock order. Terminal observations use + // the same order, so either the live monitor receives the fallback or teardown claims it for + // immediate recovery; there is no gap where a result can be attached to an exited forwarder. + let mut monitors = passive_monitors.lock().await; + monitors.remove(&thread_id); + passive_subagent_metadata.lock().await.remove(&thread_id) +} + +async fn refresh_passive_subagent_context( + thread_id: ThreadId, + ctx: &mut TurnContext, +) -> PassiveMonitorSignal { + if ctx.kind != TurnContextKind::PassiveSubagent { + return PassiveMonitorSignal::Continue; + } + let Some(metadata_map) = ctx.passive_subagent_metadata.as_ref() else { + return PassiveMonitorSignal::Continue; + }; + let Some(metadata) = metadata_map.lock().await.get(&thread_id).cloned() else { + return PassiveMonitorSignal::Continue; + }; + if let Some(initial_prompt) = metadata + .initial_prompt + .as_deref() + .and_then(trimmed_non_empty) + { + ctx.user_input = UserInput::text(initial_prompt); + ctx.passive_input_is_fallback = false; + } + if metadata.fallback.is_some() { + ctx.subagent_fallback = metadata.fallback; + } + if metadata.active_lifecycle_observed { + ctx.passive_pre_turn_timeout = Some(ACTIVE_SUBAGENT_PRE_TURN_IDLE_TIMEOUT); + } + if metadata.cancelled { + PassiveMonitorSignal::Cancelled + } else if metadata.terminal_observed { + PassiveMonitorSignal::Terminal + } else { + PassiveMonitorSignal::Continue + } +} + +async fn materialize_subagent_thread( + parent_thread_id: ThreadId, + project_id: ProjectId, + spawned_by_turn_id: TurnId, + info: SubagentActivityInfo, + shared: Arc, +) -> Result, HarnessError> { + let _lifecycle_guard = + lock_project_lifecycle(&shared.project_lifecycle_locks, project_id).await; + let Some(project_config) = shared + .store + .load_project(project_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))? + else { + return Err(HarnessError::Protocol(format!( + "project {project_id} disappeared while importing sub-agent" + ))); + }; + let parent_file = shared + .store + .load_thread(project_id, parent_thread_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))? + .ok_or_else(|| { + HarnessError::Protocol(format!( + "parent thread {parent_thread_id} disappeared while importing sub-agent" + )) + })?; + let live_existing_id = shared + .threads + .lock() + .await + .iter() + .find_map(|(thread_id, binding)| { + (binding.project == project_id + && binding.handle.harness_thread_id == info.native_thread_id) + .then_some(*thread_id) + }); + let (graph, existing) = if let Some(existing_id) = live_existing_id { + let existing = shared + .store + .load_thread(project_id, existing_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?; + (None, existing) + } else { + let graph = load_thread_graph(&shared.store, project_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?; + let existing = graph + .values() + .find(|thread| thread.harness_thread_id == info.native_thread_id) + .cloned(); + (Some(graph), existing) + }; + + if let Some(existing) = existing { + // A live binding has already passed the full ownership validation while it was imported. + // Repeated `interacted` activity can therefore use its immutable direct ownership fields + // instead of re-reading every thread file on the parent forwarder's hot path. + let disposition = match graph.as_ref() { + Some(graph) => classify_existing_link(graph, parent_thread_id, &existing), + None if existing.id == parent_thread_id => ExistingLinkDisposition::SelfLink, + None if existing.kind == ThreadKind::Primary || existing.parent_thread_id.is_none() => { + ExistingLinkDisposition::PrimaryThread + } + None if existing.parent_thread_id != Some(parent_thread_id) => { + ExistingLinkDisposition::DifferentParent + } + None => ExistingLinkDisposition::OwnedChild, + }; + if disposition != ExistingLinkDisposition::OwnedChild { + warn!( + %project_id, + %parent_thread_id, + existing_thread_id = %existing.id, + existing_kind = ?existing.kind, + existing_parent_thread_id = ?existing.parent_thread_id, + linked_harness_thread_id = %info.native_thread_id, + disposition = ?disposition, + reason = disposition.reason(), + "ignoring sub-agent materialization for an existing thread with incompatible ownership" + ); + return Ok(None); + } + let policy = subagent_monitor_policy(Some(info.action), info.status); + let opened_agent_name = if policy.should_monitor { + ensure_subagent_thread_open(&project_config, &existing, &shared).await? + } else { + shared + .threads + .lock() + .await + .get(&existing.id) + .and_then(|binding| binding.handle.agent_name.clone()) + }; + let refreshed_info = subagent_info_with_agent_name(info.clone(), opened_agent_name); + let desired_title = subagent_thread_title(&refreshed_info); + if should_refresh_subagent_title(&existing.title, &desired_title) { + shared + .store + .update_thread(project_id, existing.id, |thread| { + if should_refresh_subagent_title(&thread.title, &desired_title) { + thread.title = desired_title.clone(); + } + thread.updated_at = Utc::now(); + }) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?; + } + observe_external_subagent_with_context( + project_id, + existing.id, + SubagentObservation { + effective_model: existing.current_model.clone(), + mode: existing.mode, + initial_prompt: refreshed_info.initial_prompt, + policy, + fallback: refreshed_info.fallback, + }, + shared, + ) + .await?; + return Ok(Some(existing.id)); + } + + let graph = match graph { + Some(graph) => graph, + None => load_thread_graph(&shared.store, project_id) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?, + }; + if !parent_chain_is_valid(&graph, parent_thread_id) { + warn!( + %project_id, + %parent_thread_id, + linked_harness_thread_id = %info.native_thread_id, + "refusing to materialize a sub-agent under an invalid parent chain" + ); + return Ok(None); + } + + let model = parent_file.current_model.clone(); + let mode = parent_file.mode; + let context_window = parent_file.context_window; + let model_context_windows = parent_file.model_context_windows.clone(); + let approval_policy = parent_file.approval_policy; + let model_efforts = parent_file.model_efforts.clone(); + + let harness = shared + .harnesses + .lock() + .await + .get(&project_id) + .cloned() + .ok_or(HarnessError::ThreadNotFound(parent_thread_id))?; + let workspace_root = project_config + .workspace_root + .as_deref() + .unwrap_or(&project_config.dir) + .to_owned(); + let handle = harness + .open_thread(OpenThreadOptions { + project: project_id, + thread: None, + workspace_root: workspace_root.into(), + resume: Some(info.native_thread_id.clone()), + resume_policy: ResumePolicy::RequireExisting, + initial_model: model.clone(), + }) + .await?; + // This path calls the harness directly rather than `open_thread_with_resume_policy`, so retain + // the registry's harness-neutral strict-resume check even when the adapter also validates it. + if handle.harness_thread_id != info.native_thread_id { + return Err(HarnessError::Protocol(format!( + "linked-thread resume returned native thread {} instead of {}", + handle.harness_thread_id, info.native_thread_id + ))); + } + if let Some(native_parent) = handle.parent_harness_thread_id.as_deref() + && native_parent != parent_file.harness_thread_id + { + warn!( + %project_id, + %parent_thread_id, + proposed_parent_harness_thread_id = %parent_file.harness_thread_id, + reported_parent_harness_thread_id = %native_parent, + linked_harness_thread_id = %handle.harness_thread_id, + "refusing to materialize a native thread under a mismatched parent" + ); + return Ok(None); + } + let current_model = handle.resumed_model.clone().unwrap_or(model); + let info = subagent_info_with_agent_name(info, handle.agent_name.clone()); + let native_model = current_model.clone(); + shared.threads.lock().await.insert( + handle.thread, + ThreadBinding { + project: project_id, + handle: handle.clone(), + native_model, + }, + ); + + let now = Utc::now(); + let thread_file = ThreadFile { + version: 1, + id: handle.thread, + project_id, + title: subagent_thread_title(&info), + harness_thread_id: handle.harness_thread_id.clone(), + parent_thread_id: Some(parent_thread_id), + spawned_by_turn_id: Some(spawned_by_turn_id), + kind: ThreadKind::Subagent, + mode, + current_model: current_model.clone(), + context_window, + model_context_windows, + approval_policy, + model_efforts, + tokens: giskard_core::token::TokenLedger::default(), + created_at: now, + updated_at: now, + archived: false, + }; + shared + .store + .save_thread(project_id, &thread_file) + .await + .map_err(|error| HarnessError::Protocol(error.to_string()))?; + let policy = subagent_monitor_policy(Some(info.action), info.status); + observe_external_subagent_with_context( + project_id, + handle.thread, + SubagentObservation { + effective_model: current_model, + mode, + initial_prompt: info.initial_prompt, + policy, + fallback: info.fallback, + }, + shared, + ) + .await?; + Ok(Some(handle.thread)) +} + +async fn enqueue_subagent_materialization( + parent_thread_id: ThreadId, + job: SubagentMaterializationJob, + shared: Arc, +) { + let should_start_worker = { + let mut queues = shared.subagent_materialization_queues.lock().await; + let should_start = !queues.contains_key(&parent_thread_id); + queues.entry(parent_thread_id).or_default().push_back(job); + should_start + }; + if should_start_worker { + tokio::spawn(run_subagent_materialization_queue(parent_thread_id, shared)); + } +} + +async fn run_subagent_materialization_queue( + parent_thread_id: ThreadId, + shared: Arc, +) { + loop { + let job = { + let mut queues = shared.subagent_materialization_queues.lock().await; + let job = queues + .get_mut(&parent_thread_id) + .and_then(VecDeque::pop_front); + if job.is_none() { + queues.remove(&parent_thread_id); + } + job + }; + let Some(job) = job else { + return; + }; + let result = materialize_subagent_thread( + parent_thread_id, + job.project_id, + job.spawned_by_turn_id, + job.info, + shared.clone(), + ) + .await; + match &result { + Ok(Some(subagent_thread_id)) => { + info!( + project_id = %job.project_id, + %parent_thread_id, + %subagent_thread_id, + turn = %job.spawned_by_turn_id, + item_id = %job.item_id, + origin = %job.origin, + "materialized sub-agent thread from linked activity" + ); + } + Ok(None) => {} + Err(error) => { + warn!( + project_id = %job.project_id, + %parent_thread_id, + turn = %job.spawned_by_turn_id, + item_id = %job.item_id, + origin = %job.origin, + error = %error, + "failed to materialize sub-agent thread from linked activity" + ); + } + } + if let Some(sender) = job.result { + let _ = sender.send(result); + } + } +} + +async fn ensure_subagent_thread_open( + project_config: &ProjectConfig, + thread_file: &ThreadFile, + shared: &RegistryShared, +) -> Result, HarnessError> { + if let Some(binding) = shared.threads.lock().await.get(&thread_file.id) { + return Ok(binding.handle.agent_name.clone()); + } + let harness = shared + .harnesses + .lock() + .await + .get(&project_config.id) + .cloned() + .ok_or(HarnessError::ThreadNotFound(thread_file.id))?; + let workspace_root = project_config + .workspace_root + .as_deref() + .unwrap_or(&project_config.dir) + .to_owned(); + let handle = harness + .open_thread(OpenThreadOptions { + project: project_config.id, + thread: Some(thread_file.id), + workspace_root: workspace_root.into(), + resume: Some(thread_file.harness_thread_id.clone()), + resume_policy: ResumePolicy::RequireExisting, + initial_model: thread_file.current_model.clone(), + }) + .await?; + // This path calls the harness directly rather than `open_thread_with_resume_policy`, so retain + // the registry's harness-neutral strict-resume check even when the adapter also validates it. + if handle.harness_thread_id != thread_file.harness_thread_id { + return Err(HarnessError::Protocol(format!( + "linked-thread resume returned native thread {} instead of {}", + handle.harness_thread_id, thread_file.harness_thread_id + ))); + } + let native_model = handle + .resumed_model + .clone() + .unwrap_or_else(|| thread_file.current_model.clone()); + let agent_name = handle.agent_name.clone(); + shared.threads.lock().await.insert( + handle.thread, + ThreadBinding { + project: project_config.id, + handle, + native_model, + }, + ); + Ok(agent_name) +} + +async fn start_passive_subagent_monitor( + thread_id: ThreadId, + observation: SubagentObservation, + shared: Arc, +) -> Result<(), HarnessError> { + let SubagentObservation { + effective_model, + mode, + initial_prompt, + policy, + fallback, + } = observation; + if !register_passive_subagent_monitor( + &shared.passive_monitors, + &shared.passive_subagent_metadata, + &shared.passive_monitor_tasks, + thread_id, + initial_prompt.clone(), + fallback.clone(), + if policy.active_observed { + LifecycleSignal::Active + } else { + LifecycleSignal::None + }, + ) + .await + { + return Ok(()); + } + + let error_cleanup_shared = shared.clone(); + let result = async { + let threads = shared.threads.lock().await; + let binding = threads + .get(&thread_id) + .ok_or(HarnessError::ThreadNotFound(thread_id))?; + let project_id = binding.project; + let handle = binding.handle.clone(); + drop(threads); + + let harness = shared + .harnesses + .lock() + .await + .get(&project_id) + .cloned() + .ok_or(HarnessError::ThreadNotFound(thread_id))?; + + let stream = harness.subscribe(&handle); + let cleanup_model = effective_model.clone(); + let cleanup_mode = mode; + let prompt_text = initial_prompt.as_deref().and_then(trimmed_non_empty); + let ctx = TurnContext { + user_input: UserInput::text(prompt_text.unwrap_or("Sub-agent turn")), + model: effective_model, + mode, + kind: TurnContextKind::PassiveSubagent, + passive_input_is_fallback: prompt_text.is_none(), + subagent_fallback: fallback, + passive_subagent_metadata: Some(shared.passive_subagent_metadata.clone()), + passive_pre_turn_timeout: policy.pre_turn_timeout, + }; + + info!( + %project_id, + %thread_id, + harness_thread_id = %handle.harness_thread_id, + "starting passive monitor for external harness turn" + ); + + let cleanup_shared = shared.clone(); + let cleanup_tasks = shared.passive_monitor_tasks.clone(); + tokio::spawn(async move { + forward_events(shared, thread_id, project_id, stream, ctx, None).await; + if let Some(metadata) = take_passive_subagent_monitor_metadata( + &cleanup_shared.passive_monitors, + &cleanup_shared.passive_subagent_metadata, + thread_id, + ) + .await + { + if metadata.cancelled { + debug!( + %project_id, + %thread_id, + "passive sub-agent monitor cleanup skipped fallback after cancellation" + ); + } else if let Some(fallback) = metadata.fallback { + persist_terminal_subagent_fallback( + project_id, + thread_id, + cleanup_model, + cleanup_mode, + metadata.initial_prompt, + fallback, + cleanup_shared, + ) + .await; + } + } + finish_passive_subagent_monitor_task(&cleanup_tasks, thread_id).await; + }); + + Ok(()) + } + .await; + + if result.is_err() { + take_passive_subagent_monitor_metadata( + &error_cleanup_shared.passive_monitors, + &error_cleanup_shared.passive_subagent_metadata, + thread_id, + ) + .await; + finish_passive_subagent_monitor_task( + &error_cleanup_shared.passive_monitor_tasks, + thread_id, + ) + .await; + } + result +} + +async fn observe_external_subagent_with_context( + project_id: ProjectId, + thread_id: ThreadId, + observation: SubagentObservation, + shared: Arc, +) -> Result<(), HarnessError> { + if observation.policy.should_monitor { + // Setup is cancellation-shielded after the child record has been persisted. The detached + // task owns monitor registration and cleanup even if its HTTP importer disconnects. + let task = launch_passive_subagent_monitor(thread_id, observation, shared); + return match task.await { + Ok(result) => result, + Err(error) => { + error!( + %thread_id, + %error, + "passive sub-agent monitor setup task failed" + ); + Err(HarnessError::Protocol(format!( + "passive sub-agent monitor setup task failed: {error}" + ))) + } + }; + } + + if observation.policy.terminal_observed { + return recover_terminal_subagent_fallback( + project_id, + thread_id, + observation.effective_model, + observation.mode, + observation.initial_prompt, + observation.fallback, + shared, + ) + .await; + } + + debug!( + %thread_id, + "skipping passive monitor for sub-agent observation without active work" + ); + Ok(()) +} + +fn launch_passive_subagent_monitor( + thread_id: ThreadId, + observation: SubagentObservation, + shared: Arc, +) -> tokio::task::JoinHandle> { + tokio::spawn(start_passive_subagent_monitor( + thread_id, + observation, + shared, + )) +} + +async fn recover_terminal_subagent_fallback( + project_id: ProjectId, + thread_id: ThreadId, + effective_model: ModelRef, + mode: Mode, + initial_prompt: Option, + fallback: Option, + shared: Arc, +) -> Result<(), HarnessError> { + let attached_to_monitor = { + let monitors = shared.passive_monitors.lock().await; + if monitors.contains(&thread_id) { + update_passive_subagent_metadata( + &shared.passive_subagent_metadata, + thread_id, + initial_prompt.clone(), + fallback.clone(), + LifecycleSignal::Terminal, + ) + .await; + true + } else { + false + } + }; + if attached_to_monitor { + debug!( + %thread_id, + "attached terminal fallback to active passive sub-agent monitor" + ); + return Ok(()); + } + + let Some(fallback) = fallback else { + debug!( + %thread_id, + "terminal sub-agent observation requires no monitor or fallback recovery" + ); + return Ok(()); + }; + + persist_terminal_subagent_fallback( + project_id, + thread_id, + effective_model, + mode, + initial_prompt, + fallback, + shared, + ) + .await; + Ok(()) +} + +async fn persist_terminal_subagent_fallback( + project_id: ProjectId, + thread_id: ThreadId, + effective_model: ModelRef, + mode: Mode, + initial_prompt: Option, + fallback: SubagentFallbackTranscript, + shared: Arc, +) { + let prompt_text = initial_prompt.as_deref().and_then(trimmed_non_empty); + let ctx = FallbackTurnContext { + user_input: UserInput::text(prompt_text.unwrap_or("Sub-agent turn")), + model: effective_model, + mode, + }; + let mut seen_turn_ids = persisted_turn_ids(&shared.store, project_id, thread_id).await; + persist_subagent_fallback_transcript( + thread_id, + project_id, + &ctx, + fallback, + &mut seen_turn_ids, + &shared, + ) + .await; +} + +async fn broadcast_event_with_context( + hub: &Arc, + thread_id: ThreadId, + event: AgentEvent, + ctx: &TurnContext, +) { + broadcast_event_with_user_input(hub, thread_id, event, live_turn_user_input(ctx)).await; +} + +async fn broadcast_event_with_user_input( + hub: &Arc, + thread_id: ThreadId, + event: AgentEvent, + user_input: Option, +) { + let agent_event = match event { + AgentEvent::TurnStarted { thread, turn } => WireAgentEvent::TurnStarted { + thread, + turn, + user_input, + }, + other => other.into(), + }; + hub.broadcast( + thread_id, + ServerMessage::Event { + thread_id, + agent_event: Box::new(agent_event), + }, + ) + .await; +} + +#[derive(Default)] +struct SyntheticSubagentPrompt { + item_id: Option, + text: Option, +} + +async fn synthesize_passive_subagent_prompt_item( + thread_id: ThreadId, + turn: TurnId, + ctx: &TurnContext, + current_turn_items: &mut CurrentTurnItems, + prompt: &mut SyntheticSubagentPrompt, + hub: &Arc, + live_buffers: &Arc, +) { + let Some(text) = passive_subagent_prompt_text(ctx) else { + return; + }; + if prompt.text.as_deref() == Some(text.as_str()) { + return; + } + let item_id = *prompt.item_id.get_or_insert_with(ItemId::new); + prompt.text = Some(text.clone()); + let item = Item { + id: item_id, + harness_item_id: format!("subagent_prompt:{turn}"), + payload: ItemPayload::UserMessage { text }, + created_at: Utc::now(), + }; + current_turn_items.upsert_first(&item); + let event = AgentEvent::ItemCompleted { + thread: thread_id, + turn, + item, + }; + if live_buffers.is_active(thread_id).await { + live_buffers.append(thread_id, event.clone()).await; + } + broadcast_event_with_context(hub, thread_id, event, ctx).await; +} + +enum PassivePreTurnOutcome { + Event(Box>), + EvidenceAdopted, + Stop(PassivePreTurnStop), +} + +enum PassivePreTurnStop { + Cancelled, + Terminal, + TimedOut { timeout: Option }, +} + +async fn passive_pre_turn_recv( + stream: &mut giskard_harness::AgentEventStream, + lifecycle_notify: Option<&Arc>, + thread_id: ThreadId, + ctx: &mut TurnContext, +) -> PassivePreTurnOutcome { + let wait_for_event = async { + if let Some(notify) = lifecycle_notify { + tokio::select! { + biased; + result = stream.recv() => Some(result), + _ = notify.notified() => None, + } + } else { + Some(stream.recv().await) + } + }; + let wait_result = if let Some(pre_turn_timeout) = ctx.passive_pre_turn_timeout { + timeout(pre_turn_timeout, wait_for_event).await.ok() + } else { + Some(wait_for_event.await) + }; + + match wait_result { + Some(Some(result)) => PassivePreTurnOutcome::Event(Box::new(result)), + Some(None) => match refresh_passive_subagent_context(thread_id, ctx).await { + PassiveMonitorSignal::Continue => PassivePreTurnOutcome::EvidenceAdopted, + PassiveMonitorSignal::Cancelled => { + PassivePreTurnOutcome::Stop(PassivePreTurnStop::Cancelled) + } + PassiveMonitorSignal::Terminal => { + PassivePreTurnOutcome::Stop(PassivePreTurnStop::Terminal) + } + }, + None => { + let elapsed_timeout = ctx.passive_pre_turn_timeout; + match refresh_passive_subagent_context(thread_id, ctx).await { + PassiveMonitorSignal::Cancelled => { + PassivePreTurnOutcome::Stop(PassivePreTurnStop::Cancelled) + } + PassiveMonitorSignal::Terminal => { + PassivePreTurnOutcome::Stop(PassivePreTurnStop::Terminal) + } + PassiveMonitorSignal::Continue => { + PassivePreTurnOutcome::Stop(PassivePreTurnStop::TimedOut { + timeout: elapsed_timeout, + }) + } + } + } + } +} + +async fn forward_events( + shared: Arc, + thread_id: ThreadId, + project_id: ProjectId, + mut stream: giskard_harness::AgentEventStream, + mut ctx: TurnContext, + mut turn_gate: Option, +) { + let hub = shared.hub.clone(); + let live_buffers = shared.live_buffers.clone(); + let running_commands = shared.running_commands.clone(); + let store = shared.store.clone(); + let approvals = shared.approvals.clone(); + let server_requests = shared.server_requests.clone(); + let mut turn_id: Option = None; + let mut owned_turn: Option = None; + let mut owned_turn_completed = false; + let mut started_at = Utc::now(); + let mut current_turn_items = CurrentTurnItems::default(); + let mut diffs: Vec = Vec::new(); + let mut seen_turn_ids = persisted_turn_ids(&store, project_id, thread_id).await; + let mut seen_notices = HashSet::new(); + let mut item_ids_by_harness: HashMap<(TurnId, String), ItemId> = HashMap::new(); + let mut synthetic_subagent_prompt = SyntheticSubagentPrompt::default(); + let forwarder_started = Instant::now(); + let mut saw_context_compaction_marker = false; + let mut stream_error: Option = None; + let passive_lifecycle_notify = if ctx.kind == TurnContextKind::PassiveSubagent { + match ctx.passive_subagent_metadata.as_ref() { + Some(metadata) => metadata + .lock() + .await + .get(&thread_id) + .map(|entry| entry.lifecycle_notify.clone()), + None => None, + } + } else { + None + }; + debug!( + %project_id, + %thread_id, + context_kind = turn_context_kind_label(ctx.kind), + mode = ?ctx.mode, + provider = %ctx.model.provider, + model = %ctx.model.model, + turn_gate_held = turn_gate.as_ref().is_some_and(|lease| !lease.is_released()), + persisted_turn_count = seen_turn_ids.len(), + "event forwarder started" + ); + + let exit_reason = loop { + let recv_result = if ctx.kind == TurnContextKind::PassiveSubagent + && owned_turn.is_none() + && turn_id.is_none() + { + match passive_pre_turn_recv( + &mut stream, + passive_lifecycle_notify.as_ref(), + thread_id, + &mut ctx, + ) + .await + { + PassivePreTurnOutcome::Event(result) => *result, + PassivePreTurnOutcome::EvidenceAdopted => { + debug!( + %project_id, + %thread_id, + timeout_ms = ?ctx.passive_pre_turn_timeout.map(|value| value.as_millis()), + "passive subagent monitor adopted active lifecycle evidence" + ); + continue; + } + PassivePreTurnOutcome::Stop(stop) => { + if !matches!(stop, PassivePreTurnStop::Cancelled) + && let Some(fallback) = ctx.subagent_fallback.clone() + { + let fallback_ctx = FallbackTurnContext::from(&ctx); + persist_subagent_fallback_transcript( + thread_id, + project_id, + &fallback_ctx, + fallback, + &mut seen_turn_ids, + &shared, + ) + .await; + } + match stop { + PassivePreTurnStop::Cancelled => info!( + %project_id, + %thread_id, + elapsed_ms = forwarder_started.elapsed().as_millis(), + "passive subagent monitor cancelled before observing a turn" + ), + PassivePreTurnStop::Terminal => info!( + %project_id, + %thread_id, + elapsed_ms = forwarder_started.elapsed().as_millis(), + "passive subagent monitor stopped after terminal observation before a turn" + ), + PassivePreTurnStop::TimedOut { timeout } => info!( + %project_id, + %thread_id, + timeout_ms = timeout.map(|value| value.as_millis()).unwrap_or_default(), + elapsed_ms = forwarder_started.elapsed().as_millis(), + "passive subagent monitor timed out before observing a turn" + ), + } + break ForwarderExitReason::StreamEndedWithoutTurn; + } + } + } else { + stream.recv().await + }; + match recv_result { Ok(event) => { + if ctx.kind == TurnContextKind::PassiveSubagent + && turn_gate.is_none() + && event_turn_id(&event).is_none() + && shared.turn_gate.is_active(thread_id) + { + warn!( + %project_id, + %thread_id, + event_kind = event_kind(&event), + "passive sub-agent forwarder yielded turnless event to an active forwarder" + ); + break ForwarderExitReason::DuplicateForwarder; + } + if ctx.kind == TurnContextKind::PassiveSubagent + && owned_turn.is_none() + && turn_id.is_none() + && refresh_passive_subagent_context(thread_id, &mut ctx).await + == PassiveMonitorSignal::Cancelled + { + info!( + %project_id, + %thread_id, + "passive subagent monitor cancelled before processing a queued event" + ); + break ForwarderExitReason::StreamEndedWithoutTurn; + } let event_thread = event_thread_id(&event); if event_thread != thread_id { error!( @@ -948,6 +2594,57 @@ async fn forward_events( continue; } + if ctx.kind == TurnContextKind::PassiveSubagent + && turn_gate.is_none() + && let Some(passive_turn) = event_turn_id(&event) + && !seen_turn_ids.contains(&passive_turn) + { + let handle = shared + .threads + .lock() + .await + .get(&thread_id) + .map(|binding| binding.handle.clone()); + let Some(handle) = handle else { + error!( + %project_id, + %thread_id, + %passive_turn, + "passive sub-agent forwarder lost its thread binding" + ); + break ForwarderExitReason::DuplicateForwarder; + }; + match shared + .turn_gate + .reserve(thread_id, ActiveTurnOwner::new(project_id, &handle, &ctx)) + { + Ok(mut lease) => { + lease.acknowledge_turn(passive_turn); + turn_gate = Some(lease); + } + Err(HarnessError::ThreadBusy { .. }) => { + warn!( + %project_id, + %thread_id, + %passive_turn, + event_kind = event_kind(&event), + "passive subscriber yielded to the existing turn forwarder" + ); + break ForwarderExitReason::DuplicateForwarder; + } + Err(error) => { + error!( + %project_id, + %thread_id, + %passive_turn, + %error, + "passive subscriber could not reserve turn ownership" + ); + break ForwarderExitReason::DuplicateForwarder; + } + } + } + if let Some((event_turn, harness_item_id, existing_item_id, conflicting_item_id)) = track_item_identity(&mut item_ids_by_harness, &event) { @@ -1081,6 +2778,24 @@ async fn forward_events( continue; } + 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; + } + } + } + let command_state_changed = apply_running_command_event(&running_commands, &event).await; @@ -1133,7 +2848,39 @@ async fn forward_events( ); } } + AgentEvent::ItemStarted { item, turn, .. } => { + if let Some(info) = subagent_start_info(item) { + enqueue_subagent_materialization( + thread_id, + SubagentMaterializationJob { + project_id, + spawned_by_turn_id: *turn, + item_id: item.id, + origin: "item_started", + info, + result: None, + }, + shared.clone(), + ) + .await; + } + } AgentEvent::ItemCompleted { item, turn, .. } => { + if let Some(info) = subagent_activity_info(item) { + enqueue_subagent_materialization( + thread_id, + SubagentMaterializationJob { + project_id, + spawned_by_turn_id: *turn, + item_id: item.id, + origin: "item_completed", + info, + result: None, + }, + shared.clone(), + ) + .await; + } if ctx.kind == TurnContextKind::ManualCompaction && is_context_compaction_item(item) { @@ -1206,10 +2953,48 @@ async fn forward_events( _ => None, }; - if is_turn_start { - live_buffers.start_turn(thread_id).await; + // A harness may deliver an item for an unseen turn before TurnStarted. Start the + // 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 + .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 live_buffers.is_active(thread_id).await { + if append_to_live_buffer && live_buffers.is_active(thread_id).await { live_buffers.append(thread_id, event.clone()).await; } @@ -1250,10 +3035,7 @@ async fn forward_events( started_at, turn_id, &mut seen_turn_ids, - &store, - &hub, - &ledger, - &live_buffers, + &shared, turn_gate.as_mut(), ) .await; @@ -1277,7 +3059,22 @@ async fn forward_events( } broadcast_thread_activity(&hub, thread_id, &event, true).await; - hub.broadcast_event(thread_id, event).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 command_state_changed { broadcast_running_commands(&hub, &running_commands, thread_id).await; @@ -1320,10 +3117,7 @@ async fn forward_events( started_at, turn_id, &mut seen_turn_ids, - &store, - &hub, - &ledger, - &live_buffers, + &shared, turn_gate.as_mut(), ) .await; @@ -1414,10 +3208,7 @@ async fn forward_events( started_at, turn_id, &mut seen_turn_ids, - &store, - &hub, - &ledger, - &live_buffers, + &shared, turn_gate.as_mut(), ) .await; @@ -1470,6 +3261,107 @@ async fn forward_events( } } +async fn persist_subagent_fallback_transcript( + thread_id: ThreadId, + project_id: ProjectId, + ctx: &FallbackTurnContext, + fallback: SubagentFallbackTranscript, + seen_turn_ids: &mut HashSet, + shared: &RegistryShared, +) { + if !seen_turn_ids.is_empty() { + debug!( + %project_id, + %thread_id, + persisted_turn_count = seen_turn_ids.len(), + "skipping sub-agent fallback transcript because history already exists" + ); + return; + } + + let turn_id = TurnId::new(); + let item = Item { + id: ItemId::new(), + harness_item_id: format!("subagent_fallback:{turn_id}"), + payload: ItemPayload::AgentMessage { + text: fallback.message, + }, + created_at: Utc::now(), + }; + let status = TurnStatus { + kind: subagent_status_turn_kind(fallback.status), + message: None, + }; + let started_at = Utc::now(); + let turn = Turn { + id: turn_id, + user_input: ctx.user_input.clone(), + items: vec![item.clone()], + model: ctx.model.clone(), + mode: ctx.mode, + status: status.clone(), + usage: giskard_core::token::TokenUsage::default(), + diffs: Vec::new(), + started_at, + completed_at: Some(Utc::now()), + }; + let outcome = persist_turn( + &shared.store, + &shared.hub, + &shared.ledger, + project_id, + thread_id, + turn, + ) + .await; + if !outcome.history_appended { + return; + } + seen_turn_ids.insert(turn_id); + + for event in [ + AgentEvent::TurnStarted { + thread: thread_id, + turn: turn_id, + }, + AgentEvent::ItemCompleted { + thread: thread_id, + turn: turn_id, + item, + }, + AgentEvent::TurnCompleted { + thread: thread_id, + turn: turn_id, + usage: giskard_core::token::TokenUsage::default(), + status, + }, + ] { + broadcast_event_with_user_input( + &shared.hub, + thread_id, + event, + Some(ctx.user_input.clone()), + ) + .await; + } + info!( + %project_id, + %thread_id, + turn = %turn_id, + "persisted fallback transcript for completed sub-agent" + ); +} + +fn subagent_status_turn_kind(status: SubagentStatus) -> TurnStatusKind { + match status { + SubagentStatus::Interrupted | SubagentStatus::Shutdown => TurnStatusKind::Interrupted, + SubagentStatus::Failed | SubagentStatus::NotFound => TurnStatusKind::Failed, + SubagentStatus::Pending | SubagentStatus::Running | SubagentStatus::Completed => { + TurnStatusKind::Completed + } + } +} + #[allow(clippy::too_many_arguments)] async fn complete_forwarded_turn( thread_id: ThreadId, @@ -1483,10 +3375,7 @@ async fn complete_forwarded_turn( started_at: chrono::DateTime, turn_id: Option, seen_turn_ids: &mut HashSet, - store: &Arc, - hub: &Arc, - ledger: &LedgerHandle, - live_buffers: &Arc, + shared: &RegistryShared, turn_gate: Option<&mut ThreadTurnLease>, ) -> TurnId { let tid = turn_id.unwrap_or(completed_turn); @@ -1519,7 +3408,15 @@ async fn complete_forwarded_turn( started_at, completed_at: Some(Utc::now()), }; - let persist_outcome = persist_turn(store, hub, ledger, project_id, thread_id, turn).await; + let persist_outcome = persist_turn( + &shared.store, + &shared.hub, + &shared.ledger, + project_id, + thread_id, + turn, + ) + .await; if ctx.kind == TurnContextKind::ManualCompaction { info!( %project_id, @@ -1532,7 +3429,7 @@ async fn complete_forwarded_turn( "context compaction persistence path finished" ); } - live_buffers.clear_turn(thread_id).await; + shared.live_buffers.clear_turn(thread_id).await; if let Some(turn_gate) = turn_gate { turn_gate.release(); } @@ -1957,6 +3854,12 @@ impl CurrentTurnItems { false } + fn upsert_first(&mut self, item: &Item) { + self.items.retain(|existing| existing.id != item.id); + self.items.insert(0, item.clone()); + self.rebuild_indexes(); + } + fn append_indexed(&mut self, item: &Item) { let idx = self.items.len(); self.items.push(item.clone()); @@ -2157,77 +4060,417 @@ async fn persist_turn( "updated thread metadata for completed turn" ); - // Fold the same usage into the project + global ledgers via the single-writer actor (§10.2). - if should_record { - let date = Utc::now().format("%Y-%m-%d").to_string(); - ledger - .record(project_id, date, provider, model, usage) - .await; + // Fold the same usage into the project + global ledgers via the single-writer actor (§10.2). + if should_record { + let date = Utc::now().format("%Y-%m-%d").to_string(); + ledger + .record(project_id, date, provider, model, usage) + .await; + } + + // Push a thread-scoped token update to subscribers (§13.6). + if let Ok(ledger_json) = serde_json::to_value(&tf.tokens) { + hub.broadcast( + thread_id, + ServerMessage::TokenUpdate { + scope: TokenScope::Thread, + thread_id: Some(thread_id), + ledger: ledger_json, + }, + ) + .await; + } + + PersistTurnOutcome { + history_appended: true, + metadata_updated: true, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::sync::Arc; + + use chrono::Utc; + use giskard_core::approval::{ApprovalDecision, ApprovalKind, ApprovalRequest}; + use giskard_core::error::HarnessError; + use giskard_core::event::AgentEvent; + use giskard_core::ids::{ApprovalId, ItemId, ProjectId, ServerRequestId, ThreadId, TurnId}; + use giskard_core::item::{ + CommandExecutionStart, Item, ItemKind, ItemPayload, ItemStart, SubagentAction, + SubagentStatus, + }; + use giskard_core::model::ModelRef; + use giskard_core::server_request::ServerRequest; + use giskard_core::token::{TokenLedger, TokenUsage}; + use giskard_core::turn::{ApprovalPolicy, Mode, Turn, TurnStatus, TurnStatusKind}; + use giskard_core::user_input::UserInput; + use giskard_harness::{AgentEventStream, ThreadHandle}; + use giskard_persist::PersistStore; + use giskard_persist::store::{ProjectConfig, ThreadFile}; + use giskard_proto::{ServerMessage, ThreadActivityKind, WireAgentEvent}; + use tokio::sync::{Mutex, broadcast, mpsc}; + use tokio::task::JoinHandle; + + use super::{ + ActiveTurnOwner, CurrentTurnItems, ThreadTurnGate, TurnContext, TurnContextKind, + command_completion_is_normal_success, command_status_is_running, forward_events, + passive_subagent_prompt_text, persist_subagent_fallback_transcript, + should_refresh_subagent_title, subagent_monitor_policy, + take_passive_subagent_monitor_metadata, thread_activity_from_event, track_item_identity, + update_passive_subagent_metadata, + }; + use crate::hub::Hub; + use crate::ledger; + use crate::live_buffer::LiveBufferStore; + use crate::running_commands::RunningTaskStore; + + struct UnusedHarnessFactory; + + #[async_trait::async_trait] + impl super::HarnessFactory for UnusedHarnessFactory { + async fn create( + &self, + _config: &ProjectConfig, + ) -> Result, HarnessError> { + Err(HarnessError::Protocol( + "unused test harness factory was called".into(), + )) + } + } + + #[test] + fn command_completion_success_requires_success_status_and_zero_exit() { + assert!(command_completion_is_normal_success("completed", Some(0))); + assert!(command_completion_is_normal_success("succeeded", Some(0))); + assert!(command_completion_is_normal_success("success", Some(0))); + + assert!(!command_completion_is_normal_success( + "completed", + Some(143) + )); + assert!(!command_completion_is_normal_success("failed", Some(0))); + assert!(!command_completion_is_normal_success("interrupted", None)); + } + + #[test] + fn active_subagent_monitor_uses_a_long_pre_turn_idle_timeout() { + for action in [ + SubagentAction::Spawned, + SubagentAction::Started, + SubagentAction::Interacted, + ] { + let policy = subagent_monitor_policy(Some(action), None); + assert!(policy.should_monitor); + assert!(policy.active_observed); + assert_eq!( + policy.pre_turn_timeout, + Some(super::ACTIVE_SUBAGENT_PRE_TURN_IDLE_TIMEOUT) + ); + } + assert!( + subagent_monitor_policy(Some(SubagentAction::Spawned), Some(SubagentStatus::Pending)) + .should_monitor + ); + assert!( + subagent_monitor_policy(Some(SubagentAction::Spawned), Some(SubagentStatus::Running)) + .should_monitor + ); + + let ignored = subagent_monitor_policy(None, None); + assert!(!ignored.should_monitor); + + let interrupted = subagent_monitor_policy(Some(SubagentAction::Interrupted), None); + assert!(!interrupted.should_monitor); + assert!(interrupted.terminal_observed); + for status in [ + SubagentStatus::Completed, + SubagentStatus::Interrupted, + SubagentStatus::Failed, + SubagentStatus::Shutdown, + SubagentStatus::NotFound, + ] { + let policy = subagent_monitor_policy(Some(SubagentAction::Started), Some(status)); + assert!(!policy.should_monitor); + assert!(policy.terminal_observed); + } + } + + #[test] + fn real_prompt_equal_to_fallback_copy_is_not_suppressed() { + let mut ctx = TurnContext { + user_input: UserInput::text("Sub-agent turn"), + model: ModelRef { + provider: "openai".into(), + model: "gpt-5.6-sol".into(), + reasoning_effort: None, + }, + mode: Mode::Build, + kind: TurnContextKind::PassiveSubagent, + passive_input_is_fallback: false, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: None, + }; + assert_eq!( + passive_subagent_prompt_text(&ctx).as_deref(), + Some("Sub-agent turn") + ); + + ctx.passive_input_is_fallback = true; + assert_eq!(passive_subagent_prompt_text(&ctx), None); + } + + #[tokio::test] + async fn passive_monitor_releases_after_pre_turn_idle_timeout() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = Arc::new(PersistStore::new(tmp.path().to_path_buf())); + let project_id = ProjectId::new(); + let thread_id = ThreadId::new(); + let model = ModelRef { + provider: "openai".into(), + model: "gpt-5.6-sol".into(), + reasoning_effort: None, + }; + store + .create_project(project_id, "proj", "/tmp/test", model.clone()) + .await + .unwrap(); + let (tx, _) = broadcast::channel(8); + let hub = Arc::new(Hub::new()); + let live_buffers = Arc::new(LiveBufferStore::new()); + let running_commands = Arc::new(RunningTaskStore::new()); + let ledger = ledger::spawn(store.clone()); + let shared = Arc::new(super::RegistryShared::new( + hub, + live_buffers, + running_commands, + store, + ledger, + )); + let ctx = TurnContext { + user_input: UserInput::text("Sub-agent turn"), + model, + mode: Mode::Build, + kind: TurnContextKind::PassiveSubagent, + passive_input_is_fallback: true, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: Some(tokio::time::Duration::from_millis(20)), + }; + + let forwarder = tokio::spawn(forward_events( + shared, + thread_id, + project_id, + AgentEventStream::new(tx.subscribe()), + ctx, + None, + )); + + tokio::time::timeout(tokio::time::Duration::from_secs(1), forwarder) + .await + .expect("idle passive monitor should honor its pre-turn timeout") + .unwrap(); + drop(tx); } - // Push a thread-scoped token update to subscribers (§13.6). - if let Ok(ledger_json) = serde_json::to_value(&tf.tokens) { - hub.broadcast( + #[tokio::test] + async fn monitor_stop_waits_for_post_forwarder_cleanup() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = Arc::new(PersistStore::new(tmp.path().to_path_buf())); + let registry = Arc::new(super::HarnessRegistry::new( + Arc::new(UnusedHarnessFactory), + Arc::new(Hub::new()), + Arc::new(LiveBufferStore::new()), + Arc::new(RunningTaskStore::new()), + store.clone(), + ledger::spawn(store), + )); + let thread_id = ThreadId::new(); + registry + .shared + .passive_monitor_tasks + .register(thread_id) + .await; + + let stopping = { + let registry = registry.clone(); + tokio::spawn(async move { registry.stop_passive_subagent_monitor(thread_id).await }) + }; + tokio::time::sleep(tokio::time::Duration::from_millis(20)).await; + assert!( + !stopping.is_finished(), + "monitor stop returned before cleanup completed" + ); + + super::finish_passive_subagent_monitor_task( + ®istry.shared.passive_monitor_tasks, thread_id, - ServerMessage::TokenUpdate { - scope: TokenScope::Thread, - thread_id: Some(thread_id), - ledger: ledger_json, - }, ) .await; + tokio::time::timeout(tokio::time::Duration::from_secs(1), stopping) + .await + .expect("monitor stop should finish after cleanup") + .unwrap() + .unwrap(); } - PersistTurnOutcome { - history_appended: true, - metadata_updated: true, + #[test] + fn generated_subagent_title_refresh_is_idempotent() { + assert!(!should_refresh_subagent_title( + "Sub-agent: Linnaeus", + "Sub-agent: Linnaeus" + )); + assert!(should_refresh_subagent_title( + "Sub-agent: server_lifecycle_audit", + "Sub-agent: Linnaeus" + )); + assert!(!should_refresh_subagent_title( + "My reviewer", + "Sub-agent: Linnaeus" + )); } -} -#[cfg(test)] -mod tests { - use std::sync::Arc; + #[tokio::test] + async fn monitor_teardown_claims_late_terminal_fallback() { + let thread_id = ThreadId::new(); + let passive_monitors = Arc::new(Mutex::new(HashSet::from([thread_id]))); + let passive_subagent_metadata = Arc::new(Mutex::new(Default::default())); + let fallback = super::SubagentFallbackTranscript { + message: "late terminal result".into(), + status: SubagentStatus::Completed, + }; - use chrono::Utc; - use giskard_core::approval::{ApprovalDecision, ApprovalKind, ApprovalRequest}; - use giskard_core::error::HarnessError; - use giskard_core::event::AgentEvent; - use giskard_core::ids::{ApprovalId, ItemId, ProjectId, ServerRequestId, ThreadId, TurnId}; - use giskard_core::item::{CommandExecutionStart, Item, ItemKind, ItemPayload, ItemStart}; - use giskard_core::model::ModelRef; - use giskard_core::server_request::ServerRequest; - use giskard_core::token::{TokenLedger, TokenUsage}; - use giskard_core::turn::{ApprovalPolicy, Mode, Turn, TurnStatus, TurnStatusKind}; - use giskard_core::user_input::UserInput; - use giskard_harness::{AgentEventStream, ThreadHandle}; - use giskard_persist::PersistStore; - use giskard_persist::store::ThreadFile; - use giskard_proto::{ServerMessage, ThreadActivityKind, WireAgentEvent}; - use tokio::sync::{Mutex, broadcast, mpsc}; - use tokio::task::JoinHandle; + update_passive_subagent_metadata( + &passive_subagent_metadata, + thread_id, + Some("late prompt".into()), + Some(fallback), + super::LifecycleSignal::Terminal, + ) + .await; - use super::{ - ActiveTurnOwner, CurrentTurnItems, ThreadTurnGate, TurnContext, TurnContextKind, - command_completion_is_normal_success, command_status_is_running, forward_events, - thread_activity_from_event, track_item_identity, - }; - use crate::hub::Hub; - use crate::ledger; - use crate::live_buffer::LiveBufferStore; - use crate::running_commands::RunningTaskStore; + let claimed = take_passive_subagent_monitor_metadata( + &passive_monitors, + &passive_subagent_metadata, + thread_id, + ) + .await + .expect("teardown should claim monitor metadata"); + assert_eq!(claimed.initial_prompt.as_deref(), Some("late prompt")); + assert_eq!( + claimed + .fallback + .as_ref() + .map(|value| value.message.as_str()), + Some("late terminal result") + ); + assert!(!passive_monitors.lock().await.contains(&thread_id)); + assert!( + !passive_subagent_metadata + .lock() + .await + .contains_key(&thread_id) + ); + } - #[test] - fn command_completion_success_requires_success_status_and_zero_exit() { - assert!(command_completion_is_normal_success("completed", Some(0))); - assert!(command_completion_is_normal_success("succeeded", Some(0))); - assert!(command_completion_is_normal_success("success", Some(0))); + #[tokio::test] + async fn subagent_fallback_transcript_persists_when_history_is_empty() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = Arc::new(PersistStore::new(tmp.path().to_path_buf())); + let project_id = ProjectId::new(); + let thread_id = ThreadId::new(); + let model = ModelRef { + provider: "openai".into(), + model: "gpt-5.6-sol".into(), + reasoning_effort: None, + }; + store + .create_project(project_id, "proj", "/tmp/test", model.clone()) + .await + .unwrap(); + let now = Utc::now(); + store + .save_thread( + project_id, + &ThreadFile { + version: 1, + id: thread_id, + project_id, + title: "Sub-agent".into(), + harness_thread_id: "native-child".into(), + parent_thread_id: Some(ThreadId::new()), + spawned_by_turn_id: Some(TurnId::new()), + kind: giskard_core::ThreadKind::Subagent, + mode: Mode::Build, + current_model: model.clone(), + context_window: 128_000, + model_context_windows: Default::default(), + approval_policy: ApprovalPolicy::Ask, + model_efforts: Default::default(), + tokens: TokenLedger::default(), + created_at: now, + updated_at: now, + archived: false, + }, + ) + .await + .unwrap(); - assert!(!command_completion_is_normal_success( - "completed", - Some(143) + let hub = Arc::new(Hub::new()); + let (client_tx, mut client_rx) = mpsc::channel(8); + hub.subscribe(thread_id, 1, client_tx).await; + let shared = Arc::new(super::RegistryShared::new( + hub, + Arc::new(LiveBufferStore::new()), + Arc::new(RunningTaskStore::new()), + store.clone(), + ledger::spawn(store.clone()), )); - assert!(!command_completion_is_normal_success("failed", Some(0))); - assert!(!command_completion_is_normal_success("interrupted", None)); + let ctx = super::FallbackTurnContext { + user_input: UserInput::text("Sub-agent turn"), + model, + mode: Mode::Build, + }; + let mut seen_turn_ids = HashSet::new(); + + persist_subagent_fallback_transcript( + thread_id, + project_id, + &ctx, + super::SubagentFallbackTranscript { + message: "Completed child work".into(), + status: SubagentStatus::Completed, + }, + &mut seen_turn_ids, + &shared, + ) + .await; + + let turns = store.load_all_turns(project_id, thread_id).await.unwrap(); + assert_eq!(turns.len(), 1); + assert_eq!(turns[0].user_input.as_text(), Some("Sub-agent turn")); + assert!(matches!( + &turns[0].items[0].payload, + ItemPayload::AgentMessage { text } if text == "Completed child work" + )); + assert_eq!(turns[0].status.kind, TurnStatusKind::Completed); + + 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" + ); + } + } + } + assert!(saw_item, "fallback transcript should be broadcast live"); } #[test] @@ -2546,6 +4789,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -2625,13 +4871,8 @@ mod tests { ); while let Ok(message) = client_rx.try_recv() { assert!( - !matches!( - message, - ServerMessage::Event { - agent_event: WireAgentEvent::ContextWindowUpdated { .. }, - .. - } - ), + !matches!(message, ServerMessage::Event { agent_event, .. } + if matches!(agent_event.as_ref(), WireAgentEvent::ContextWindowUpdated { .. })), "a mismatched turn model must not be broadcast" ); } @@ -2663,6 +4904,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -2742,22 +4986,20 @@ mod tests { let mut matching_updates = 0; while let Ok(message) = client_rx.try_recv() { - if let ServerMessage::Event { - agent_event: - WireAgentEvent::ContextWindowUpdated { - thread, - turn, - model: event_model, - context_window, - }, - .. - } = message - { - matching_updates += 1; - assert_eq!(thread, thread_id); - assert_eq!(turn, turn_id); - assert_eq!(event_model, model); - assert_eq!(context_window, 258_400); + if let ServerMessage::Event { agent_event, .. } = message { + if 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); + } } } assert_eq!( @@ -2792,6 +5034,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -2912,6 +5157,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3062,6 +5310,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3183,6 +5434,9 @@ mod tests { project_id, title: "target".into(), harness_thread_id: "th_target".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3312,6 +5566,9 @@ mod tests { project_id, title: "target".into(), harness_thread_id: "th_target".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3407,6 +5664,9 @@ mod tests { project_id, title: "target".into(), harness_thread_id: "th_target".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3572,6 +5832,9 @@ mod tests { project_id, title: "target".into(), harness_thread_id: "th_target".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3632,14 +5895,14 @@ mod tests { .expect("broadcast") .expect("message"); match received { - ServerMessage::Event { - agent_event: WireAgentEvent::ServerRequestReceived { turn, request, .. }, - .. - } => { - assert!(turn.is_none()); - assert_eq!(request.id, request_id); - assert_eq!(request.method, "mcpServer/elicitation/request"); - } + ServerMessage::Event { agent_event, .. } => match *agent_event { + WireAgentEvent::ServerRequestReceived { turn, request, .. } => { + assert!(turn.is_none()); + assert_eq!(request.id, request_id); + assert_eq!(request.method, "mcpServer/elicitation/request"); + } + other => panic!("expected turnless server request event, got {other:?}"), + }, other => panic!("expected turnless server request event, got {other:?}"), } @@ -3661,6 +5924,115 @@ mod tests { ); } + #[tokio::test] + async fn passive_forwarder_does_not_duplicate_turnless_event_owned_by_user_forwarder() { + let tmp = tempfile::TempDir::new().unwrap(); + let store = Arc::new(PersistStore::new(tmp.path().to_path_buf())); + let project_id = ProjectId::new(); + let thread_id = ThreadId::new(); + let model = ModelRef { + provider: "openai".into(), + model: "gpt-5.5".into(), + reasoning_effort: None, + }; + store + .create_project(project_id, "proj", "/tmp/test", model.clone()) + .await + .unwrap(); + let (tx, _) = broadcast::channel(16); + let user_stream = AgentEventStream::new(tx.subscribe()); + let passive_stream = AgentEventStream::new(tx.subscribe()); + let hub = Arc::new(Hub::new()); + let (client_tx, mut client_rx) = mpsc::channel(16); + hub.subscribe(thread_id, 1, client_tx).await; + let shared = Arc::new(super::RegistryShared::new( + hub, + Arc::new(LiveBufferStore::new()), + Arc::new(RunningTaskStore::new()), + store.clone(), + ledger::spawn(store), + )); + let user_ctx = TurnContext { + user_input: UserInput::text("user turn"), + model: model.clone(), + mode: Mode::Build, + kind: TurnContextKind::User, + passive_input_is_fallback: false, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: None, + }; + let handle = ThreadHandle::detached(thread_id, "native-thread".into()); + let lease = shared + .turn_gate + .reserve( + thread_id, + ActiveTurnOwner::new(project_id, &handle, &user_ctx), + ) + .unwrap(); + let user_forwarder = tokio::spawn(forward_events( + shared.clone(), + thread_id, + project_id, + user_stream, + user_ctx, + Some(lease), + )); + + shared.passive_monitors.lock().await.insert(thread_id); + shared.passive_monitor_tasks.register(thread_id).await; + let passive_forwarder = tokio::spawn(forward_events( + shared.clone(), + thread_id, + project_id, + passive_stream, + TurnContext { + user_input: UserInput::text("Sub-agent turn"), + model, + mode: Mode::Build, + kind: TurnContextKind::PassiveSubagent, + passive_input_is_fallback: true, + subagent_fallback: None, + passive_subagent_metadata: Some(shared.passive_subagent_metadata.clone()), + passive_pre_turn_timeout: Some(tokio::time::Duration::from_secs(1)), + }, + None, + )); + + tx.send(AgentEvent::Notice { + thread: thread_id, + turn: None, + message: "one owner".into(), + }) + .unwrap(); + + let first = tokio::time::timeout(tokio::time::Duration::from_secs(1), client_rx.recv()) + .await + .expect("normal forwarder should broadcast the turnless notice") + .expect("subscriber should remain connected"); + assert!(matches!( + first, + ServerMessage::Event { agent_event, .. } + if matches!(*agent_event, WireAgentEvent::Notice { .. }) + )); + assert!( + tokio::time::timeout(tokio::time::Duration::from_millis(100), client_rx.recv()) + .await + .is_err(), + "passive and user forwarders must not both broadcast the same turnless event" + ); + + drop(tx); + tokio::time::timeout(tokio::time::Duration::from_secs(1), passive_forwarder) + .await + .expect("passive duplicate forwarder should exit") + .unwrap(); + tokio::time::timeout(tokio::time::Duration::from_secs(1), user_forwarder) + .await + .expect("user forwarder should exit after stream close") + .unwrap(); + } + #[tokio::test] async fn forwarder_deduplicates_identical_notices_in_one_turn() { let tmp = tempfile::TempDir::new().unwrap(); @@ -3686,6 +6058,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3757,7 +6132,7 @@ mod tests { while tokio::time::Instant::now() < deadline && !completed { match tokio::time::timeout(tokio::time::Duration::from_secs(1), client_rx.recv()).await { - Ok(Some(ServerMessage::Event { agent_event, .. })) => match agent_event { + Ok(Some(ServerMessage::Event { agent_event, .. })) => match *agent_event { WireAgentEvent::Notice { .. } => notice_count += 1, WireAgentEvent::TurnCompleted { .. } => completed = true, _ => {} @@ -3796,6 +6171,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -3826,6 +6204,10 @@ mod tests { model: model.clone(), mode: Mode::Build, kind: TurnContextKind::ManualCompaction, + passive_input_is_fallback: false, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: None, }; let gate = ThreadTurnGate::default(); let handle = ThreadHandle { @@ -3833,33 +6215,27 @@ mod tests { harness_thread_id: "native-test-thread".into(), resumed_model: None, warning: None, + agent_name: None, + parent_harness_thread_id: None, }; let lease = gate .reserve(thread_id, ActiveTurnOwner::new(project_id, &handle, &ctx)) .unwrap(); let ctx_for_second_reserve = ctx.clone(); + let mut shared = super::RegistryShared::new( + hub.clone(), + live_buffers.clone(), + running_commands.clone(), + store.clone(), + ledger, + ); + shared.approvals = approvals; + shared.server_requests = server_requests; + let shared = Arc::new(shared); tokio::spawn({ - let hub = hub.clone(); - let live_buffers = live_buffers.clone(); - let running_commands = running_commands.clone(); - let store = store.clone(); async move { - forward_events( - thread_id, - project_id, - stream, - hub, - live_buffers, - running_commands, - store, - approvals, - server_requests, - ledger, - ctx, - Some(lease), - ) - .await; + forward_events(shared, thread_id, project_id, stream, ctx, Some(lease)).await; } }); @@ -3874,6 +6250,7 @@ mod tests { title: "Context compacted".into(), detail: None, metadata: None, + subagent: None, }, created_at: Utc::now(), }, @@ -3886,7 +6263,7 @@ mod tests { match tokio::time::timeout(tokio::time::Duration::from_secs(1), client_rx.recv()).await { Ok(Some(ServerMessage::Event { agent_event, .. })) => { - if matches!(agent_event, WireAgentEvent::TurnCompleted { .. }) { + if matches!(*agent_event, WireAgentEvent::TurnCompleted { .. }) { completed = true; } } @@ -3969,23 +6346,18 @@ mod tests { model, mode: Mode::Build, kind: TurnContextKind::User, + passive_input_is_fallback: false, + subagent_fallback: None, + passive_subagent_metadata: None, + passive_pre_turn_timeout: None, }; + let mut shared = + super::RegistryShared::new(hub, live_buffers, running_commands, store, ledger); + shared.approvals = approvals; + shared.server_requests = server_requests; + let shared = Arc::new(shared); tokio::spawn(async move { - forward_events( - thread_id, - project_id, - stream, - hub, - live_buffers, - running_commands, - store, - approvals, - server_requests, - ledger, - ctx, - None, - ) - .await; + forward_events(shared, thread_id, project_id, stream, ctx, None).await; }) } @@ -4058,6 +6430,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -4217,15 +6592,13 @@ mod tests { "earlier turn item must remain untouched" ); while let Ok(message) = client_rx.try_recv() { - if let ServerMessage::Event { - agent_event: WireAgentEvent::ItemCompleted { item, .. }, - .. - } = message - { - assert_ne!( - item.id, conflicting_item_id, - "conflicting native identity must not be broadcast" - ); + 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" + ); + } } } } @@ -4255,6 +6628,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -4394,7 +6770,7 @@ mod tests { tokio::time::timeout(tokio::time::Duration::from_millis(100), client_rx.recv()) .await { - match agent_event { + match *agent_event { WireAgentEvent::ItemStarted { item, .. } if item.harness_item_id == reused_harness => { @@ -4460,6 +6836,9 @@ mod tests { project_id, title: "t".into(), harness_thread_id: "th".into(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: giskard_core::ThreadKind::Primary, mode: Mode::Build, current_model: model.clone(), context_window: 128_000, @@ -4569,17 +6948,17 @@ mod tests { let mut delta_texts = Vec::new(); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); while tokio::time::Instant::now() < deadline { - if let Ok(Some(ServerMessage::Event { - agent_event: - WireAgentEvent::ItemDelta { - delta: giskard_proto::ItemDelta::Text { text }, - .. - }, - .. - })) = tokio::time::timeout(tokio::time::Duration::from_millis(100), client_rx.recv()) - .await + if let Ok(Some(ServerMessage::Event { agent_event, .. })) = + tokio::time::timeout(tokio::time::Duration::from_millis(100), client_rx.recv()) + .await { - delta_texts.push(text); + if let WireAgentEvent::ItemDelta { + delta: giskard_proto::ItemDelta::Text { text }, + .. + } = *agent_event + { + delta_texts.push(text); + } } } assert_eq!( diff --git a/crates/giskard-server/src/routes.rs b/crates/giskard-server/src/routes.rs index b130d34..63e177c 100644 --- a/crates/giskard-server/src/routes.rs +++ b/crates/giskard-server/src/routes.rs @@ -21,6 +21,8 @@ use futures::{SinkExt, StreamExt}; use giskard_core::error::{HarnessError, PersistError}; use giskard_core::ids::{ProjectId, ThreadId}; use giskard_core::model::{ModelDescriptor, ModelRef}; +use giskard_core::text::trimmed_non_empty; +use giskard_core::thread::ThreadKind; use giskard_core::turn::{ApprovalPolicy, Mode, TurnOverrides}; use giskard_core::user_input::UserInput; use giskard_persist::Config; @@ -33,8 +35,12 @@ use crate::auth::{ SESSION_COOKIE, TokenPurpose, auth_middleware, create_session_cookie, get_session_token_from_header, sign_token, verify_token, }; +use crate::thread_graph::{ + descendant_deletion_order, graph_issue, load_thread_graph, should_refresh_subagent_title, +}; const HARNESS_CONTROL_TIMEOUT: Duration = Duration::from_secs(2); +const PROJECT_LIFECYCLE_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const MAX_THREAD_TITLE_CHARS: usize = 120; const GENERATED_THREAD_TITLE_CHARS: usize = 72; const GENERATED_THREAD_TITLE_WORDS: usize = 8; @@ -54,6 +60,10 @@ pub fn protected_routes(state: AppState) -> Router { "/api/projects/{id}/threads/start", post(start_thread_with_message), ) + .route( + "/api/projects/{id}/threads/{parent_thread_id}/subagent-links/{item_id}/open", + post(open_subagent_link), + ) .route( "/api/projects/{id}/threads/{thread_id}", delete(delete_thread), @@ -376,6 +386,11 @@ async fn delete_project( State(state): State, AxumPath(id): AxumPath, ) -> Result { + let _lifecycle_guard = state + .registry + .lock_project_lifecycle_with_timeout(id, PROJECT_LIFECYCLE_LOCK_TIMEOUT) + .await + .map_err(harness_api_error)?; state .store .load_project(id) @@ -416,7 +431,9 @@ async fn reject_thread_mutation_if_live( state: &AppState, thread_id: ThreadId, ) -> Result<(), ApiError> { - if state.live_buffers.is_active(thread_id).await { + if state.registry.thread_has_active_turn(thread_id).await + || state.live_buffers.is_active(thread_id).await + { return Err(ApiError::Conflict( "thread has an active turn; stop it before archiving or deleting".into(), )); @@ -434,10 +451,10 @@ async fn list_threads( AxumPath(project_id): AxumPath, ) -> Result, ApiError> { let thread_ids = state.store.list_threads(project_id).await?; - let mut threads = Vec::new(); + let mut loaded = Vec::new(); for tid in thread_ids { match state.store.load_thread(project_id, tid).await { - Ok(Some(tf)) => threads.push(thread_summary(&tf)), + Ok(Some(tf)) => loaded.push(tf), Ok(None) => {} Err(e) => { warn!( @@ -449,10 +466,45 @@ async fn list_threads( } } } + let graph = loaded + .iter() + .cloned() + .map(|thread| (thread.id, thread)) + .collect(); + for thread in &loaded { + if let Some(issue) = graph_issue(&graph, thread) { + error!( + %project_id, + thread_id = %thread.id, + parent_thread_id = ?thread.parent_thread_id, + kind = ?thread.kind, + issue, + action = "list_threads", + "invalid persisted thread graph" + ); + } + } + let mut threads = loaded.iter().map(thread_summary).collect::>(); threads.sort_by_key(|t| std::cmp::Reverse(t.updated_at)); Ok(Json(ListThreadsResponse { threads })) } +async fn find_thread_by_harness_id( + state: &AppState, + project_id: ProjectId, + harness_thread_id: &str, +) -> Result, ApiError> { + for tid in state.store.list_threads(project_id).await? { + let Some(tf) = state.store.load_thread(project_id, tid).await? else { + continue; + }; + if tf.harness_thread_id == harness_thread_id { + return Ok(Some(tf)); + } + } + Ok(None) +} + async fn open_thread( State(state): State, AxumPath(project_id): AxumPath, @@ -512,17 +564,30 @@ async fn open_thread( // thread through this endpoint *before* subscribing over the WebSocket, so a 500 here // would make the whole thread unviewable. Degrade to a read-only open instead: the // client proceeds to subscribe and the persisted history loads; only new turns fail. - let handle = match state - .registry - .open_thread( - &project_config, - ws_root, - Some(thread_id), - Some(thread_file.harness_thread_id.clone()), - current_model.clone(), - ) - .await - { + let open_result = if thread_file.kind == ThreadKind::Subagent { + state + .registry + .open_linked_thread( + &project_config, + ws_root, + Some(thread_id), + thread_file.harness_thread_id.clone(), + current_model.clone(), + ) + .await + } else { + state + .registry + .open_thread( + &project_config, + ws_root, + Some(thread_id), + Some(thread_file.harness_thread_id.clone()), + current_model.clone(), + ) + .await + }; + let handle = match open_result { Ok(handle) => handle, Err(error) => { warn!( @@ -592,6 +657,65 @@ async fn open_thread( "creating a new thread requires an initial message".into(), )); }; + let _lifecycle_guard = state + .registry + .lock_project_lifecycle_with_timeout(project_id, PROJECT_LIFECYCLE_LOCK_TIMEOUT) + .await + .map_err(harness_api_error)?; + + if let Some(existing) = find_thread_by_harness_id(&state, project_id, &resume).await? { + let (handle, warning) = + if let Some(handle) = state.registry.get_thread_handle(existing.id).await { + (handle, None) + } else { + let handle = if existing.kind == ThreadKind::Subagent { + state + .registry + .open_linked_thread( + &project_config, + ws_root, + Some(existing.id), + existing.harness_thread_id.clone(), + existing.current_model.clone(), + ) + .await + } else { + state + .registry + .open_thread( + &project_config, + ws_root, + Some(existing.id), + Some(existing.harness_thread_id.clone()), + existing.current_model.clone(), + ) + .await + } + .map_err(harness_api_error)?; + let warning = handle.warning.as_ref().map(|warning| { + warning_info( + warning.code.clone(), + warning.message.clone(), + warning.detail.clone(), + handle.thread, + "open_thread", + ) + }); + (handle, warning) + }; + refresh_route_imported_subagent_title( + &state, + project_id, + existing.id, + handle.agent_name.as_deref(), + ) + .await?; + return Ok(Json(OpenThreadResponse { + thread_id: handle.thread, + harness_thread_id: handle.harness_thread_id, + warning, + })); + } let handle = state .registry @@ -614,14 +738,18 @@ async fn open_thread( crate::models::resolve_catalog_descriptor(&catalog, &app_config, ¤t_model); let context_window = descriptor.context_window; let now = Utc::now(); + let title = "New thread".to_owned(); let thread_file = ThreadFile { version: 1, id: handle.thread, project_id, - title: "New thread".into(), + title, harness_thread_id: handle.harness_thread_id.clone(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: Mode::Build, - current_model, + current_model: current_model.clone(), context_window, model_context_windows: std::collections::HashMap::new(), approval_policy: ApprovalPolicy::Ask, @@ -650,6 +778,42 @@ async fn open_thread( })) } +async fn open_subagent_link( + State(state): State, + AxumPath((project_id, parent_thread_id, item_id)): AxumPath<(ProjectId, ThreadId, ItemId)>, +) -> Result, ApiError> { + // Materialization is serialized with every other lifecycle mutation for the project and may + // be queued behind a slow native resume. Bound the complete browser action so this endpoint + // has the same availability guarantee as HTTP handlers waiting directly on the lifecycle lock. + let thread_id = tokio::time::timeout( + PROJECT_LIFECYCLE_LOCK_TIMEOUT, + state + .registry + .open_subagent_link(project_id, parent_thread_id, item_id), + ) + .await + .map_err(|_| { + ApiError::Unavailable(format!( + "timed out opening sub-agent link from thread {parent_thread_id}" + )) + })? + .map_err(harness_api_error)? + .ok_or_else(|| { + ApiError::Conflict(format!( + "item {item_id} is not an openable sub-agent link from thread {parent_thread_id}" + )) + })?; + let thread = state + .store + .load_thread(project_id, thread_id) + .await? + .ok_or(ApiError::NotFound)?; + Ok(Json(OpenSubagentLinkResponse { + thread_id, + title: thread.title, + })) +} + async fn start_thread_with_message( State(state): State, AxumPath(project_id): AxumPath, @@ -731,6 +895,9 @@ async fn start_thread_with_message( project_id, title: title.clone(), harness_thread_id: handle.harness_thread_id.clone(), + parent_thread_id: None, + spawned_by_turn_id: None, + kind: ThreadKind::Primary, mode: req.mode, current_model: model_ref.clone(), context_window: model_descriptor.context_window, @@ -1125,6 +1292,9 @@ fn thread_summary(tf: &ThreadFile) -> ThreadSummary { ThreadSummary { id: tf.id, title: tf.title.clone(), + parent_thread_id: tf.parent_thread_id, + spawned_by_turn_id: tf.spawned_by_turn_id, + kind: tf.kind, mode: tf.mode, archived: tf.archived, created_at: tf.created_at, @@ -1145,11 +1315,44 @@ fn normalize_thread_title(raw: &str) -> Result { Ok(title) } +async fn refresh_route_imported_subagent_title( + state: &AppState, + project_id: ProjectId, + thread_id: ThreadId, + agent_name: Option<&str>, +) -> Result<(), ApiError> { + let Some(agent_name) = agent_name.and_then(trimmed_non_empty) else { + return Ok(()); + }; + let desired = normalize_thread_title(&format!("Sub-agent: {agent_name}"))?; + let Some(thread) = state.store.load_thread(project_id, thread_id).await? else { + return Ok(()); + }; + if thread.kind != ThreadKind::Subagent + || !should_refresh_subagent_title(&thread.title, &desired) + { + return Ok(()); + } + state + .store + .update_thread(project_id, thread_id, |thread| { + thread.title = desired.clone(); + thread.updated_at = Utc::now(); + }) + .await?; + Ok(()) +} + async fn archive_thread( State(state): State, AxumPath((project_id, thread_id)): AxumPath<(ProjectId, ThreadId)>, Json(req): Json, ) -> Result, ApiError> { + if state.registry.thread_has_passive_monitor(thread_id).await { + return Err(ApiError::Conflict( + "thread has delegated sub-agent work; wait for it to finish before archiving".into(), + )); + } reject_thread_mutation_if_live(&state, thread_id).await?; let project_config = state .store @@ -1230,23 +1433,110 @@ async fn delete_thread( State(state): State, AxumPath((project_id, thread_id)): AxumPath<(ProjectId, ThreadId)>, ) -> Result { - reject_thread_mutation_if_live(&state, thread_id).await?; + let _lifecycle_guard = state + .registry + .lock_project_lifecycle_with_timeout(project_id, PROJECT_LIFECYCLE_LOCK_TIMEOUT) + .await + .map_err(harness_api_error)?; + let graph = load_thread_graph(&state.store, project_id).await?; + let deletion_order = descendant_deletion_order(&graph, thread_id); + if deletion_order.is_empty() { + return Err(ApiError::NotFound); + } + // Preflight the complete subtree before deleting any native or local record. A busy descendant + // must reject the entire request instead of leaving a partially deleted ownership tree. + for candidate in &deletion_order { + reject_thread_mutation_if_live(&state, *candidate).await?; + } + // Idle pre-turn monitors are cancellable delegated subscriptions, not durable work. Stop the + // complete subtree before deleting anything, then preflight again so a child turn that raced + // the first check rejects the request without leaving a partially deleted ownership graph. + for candidate in &deletion_order { + state + .registry + .stop_passive_subagent_monitor(*candidate) + .await + .map_err(harness_api_error)?; + } + for candidate in &deletion_order { + reject_thread_mutation_if_live(&state, *candidate).await?; + } let project_config = state .store .load_project(project_id) .await? .ok_or(ApiError::NotFound)?; - let thread_file = state - .store - .load_thread(project_id, thread_id) - .await? - .ok_or(ApiError::NotFound)?; - state - .registry - .delete_thread(&project_config, thread_id, thread_file.harness_thread_id) - .await - .map_err(|e| ApiError::Internal(e.to_string()))?; - state.store.delete_thread(project_id, thread_id).await?; + let descendant_count = deletion_order.len().saturating_sub(1); + info!( + %project_id, + %thread_id, + descendant_count, + action = "delete_thread", + "deleting thread subtree in leaf-first order" + ); + for (deleted_count, candidate) in deletion_order.iter().enumerate() { + let Some(thread_file) = graph.get(candidate) else { + error!( + %project_id, + root_thread_id = %thread_id, + thread_id = %candidate, + deleted_count, + action = "delete_thread", + "thread disappeared from the loaded deletion graph" + ); + return Err(ApiError::Internal( + "thread deletion graph changed unexpectedly".into(), + )); + }; + if let Err(error) = state + .registry + .delete_thread( + &project_config, + *candidate, + thread_file.harness_thread_id.clone(), + ) + .await + { + error!( + %project_id, + root_thread_id = %thread_id, + thread_id = %candidate, + harness_thread_id = %thread_file.harness_thread_id, + deleted_count, + total_count = deletion_order.len(), + action = "delete_thread", + %error, + "failed to delete native thread while cascading thread deletion" + ); + return Err(ApiError::Internal(format!( + "failed to delete thread subtree after deleting {deleted_count} thread(s): {error}" + ))); + } + if let Err(error) = state.store.delete_thread(project_id, *candidate).await { + error!( + %project_id, + root_thread_id = %thread_id, + thread_id = %candidate, + harness_thread_id = %thread_file.harness_thread_id, + deleted_count, + total_count = deletion_order.len(), + action = "delete_thread", + %error, + "native thread was deleted but local thread removal failed" + ); + return Err(error.into()); + } + info!( + %project_id, + root_thread_id = %thread_id, + thread_id = %candidate, + harness_thread_id = %thread_file.harness_thread_id, + deleted_count = deleted_count + 1, + total_count = deletion_order.len(), + action = "delete_thread", + "deleted thread from cascading subtree" + ); + } Ok(axum::http::StatusCode::NO_CONTENT) } @@ -2018,6 +2308,7 @@ fn harness_api_error(error: HarnessError) -> ApiError { HarnessError::ThreadBusy { .. } => { ApiError::Conflict("Thread already has an active turn.".into()) } + HarnessError::Timeout(message) => ApiError::Unavailable(message), other => ApiError::Internal(other.to_string()), } } @@ -3008,17 +3299,30 @@ async fn ensure_thread_open( %action, "reopening persisted thread" ); - let handle = state - .registry - .open_thread( - &project_config, - ws_root, - Some(thread_id), - Some(thread_file.harness_thread_id.clone()), - current_model, - ) - .await - .map_err(|e| WsError::from_harness(e, action, Some(thread_id)))?; + let handle = if thread_file.kind == ThreadKind::Subagent { + state + .registry + .open_linked_thread( + &project_config, + ws_root, + Some(thread_id), + thread_file.harness_thread_id.clone(), + current_model, + ) + .await + } else { + state + .registry + .open_thread( + &project_config, + ws_root, + Some(thread_id), + Some(thread_file.harness_thread_id.clone()), + current_model, + ) + .await + } + .map_err(|e| WsError::from_harness(e, action, Some(thread_id)))?; if handle.thread != thread_id { return Err(WsError::new( @@ -3297,17 +3601,30 @@ async fn switch_provider_cold( "attempting verified cold-resume provider switch" ); - let handle = state - .registry - .open_thread( - &project_config, - ws_root, - Some(thread_id), - Some(thread_file.harness_thread_id.clone()), - requested.clone(), - ) - .await - .map_err(|e| WsError::from_harness(e, "select_model", Some(thread_id)))?; + let handle = if thread_file.kind == ThreadKind::Subagent { + state + .registry + .open_linked_thread( + &project_config, + ws_root, + Some(thread_id), + thread_file.harness_thread_id.clone(), + requested.clone(), + ) + .await + } else { + state + .registry + .open_thread( + &project_config, + ws_root, + Some(thread_id), + Some(thread_file.harness_thread_id.clone()), + requested.clone(), + ) + .await + } + .map_err(|e| WsError::from_harness(e, "select_model", Some(thread_id)))?; let confirmed = handle.resumed_model.as_ref().is_some_and(|effective| { effective.provider == requested.provider && effective.model == requested.model @@ -3573,6 +3890,7 @@ pub enum ApiError { BadRequest(String), Forbidden(String), Conflict(String), + Unavailable(String), Internal(String), } @@ -3599,6 +3917,11 @@ impl IntoResponse for ApiError { msg, ApiErrorLogLevel::Warn, ), + ApiError::Unavailable(msg) => ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + msg, + ApiErrorLogLevel::Warn, + ), ApiError::Internal(msg) => ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, msg, diff --git a/crates/giskard-server/src/running_commands.rs b/crates/giskard-server/src/running_commands.rs index 2398216..5009fc9 100644 --- a/crates/giskard-server/src/running_commands.rs +++ b/crates/giskard-server/src/running_commands.rs @@ -442,6 +442,8 @@ mod tests { input: serde_json::json!({ "q": name }), server: Some("wiki".into()), status: Some("in_progress".into()), + metadata: None, + subagent: None, started_at_ms: Some(1_785_000_000_000), }), }, @@ -467,6 +469,8 @@ mod tests { output: Some(serde_json::json!("a big result")), server: Some("wiki".into()), status: status.map(Into::into), + metadata: None, + subagent: None, error: None, }, created_at: Utc::now(), diff --git a/crates/giskard-server/src/thread_graph.rs b/crates/giskard-server/src/thread_graph.rs new file mode 100644 index 0000000..99d53d9 --- /dev/null +++ b/crates/giskard-server/src/thread_graph.rs @@ -0,0 +1,331 @@ +use std::collections::{HashMap, HashSet}; + +use giskard_core::ids::{ProjectId, ThreadId}; +use giskard_core::thread::ThreadKind; +use giskard_persist::PersistStore; +use giskard_persist::store::ThreadFile; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExistingLinkDisposition { + OwnedChild, + SelfLink, + PrimaryThread, + DifferentParent, + WouldCycle, +} + +impl ExistingLinkDisposition { + pub(crate) fn reason(self) -> &'static str { + match self { + Self::OwnedChild => "existing sub-agent already belongs to this parent", + Self::SelfLink => "thread cannot be its own child", + Self::PrimaryThread => "existing primary thread cannot be reclassified as a sub-agent", + Self::DifferentParent => "existing sub-agent belongs to a different parent", + Self::WouldCycle => "sub-agent relationship would create a thread cycle", + } + } +} + +pub(crate) async fn load_thread_graph( + store: &PersistStore, + project_id: ProjectId, +) -> Result, giskard_core::error::PersistError> { + let mut graph = HashMap::new(); + for thread_id in store.list_threads(project_id).await? { + if let Some(thread) = store.load_thread(project_id, thread_id).await? { + graph.insert(thread_id, thread); + } + } + Ok(graph) +} + +pub(crate) fn classify_existing_link( + graph: &HashMap, + proposed_parent: ThreadId, + existing: &ThreadFile, +) -> ExistingLinkDisposition { + if existing.id == proposed_parent { + return ExistingLinkDisposition::SelfLink; + } + if existing.kind == ThreadKind::Primary || existing.parent_thread_id.is_none() { + return ExistingLinkDisposition::PrimaryThread; + } + if existing.parent_thread_id != Some(proposed_parent) { + return ExistingLinkDisposition::DifferentParent; + } + if parent_chain_reaches(graph, proposed_parent, existing.id) { + return ExistingLinkDisposition::WouldCycle; + } + ExistingLinkDisposition::OwnedChild +} + +pub(crate) fn parent_chain_is_valid( + graph: &HashMap, + start: ThreadId, +) -> bool { + let mut current = start; + let mut seen = HashSet::new(); + loop { + if !seen.insert(current) { + return false; + } + let Some(thread) = graph.get(¤t) else { + return false; + }; + match (thread.kind, thread.parent_thread_id) { + (ThreadKind::Subagent, Some(parent)) => current = parent, + (ThreadKind::Primary, None) => return true, + _ => return false, + } + } +} + +pub(crate) fn graph_issue( + graph: &HashMap, + thread: &ThreadFile, +) -> Option<&'static str> { + match (thread.kind, thread.parent_thread_id) { + (ThreadKind::Primary, Some(_)) => Some("primary thread has a parent"), + (ThreadKind::Subagent, None) => Some("sub-agent thread has no parent"), + (ThreadKind::Subagent, Some(_)) if !parent_chain_is_valid(graph, thread.id) => { + Some("sub-agent parent chain is missing or cyclic") + } + _ => None, + } +} + +pub(crate) fn should_refresh_subagent_title(current: &str, desired: &str) -> bool { + current != desired + && (current.starts_with("Sub-agent") + || current + .chars() + .all(|ch| ch.is_ascii_hexdigit() || ch == '-')) +} + +/// Return a deterministic leaf-first deletion order for `root` and every thread that names it, +/// directly or transitively, as its parent. The visited set also makes malformed persisted cycles +/// finite; deleting either node of a two-node cycle includes both nodes exactly once. +pub(crate) fn descendant_deletion_order( + graph: &HashMap, + root: ThreadId, +) -> Vec { + fn visit( + graph: &HashMap, + current: ThreadId, + seen: &mut HashSet, + order: &mut Vec, + ) { + if !seen.insert(current) { + return; + } + let mut children = graph + .values() + .filter(|thread| thread.parent_thread_id == Some(current)) + .map(|thread| thread.id) + .collect::>(); + children.sort_by_key(ToString::to_string); + for child in children { + visit(graph, child, seen, order); + } + order.push(current); + } + + if !graph.contains_key(&root) { + return Vec::new(); + } + let mut seen = HashSet::new(); + let mut order = Vec::new(); + visit(graph, root, &mut seen, &mut order); + order +} + +fn parent_chain_reaches( + graph: &HashMap, + start: ThreadId, + target: ThreadId, +) -> bool { + let mut current = Some(start); + let mut seen = HashSet::new(); + while let Some(thread_id) = current { + if thread_id == target { + return true; + } + if !seen.insert(thread_id) { + return true; + } + current = graph + .get(&thread_id) + .and_then(|thread| thread.parent_thread_id); + } + false +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use giskard_core::model::ModelRef; + use giskard_core::token::TokenLedger; + use giskard_core::turn::{ApprovalPolicy, Mode}; + + use super::*; + + fn thread(id: ThreadId, kind: ThreadKind, parent: Option) -> ThreadFile { + ThreadFile { + version: 1, + id, + project_id: ProjectId::new(), + title: id.to_string(), + harness_thread_id: format!("native-{id}"), + parent_thread_id: parent, + spawned_by_turn_id: None, + kind, + mode: Mode::Build, + current_model: ModelRef { + provider: "test".into(), + model: "test".into(), + reasoning_effort: None, + }, + context_window: 1, + model_context_windows: HashMap::new(), + approval_policy: ApprovalPolicy::Ask, + model_efforts: HashMap::new(), + tokens: TokenLedger::default(), + created_at: Utc::now(), + updated_at: Utc::now(), + archived: false, + } + } + + #[test] + fn classifies_existing_links_without_mutating_ownership() { + let root = ThreadId::new(); + let child = ThreadId::new(); + let other_root = ThreadId::new(); + let other_child = ThreadId::new(); + let mut graph = HashMap::from([ + (root, thread(root, ThreadKind::Primary, None)), + (child, thread(child, ThreadKind::Subagent, Some(root))), + (other_root, thread(other_root, ThreadKind::Primary, None)), + ( + other_child, + thread(other_child, ThreadKind::Subagent, Some(other_root)), + ), + ]); + + assert_eq!( + classify_existing_link(&graph, root, graph.get(&root).unwrap()), + ExistingLinkDisposition::SelfLink + ); + assert_eq!( + classify_existing_link(&graph, child, graph.get(&root).unwrap()), + ExistingLinkDisposition::PrimaryThread + ); + assert_eq!( + classify_existing_link(&graph, root, graph.get(&other_child).unwrap()), + ExistingLinkDisposition::DifferentParent + ); + assert_eq!( + classify_existing_link(&graph, root, graph.get(&child).unwrap()), + ExistingLinkDisposition::OwnedChild + ); + + graph.get_mut(&root).unwrap().kind = ThreadKind::Subagent; + graph.get_mut(&root).unwrap().parent_thread_id = Some(child); + assert_eq!( + classify_existing_link(&graph, child, graph.get(&root).unwrap()), + ExistingLinkDisposition::WouldCycle + ); + assert_eq!( + graph_issue(&graph, graph.get(&root).unwrap()), + Some("sub-agent parent chain is missing or cyclic") + ); + } + + #[test] + fn validates_complete_parent_chains_and_reports_dangling_ones() { + let root = ThreadId::new(); + let child = ThreadId::new(); + let grandchild = ThreadId::new(); + let dangling = ThreadId::new(); + let malformed_parent = ThreadId::new(); + let malformed_child = ThreadId::new(); + let missing = ThreadId::new(); + let graph = HashMap::from([ + (root, thread(root, ThreadKind::Primary, None)), + (child, thread(child, ThreadKind::Subagent, Some(root))), + ( + grandchild, + thread(grandchild, ThreadKind::Subagent, Some(child)), + ), + ( + dangling, + thread(dangling, ThreadKind::Subagent, Some(missing)), + ), + ( + malformed_parent, + thread(malformed_parent, ThreadKind::Primary, Some(root)), + ), + ( + malformed_child, + thread( + malformed_child, + ThreadKind::Subagent, + Some(malformed_parent), + ), + ), + ]); + + assert!(parent_chain_is_valid(&graph, root)); + assert!(parent_chain_is_valid(&graph, grandchild)); + assert!(!parent_chain_is_valid(&graph, dangling)); + assert!(!parent_chain_is_valid(&graph, malformed_parent)); + assert!(!parent_chain_is_valid(&graph, malformed_child)); + assert_eq!( + graph_issue(&graph, graph.get(&dangling).unwrap()), + Some("sub-agent parent chain is missing or cyclic") + ); + assert_eq!( + graph_issue(&graph, graph.get(&malformed_parent).unwrap()), + Some("primary thread has a parent") + ); + assert_eq!( + graph_issue(&graph, graph.get(&malformed_child).unwrap()), + Some("sub-agent parent chain is missing or cyclic") + ); + } + + #[test] + fn orders_descendants_before_their_parent_and_handles_cycles() { + let root = ThreadId::new(); + let child = ThreadId::new(); + let grandchild = ThreadId::new(); + let sibling = ThreadId::new(); + let mut graph = HashMap::from([ + (root, thread(root, ThreadKind::Primary, None)), + (child, thread(child, ThreadKind::Subagent, Some(root))), + ( + grandchild, + thread(grandchild, ThreadKind::Subagent, Some(child)), + ), + (sibling, thread(sibling, ThreadKind::Subagent, Some(root))), + ]); + + let order = descendant_deletion_order(&graph, root); + assert_eq!(order.last(), Some(&root)); + assert!( + order.iter().position(|id| *id == grandchild) + < order.iter().position(|id| *id == child) + ); + assert!(order.iter().position(|id| *id == child) < order.iter().position(|id| *id == root)); + assert!( + order.iter().position(|id| *id == sibling) < order.iter().position(|id| *id == root) + ); + + graph.get_mut(&root).unwrap().kind = ThreadKind::Subagent; + graph.get_mut(&root).unwrap().parent_thread_id = Some(child); + let cycle_order = descendant_deletion_order(&graph, root); + assert_eq!(cycle_order.len(), graph.len()); + assert_eq!(cycle_order.last(), Some(&root)); + assert_eq!(cycle_order.iter().filter(|id| **id == child).count(), 1); + } +} diff --git a/crates/giskard-server/static/app.css b/crates/giskard-server/static/app.css index ff6a183..1b71c20 100644 --- a/crates/giskard-server/static/app.css +++ b/crates/giskard-server/static/app.css @@ -94,7 +94,10 @@ .project-menu button:hover { background:var(--panel2); } .project-threads[hidden] { display:none; } .thread-row { position:relative; display:flex; align-items:center; gap:4px; margin:3px 0 3px 8px; border-radius:6px; } + .thread-row.subagent-thread-row { margin-left:24px; } .thread { flex:1; min-width:0; padding:5px 8px; border-radius:6px; cursor:pointer; color:var(--muted); font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:flex; align-items:center; gap:6px; } + .thread.subagent-thread { font-size:12px; } + .thread.subagent-thread::before { content:"sub"; flex:none; color:var(--muted); font-size:10px; } .thread:hover { background:var(--panel2); } .thread.active { background:var(--panel2); color:var(--fg); } .thread-status { flex:none; width:10px; text-align:center; color:var(--muted); font-weight:700; } @@ -111,6 +114,7 @@ .thread-menu button { display:block; width:100%; padding:6px 8px; border:0; background:transparent; text-align:left; } .thread-menu button:hover { background:var(--panel2); } .thread-section-label { margin:8px 0 3px 8px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:0; } + .subagent-open-btn { margin-top:8px; padding:4px 8px; font-size:12px; } header.thr { display:flex; gap:12px; align-items:center; padding:10px 14px; border-bottom:1px solid var(--border); background:var(--panel); flex-wrap:wrap; } /* Header controls are chrome, not content: keep their type a step smaller than the body so the @@ -120,6 +124,8 @@ color:var(--muted); border-color:var(--border); background:transparent; opacity:.65; } header.thr .gauge { font-size:11px; } + .parent-thread-btn { display:flex; align-items:center; gap:5px; color:var(--muted); } + .parent-thread-btn[hidden] { display:none; } .mcp-wrap { position:relative; } .mcp-btn { display:flex; align-items:center; gap:6px; } .mcp-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); display:inline-block; } @@ -180,6 +186,29 @@ .tasks-summary { color:var(--muted); font-size:12px; margin-bottom:8px; } .tasks-section { margin-top:8px; padding-top:8px; border-top:1px solid var(--border); } .tasks-section-title { margin-bottom:6px; color:var(--fg); font-size:11px; font-weight:600; text-transform:uppercase; } + .subagents-wrap { position:relative; } + .subagents-btn { display:flex; align-items:center; gap:6px; } + .subagents-btn.state-idle { color:var(--muted); } + .subagents-btn.state-running { color:#f0d98c; border-color:#d29922; background:rgba(210,153,34,.08); } + .subagents-popover { + position:absolute; left:0; top:34px; z-index:35; width:min(460px, calc(100vw - 24px)); + max-height:70vh; overflow:auto; padding:10px; border:1px solid var(--border); + border-radius:8px; background:var(--panel); box-shadow:0 20px 48px rgba(0,0,0,.45); + } + .subagents-popover[hidden] { display:none; } + .subagents-head { display:flex; align-items:center; gap:8px; margin-bottom:8px; } + .subagents-head strong { flex:1; } + .subagents-head button { font-size:12px; padding:4px 8px; } + .subagents-summary { color:var(--muted); font-size:12px; margin-bottom:8px; } + .subagents-list { display:grid; gap:8px; } + .subagent-card { display:grid; gap:4px; width:100%; padding:8px; border:1px solid var(--border); border-radius:8px; background:var(--panel2); text-align:left; } + .subagent-card:hover { border-color:var(--accent); } + .subagent-card.active { border-color:var(--accent); background:rgba(79,140,255,.10); } + .subagent-card-title { display:flex; align-items:center; gap:8px; min-width:0; } + .subagent-card-name { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .subagent-card-state { font-size:11px; color:var(--muted); } + .subagent-card-state.running { color:#f0d98c; } + .subagent-card-meta { color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .usage-wrap { position:relative; margin-left:auto; } .usage-btn { display:flex; align-items:center; gap:6px; } .usage-popover { diff --git a/crates/giskard-server/static/app.js b/crates/giskard-server/static/app.js index f643295..dbb8e24 100644 --- a/crates/giskard-server/static/app.js +++ b/crates/giskard-server/static/app.js @@ -45,6 +45,7 @@ let state = { pickerTypeahead:"", pickerTypeaheadTimer:null, pickerSelectedRow:null, currentPlan:null, planExpanded:localStorage.getItem("giskard.planExpanded")==="1", threadActivity:new Map(), pendingApprovalFocus:null, notifiedApprovals:new Map(), approvalNotifications:new Map(), browserDiagnostics:[], + subagentImports:new Map(), projectThreads:new Map(), lastNotificationPromptNoticeAt:0, swRegistration:null, collapsedProjects:new Set(loadCollapsedProjects()), pendingRemoveProject:null, projectDirs:{} }; @@ -612,27 +613,94 @@ function restoreLastThread() { try { last = JSON.parse(localStorage.getItem("giskard.lastThread") || "null"); } catch { last = null; } if (!last || !last.pid || !last.tid) return; const el = document.querySelector(`.thread[data-tid="${last.tid}"]`); - if (!el) { localStorage.removeItem("giskard.lastThread"); return; } - openThread(last.pid, last.tid, currentThreadTitle(el), { silent:true }); + const meta = knownProjectThreads(last.pid).find(t => String(t.id) === String(last.tid)); + if (!meta) { localStorage.removeItem("giskard.lastThread"); return; } + openThread(last.pid, last.tid, el ? currentThreadTitle(el) : (meta.title || "Thread"), { silent:true }); } async function loadThreads(pid) { const box = $("threads-"+pid); if (!box) return; try { const { threads } = await api("GET",`/api/projects/${pid}/threads`); + rememberProjectThreads(pid, threads); box.innerHTML=""; - for (const t of threads.filter(t => !t.archived)) box.append(threadRow(pid, t)); - const archived = threads.filter(t => t.archived); + appendThreadRows(box, pid, threads.filter(t => !t.archived && !isManagedSubagentThread(t, threads))); + const archived = threads.filter(t => t.archived && !isManagedSubagentThread(t, threads)); if (archived.length) { const label = document.createElement("div"); label.className = "thread-section-label"; label.textContent = "Archived"; box.append(label); - for (const t of archived) box.append(threadRow(pid, t)); + appendThreadRows(box, pid, archived); } } catch {} } +function rememberProjectThreads(pid, threads) { + if (!pid || !Array.isArray(threads)) return; + const projectId = String(pid); + const normalized = threads.map(t => Object.assign({}, t, { + id:String(t.id), + parent_thread_id:t.parent_thread_id ? String(t.parent_thread_id) : null, + spawned_by_turn_id:t.spawned_by_turn_id ? String(t.spawned_by_turn_id) : null + })); + state.projectThreads.set(projectId, normalized); + + // Link results are browser-local accelerators only. Discard them when the authoritative thread + // list reloads; a later click resolves the trusted item coordinates idempotently on the server. + const projectPrefix = `${projectId}:`; + for (const key of Array.from(state.subagentImports.keys())) { + if (key.startsWith(projectPrefix)) state.subagentImports.delete(key); + } + renderParentThreadButton(); + renderSubagentsButton(); +} + +function knownProjectThreads(pid) { + return state.projectThreads.get(String(pid || state.projectId)) || []; +} + +function appendThreadRows(box, pid, threads) { + const byParent = new Map(); + const ids = new Set(threads.map(t => String(t.id))); + const roots = []; + for (const t of threads) { + const parent = t.parent_thread_id ? String(t.parent_thread_id) : ""; + if (parent && ids.has(parent)) { + if (!byParent.has(parent)) byParent.set(parent, []); + byParent.get(parent).push(t); + } else { + roots.push(t); + } + } + const appendOne = (t) => { + box.append(threadRow(pid, t)); + for (const child of byParent.get(String(t.id)) || []) appendOne(child); + }; + for (const t of roots) appendOne(t); +} + +// Hide only sub-agents whose ownership chain is complete and terminates at a primary root. +// Dangling, malformed, and cyclic metadata stays in the main sidebar as a recovery path. +function isManagedSubagentThread(t, threads) { + if (!t || t.kind !== "subagent" || !t.parent_thread_id) return false; + const byId = new Map((threads || []).map(thread => [String(thread.id), thread])); + const seen = new Set(); + let current = t; + while (current) { + const id = String(current.id || ""); + if (!id || seen.has(id)) return false; + seen.add(id); + const parentId = current.parent_thread_id ? String(current.parent_thread_id) : ""; + // `ThreadKind::Primary` is the serde default and may be omitted from summaries. + if (!parentId) return !current.kind || current.kind === "primary"; + if (current.kind !== "subagent") return false; + current = byId.get(parentId); + if (!current) return false; + } + return false; +} + function loadCollapsedProjects() { try { const ids = JSON.parse(localStorage.getItem(PROJECT_COLLAPSE_KEY) || "[]"); @@ -737,6 +805,17 @@ function updateThreadRowTitle(tid, title) { const pid = el.dataset.pid || state.projectId; applyThreadTitleToElement(el, pid, tid, title); }); + updateKnownThreadTitle(tid, title); +} + +function updateKnownThreadTitle(tid, title) { + const key = String(tid || ""); + if (!key || !title) return; + for (const threads of state.projectThreads.values()) { + const thread = threads.find(t => String(t.id) === key); + if (thread) thread.title = title; + } + renderSubagentsButton(); } function currentThreadTitle(el) { @@ -759,6 +838,50 @@ function threadMetaForId(tid) { }; } +function knownThreadForId(pid, tid) { + const key = String(tid || ""); + if (!key) return null; + return knownProjectThreads(pid).find(thread => String(thread.id) === key) || null; +} + +function activeParentThread() { + if (!state.projectId || !state.threadId) return null; + const current = knownThreadForId(state.projectId, state.threadId); + const parentId = current && current.parent_thread_id ? String(current.parent_thread_id) : ""; + if (!parentId) return null; + const parent = knownThreadForId(state.projectId, parentId); + return { + id:parentId, + title:(parent && parent.title) || "Parent thread" + }; +} + +function renderParentThreadButton() { + const btn = $("parentThreadBtn"); + if (!btn) return; + const parent = activeParentThread(); + btn.hidden = !parent; + btn.disabled = !parent; + btn.dataset.parentThreadId = parent ? parent.id : ""; + const label = parent ? `Back to parent thread: ${parent.title}` : "Back to parent thread"; + btn.title = label; + btn.setAttribute("aria-label", label); +} + +async function openParentThread() { + const parent = activeParentThread(); + if (!parent) return; + const btn = $("parentThreadBtn"); + btn.disabled = true; + try { + await openThread(state.projectId, parent.id, parent.title); + } finally { + renderParentThreadButton(); + } +} + +$("parentThreadBtn").onclick = openParentThread; + function clearThreadActivity(tid) { if (!tid) return; const activity = state.threadActivity.get(String(tid)); @@ -771,6 +894,7 @@ function clearThreadActivity(tid) { state.threadActivity.delete(String(tid)); } renderThreadActivityIndicator(tid); + renderSubagentsButton(); } function renderThreadActivityIndicator(tid) { @@ -805,6 +929,7 @@ function setThreadActivity(tid, activity) { const key = String(tid); state.threadActivity.set(key, activity); renderThreadActivityIndicator(key); + renderSubagentsButton(); } function setActiveThreadActivity(kind, activeTurn, summary, extra) { @@ -832,6 +957,7 @@ function clearActiveThreadActivityLater(tid, kind) { if (!activity || activity.source !== "active_thread_event" || activity.kind !== kind || activity.active_turn) return; state.threadActivity.delete(key); renderThreadActivityIndicator(key); + renderSubagentsButton(); }, ACTIVE_THREAD_COMPLETED_MARK_MS); } @@ -850,6 +976,7 @@ function clearApprovalThreadActivity(tid, approvalId) { state.threadActivity.delete(key); } renderThreadActivityIndicator(key); + renderSubagentsButton(); } function clearServerRequestThreadActivity(tid, requestId) { @@ -867,6 +994,7 @@ function clearServerRequestThreadActivity(tid, requestId) { state.threadActivity.delete(key); } renderThreadActivityIndicator(key); + renderSubagentsButton(); } function normalizeThreadTitleInput(value) { @@ -961,11 +1089,38 @@ async function renameThread(pid, tid, title) { return api("PATCH", `/api/projects/${pid}/threads/${tid}/title`, { title }); } +function threadDescendantIds(pid, tid) { + const childrenByParent = new Map(); + for (const thread of knownProjectThreads(pid)) { + const parentId = thread.parent_thread_id ? String(thread.parent_thread_id) : ""; + if (!parentId) continue; + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []); + childrenByParent.get(parentId).push(String(thread.id)); + } + const rootId = String(tid); + const seen = new Set([rootId]); + const descendants = []; + const pending = [...(childrenByParent.get(rootId) || [])]; + while (pending.length) { + const childId = pending.pop(); + if (!childId || seen.has(childId)) continue; + seen.add(childId); + descendants.push(childId); + pending.push(...(childrenByParent.get(childId) || [])); + } + return descendants; +} + async function deleteThread(pid, tid, title) { - if (!confirm(`Delete thread "${title}"? This also deletes the Codex thread.`)) return; + const descendants = threadDescendantIds(pid, tid); + const cascade = descendants.length + ? `, its ${descendants.length} linked sub-agent thread${descendants.length === 1 ? "" : "s"}, and all corresponding Codex threads` + : " and its corresponding Codex thread"; + if (!confirm(`Permanently delete thread "${title}"${cascade}? This cannot be undone.`)) return; try { await api("DELETE", `/api/projects/${pid}/threads/${tid}`); - clearThreadView(tid); + const deletedIds = new Set([String(tid), ...descendants]); + if (state.threadId && deletedIds.has(String(state.threadId))) clearThreadView(state.threadId); await loadThreads(pid); } catch (e) { notice("Delete thread failed: " + e.message, "error"); @@ -984,6 +1139,7 @@ function clearThreadView(tid) { try { ws.close(); } catch {} } state.projectId = null; state.threadId = null; + renderParentThreadButton(); state.draftThread = null; state.firstTurnStartingThreadId = null; state.pendingUserEl = null; state.pendingUserText = null; @@ -1290,6 +1446,7 @@ function openDraftThread(pid, defaultModel) { state.projectId = pid; state.threadId = null; + renderParentThreadButton(); state.draftThread = { projectId:pid, title:"New thread" }; state.firstTurnStartingThreadId = null; state.pendingUserEl = null; @@ -1300,9 +1457,11 @@ function openDraftThread(pid, defaultModel) { state.mcpServers = []; state.mcpError = null; state.expandedMcps = new Set(); state.mcpCapabilities = { status:false, reload:false, oauth_login:false }; $("tasksMenu").hidden = true; + $("subagentsMenu").hidden = true; $("mcpMenu").hidden = true; $("usageMenu").hidden = true; renderMcpButton(); + renderSubagentsButton(); loadProjectModels(pid); // load this project's model list (config + discovery + Codex names) setMode("build"); setApprovalPolicy("ask"); @@ -1355,6 +1514,7 @@ async function openThread(pid, tid, title, opts) { clearThreadActivity(tid); state.projectId = pid; state.threadId = tid; state.pendingUserEl = null; state.pendingUserText = null; + renderParentThreadButton(); state.threadReadOnly = false; state.readOnlyProvider = null; state.readOnlyMessage = null; updateReadOnlyBanner(); state.draftThread = null; @@ -1365,9 +1525,11 @@ async function openThread(pid, tid, title, opts) { state.mcpServers = []; state.mcpError = null; state.expandedMcps = new Set(); state.mcpCapabilities = { status:false, reload:false, oauth_login:false }; $("tasksMenu").hidden = true; + $("subagentsMenu").hidden = true; $("mcpMenu").hidden = true; $("usageMenu").hidden = true; renderMcpButton(); + renderSubagentsButton(); loadMcpServers({ announce:false }); loadProjectModels(pid); // load this project's model list (config + discovery + Codex names) setTurnActive(false); @@ -1794,6 +1956,7 @@ function handleThreadActivity(msg) { } state.threadActivity.set(tid, activity); renderThreadActivityIndicator(tid); + renderSubagentsButton(); if (activity.kind === "approval_requested") maybeNotifyApproval(tid, activity); } @@ -2421,6 +2584,7 @@ function handleEvent(ev) { if (ev.turn) { document.querySelectorAll('.msg[data-turn="pending"]').forEach(m => { m.dataset.turn = ev.turn; }); } + renderLiveTurnUserInput(ev.turn, ev.user_input); setTurnActive(true); setActiveThreadActivity("progress", true, "Turn running"); break; @@ -2530,6 +2694,7 @@ function renderLiveTurnSnapshot(snap) { // Adopt the live turn id so its rows stamp correctly even if the accumulated events don't lead // with a turn_started (the turn_started handler will confirm the same id). state.currentRenderTurnId = snap.turn_id; + renderLiveTurnUserInput(snap.turn_id, snap.user_input); setTurnActive(true); setActiveThreadActivity("progress", true, "Turn running"); } @@ -2547,6 +2712,39 @@ function renderLiveTurnSnapshot(snap) { } } +function renderLiveTurnUserInput(turnId, userInput) { + const text = userInput && userInput.text; + if (!turnId || !text) return; + const exists = Array.from(document.querySelectorAll(".msg.user")).some( + row => row.dataset && String(row.dataset.turn || "") === String(turnId) + ); + if (exists) return; + const body = bubble("user","you"); + body.parentElement.dataset.liveUserInput = "true"; + renderItemBody(body, { kind:"user_message", text }); +} + +function provisionalUserBodyForTurn(turnId) { + const target = renderTarget(); + return Array.from(target.querySelectorAll(".msg.user[data-live-user-input='true'] .body")).find( + body => body.parentElement && String(body.parentElement.dataset.turn || "") === String(turnId || "") + ) || null; +} + +function isSyntheticSubagentPrompt(item) { + return !!(item && String(item.harness_item_id || "").startsWith("subagent_prompt:")); +} + +function placeRowFirstInTurn(row, turnId) { + const target = row && row.parentElement; + if (!target || !turnId) return; + const first = Array.from(target.children).find(candidate => + candidate !== row && candidate.classList && candidate.classList.contains("msg") && + String(candidate.dataset.turn || "") === String(turnId) + ); + if (first) target.insertBefore(row, first); +} + function renderApprovalRequest(request) { if (!request || !request.id) return; const id = String(request.id); @@ -3973,6 +4171,108 @@ function renderTasksButton(cmds) { btn.title = count ? `${count} running task${count === 1 ? "" : "s"}` : "No running tasks"; $("tasksCount").textContent = String(count); } + +function subagentThreadsForActiveProject() { + const activeThreadId = state.threadId ? String(state.threadId) : ""; + const threads = knownProjectThreads(state.projectId); + return threads.filter(t => + isManagedSubagentThread(t, threads) && String(t.parent_thread_id || "") === activeThreadId + ); +} + +function subagentActivityForThread(threadId) { + return state.threadActivity.get(String(threadId || "")) || null; +} + +function subagentIsRunning(thread) { + const activity = subagentActivityForThread(thread.id); + return !!(activity && activity.active_turn); +} + +function subagentCounts() { + const agents = subagentThreadsForActiveProject(); + const running = agents.filter(subagentIsRunning).length; + return { total:agents.length, running }; +} + +function renderSubagentsButton() { + const btn = $("subagentsBtn"); + if (!btn) return; + const counts = subagentCounts(); + const stateName = counts.running ? "running" : "idle"; + btn.className = `badge subagents-btn state-${stateName}`; + btn.disabled = !state.projectId || (!counts.total && !counts.running); + btn.title = counts.total + ? `${counts.running} running sub-agent${counts.running === 1 ? "" : "s"} · ${counts.total} total` + : "No sub-agents"; + $("subagentsCount").textContent = String(counts.running || counts.total); + if (!$("subagentsMenu").hidden) renderSubagentsMenu(); +} + +function renderSubagentsMenu() { + const menu = $("subagentsMenu"); + const agents = subagentThreadsForActiveProject(); + const counts = subagentCounts(); + const rows = agents.map(renderSubagentCard).join(""); + const summary = agents.length + ? `
${counts.running} running · ${counts.total} total
` + : ""; + menu.innerHTML = ` +
+ Sub-agents + +
+ ${summary} +
${rows || `
No sub-agents for this thread yet.
`}
`; + $("subagentsClose").onclick = () => { $("subagentsMenu").hidden = true; }; + menu.querySelectorAll("[data-subagent-thread-id]").forEach(btn => { + btn.onclick = () => { + const tid = btn.dataset.subagentThreadId; + const meta = threadMetaForId(tid) || agents.find(t => String(t.id) === String(tid)); + openThread(state.projectId, tid, (meta && meta.title) || "Sub-agent"); + }; + }); +} + +function renderSubagentCard(thread) { + const activity = subagentActivityForThread(thread.id); + const running = !!(activity && activity.active_turn); + const stateLabel = running ? "Running" : (activity && activity.kind === "error" ? "Error" : "Idle"); + const parent = thread.parent_thread_id ? knownProjectThreads(state.projectId).find(t => String(t.id) === String(thread.parent_thread_id)) : null; + const parentLabel = parent && parent.title ? `Parent: ${parent.title}` : "Parent thread"; + const summary = activity && activity.summary ? activity.summary : parentLabel; + const active = state.threadId && String(state.threadId) === String(thread.id); + const name = subagentDisplayName(thread); + return ``; +} + +function subagentDisplayName(thread) { + const title = String((thread && thread.title) || "").trim(); + const name = title.replace(/^Sub-agent:\s*/i, "").trim(); + return name && !subagentNameLooksLikeId(name) ? name : "Sub-agent"; +} + +function subagentNameLooksLikeId(name) { + return /^[0-9a-f]{8,}(?:-[0-9a-f]{4,})+$/i.test(String(name || "").trim()); +} + +function toggleSubagentsMenu() { + const menu = $("subagentsMenu"); + menu.hidden = !menu.hidden; + if (!menu.hidden) { + $("tasksMenu").hidden = true; + $("mcpMenu").hidden = true; + $("usageMenu").hidden = true; + renderSubagentsMenu(); + } +} + function taskButtonState(cmds) { if (!cmds.length) return "idle"; if (cmds.some(cmd => cmd.terminating)) return "stopping"; @@ -4054,6 +4354,7 @@ function toggleTasksMenu() { const menu = $("tasksMenu"); menu.hidden = !menu.hidden; if (!menu.hidden) { + $("subagentsMenu").hidden = true; $("mcpMenu").hidden = true; $("usageMenu").hidden = true; renderTasksMenu(); @@ -4061,6 +4362,14 @@ function toggleTasksMenu() { } $("tasksBtn").onclick = (e) => { e.stopPropagation(); toggleTasksMenu(); }; $("tasksMenu").onclick = (e) => e.stopPropagation(); +$("subagentsBtn").onclick = (e) => { e.stopPropagation(); toggleSubagentsMenu(); }; +$("subagentsMenu").onclick = (e) => e.stopPropagation(); +document.addEventListener("click", (e) => { + const menu = $("subagentsMenu"); + if (menu.hidden) return; + if (e.target.closest && e.target.closest(".subagents-wrap")) return; + menu.hidden = true; +}); document.addEventListener("click", (e) => { const menu = $("tasksMenu"); if (menu.hidden) return; @@ -4165,6 +4474,8 @@ function startToolCall(item, turnId) { output:null, server:tool.server || null, status:tool.status || "in_progress", + metadata:tool.metadata || null, + subagent:tool.subagent || null, error:null }); } @@ -4184,6 +4495,8 @@ function appendToolProgress(turnId, itemId, text) { output:null, server:null, status:"in_progress", + metadata:null, + subagent:null, error:null }); } @@ -4329,6 +4642,9 @@ function addItem(item, turnId, fromHistory) { } registerRenderedItemBody(existing, item, turnId); renderItemBodyForItem(existing, item, turnId); + if (p.kind==="user_message" && isSyntheticSubagentPrompt(item)) { + placeRowFirstInTurn(existing.parentElement, turnId); + } if (p.kind==="command_execution") finishRunningCommand(item, turnId); markRenderedItem(item, turnId); return; @@ -4358,9 +4674,19 @@ function addItem(item, turnId, fromHistory) { markRenderedItem(item, turnId); return; } + const provisionalBody = provisionalUserBodyForTurn(turnId); + if (provisionalBody) { + delete provisionalBody.parentElement.dataset.liveUserInput; + renderItemBodyForItem(provisionalBody, item, turnId); + registerRenderedItemBody(provisionalBody, item, turnId); + placeRowFirstInTurn(provisionalBody.parentElement, turnId); + markRenderedItem(item, turnId); + return; + } const body = bubble("user","you"); renderItemBody(body, p); registerRenderedItemBody(body, item, turnId); + if (isSyntheticSubagentPrompt(item)) placeRowFirstInTurn(body.parentElement, turnId); } else { if (p.kind==="file_change") { @@ -4563,6 +4889,7 @@ function renderItemBody(body, p) { renderToolBody(body, p); } else if (p.kind==="activity") { body.innerHTML = renderActivity(p); + attachSubagentLinkActions(body, p); } const taskItemId = msg.dataset.commandItemId || msg.dataset.toolItemId || ""; if (taskItemId) { @@ -4954,6 +5281,7 @@ function renderToolBody(body, p) { phase:stateName === "running" ? "running" : "completed", blocks:toolIoBlocks(p) }); + attachSubagentLinkActions(body, p); if (p.error) { const err = document.createElement("div"); err.className = "meta"; @@ -5052,11 +5380,95 @@ function toolTerminalDurationMs(msg, startedAtMs) { } function renderActivity(p) { if (isImageViewActivity(p)) return renderImageViewActivity(p); + if (subagentLinkInfo(p)) return renderSubagentActivity(p); const detail = p.detail ? `
${escapeHtml(p.detail)}
` : ""; const metadata = visibleActivityMetadata(p); const meta = metadata ? `
${escapeHtml(jsonPreview(metadata))}
` : ""; return `
${escapeHtml(p.title||"Activity")}
${detail}${meta}`; } +function subagentLinkInfo(p) { + const link = p && p.subagent; + if (!link) return null; + return { + agentPath:String(link.path || ""), + title:p.title || "Sub-agent" + }; +} +function renderSubagentActivity(p) { + const info = subagentLinkInfo(p); + if (!info) return ""; + const detail = p.detail ? `
${escapeHtml(p.detail)}
` : ""; + return [ + `
${escapeHtml(info.title)}
`, + detail, + `` + ].join(""); +} +function attachSubagentLinkActions(body, p) { + const info = subagentLinkInfo(p); + if (!info) return; + let btn = body.querySelector(".subagent-open-btn"); + if (!btn) { + btn = document.createElement("button"); + btn.type = "button"; + btn.className = "subagent-open-btn"; + btn.textContent = "Open linked thread"; + body.append(btn); + } + const parentTid = state.threadId; + btn.dataset.agentPath = info.agentPath; + btn.onclick = () => openSubagentThreadFromActivity(btn, info, { + focus:true, + parentTid, + itemId:subagentActivityItemId(btn) + }); +} + +function validGiskardItemId(value) { + const itemId = value === undefined || value === null ? "" : String(value).trim(); + return /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i.test(itemId) ? itemId : null; +} + +function subagentActivityItemId(btn) { + const row = btn && btn.closest ? btn.closest(".msg") : null; + const ids = identityTokens(row && row.dataset ? row.dataset.item : null); + return ids.map(validGiskardItemId).find(Boolean) || null; +} +function subagentImportKey(pid, parentTid, itemId) { + return [pid || "", parentTid || "", itemId || ""].join(":"); +} +async function importSubagentThread(parentTid, itemId) { + const pid = state.projectId; + const res = await api( + "POST", + `/api/projects/${pid}/threads/${parentTid}/subagent-links/${itemId}/open` + ); + return { threadId:res.thread_id, title:res.title || "Sub-agent" }; +} +async function openSubagentThreadFromActivity(btn, info, opts) { + opts = opts || {}; + const itemId = validGiskardItemId(opts.itemId); + if (!state.projectId || !opts.parentTid || !info || !itemId) return; + btn.dataset.linkItemId = itemId; + const key = subagentImportKey(state.projectId, opts.parentTid, itemId); + let imported = state.subagentImports.get(key); + if (!imported || !imported.threadId) { + btn.disabled = true; + btn.textContent = "Opening..."; + try { + const result = await importSubagentThread(opts.parentTid, itemId); + imported = { status:"ready", threadId:result.threadId, title:result.title }; + state.subagentImports.set(key, imported); + await loadThreads(state.projectId); + } catch (e) { + btn.disabled = false; + btn.textContent = "Open linked thread"; + notice("Open linked thread failed: " + apiFailureMessage(e), "error"); + return; + } + } + await openThread(state.projectId, imported.threadId, imported.title || "Sub-agent"); +} function isImageViewActivity(p) { return !!(p && p.kind === "activity" && p.title === "Image viewed" && imageViewPath(p)); } @@ -5278,6 +5690,7 @@ function toggleMcpMenu() { menu.hidden = !menu.hidden; if (!menu.hidden) { $("tasksMenu").hidden = true; + $("subagentsMenu").hidden = true; $("usageMenu").hidden = true; renderMcpMenu(); loadMcpServers({ announce:false }); @@ -6230,6 +6643,7 @@ function toggleUsageMenu() { menu.hidden = !menu.hidden; if (!menu.hidden) { $("tasksMenu").hidden = true; + $("subagentsMenu").hidden = true; $("mcpMenu").hidden = true; renderUsageMenu(); } diff --git a/crates/giskard-server/static/index.html b/crates/giskard-server/static/index.html index 320b8dd..436f689 100644 --- a/crates/giskard-server/static/index.html +++ b/crates/giskard-server/static/index.html @@ -79,6 +79,10 @@

Giskard

Giskard