Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`,
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions crates/giskard-core/src/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,51 @@ pub struct ToolCallStart {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent: Option<SubagentLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at_ms: Option<i64>,
}

/// 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<String>,
/// 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<String>,
pub action: SubagentAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<SubagentStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}

#[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 {
Expand Down Expand Up @@ -149,6 +191,10 @@ pub enum ItemPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent: Option<SubagentLink>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
Activity {
Expand All @@ -157,6 +203,8 @@ pub enum ItemPayload {
detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent: Option<SubagentLink>,
},
}

Expand Down Expand Up @@ -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),
}),
};
Expand Down
8 changes: 7 additions & 1 deletion crates/giskard-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
16 changes: 16 additions & 0 deletions crates/giskard-core/src/text.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
10 changes: 10 additions & 0 deletions crates/giskard-core/src/thread.rs
Original file line number Diff line number Diff line change
@@ -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,
}
47 changes: 47 additions & 0 deletions crates/giskard-harness-codex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <requested-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:
Expand Down
Loading
Loading