From d16ae94ecfc819279080d6c5da9038de0d6d0efb Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Tue, 28 Jul 2026 23:42:28 +0800 Subject: [PATCH 01/17] chore: pin official ACP v1 and A2A 1.0 spec snapshots in protocol-lock.json --- protocol-lock.json | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 protocol-lock.json diff --git a/protocol-lock.json b/protocol-lock.json new file mode 100644 index 00000000..116854ea --- /dev/null +++ b/protocol-lock.json @@ -0,0 +1,37 @@ +{ + "description": "Lock file pinning the official ACP/A2A specification snapshots used by the adapters. Upgrading a protocol requires explicitly updating this file, the generated bindings, and the conformance fixtures in the same change.", + "protocols": { + "acp": { + "version": "1", + "snapshot": "~/Downloads/agent-client-protocol-main", + "truthSources": [ + { + "path": "schema/v1/schema.json", + "role": "wire schema (normative)", + "sha256": "7f1fba1561163729115247df75b67aeed02085115fbc7ef0131fb01d456c08f9" + } + ], + "supplementary": [ + "schema/v1/src/", + "docs/protocol/v1/" + ], + "notes": "schema/v2 is unstable and is NOT adopted." + }, + "a2a": { + "version": "1.0", + "snapshot": "~/Downloads/A2A-main", + "truthSources": [ + { + "path": "specification/a2a.proto", + "role": "normative specification", + "sha256": "945df6e34001b2bfd0fd62d9484b63094dfad9d78705e41e2873441c419ae2d1" + } + ], + "supplementary": [ + "docs/specification.md", + "specification/json/README.md" + ], + "notes": "protobuf JSON mapping is used for the JSON-RPC binding; specification/json/README.md is an entry note only and does not replace the proto." + } + } +} From 695fca8ca947be2101684d81703ca65f39f38c29 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 00:16:06 +0800 Subject: [PATCH 02/17] feat(protocol): freeze canonical Item/Session/Turn/Event/Usage types and method registry --- crates/protocol/src/canonical/error.rs | 95 +++ crates/protocol/src/canonical/event.rs | 430 ++++++++++++ crates/protocol/src/canonical/goal.rs | 50 ++ crates/protocol/src/canonical/ids.rs | 142 ++++ crates/protocol/src/canonical/item.rs | 604 +++++++++++++++++ crates/protocol/src/canonical/methods.rs | 622 ++++++++++++++++++ crates/protocol/src/canonical/mod.rs | 25 + crates/protocol/src/canonical/model.rs | 29 + crates/protocol/src/canonical/page.rs | 24 + crates/protocol/src/canonical/patch.rs | 92 +++ crates/protocol/src/canonical/queue.rs | 40 ++ crates/protocol/src/canonical/rpc_admin.rs | 266 ++++++++ crates/protocol/src/canonical/rpc_session.rs | 313 +++++++++ crates/protocol/src/canonical/rpc_turn.rs | 265 ++++++++ crates/protocol/src/canonical/session.rs | 141 ++++ crates/protocol/src/canonical/turn.rs | 66 ++ crates/protocol/src/canonical/usage.rs | 158 +++++ crates/protocol/src/lib.rs | 1 + crates/protocol/tests/canonical_golden.rs | 87 +++ .../tests/golden/item_approval_waiting.json | 19 + .../golden/item_unknown_future_variant.json | 6 + .../tests/golden/item_user_message.json | 19 + .../tests/golden/patch_title_null.json | 5 + 23 files changed, 3499 insertions(+) create mode 100644 crates/protocol/src/canonical/error.rs create mode 100644 crates/protocol/src/canonical/event.rs create mode 100644 crates/protocol/src/canonical/goal.rs create mode 100644 crates/protocol/src/canonical/ids.rs create mode 100644 crates/protocol/src/canonical/item.rs create mode 100644 crates/protocol/src/canonical/methods.rs create mode 100644 crates/protocol/src/canonical/mod.rs create mode 100644 crates/protocol/src/canonical/model.rs create mode 100644 crates/protocol/src/canonical/page.rs create mode 100644 crates/protocol/src/canonical/patch.rs create mode 100644 crates/protocol/src/canonical/queue.rs create mode 100644 crates/protocol/src/canonical/rpc_admin.rs create mode 100644 crates/protocol/src/canonical/rpc_session.rs create mode 100644 crates/protocol/src/canonical/rpc_turn.rs create mode 100644 crates/protocol/src/canonical/session.rs create mode 100644 crates/protocol/src/canonical/turn.rs create mode 100644 crates/protocol/src/canonical/usage.rs create mode 100644 crates/protocol/tests/canonical_golden.rs create mode 100644 crates/protocol/tests/golden/item_approval_waiting.json create mode 100644 crates/protocol/tests/golden/item_unknown_future_variant.json create mode 100644 crates/protocol/tests/golden/item_user_message.json create mode 100644 crates/protocol/tests/golden/patch_title_null.json diff --git a/crates/protocol/src/canonical/error.rs b/crates/protocol/src/canonical/error.rs new file mode 100644 index 00000000..aa4f1a5c --- /dev/null +++ b/crates/protocol/src/canonical/error.rs @@ -0,0 +1,95 @@ +//! Unified application error model for all four API surfaces. +//! +//! Truth source: `devo-api-design/01-native-api.md` §7. Application errors use +//! the JSON-RPC `-32000..-32099` range; adapters translate this shape into +//! their own protocol's error form. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +/// Stable machine-readable error codes. UI localization keys off these, never +/// off `message`. +pub mod codes { + pub const NOT_INITIALIZED: &str = "NOT_INITIALIZED"; + pub const UNSUPPORTED_PROTOCOL_VERSION: &str = "UNSUPPORTED_PROTOCOL_VERSION"; + pub const SESSION_NOT_FOUND: &str = "SESSION_NOT_FOUND"; + pub const GOAL_NOT_FOUND: &str = "GOAL_NOT_FOUND"; + pub const INVALID_CWD: &str = "INVALID_CWD"; + pub const CWD_ACCESS_DENIED: &str = "CWD_ACCESS_DENIED"; + pub const UNSUPPORTED_MODALITY: &str = "UNSUPPORTED_MODALITY"; + pub const INVALID_ITEM_SHAPE: &str = "INVALID_ITEM_SHAPE"; + pub const INVALID_TOOL_PAIRING: &str = "INVALID_TOOL_PAIRING"; + pub const TURN_ALREADY_ACTIVE: &str = "TURN_ALREADY_ACTIVE"; + pub const TURN_NOT_STEERABLE: &str = "TURN_NOT_STEERABLE"; + pub const QUEUE_ITEM_NOT_FOUND: &str = "QUEUE_ITEM_NOT_FOUND"; + pub const RESTORE_PLAN_NOT_FOUND: &str = "RESTORE_PLAN_NOT_FOUND"; + pub const RESTORE_PLAN_EXPIRED: &str = "RESTORE_PLAN_EXPIRED"; + pub const VERSION_CONFLICT: &str = "VERSION_CONFLICT"; + pub const WORKSPACE_VERSION_CONFLICT: &str = "WORKSPACE_VERSION_CONFLICT"; + pub const IDEMPOTENCY_CONFLICT: &str = "IDEMPOTENCY_CONFLICT"; + pub const CURSOR_EXPIRED: &str = "CURSOR_EXPIRED"; + pub const CONTROL_REQUEST_ALREADY_RESOLVED: &str = "CONTROL_REQUEST_ALREADY_RESOLVED"; + pub const GOAL_TRANSITION_INVALID: &str = "GOAL_TRANSITION_INVALID"; + pub const SERVER_OVERLOADED: &str = "SERVER_OVERLOADED"; + pub const ROLLOUT_VERSION_UNSUPPORTED: &str = "ROLLOUT_VERSION_UNSUPPORTED"; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentError { + /// Stable machine-readable code, see `codes`. + pub error_code: String, + /// Developer-facing message. + pub message: String, + pub retryable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub field_violations: Vec, + /// Current resource version when reporting a version conflict. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_version: Option, + /// Whether the client must re-fetch a snapshot before retrying. + pub requires_snapshot: bool, + /// Must never contain secrets or restricted content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl AgentError { + pub fn new(error_code: impl Into, message: impl Into) -> Self { + Self { + error_code: error_code.into(), + message: message.into(), + retryable: false, + retry_after_ms: None, + field_violations: Vec::new(), + current_version: None, + requires_snapshot: false, + details: None, + } + } + + pub fn retryable(mut self) -> Self { + self.retryable = true; + self + } +} + +impl std::fmt::Display for AgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.error_code, self.message) + } +} + +impl std::error::Error for AgentError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct FieldViolation { + pub field: String, + pub message: String, +} diff --git a/crates/protocol/src/canonical/event.rs b/crates/protocol/src/canonical/event.rs new file mode 100644 index 00000000..8f39d922 --- /dev/null +++ b/crates/protocol/src/canonical/event.rs @@ -0,0 +1,430 @@ +//! Event envelope, streams, subscriptions, and typed server notifications. +//! +//! Truth source: `devo-api-design/08-events-subscription.md`. +//! Core proposition: an event first becomes a replayable fact, then gets +//! delivered — delivery is consumption, not production. + +use std::path::PathBuf; + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::error::AgentError; +use super::goal::Goal; +use super::goal::GoalStatus; +use super::ids::EventId; +use super::ids::ItemId; +use super::ids::QueueItemId; +use super::ids::RestorePlanId; +use super::ids::SessionId; +use super::ids::SubscriptionId; +use super::ids::TurnId; +use super::item::ApprovalDecision; +use super::item::CompactionTrigger; +use super::item::ContextUsage; +use super::item::ItemEnvelope; +use super::item::SpawnedWorkState; +use super::queue::QueueChange; +use super::queue::QueueEntry; +use super::session::Session; +use super::session::SessionFlag; +use super::session::SessionStatus; +use super::turn::Turn; +use super::turn::TurnStatus; +use super::usage::SessionUsage; + +// --------------------------------------------------------------------------- +// Envelope +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct EventMeta { + pub event_id: EventId, + /// Whitelisted stream, e.g. `runtime:` / + /// `sessions:` / `session:` / `task:`. + pub stream_id: String, + /// Present only when `persisted = true`; strictly increasing within the + /// stream and usable as a cursor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, + pub emitted_at: DateTime, + /// `false` = purely transient (e.g. high-frequency token deltas): no + /// stream seq, cannot be acked, excluded from replay. + pub persisted: bool, + pub schema_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_client_id: Option, +} + +/// One event with its metadata; used both for live delivery and for replay. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct EventEnvelope { + #[serde(rename = "event")] + pub meta: EventMeta, + pub notification: ServerNotification, +} + +// --------------------------------------------------------------------------- +// Notifications (Server -> Client) +// --------------------------------------------------------------------------- + +/// Delta channels for living items. Transient deltas carry +/// `itemId + baseRevision + chunkIndex + delta`, are ordered per +/// item/channel, may be coalesced, and never enter the cursor log. +/// `item/completed` is the delta barrier for an item. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum DeltaChannel { + AssistantMessage, + Reasoning, + CommandExecutionOutput, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ItemDelta { + pub item_id: ItemId, + pub base_revision: u32, + pub chunk_index: u64, + pub delta: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "method", content = "params", rename_all_fields = "camelCase")] +pub enum ServerNotification { + // ── Connection ── + #[serde(rename = "initialized")] + Initialized { + connection_id: String, + protocol_version: String, + server_instance_id: String, + }, + #[serde(rename = "runtime/warning")] + RuntimeWarning { + code: String, + message: String, + retryable: bool, + }, + #[serde(rename = "runtime/shutdown")] + RuntimeShutdown { reason: Option }, + + // ── Session ── + #[serde(rename = "session/created")] + SessionCreated { session: Box }, + #[serde(rename = "session/metadataUpdated")] + SessionMetadataUpdated { session: Box }, + #[serde(rename = "session/cwdChanged")] + SessionCwdChanged { session_id: SessionId, cwd: PathBuf }, + #[serde(rename = "session/statusChanged")] + SessionStatusChanged { + session_id: SessionId, + status: SessionStatus, + flags: Vec, + active_turn_id: Option, + }, + #[serde(rename = "session/archived")] + SessionArchived { session_id: SessionId, archived: bool }, + #[serde(rename = "session/deleted")] + SessionDeleted { session_id: SessionId }, + #[serde(rename = "workspace/restoreStarted")] + WorkspaceRestoreStarted { + session_id: SessionId, + restore_plan_id: RestorePlanId, + }, + #[serde(rename = "workspace/restoreCompleted")] + WorkspaceRestoreCompleted { + session_id: SessionId, + restore_plan_id: RestorePlanId, + succeeded: bool, + error: Option, + }, + + // ── Turn / Item ── + #[serde(rename = "turn/started")] + TurnStarted { turn: Box }, + #[serde(rename = "turn/statusChanged")] + TurnStatusChanged { + turn_id: TurnId, + status: TurnStatus, + }, + #[serde(rename = "turn/completed")] + TurnCompleted { turn: Box }, + /// Item birth; carries the revision=1 full snapshot (delta baseline). + #[serde(rename = "item/started")] + ItemStarted { item: Box }, + /// Non-delta content change of a living item; carries a full snapshot + /// with a strictly increasing revision; clients replace by id. + #[serde(rename = "item/updated")] + ItemUpdated { item: Box }, + #[serde(rename = "item/assistantMessage/delta")] + ItemAssistantMessageDelta(ItemDelta), + #[serde(rename = "item/reasoning/delta")] + ItemReasoningDelta(ItemDelta), + #[serde(rename = "item/commandExecution/outputDelta")] + ItemCommandExecutionOutputDelta(ItemDelta), + /// All terminal states (Completed/Failed/Interrupted/Lost) go through + /// this one notification with the terminal full snapshot; no separate + /// `item/failed` exists. + #[serde(rename = "item/completed")] + ItemCompleted { item: Box }, + /// `drained` carries `queueItemId + startedTurnId` and is generated in + /// the same session-actor operation as the matching `turn/started`, so + /// External/A2A handles bind atomically. + #[serde(rename = "queue/updated")] + QueueUpdated { + session_id: SessionId, + change: QueueChange, + queue_item_id: QueueItemId, + #[serde(default, skip_serializing_if = "Option::is_none")] + started_turn_id: Option, + queue: Vec, + }, + + // ── Goal ── + #[serde(rename = "session/goal/created")] + GoalCreated { goal: Goal }, + #[serde(rename = "session/goal/updated")] + GoalUpdated { goal: Goal }, + #[serde(rename = "session/goal/statusChanged")] + GoalStatusChanged { + session_id: SessionId, + goal_id: super::ids::GoalId, + status: GoalStatus, + }, + #[serde(rename = "session/goal/cleared")] + GoalCleared { + session_id: SessionId, + goal_id: super::ids::GoalId, + }, + + // ── Model / Context / Usage ── + #[serde(rename = "model/queryFailed")] + ModelQueryFailed { + session_id: SessionId, + turn_id: TurnId, + error: AgentError, + }, + #[serde(rename = "model/queryRetrying")] + ModelQueryRetrying { + session_id: SessionId, + turn_id: TurnId, + attempt: u32, + max_attempts: u32, + next_delay_ms: u64, + error: AgentError, + }, + #[serde(rename = "context/usageUpdated")] + ContextUsageUpdated { + session_id: SessionId, + usage: ContextUsage, + }, + #[serde(rename = "context/compactionStarted")] + ContextCompactionStarted { + session_id: SessionId, + turn_id: TurnId, + trigger: CompactionTrigger, + }, + #[serde(rename = "context/compactionCompleted")] + ContextCompactionCompleted { + session_id: SessionId, + turn_id: TurnId, + item_id: ItemId, + }, + #[serde(rename = "session/usage/updated")] + SessionUsageUpdated { + session_id: SessionId, + usage: Box, + }, + + // ── Background ── + #[serde(rename = "task/started")] + TaskStarted { item_id: ItemId }, + #[serde(rename = "task/delta")] + TaskDelta { + item_id: ItemId, + chunk_index: u64, + delta: String, + }, + #[serde(rename = "task/completed")] + TaskCompleted { + item_id: ItemId, + exit_code: Option, + }, + #[serde(rename = "task/lost")] + TaskLost { item_id: ItemId }, + #[serde(rename = "agent/started")] + AgentStarted { + item_id: ItemId, + agent_session_id: SessionId, + }, + #[serde(rename = "agent/progress")] + AgentProgress { item_id: ItemId, summary: String }, + #[serde(rename = "agent/completed")] + AgentCompleted { + item_id: ItemId, + agent_session_id: SessionId, + state: SpawnedWorkState, + }, + + // ── Security ── + #[serde(rename = "permission/decision")] + PermissionDecision { + session_id: SessionId, + approval_id: String, + decision: ApprovalDecision, + }, + #[serde(rename = "security/alert")] + SecurityAlert { code: String, message: String }, + #[serde(rename = "credential/changed")] + CredentialChanged { + credential_id: String, + provider: String, + change: CredentialChange, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum CredentialChange { + Added, + Updated, + Deleted, +} + +// --------------------------------------------------------------------------- +// Subscription +// --------------------------------------------------------------------------- + +/// A durable position within one stream; only persisted events are cursorable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct EventCursor { + pub stream_id: String, + pub seq: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum StreamSelector { + SessionsByCwd { cwd: PathBuf }, + Session { session_id: SessionId }, + BackgroundTask { item_id: ItemId }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct StreamSnapshot { + pub stream_id: String, + /// The snapshot is consistent with this barrier seq: the server read the + /// barrier, registered the subscription, and produced the snapshot in one + /// critical section, so nothing between read and subscribe is lost. + pub barrier_seq: u64, + pub data: SnapshotData, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum SnapshotData { + SessionsList { sessions: Vec }, + Session { + session: Box, + #[serde(default, skip_serializing_if = "Option::is_none")] + active_turn: Option>, + queue: Vec, + }, + BackgroundTask { item: Box }, +} + +/// Forced full content of an active item on resubscription, so transient +/// deltas lost during the disconnect are corrected. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct LiveItemSnapshot { + pub item: ItemEnvelope, + pub accumulated: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ChannelAccumulation { + pub channel: DeltaChannel, + pub text: String, + pub next_chunk_index: u64, +} + +/// An unanswered server->client control request (approval / structured +/// question). The first valid response wins; others get +/// `CONTROL_REQUEST_ALREADY_RESOLVED`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PendingControlRequest { + pub request_id: String, + pub kind: ControlRequestKind, + /// The waiting-state item (Approval / UserInputRequest). + pub item: ItemEnvelope, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ControlRequestKind { + ApprovalCommand, + ApprovalFileChange, + ApprovalPermission, + UserInput, + GoalCompletion, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionCreateParams { + pub selectors: Vec, + pub include_snapshot: bool, + /// Positions from the client's last acks when resubscribing. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub after: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionCreateResult { + pub subscription_id: SubscriptionId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub snapshots: Vec, + /// Persisted events in `(after, barrier]`, ordered by stream/seq. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub replay: Vec, + /// Mandatory when `after` is present, even if `include_snapshot = false`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recovery_snapshots: Vec, + pub cursors: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pending_control_requests: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionUpdateParams { + pub subscription_id: SubscriptionId, + pub selectors: Vec, +} + +/// Monotonic ack of cursors; also the server's basis for truncating the +/// persisted event log. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionAckParams { + pub subscription_id: SubscriptionId, + pub cursors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SubscriptionUnsubscribeParams { + pub subscription_id: SubscriptionId, +} diff --git a/crates/protocol/src/canonical/goal.rs b/crates/protocol/src/canonical/goal.rs new file mode 100644 index 00000000..41c97294 --- /dev/null +++ b/crates/protocol/src/canonical/goal.rs @@ -0,0 +1,50 @@ +//! Canonical Goal: the thin public surface (objective/status/budget/progress +//! summary). Plan/acceptance details are server-internal orchestration and do +//! not enter the API. +//! +//! Truth source: `devo-api-design/01-native-api.md` §4.5. + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ids::GoalId; +use super::ids::SessionId; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Goal { + pub id: GoalId, + pub session_id: SessionId, + pub objective: String, + pub status: GoalStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_budget: Option, + pub tokens_used: u64, + pub time_used_seconds: u64, + /// Human-readable progress summary; no percentage (a value that cannot be + /// honestly computed does not enter the protocol). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress_summary: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// State machine: `active ⇄ paused / blocked / usageLimited / budgetLimited` +/// (the latter two are NOT terminal — topping up quota or resuming returns to +/// `active`); terminal states are `completed / failed / canceled`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum GoalStatus { + Active, + Paused, + Blocked, + UsageLimited, + BudgetLimited, + Completed, + Failed, + Canceled, +} diff --git a/crates/protocol/src/canonical/ids.rs b/crates/protocol/src/canonical/ids.rs new file mode 100644 index 00000000..b05cf1b6 --- /dev/null +++ b/crates/protocol/src/canonical/ids.rs @@ -0,0 +1,142 @@ +//! Opaque identifier newtypes for the canonical protocol surface. +//! +//! IDs are opaque strings on the wire. Newly created resources use a prefixed +//! form (`ses_` / `turn_` / `item_` / ...); legacy bare UUIDs from pre-v2 +//! rollouts remain valid, must round-trip unchanged, and are accepted anywhere +//! an ID is expected. Clients must not parse IDs. + +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +macro_rules! define_opaque_id { + ($name:ident, $prefix:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl JsonSchema for $name { + fn schema_name() -> String { + String::from(stringify!($name)) + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + String::json_schema(generator) + } + } + + impl TS for $name { + type WithoutGenerics = Self; + type OptionInnerType = Self; + + fn name(_: &ts_rs::Config) -> String { + String::from(stringify!($name)) + } + + fn inline(cfg: &ts_rs::Config) -> String { + Self::name(cfg) + } + + fn decl(_: &ts_rs::Config) -> String { + String::from(concat!("type ", stringify!($name), " = string;")) + } + } + + impl $name { + /// Generates a new prefixed ID (``). + pub fn new() -> Self { + Self(format!("{}{}", $prefix, Uuid::now_v7().simple())) + } + + /// Wraps a legacy bare UUID from pre-v2 rollout files, preserving + /// the original textual form so it round-trips unchanged. + pub fn from_legacy_uuid(value: Uuid) -> Self { + Self(value.to_string()) + } + + /// Wraps an ID string received over the wire without interpreting it. + pub fn from_string(value: String) -> Self { + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + + impl FromStr for $name { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_owned())) + } + } + }; +} + +define_opaque_id!(SessionId, "ses_"); +define_opaque_id!(TurnId, "turn_"); +define_opaque_id!(ItemId, "item_"); +define_opaque_id!(GoalId, "goal_"); +define_opaque_id!(EventId, "evt_"); +define_opaque_id!(RunId, "run_"); +define_opaque_id!(SubscriptionId, "sub_"); +define_opaque_id!(QueueItemId, "qit_"); +define_opaque_id!(RestorePlanId, "rpl_"); + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn new_ids_carry_their_prefix() { + assert!(SessionId::new().as_str().starts_with("ses_")); + assert!(TurnId::new().as_str().starts_with("turn_")); + assert!(ItemId::new().as_str().starts_with("item_")); + assert!(GoalId::new().as_str().starts_with("goal_")); + } + + #[test] + fn legacy_bare_uuid_round_trips_unchanged() { + let uuid = Uuid::now_v7(); + let id = SessionId::from_legacy_uuid(uuid); + assert_eq!(id.as_str(), uuid.to_string()); + let json = serde_json::to_string(&id).expect("serialize"); + assert_eq!(json, format!("\"{uuid}\"")); + let back: SessionId = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, id); + } +} diff --git a/crates/protocol/src/canonical/item.rs b/crates/protocol/src/canonical/item.rs new file mode 100644 index 00000000..8fad600c --- /dev/null +++ b/crates/protocol/src/canonical/item.rs @@ -0,0 +1,604 @@ +//! The canonical `Item` tagged union: the single domain definition shared by +//! persistence (rollout JSONL) and the wire (`item/*` events). +//! +//! Truth source: `devo-api-design/06-item-model.md`. +//! +//! Hard rules (from the design): +//! - exactly one `Item` definition crate-wide; events carry typed items, never +//! a `serde_json::Value` payload bag; +//! - `Item <-> ResponseItem` conversion exists only at the model boundary +//! (ContextBuilder / TurnFinalizer), not here; +//! - `InternalEntry` is rollout-only and never appears in the public schema. + +use std::path::PathBuf; + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +use super::ids::GoalId; +use super::ids::ItemId; +use super::ids::SessionId; +use super::ids::TurnId; + +// --------------------------------------------------------------------------- +// Envelope +// --------------------------------------------------------------------------- + +/// Persistence-and-event shared header for every item. The item payload does +/// not embed an id; identity, ordering, revision and the common lifecycle +/// state live here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ItemEnvelope { + pub id: ItemId, + pub session_id: SessionId, + pub turn_id: TurnId, + /// Sequence position assigned on first appearance; strictly increasing + /// within a session. Later updates reuse the same `seq`. + pub seq: u64, + /// Strictly increasing per `id`; `1` on first appearance. Readers fold + /// updates by `(id, revision)`. + pub revision: u32, + pub created_at: DateTime, + pub updated_at: DateTime, + pub state: ItemState, + pub item: Item, +} + +/// Common delivery lifecycle of an item, owned by the envelope. Variants must +/// not duplicate their own status field (linked-work state on +/// `SubAgent`/`BackgroundTask` describes the external work, not delivery). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ItemState { + Running, + Waiting, + Completed, + Failed, + Interrupted, + Lost, +} + +// --------------------------------------------------------------------------- +// Item +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum Item { + // ── Conversation ── + UserMessage { + /// Domain-level dedup key: the same logical message materializes only + /// once across steer/queue races and RPC retries. + #[serde(default, skip_serializing_if = "Option::is_none")] + client_user_message_id: Option, + content: Vec, + /// Records the path this message actually took (absorbs the legacy + /// `SteerInput` variant). Input still sitting in the queue is not yet + /// an item of any turn. + #[serde(default)] + entry: UserMessageEntry, + }, + AssistantMessage { + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase: Option, + }, + /// The provider's encrypted reasoning payload is stored by reference and + /// re-attached when building outbound context. + Reasoning { + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_payload_ref: Option, + }, + /// Typed projection of the `update_plan` tool: the tool call/result pair + /// remains the replay truth (hidden from display); this variant is the + /// single UI-facing plan truth and evolves over the whole turn. + Plan { entries: Vec }, + + // ── Local tools (call/result pairing + approval/sandbox) ── + ToolCall { + call_id: String, + tool_name: String, + source: ToolSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + server_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + input: Option, + }, + ToolResult { + call_id: String, + output: JsonValue, + /// Compressed UI rendering; does not change replay semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + display_content: Option, + is_error: bool, + truncated: bool, + }, + /// Specialized result variant for the exec tool family + /// (`exec_command`/`write_stdin`). Model-initiated shell calls are the + /// majority; user `!` commands land in the same variant with + /// `origin = userShell`. Displaying the command plus prompt replay needs + /// the original model input, hence a dedicated variant. + CommandExecution { + call_id: String, + command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + argv: Option>, + cwd: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + execution_handle: Option, + is_error: bool, + execution_mode: ExecutionMode, + origin: ExecOrigin, + #[serde(default, skip_serializing_if = "Option::is_none")] + sandbox: Option, + }, + + // ── Hosted tools (executed provider-side, passed through as a block) ── + /// Generalizes the legacy `WebSearch`/`ImageGeneration` variants: future + /// provider-hosted tools (code_interpreter, ...) need zero protocol + /// changes; clients render by `tool_name`. + HostedToolCall { + call_id: String, + tool_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + output: Option, + }, + + // ── File changes (closes the persistence hole) ── + /// Specialized result for the write/edit/patch tool family. Per-file + /// granularity (one apply_patch may touch many files), approval is per + /// change, and a stable item lets diff-review UIs update in place. + FileChange { + call_id: String, + changes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + sandbox: Option, + }, + + // ── Interaction ── + /// One logical approval interaction = one item (merges the legacy + /// `ApprovalRequest` + `ApprovalDecision` pair); `decision = None` is the + /// waiting state, filled in place on response. + Approval { + approval_id: String, + /// Points at the action item being approved + /// (CommandExecution/FileChange/ToolCall). + #[serde(default, skip_serializing_if = "Option::is_none")] + target_item_id: Option, + action_summary: String, + justification: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + resource: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + available_scopes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + decision: Option, + }, + /// Structured questions from a tool (options/forms). Must be persisted so + /// pending questions survive reconnect/resume and remain answerable. + UserInputRequest { + request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + target_item_id: Option, + questions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + answers: Option, + }, + + // ── Linked (spawned async work, keeps the parent history complete) ── + /// Not merged into `BackgroundTask`: the control planes are disjoint + /// (process stdin/stdout vs. a full session's message channel and + /// permission inheritance). Both share `SpawnedWorkState`. + SubAgent { + #[serde(default, skip_serializing_if = "Option::is_none")] + origin_call_id: Option, + agent_session_id: SessionId, + parent_session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + role: Option, + task: String, + state: SpawnedWorkState, + }, + BackgroundTask { + #[serde(default, skip_serializing_if = "Option::is_none")] + origin_call_id: Option, + task_kind: BackgroundTaskKind, + state: SpawnedWorkState, + #[serde(default, skip_serializing_if = "Option::is_none")] + execution_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + exit_code: Option, + }, + + // ── System ── + ContextCompaction { + trigger: CompactionTrigger, + before: ContextUsage, + #[serde(default, skip_serializing_if = "Option::is_none")] + after: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + summary: Option, + }, + /// Human-readable milestone summary only; no percentage — a value that + /// cannot be honestly computed does not enter the protocol. + GoalProgress { goal_id: GoalId, summary: String }, + /// Non-fatal events (model retry, capability downgrade, quota pressure) + /// that must leave a trace without failing the turn. + Warning { + code: String, + message: String, + retryable: bool, + }, +} + +/// Client-side decode layer around `Item`: unknown variants (from a newer +/// server) degrade to `Unknown` with the raw JSON preserved, instead of +/// failing the whole decode. The server validates inbound items strictly and +/// never uses this wrapper. +/// +/// Caveat of the untagged fallback: a *malformed* known item also degrades to +/// `Unknown`; clients should surface that, not silently drop it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ItemOrUnknown { + Known(Item), + Unknown(JsonValue), +} + +impl ItemOrUnknown { + /// Returns the raw JSON for `Unknown` items (or serializes the known + /// item) so nothing is lost on the client. + pub fn raw(&self) -> JsonValue { + match self { + Self::Known(item) => serde_json::to_value(item).unwrap_or(JsonValue::Null), + Self::Unknown(raw) => raw.clone(), + } + } +} + +/// Rollout-only internal records. These are not public items: they never +/// appear in `item/*` events or the public schema, and the rollout reader +/// hands them straight to the recovery pipeline. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum InternalEntry { + TurnSummary { text: String }, + ToolProgress { call_id: String, message: String }, + HookPrompt { text: String }, +} + +// --------------------------------------------------------------------------- +// User input +// --------------------------------------------------------------------------- + +/// One submission = one `UserMessage` item whose content is a list of parts. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum UserInput { + Text { + text: String, + }, + Image { + uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + LocalImage { + path: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + /// Experimental; not part of the v1 guaranteed modalities. + Audio { + uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + mime_type: Option, + }, + Skill { + name: String, + }, + Mention { + uri: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +/// How a user message entered the system. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum UserMessageEntry { + /// Submitted while idle; immediately started a new turn. + #[default] + TurnStart, + /// Submitted while busy into the queue; started its own turn when drained. + Queue, + /// Injected into a running turn (including promotion from the queue). + Steer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum AssistantPhase { + Commentary, + Final, +} + +// --------------------------------------------------------------------------- +// Tooling +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PlanEntry { + pub step: String, + pub status: PlanStepStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum PlanStepStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ToolSource { + Builtin, + Mcp, + Plugin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ExecutionMode { + Foreground, + Background, +} + +/// Who initiated a command execution: a model tool call, or the user's `!` +/// shell escape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ExecOrigin { + AgentTool, + UserShell, +} + +/// Compact description of how a sandboxed execution ran. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SandboxExecutionSummary { + /// Sandbox backend, e.g. `seatbelt` / `landlock` / `windows` / `none`. + pub backend: String, + pub network_access: bool, + /// Whether the execution ran outside the sandbox after approval. + pub escalated: bool, +} + +/// One file touched by a write/edit/patch tool call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct FileChangeEntry { + pub path: PathBuf, + pub change: FileChangeKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum FileChangeKind { + Add { content: String }, + Delete { content: String }, + Update { + unified_diff: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + move_path: Option, + }, +} + +// --------------------------------------------------------------------------- +// Approval & questions +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalDecision { + pub decision: ApprovalDecisionKind, + pub scope: ApprovalScope, + pub decided_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ApprovalDecisionKind { + Approved, + Denied, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ApprovalScope { + Once, + Turn, + Session, + PathPrefix, + Host, + Tool, + CommandPrefix, + CommandPrefixPersist, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum ApprovalTarget { + Path { path: PathBuf }, + Host { host: String }, + Command { command: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UserQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(default)] + pub is_other: bool, + #[serde(default)] + pub is_secret: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UserQuestionOption { + pub label: String, + pub description: String, +} + +// --------------------------------------------------------------------------- +// Linked work & system +// --------------------------------------------------------------------------- + +/// State of spawned external work referenced by `SubAgent`/`BackgroundTask`. +/// Distinct from the parent item envelope's delivery lifecycle. `Lost` makes +/// spawned work whose terminal state cannot be confirmed after a runtime +/// restart recognizable instead of pretending it completed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum SpawnedWorkState { + Running, + Completed, + Failed, + Cancelled, + Lost, +} + +/// v1 only has shell background tasks; new kinds are backward-compatible +/// variant additions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum BackgroundTaskKind { + Shell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum CompactionTrigger { + AutoThreshold, + Manual, + ProviderRetry, +} + +/// Context-window occupancy snapshot (distinct from billing usage, see the +/// usage module). `measured = false` marks parts that were not precisely +/// metered. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ContextUsage { + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub reasoning_output_tokens: u64, + pub total_tokens: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_window_tokens: Option, + pub measured: bool, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn user_message_serializes_with_camel_case_tag_and_defaults() { + let item = Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: "hello".to_owned(), + }], + entry: UserMessageEntry::default(), + }; + let json = serde_json::to_value(&item).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ + "type": "userMessage", + "content": [{"type": "text", "text": "hello"}], + "entry": "turnStart" + }) + ); + let back: Item = serde_json::from_value(json).expect("deserialize"); + assert_eq!(back, item); + } + + #[test] + fn legacy_user_message_without_entry_defaults_to_turn_start() { + let item: Item = serde_json::from_value(serde_json::json!({ + "type": "userMessage", + "content": [{"type": "text", "text": "hi"}] + })) + .expect("deserialize"); + assert_eq!( + item, + Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: "hi".to_owned() + }], + entry: UserMessageEntry::TurnStart, + } + ); + } + + #[test] + fn approval_waiting_state_has_no_decision_field() { + let item = Item::Approval { + approval_id: "appr_1".to_owned(), + target_item_id: None, + action_summary: "run cargo test".to_owned(), + justification: "tests needed".to_owned(), + resource: None, + available_scopes: vec![], + target: None, + decision: None, + }; + let json = serde_json::to_value(&item).expect("serialize"); + assert_eq!(json.get("decision"), None); + let back: Item = serde_json::from_value(json).expect("deserialize"); + assert_eq!(back, item); + } +} diff --git a/crates/protocol/src/canonical/methods.rs b/crates/protocol/src/canonical/methods.rs new file mode 100644 index 00000000..3453c085 --- /dev/null +++ b/crates/protocol/src/canonical/methods.rs @@ -0,0 +1,622 @@ +//! The single Native API method registry. Truth source for the wire contract: +//! OpenRPC, JSON Schema, the TS SDK and method docs are all generated from +//! this registry, and CI asserts that the registry, the OpenRPC document and +//! client constants enumerate the same method set. +//! +//! Gate (01 §10): a method may not ship with only a prose description — every +//! method registers its params/result types, error codes, capability and +//! idempotency behavior exactly once, here. + +use schemars::JsonSchema; +use schemars::schema::RootSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use super::error::codes; +use super::event::*; +use super::item::ApprovalDecision; +use super::item::ItemEnvelope; +use super::page::Page; +use super::rpc_admin::*; +use super::rpc_session::*; +use super::rpc_turn::*; +use super::turn::Turn; + +/// How a write method protects against retries and lost updates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Idempotency { + /// Reads and naturally idempotent operations (`turn/interrupt`, + /// `subscription/ack`, `session/queue/remove`). + None, + /// Carries `idempotencyKey`, scoped to `(clientIdentity, method, key)`. + Key, + /// Carries `expectedVersion` (guards against lost update). + ExpectedVersion, + /// The single-use `restorePlanId` is the idempotency identity. + RestorePlan, +} + +pub struct MethodSpec { + pub name: &'static str, + pub params_schema: fn() -> RootSchema, + pub result_schema: fn() -> RootSchema, + pub error_codes: &'static [&'static str], + /// Experimental methods name the initialize capability that gates them. + pub required_capability: Option<&'static str>, + pub idempotency: Idempotency, +} + +fn schema_of() -> RootSchema { + schemars::schema_for!(T) +} + +// ── Server -> Client reverse requests (01 §6) ── + +/// Interactions needing one unique verifiable answer use JSON-RPC requests, +/// not notifications. Each corresponds to a `waiting`-state item; the first +/// valid response wins, the rest get `CONTROL_REQUEST_ALREADY_RESOLVED`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalRespondParams { + pub request_id: String, + pub decision: ApprovalDecision, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserInputRespondParams { + pub request_id: String, + pub answers: JsonValue, +} + +const SESSION_ERRORS: &[&str] = &[codes::SESSION_NOT_FOUND]; +const TURN_ERRORS: &[&str] = &[codes::SESSION_NOT_FOUND, codes::TURN_ALREADY_ACTIVE]; +const GOAL_ERRORS: &[&str] = &[ + codes::SESSION_NOT_FOUND, + codes::GOAL_NOT_FOUND, + codes::GOAL_TRANSITION_INVALID, +]; + +pub static NATIVE_METHODS: &[MethodSpec] = &[ + // ── Connection & subscription ── + MethodSpec { + name: "initialize", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::UNSUPPORTED_PROTOCOL_VERSION], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "runtime/ping", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "subscription/create", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::NOT_INITIALIZED, codes::CURSOR_EXPIRED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "subscription/update", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::NOT_INITIALIZED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "subscription/ack", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::CURSOR_EXPIRED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "subscription/unsubscribe", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + // ── Session ── + MethodSpec { + name: "session/new", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::INVALID_CWD, codes::CWD_ACCESS_DENIED, codes::IDEMPOTENCY_CONFLICT], + required_capability: None, + idempotency: Idempotency::Key, + }, + MethodSpec { + name: "session/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/resume", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/fork", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/rollback/preview", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/rollback/commit", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[ + codes::SESSION_NOT_FOUND, + codes::RESTORE_PLAN_NOT_FOUND, + codes::RESTORE_PLAN_EXPIRED, + codes::WORKSPACE_VERSION_CONFLICT, + ], + required_capability: None, + idempotency: Idempotency::RestorePlan, + }, + MethodSpec { + name: "session/metadata/update", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::VERSION_CONFLICT], + required_capability: None, + idempotency: Idempotency::ExpectedVersion, + }, + MethodSpec { + name: "session/cwd/change", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::INVALID_CWD, codes::CWD_ACCESS_DENIED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/archive", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/delete", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/turns/list", + params_schema: schema_of::, + result_schema: schema_of::>, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/items/list", + params_schema: schema_of::, + result_schema: schema_of::>, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + // ── Turn & queue ── + MethodSpec { + name: "turn/start", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::TURN_ALREADY_ACTIVE, codes::IDEMPOTENCY_CONFLICT, codes::UNSUPPORTED_MODALITY], + required_capability: None, + idempotency: Idempotency::Key, + }, + MethodSpec { + name: "turn/steer", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::TURN_NOT_STEERABLE, codes::IDEMPOTENCY_CONFLICT, codes::UNSUPPORTED_MODALITY], + required_capability: None, + idempotency: Idempotency::Key, + }, + MethodSpec { + name: "turn/interrupt", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "turn/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/queue/push", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::IDEMPOTENCY_CONFLICT, codes::UNSUPPORTED_MODALITY], + required_capability: None, + idempotency: Idempotency::Key, + }, + MethodSpec { + name: "session/queue/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/queue/update", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::QUEUE_ITEM_NOT_FOUND], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/queue/remove", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::QUEUE_ITEM_NOT_FOUND], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/queue/steer", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::QUEUE_ITEM_NOT_FOUND, codes::TURN_NOT_STEERABLE], + required_capability: None, + idempotency: Idempotency::None, + }, + // ── Catalog / context ── + MethodSpec { + name: "model/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "tool/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "skill/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "skill/set_enabled", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "mcp/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "context/usage/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/compact/start", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: TURN_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + // ── Goal ── + MethodSpec { + name: "session/goal/set", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::SESSION_NOT_FOUND, codes::IDEMPOTENCY_CONFLICT], + required_capability: None, + idempotency: Idempotency::Key, + }, + MethodSpec { + name: "session/goal/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/goal/pause", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: GOAL_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/goal/resume", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: GOAL_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/goal/complete", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: GOAL_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/goal/cancel", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: GOAL_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/goal/clear", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: GOAL_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + // ── Task & agent ── + MethodSpec { + name: "task/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "task/write_stdin", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "task/interrupt", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "agent/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "agent/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "agent/message", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "agent/cancel", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + // ── Security ── + MethodSpec { + name: "permission/profile/read", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "permission/profile/update", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: SESSION_ERRORS, + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "credential/list", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "credential/set", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "credential/delete", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[], + required_capability: None, + idempotency: Idempotency::None, + }, +]; + +/// Server -> Client reverse requests (01 §6). Params of the request itself +/// are the waiting-state item payload (`PendingControlRequest`); the schemas +/// here describe the client's answer. +pub static REVERSE_METHODS: &[MethodSpec] = &[ + MethodSpec { + name: "approval/command/request", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "approval/fileChange/request", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "approval/permission/request", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "userInput/request", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + required_capability: None, + idempotency: Idempotency::None, + }, + MethodSpec { + name: "session/goal/completionApproval/request", + params_schema: schema_of::, + result_schema: schema_of::, + error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + required_capability: None, + idempotency: Idempotency::None, + }, +]; + +pub fn method_names() -> Vec<&'static str> { + NATIVE_METHODS.iter().map(|spec| spec.name).collect() +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn method_names_are_unique() { + let names: HashSet<_> = NATIVE_METHODS.iter().map(|spec| spec.name).collect(); + assert_eq!(names.len(), NATIVE_METHODS.len()); + } + + #[test] + fn write_methods_declare_idempotency_explicitly() { + for spec in NATIVE_METHODS { + if spec.name == "turn/interrupt" + || spec.name == "subscription/ack" + || spec.name == "session/queue/remove" + { + assert_eq!(spec.idempotency, Idempotency::None, "{}", spec.name); + } + } + for name in [ + "session/new", + "turn/start", + "turn/steer", + "session/queue/push", + "session/goal/set", + ] { + let spec = NATIVE_METHODS + .iter() + .find(|spec| spec.name == name) + .expect("registered"); + assert_eq!(spec.idempotency, Idempotency::Key, "{name}"); + } + } + + #[test] + fn every_method_produces_schemas() { + for spec in NATIVE_METHODS.iter().chain(REVERSE_METHODS) { + let _ = (spec.params_schema)(); + let _ = (spec.result_schema)(); + } + } +} diff --git a/crates/protocol/src/canonical/mod.rs b/crates/protocol/src/canonical/mod.rs new file mode 100644 index 00000000..16d06b6f --- /dev/null +++ b/crates/protocol/src/canonical/mod.rs @@ -0,0 +1,25 @@ +//! Canonical protocol types: the single domain definition shared by +//! persistence (rollout JSONL) and all four wire surfaces (Native, ACP, +//! External, A2A). +//! +//! These types are the schema truth source (`devo-api-design/README.md` §3): +//! wire JSON is camelCase, times are RFC 3339 UTC, IDs are opaque strings. +//! They are introduced alongside the legacy protocol types (05 P0/P1) and do +//! not replace them until the migration phases land. + +pub mod error; +pub mod event; +pub mod goal; +pub mod ids; +pub mod item; +pub mod methods; +pub mod model; +pub mod page; +pub mod patch; +pub mod queue; +pub mod rpc_admin; +pub mod rpc_session; +pub mod rpc_turn; +pub mod session; +pub mod turn; +pub mod usage; diff --git a/crates/protocol/src/canonical/model.rs b/crates/protocol/src/canonical/model.rs new file mode 100644 index 00000000..e36ee95c --- /dev/null +++ b/crates/protocol/src/canonical/model.rs @@ -0,0 +1,29 @@ +//! Model binding: which provider/model a session or turn runs with. +//! +//! `Session.model` is the live current value (what the next turn will use); +//! `Turn.model` and `UsageRecord.model` are snapshots copied at event time. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use crate::ReasoningEffort; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ModelBinding { + pub provider: String, + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, +} + +/// Permission presets selectable per session (`permission/profile/*`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum PermissionProfile { + Default, + AutoReview, + FullAccess, +} diff --git a/crates/protocol/src/canonical/page.rs b/crates/protocol/src/canonical/page.rs new file mode 100644 index 00000000..91083709 --- /dev/null +++ b/crates/protocol/src/canonical/page.rs @@ -0,0 +1,24 @@ +//! Paged result envelope shared by all list methods. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Page { + pub data: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +/// Cursor/limit pair shared by paged list params. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PageParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} diff --git a/crates/protocol/src/canonical/patch.rs b/crates/protocol/src/canonical/patch.rs new file mode 100644 index 00000000..b32ba877 --- /dev/null +++ b/crates/protocol/src/canonical/patch.rs @@ -0,0 +1,92 @@ +//! Tri-state patch field: `Missing | Null | Value(T)`. +//! +//! Public wire types never expose `Option>` for patches. A field +//! that is omitted from the JSON object means "leave unchanged", an explicit +//! `null` means "clear", and any other value means "set". Struct fields using +//! this type must be annotated with: +//! +//! ```rust,ignore +//! #[serde(default, skip_serializing_if = "PatchField::is_missing")] +//! pub title: PatchField, +//! ``` + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; + +#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum PatchField { + Missing, + Null, + Value(T), +} + +impl Default for PatchField { + fn default() -> Self { + Self::Missing + } +} + +impl PatchField { + pub fn is_missing(&self) -> bool { + matches!(self, Self::Missing) + } +} + +impl Serialize for PatchField { + fn serialize(&self, serializer: S) -> Result { + match self { + // Callers are expected to skip `Missing` via `skip_serializing_if`; + // serializing it directly degrades to `null` rather than failing. + Self::Missing | Self::Null => serializer.serialize_none(), + Self::Value(value) => value.serialize(serializer), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for PatchField { + fn deserialize>(deserializer: D) -> Result { + // A present field is either `null` or a value; absence is handled by + // `#[serde(default)]` on the containing struct field. + Ok(Option::::deserialize(deserializer)? + .map_or(Self::Null, Self::Value)) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde::Deserialize; + use serde::Serialize; + + use super::*; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Patch { + #[serde(default, skip_serializing_if = "PatchField::is_missing")] + title: PatchField, + } + + #[test] + fn missing_null_and_value_are_distinct() { + let missing: Patch = serde_json::from_str("{}").expect("missing"); + assert_eq!(missing.title, PatchField::Missing); + + let null: Patch = serde_json::from_str("{\"title\":null}").expect("null"); + assert_eq!(null.title, PatchField::Null); + + let value: Patch = serde_json::from_str("{\"title\":\"hi\"}").expect("value"); + assert_eq!(value.title, PatchField::Value("hi".to_owned())); + } + + #[test] + fn missing_is_omitted_when_serializing() { + let patch = Patch { + title: PatchField::Missing, + }; + assert_eq!(serde_json::to_string(&patch).expect("serialize"), "{}"); + } +} diff --git a/crates/protocol/src/canonical/queue.rs b/crates/protocol/src/canonical/queue.rs new file mode 100644 index 00000000..a53042f9 --- /dev/null +++ b/crates/protocol/src/canonical/queue.rs @@ -0,0 +1,40 @@ +//! Session input queue: pre-item entries that have not materialized into a +//! user message and do not belong to any turn. Because they are pre-items they +//! are freely editable; the "persisted messages are immutable" rule does not +//! apply. +//! +//! Truth source: `devo-api-design/01-native-api.md` §4.3. + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ids::QueueItemId; +use super::item::UserInput; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct QueueEntry { + pub queue_item_id: QueueItemId, + pub position: u32, + /// Full editable content; `session/queue/update` replaces it wholesale. + pub input: Vec, + /// Short single-line preview for list rendering. + pub preview: String, + pub enqueued_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum QueueChange { + Added, + Updated, + Removed, + /// Promoted out of the queue into the running turn as a steer. + Promoted, + /// Dequeued to start its own new turn. + Drained, +} diff --git a/crates/protocol/src/canonical/rpc_admin.rs b/crates/protocol/src/canonical/rpc_admin.rs new file mode 100644 index 00000000..59ba0020 --- /dev/null +++ b/crates/protocol/src/canonical/rpc_admin.rs @@ -0,0 +1,266 @@ +//! Params/result types for connection, catalog, context, permission and +//! credential methods. Truth source: `devo-api-design/01-native-api.md` +//! §4.1/§4.4/§4.7. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ids::SessionId; +use super::item::ContextUsage; +use super::item::ToolSource; +use super::model::PermissionProfile; + +// ── initialize ── + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientCapabilities { + /// Modalities the client can render, e.g. `text`, `image`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modalities: Vec, + /// Delta encodings the client accepts. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delta_encodings: Vec, + /// Experimental extensions the client opts into. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub experimental: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + /// Date version requested by the client, e.g. `2026-08-01`. + pub protocol_version: String, + /// Stable identity of this client installation; scopes idempotency keys. + pub client_identity: String, + #[serde(default)] + pub capabilities: ClientCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub connection_id: String, + /// The negotiated (possibly downgraded) protocol date version. + pub protocol_version: String, + pub server_instance_id: String, + pub capabilities: ServerCapabilities, + pub limits: ServerLimits, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ServerCapabilities { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modalities: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub delta_encodings: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub experimental: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ServerLimits { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_input_chars: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_queue_depth: Option, +} + +// ── runtime/ping ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct RuntimePingParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct RuntimePingResult { + pub server_time_ms: i64, +} + +// ── catalog ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ModelListParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ModelInfo { + pub provider: String, + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ModelListResult { + pub models: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ToolListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ToolInfo { + pub name: String, + pub source: ToolSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ToolListResult { + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SkillListParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SkillInfo { + pub name: String, + pub path: std::path::PathBuf, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SkillListResult { + pub skills: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SkillSetEnabledParams { + pub name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SkillSetEnabledResult {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpListParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpServerInfo { + pub name: String, + pub status: String, + pub tool_count: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct McpListResult { + pub servers: Vec, +} + +// ── context/usage/read ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ContextUsageReadParams { + pub session_id: SessionId, +} + +/// Parts that were not precisely metered are marked `measured = false`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ContextUsageReadResult { + pub usage: ContextUsage, +} + +// ── permission/profile/* ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PermissionProfileReadParams { + pub session_id: SessionId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PermissionProfileReadResult { + pub profile: PermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PermissionProfileUpdateParams { + pub session_id: SessionId, + pub profile: PermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PermissionProfileUpdateResult { + pub profile: PermissionProfile, +} + +// ── credential/* ── + +/// Secrets are never echoed back; only id/provider/mask are returned. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialInfo { + pub id: String, + pub provider: String, + pub masked: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialListParams {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialListResult { + pub credentials: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialSetParams { + pub provider: String, + /// The secret itself; write-only, never appears in any response. + pub secret: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialSetResult { + pub credential: CredentialInfo, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialDeleteParams { + pub credential_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CredentialDeleteResult {} diff --git a/crates/protocol/src/canonical/rpc_session.rs b/crates/protocol/src/canonical/rpc_session.rs new file mode 100644 index 00000000..c9fbb7a1 --- /dev/null +++ b/crates/protocol/src/canonical/rpc_session.rs @@ -0,0 +1,313 @@ +//! Params/result types for session-domain methods (`session/*`, +//! `session/goal/*`). Truth source: `devo-api-design/01-native-api.md` §4.2/§4.5. + +use std::path::PathBuf; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::goal::Goal; +use super::ids::GoalId; +use super::ids::RestorePlanId; +use super::ids::SessionId; +use super::ids::TurnId; +use super::model::ModelBinding; +use super::page::Page; +use super::page::PageParams; +use super::patch::PatchField; +use super::session::Session; +use super::session::SessionSettings; + +// ── session/new ── + +/// Deliberately minimal: create binds a cwd, nothing else. Model/settings are +/// changed later via `session/metadata/update`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionNewParams { + pub cwd: PathBuf, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionNewResult { + pub session: Session, +} + +// ── session/list ── + +/// No filtering by status/archive flags; only title search is supported. +/// Returned sessions use `turnsView = notLoaded` (no embedded history). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionListParams { + /// Restrict to these cwds; empty means all known cwds. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, + /// Case-insensitive substring match on title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub search: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +pub type SessionListResult = Page; + +// ── session/read ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionReadParams { + pub session_id: SessionId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionReadResult { + pub session: Session, +} + +// ── session/resume ── + +/// Addressed by session id only; never changes the session's cwd. The result +/// returns the real cwd so clients connecting from another directory can +/// surface it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionResumeParams { + pub session_id: SessionId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionResumeResult { + pub session: Session, +} + +// ── session/fork ── + +/// Forks at a turn boundary into parallel history; the goal is copied by +/// value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionForkParams { + pub session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionForkResult { + pub session: Session, +} + +// ── session/rollback/preview + commit ── + +/// Which user turns to keep, counted by user turn index. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum RollbackMode { + /// Keep the selected user turn, drop everything after it. + ThroughUserTurn, + /// Drop the selected user turn as well. + BeforeUserTurn, +} + +/// Computes the history/workspace impact without changing any state. The +/// client must show the impact and get confirmation before `commit`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionRollbackPreviewParams { + pub session_id: SessionId, + pub user_turn_index: u32, + pub mode: RollbackMode, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct RestorePlan { + pub restore_plan_id: RestorePlanId, + /// Files the workspace restore would touch (restore or delete). + pub affected_files: Vec, + /// Turns/items the history truncation would drop, for display. + pub dropped_turn_count: u32, + /// Workspace version/hash captured at preview time; `commit` revalidates + /// it and rejects with `WORKSPACE_VERSION_CONFLICT` on drift. + pub workspace_version: String, +} + +pub type SessionRollbackPreviewResult = RestorePlan; + +/// Commits a previously previewed plan. Plans are short-lived, single-use, +/// and bound to the session, target checkpoint and caller identity; retrying +/// with the same `restorePlanId` returns the first commit's result instead of +/// restoring again. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionRollbackCommitParams { + pub restore_plan_id: RestorePlanId, + pub expected_workspace_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionRollbackCommitResult { + pub restored_turn_count: u32, + pub restored_file_count: u32, +} + +// ── session/metadata/update ── + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionMetadataUpdateParams { + pub session_id: SessionId, + pub expected_version: u64, + #[serde(default, skip_serializing_if = "PatchField::is_missing")] + #[ts(type = "string | null")] + pub title: PatchField, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionMetadataUpdateResult { + pub session: Session, +} + +// ── session/cwd/change ── + +/// Explicitly migrates the session to another cwd; recomputes +/// permissions/skills/git/memory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionCwdChangeParams { + pub session_id: SessionId, + pub cwd: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionCwdChangeResult { + pub session: Session, +} + +// ── session/archive / session/delete ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionArchiveParams { + pub session_id: SessionId, + pub archived: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionArchiveResult { + pub session: Session, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteParams { + pub session_id: SessionId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteResult {} + +// ── session/turns/list / session/items/list ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnsListParams { + pub session_id: SessionId, + #[serde(flatten)] + pub page: PageParams, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionItemsListParams { + pub session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(flatten)] + pub page: PageParams, +} + +// ── session/compact/start ── + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionCompactStartParams { + pub session_id: SessionId, +} + +// ── session/goal/* ── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum GoalIfExists { + Replace, + Reject, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalSetParams { + pub session_id: SessionId, + pub objective: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_budget: Option, + pub if_exists: GoalIfExists, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalSetResult { + pub goal: Goal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalReadParams { + pub session_id: SessionId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalReadResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, +} + +/// Shared params for goal lifecycle transitions; `expectedGoalId` prevents +/// acting on a goal that was replaced concurrently. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalTransitionParams { + pub session_id: SessionId, + pub expected_goal_id: GoalId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalTransitionResult { + pub goal: Goal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionGoalClearResult {} diff --git a/crates/protocol/src/canonical/rpc_turn.rs b/crates/protocol/src/canonical/rpc_turn.rs new file mode 100644 index 00000000..18fc0531 --- /dev/null +++ b/crates/protocol/src/canonical/rpc_turn.rs @@ -0,0 +1,265 @@ +//! Params/result types for turn/queue/task/agent methods. +//! Truth source: `devo-api-design/01-native-api.md` §4.3/§4.6. + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ids::ItemId; +use super::ids::QueueItemId; +use super::ids::SessionId; +use super::ids::TurnId; +use super::item::ItemEnvelope; +use super::item::UserInput; +use super::queue::QueueEntry; +use super::turn::Turn; + +// ── turn/start ── + +/// Precondition: the session is idle; otherwise `TURN_ALREADY_ACTIVE`. What +/// "send while busy" means (queue vs. steer) is a client-side choice, not a +/// server policy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnStartParams { + pub session_id: SessionId, + pub input: Vec, + /// Domain-level dedup key for the materialized user message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_user_message_id: Option, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnStartResult { + pub turn: Turn, +} + +// ── turn/steer ── + +/// Injects into the running turn: the item is persisted immediately +/// (`entry = steer`) and takes effect at the next injection boundary. If the +/// turn ended before injection, the input degrades back into the queue +/// (message is never lost) — the result says which happened. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnSteerParams { + pub session_id: SessionId, + /// Precondition guard against steering the wrong turn. + pub expected_turn_id: TurnId, + pub input: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_user_message_id: Option, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "outcome", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum TurnSteerResult { + Injected { item_id: ItemId }, + /// Turn ended before the injection boundary; input was queued instead. + DegradedToQueue { entry: QueueEntry }, +} + +// ── turn/interrupt / turn/read ── + +/// Idempotent; cascades into sub-agents; tool terminal states are written as +/// `interrupted`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnInterruptParams { + pub session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnInterruptResult {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnReadParams { + pub session_id: SessionId, + pub turn_id: TurnId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnReadResult { + pub turn: Turn, +} + +// ── session/queue/* ── + +/// If the session is idle the input immediately executes as a new turn +/// (`started`); otherwise it is queued as an editable pre-item. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueuePushParams { + pub session_id: SessionId, + pub input: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_user_message_id: Option, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "outcome", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum SessionQueuePushResult { + Started { turn: Box }, + Queued { entry: Box }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueListParams { + pub session_id: SessionId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueListResult { + pub entries: Vec, +} + +/// Queue entries are pre-items and freely editable; `input` is replaced +/// wholesale, `queueItemId` is stable. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueUpdateParams { + pub session_id: SessionId, + pub queue_item_id: QueueItemId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueUpdateResult { + pub entry: QueueEntry, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueRemoveParams { + pub session_id: SessionId, + pub queue_item_id: QueueItemId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueRemoveResult {} + +/// Promotes a queued entry into the running turn as a steer; fails with +/// `TURN_NOT_STEERABLE` when no steerable turn exists. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueSteerParams { + pub session_id: SessionId, + pub queue_item_id: QueueItemId, + pub expected_turn_id: TurnId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionQueueSteerResult { + pub item_id: ItemId, +} + +// ── task/* ── + +/// Background tasks are addressed by their item id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TaskReadParams { + pub item_id: ItemId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TaskReadResult { + pub item: ItemEnvelope, + /// Tail of captured output for quick display; full output streams on the + /// `task:` stream. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tail: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TaskWriteStdinParams { + pub item_id: ItemId, + pub data: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TaskWriteStdinResult {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TaskInterruptParams { + pub item_id: ItemId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TaskInterruptResult {} + +// ── agent/* ── + +/// Sub-agents are created by tools/internal orchestration only; there is no +/// public `agent/spawn`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentListResult { + /// `SubAgent` items linking to the agent sessions. + pub agents: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentReadParams { + pub item_id: ItemId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentReadResult { + pub item: ItemEnvelope, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recent_progress: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentMessageParams { + pub item_id: ItemId, + pub input: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentMessageResult {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentCancelParams { + pub item_id: ItemId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentCancelResult {} diff --git a/crates/protocol/src/canonical/session.rs b/crates/protocol/src/canonical/session.rs new file mode 100644 index 00000000..73caab81 --- /dev/null +++ b/crates/protocol/src/canonical/session.rs @@ -0,0 +1,141 @@ +//! Canonical Session structure. Truth source: `devo-api-design/07-session-turn.md`. +//! +//! State is split into three orthogonal dimensions — `status` (lifecycle, +//! derived from the active turn) × `flags` (stackable blocking reasons) × +//! `archived` (user intent) — replacing the old single mixed enum. + +use std::path::PathBuf; + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ids::SessionId; +use super::ids::TurnId; +use super::model::ModelBinding; +use super::model::PermissionProfile; +use super::usage::SessionUsage; +use crate::ReasoningEffort; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Session { + pub id: SessionId, + /// Optimistic concurrency token for `expectedVersion` writes. + pub version: u64, + + // ── Identity (immutable after creation; `cwd` moves only via an explicit + // `session/cwd/change`) ── + /// Execution scope of the session: normalized absolute path. It is part + /// of the session's identity, not the client's location; resuming from a + /// different cwd does not change it. + pub cwd: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent: Option, + pub ephemeral: bool, + pub created_at: DateTime, + + // ── Three orthogonal state dimensions ── + pub status: SessionStatus, + /// Stackable blocking reasons; deduplicated and serialized in enum order + /// so equivalent snapshots produce stable JSON. + pub flags: Vec, + pub archived: bool, + + // ── Runtime pointers ── + /// Invariant: `status == Active` iff `active_turn_id.is_some()`; updated + /// atomically by the single session actor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_turn_id: Option, + /// The queue is session state, not turn state. + pub queued_count: u32, + + // ── Mutable configuration (current values) ── + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Current binding: what the next turn will use. + pub model: ModelBinding, + pub settings: SessionSettings, + + // ── Snapshot: an observation that may be stale ── + /// Computed at creation and recomputed on `session/cwd/change`; clients + /// treat it as potentially stale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_info: Option, + + // ── Derived caches (server-maintained, client read-only) ── + pub preview: String, + pub last_activity_at: DateTime, + /// Redundant aggregate of turn usages for list views; the ledger wins on + /// any disagreement. + pub usage: SessionUsage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum SessionStatus { + Idle, + Active, +} + +/// Blocking reasons, stackable on top of `status`. "Waiting" is a flag, not a +/// status: clients can tell "working" apart from "blocked on you". +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum SessionFlag { + /// A pending approval request. + WaitingApproval, + /// An unanswered structured question. + WaitingUserInput, + /// Compaction in progress. + Compacting, + /// Goal-internal orchestration in progress. + UpdatingGoal, +} + +/// The two kinds of lineage must stay separate: a fork is parallel history, a +/// subagent is a spawned executor; permissions, visibility and presentation +/// all differ. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum SessionParent { + Fork { + session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + at_turn_id: Option, + }, + Agent { + session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + role: Option, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionSettings { + pub permission_profile: PermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + /// ACP-style session mode id, if one is active. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +/// Snapshot semantics: the current value is *copied* into the record at +/// observation time; later changes to the source do not affect the copy. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GitInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_url: Option, + pub dirty: bool, + pub observed_at: DateTime, +} diff --git a/crates/protocol/src/canonical/turn.rs b/crates/protocol/src/canonical/turn.rs new file mode 100644 index 00000000..73f62b8d --- /dev/null +++ b/crates/protocol/src/canonical/turn.rs @@ -0,0 +1,66 @@ +//! Canonical Turn structure. Truth source: `devo-api-design/07-session-turn.md`. +//! +//! A turn is the lifecycle of one user intent. Items are not embedded; they +//! are the append-only item sequence (see the item module) read through the +//! paged history APIs. + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::error::AgentError; +use super::ids::SessionId; +use super::ids::TurnId; +use super::model::ModelBinding; +use super::usage::TurnUsage; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Turn { + pub id: TurnId, + pub session_id: SessionId, + /// Nth turn within the session. + pub sequence: u32, + pub kind: TurnKind, + pub status: TurnStatus, + /// Snapshot: the binding this turn actually ran with. `session.model` may + /// change afterwards; history must faithfully answer "what did this turn + /// run with". + pub model: ModelBinding, + pub started_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, +} + +/// Completion invariants are defined per kind (07 §4.3): all kinds require +/// foreground items terminal and no pending approval/question; `Regular` and +/// `GoalContinuation` successes additionally require a final assistant +/// message; `Compaction` requires a terminal `ContextCompaction` instead. +/// +/// v1 keeps exactly three variants: the legacy `Review` kind was dead code +/// and an open `Other(String)` variant breaks exhaustive client matching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum TurnKind { + Regular, + Compaction, + /// Goal-driven autonomous turn. Has no user message by design and is not + /// steerable; only `Regular + InProgress` turns may be steered. + GoalContinuation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum TurnStatus { + InProgress, + Completed, + Interrupted, + Failed, +} diff --git a/crates/protocol/src/canonical/usage.rs b/crates/protocol/src/canonical/usage.rs new file mode 100644 index 00000000..28f8d105 --- /dev/null +++ b/crates/protocol/src/canonical/usage.rs @@ -0,0 +1,158 @@ +//! Usage accounting: every model call produces a metering record, without +//! exception. Metering is a property of the call channel +//! (`InstrumentedProvider` + `CallContext`), not of each call site's +//! diligence. +//! +//! Truth source: `devo-api-design/09-usage.md`. + +use chrono::DateTime; +use chrono::Utc; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ids::SessionId; +use super::ids::TurnId; +use super::model::ModelBinding; + +/// Metering context that every model call must carry; the instrumented +/// provider wrapper makes it compile-time impossible to call without one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CallContext { + pub session_id: SessionId, + /// Attribution: AutoReview/Compaction belong to their triggering turn; + /// title generation etc. belong to the session only (`None`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub purpose: UsagePurpose, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum UsagePurpose { + /// Main loop, including model retries and post-tool follow-ups. + TurnQuery, + /// Model reviewer before approval. + AutoReview, + /// History compaction summary. + Compaction, + /// Session title generation. + TitleGeneration, + /// Goal classification/orchestration (reserved). + GoalClassification, + /// Memory extraction/merge (reserved). + MemoryExtraction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum UsageCallOutcome { + Succeeded, + Failed, + Cancelled, +} + +/// Provider-reported token usage for one call. Distinct from +/// `ContextUsage` (context-window occupancy): usage answers "how much was +/// spent", context answers "how full is the window". +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TokenUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub reasoning_tokens: u64, + /// Billed at a different rate by most providers, hence separate. + pub cache_read_input_tokens: u64, + pub cache_creation_input_tokens: u64, +} + +/// Monetary amount in micro-units (1/1_000_000) of `currency`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Money { + pub currency: String, + pub micros: i64, +} + +/// One append-only metering record per model call attempt. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UsageRecord { + pub call_id: String, + pub session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub purpose: UsagePurpose, + /// Snapshot: the model this call actually used. + pub model: ModelBinding, + pub outcome: UsageCallOutcome, + /// `None` when the provider did not report usage; never fabricated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + /// `None` when no price list is available; never fabricated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimated_cost: Option, + pub recorded_at: DateTime, +} + +/// Aggregated token/call totals. `call_count` includes failed and cancelled +/// attempts; `metered_call_count` counts attempts with reported usage. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UsageTotals { + pub input_tokens: u64, + pub output_tokens: u64, + pub reasoning_tokens: u64, + pub cache_read_input_tokens: u64, + pub cache_creation_input_tokens: u64, + pub call_count: u32, + pub metered_call_count: u32, + pub failed_call_count: u32, + pub cancelled_call_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimated_cost: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct PurposeUsage { + pub purpose: UsagePurpose, + pub input_tokens: u64, + pub output_tokens: u64, + pub reasoning_tokens: u64, + pub cache_read_input_tokens: u64, + pub cache_creation_input_tokens: u64, + pub call_count: u32, + pub metered_call_count: u32, + pub failed_call_count: u32, + pub cancelled_call_count: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub estimated_cost: Option, +} + +/// Split so that "how much did this turn cost" and "the bill" reconcile: +/// `query` covers only `TurnQuery`; `overhead` covers the turn's +/// `AutoReview + Compaction`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct TurnUsage { + pub query: UsageTotals, + pub overhead: UsageTotals, +} + +/// Derived cache aggregated from the usage ledger (truth = ledger sum). +/// Exists solely so list views need not page through turn details; on any +/// disagreement the ledger wins. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsage { + pub total: UsageTotals, + pub by_purpose: Vec, + /// Pre-upgrade historical totals that cannot be decomposed into calls; + /// not disguised as call records. `total = legacy + ledger`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub legacy: Option, + pub updated_at: DateTime, +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 4e62dbb5..ad48d747 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -19,6 +19,7 @@ mod acp_session_update; pub mod acp_ts; mod agent; mod approval; +pub mod canonical; mod command_exec; mod connection; mod conversation; diff --git a/crates/protocol/tests/canonical_golden.rs b/crates/protocol/tests/canonical_golden.rs new file mode 100644 index 00000000..a85fa59f --- /dev/null +++ b/crates/protocol/tests/canonical_golden.rs @@ -0,0 +1,87 @@ +//! Golden JSON fixtures for the canonical wire contract (P0 gate, 01 §10.4): +//! the patch tri-state, legacy bare-UUID IDs and the unknown-item fallback +//! must keep stable JSON representations. + +use devo_protocol::canonical::ids::SessionId; +use devo_protocol::canonical::item::Item; +use devo_protocol::canonical::item::ItemEnvelope; +use devo_protocol::canonical::item::ItemOrUnknown; +use devo_protocol::canonical::patch::PatchField; +use devo_protocol::canonical::rpc_session::SessionMetadataUpdateParams; +use pretty_assertions::assert_eq; + +fn read_golden(name: &str) -> serde_json::Value { + let path = format!("{}/tests/golden/{name}", env!("CARGO_MANIFEST_DIR")); + let text = std::fs::read_to_string(path).expect("read golden fixture"); + serde_json::from_str(&text).expect("golden fixture is valid JSON") +} + +#[test] +fn user_message_envelope_matches_golden_and_round_trips() { + let golden = read_golden("item_user_message.json"); + let envelope: ItemEnvelope = + serde_json::from_value(golden.clone()).expect("golden userMessage parses"); + // A legacy bare UUID session id round-trips unchanged. + assert_eq!( + envelope.session_id.as_str(), + "019b1c2d-3e4f-7890-abcd-ef1234567890" + ); + assert_eq!( + serde_json::to_value(&envelope).expect("serialize"), + golden, + "serialization must be stable against the golden fixture" + ); +} + +#[test] +fn approval_waiting_state_matches_golden_and_round_trips() { + let golden = read_golden("item_approval_waiting.json"); + let envelope: ItemEnvelope = + serde_json::from_value(golden.clone()).expect("golden approval parses"); + let Item::Approval { decision, .. } = &envelope.item else { + panic!("expected approval item"); + }; + assert_eq!(*decision, None, "waiting state has no decision"); + assert_eq!(serde_json::to_value(&envelope).expect("serialize"), golden); +} + +#[test] +fn unknown_future_variant_degrades_with_raw_preserved() { + let golden = read_golden("item_unknown_future_variant.json"); + let decoded: ItemOrUnknown = + serde_json::from_value(golden.clone()).expect("unknown item decodes"); + let ItemOrUnknown::Unknown(raw) = &decoded else { + panic!("future variant must degrade to Unknown, got {decoded:?}"); + }; + assert_eq!(*raw, golden, "raw JSON is preserved verbatim"); + assert_eq!(decoded.raw(), golden); +} + +#[test] +fn known_item_does_not_fall_into_unknown() { + let golden = read_golden("item_user_message.json"); + let item_json = golden.get("item").cloned().expect("item payload"); + let decoded: ItemOrUnknown = serde_json::from_value(item_json).expect("decode"); + assert!(matches!(decoded, ItemOrUnknown::Known(_))); +} + +#[test] +fn patch_field_null_is_explicit_clear() { + let golden = read_golden("patch_title_null.json"); + let params: SessionMetadataUpdateParams = + serde_json::from_value(golden).expect("params parse"); + assert_eq!(params.title, PatchField::Null); +} + +#[test] +fn legacy_bare_uuid_id_round_trips() { + let id: SessionId = serde_json::from_value(serde_json::json!( + "019b1c2d-3e4f-7890-abcd-ef1234567890" + )) + .expect("legacy id parses"); + assert_eq!(id.as_str(), "019b1c2d-3e4f-7890-abcd-ef1234567890"); + assert_eq!( + serde_json::to_value(&id).expect("serialize"), + serde_json::json!("019b1c2d-3e4f-7890-abcd-ef1234567890") + ); +} diff --git a/crates/protocol/tests/golden/item_approval_waiting.json b/crates/protocol/tests/golden/item_approval_waiting.json new file mode 100644 index 00000000..17e9a775 --- /dev/null +++ b/crates/protocol/tests/golden/item_approval_waiting.json @@ -0,0 +1,19 @@ +{ + "id": "item_019b1c2d3e4f7890abcdef1234567892", + "sessionId": "ses_019b1c2d3e4f7890abcdef1234567893", + "turnId": "turn_019b1c2d3e4f7890abcdef1234567891", + "seq": 7, + "revision": 1, + "createdAt": "2026-08-01T00:00:01Z", + "updatedAt": "2026-08-01T00:00:01Z", + "state": "waiting", + "item": { + "type": "approval", + "approvalId": "appr-42", + "actionSummary": "run `cargo test`", + "justification": "verify the change", + "resource": "command", + "availableScopes": ["once", "turn", "session"], + "target": { "kind": "command", "command": "cargo test" } + } +} diff --git a/crates/protocol/tests/golden/item_unknown_future_variant.json b/crates/protocol/tests/golden/item_unknown_future_variant.json new file mode 100644 index 00000000..83433405 --- /dev/null +++ b/crates/protocol/tests/golden/item_unknown_future_variant.json @@ -0,0 +1,6 @@ +{ + "type": "codeInterpreter", + "callId": "ci-1", + "output": { "stdout": "ok" }, + "brandNewField": [1, 2, 3] +} diff --git a/crates/protocol/tests/golden/item_user_message.json b/crates/protocol/tests/golden/item_user_message.json new file mode 100644 index 00000000..69d00738 --- /dev/null +++ b/crates/protocol/tests/golden/item_user_message.json @@ -0,0 +1,19 @@ +{ + "id": "item_019b1c2d3e4f7890abcdef1234567890", + "sessionId": "019b1c2d-3e4f-7890-abcd-ef1234567890", + "turnId": "turn_019b1c2d3e4f7890abcdef1234567891", + "seq": 1, + "revision": 1, + "createdAt": "2026-08-01T00:00:00Z", + "updatedAt": "2026-08-01T00:00:00Z", + "state": "completed", + "item": { + "type": "userMessage", + "clientUserMessageId": "cmsg-1", + "content": [ + { "type": "text", "text": "hello world" }, + { "type": "image", "uri": "data:image/png;base64,AA==", "mimeType": "image/png" } + ], + "entry": "turnStart" + } +} diff --git a/crates/protocol/tests/golden/patch_title_null.json b/crates/protocol/tests/golden/patch_title_null.json new file mode 100644 index 00000000..eb853b58 --- /dev/null +++ b/crates/protocol/tests/golden/patch_title_null.json @@ -0,0 +1,5 @@ +{ + "sessionId": "ses_019b1c2d3e4f7890abcdef1234567893", + "expectedVersion": 3, + "title": null +} From 94ecf8d7ca52fa67827f20ed5055ccf4dfec9e28 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 00:22:21 +0800 Subject: [PATCH 03/17] feat(protocol): allow unknown legacy git dirty flag and lumped legacy token totals --- crates/protocol/src/canonical/session.rs | 4 +++- crates/protocol/src/canonical/usage.rs | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/protocol/src/canonical/session.rs b/crates/protocol/src/canonical/session.rs index 73caab81..aacb2310 100644 --- a/crates/protocol/src/canonical/session.rs +++ b/crates/protocol/src/canonical/session.rs @@ -136,6 +136,8 @@ pub struct GitInfo { pub branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub origin_url: Option, - pub dirty: bool, + /// `None` = unknown (e.g. converted from legacy data that never recorded + /// the dirty flag); fresh snapshots always compute it. + pub dirty: Option, pub observed_at: DateTime, } diff --git a/crates/protocol/src/canonical/usage.rs b/crates/protocol/src/canonical/usage.rs index 28f8d105..cdcbadeb 100644 --- a/crates/protocol/src/canonical/usage.rs +++ b/crates/protocol/src/canonical/usage.rs @@ -102,6 +102,10 @@ pub struct UsageRecord { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct UsageTotals { + /// Aggregate tokens. For ledger-derived totals this equals + /// `input_tokens + output_tokens`; for legacy lumps it is the only known + /// number (the input/output split was never recorded). + pub total_tokens: u64, pub input_tokens: u64, pub output_tokens: u64, pub reasoning_tokens: u64, From a869668c2c13b8a064b04d1ba26f258604f51e69 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 01:09:53 +0800 Subject: [PATCH 04/17] feat(core): add v2 rollout envelope, dual-format reader, and legacy projector --- .../core/src/conversation/legacy_projector.rs | 750 +++++++++++++++++ crates/core/src/conversation/mod.rs | 8 + crates/core/src/conversation/rollout_v2.rs | 328 ++++++++ .../fixtures/rollout_v1/basic_session.jsonl | 12 + .../fixtures/rollout_v1/internal_lines.jsonl | 5 + .../fixtures/rollout_v1/orphan_decision.jsonl | 5 + crates/core/tests/legacy_projector.rs | 781 ++++++++++++++++++ 7 files changed, 1889 insertions(+) create mode 100644 crates/core/src/conversation/legacy_projector.rs create mode 100644 crates/core/src/conversation/rollout_v2.rs create mode 100644 crates/core/tests/fixtures/rollout_v1/basic_session.jsonl create mode 100644 crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl create mode 100644 crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl create mode 100644 crates/core/tests/legacy_projector.rs diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs new file mode 100644 index 00000000..652b8265 --- /dev/null +++ b/crates/core/src/conversation/legacy_projector.rs @@ -0,0 +1,750 @@ +//! Stateful pure converter from the frozen legacy rollout format (v1) to the +//! canonical v2 line stream. +//! +//! Truth source: `devo-api-design/05-migration.md` §2.2 and +//! `06-item-model.md` §4. One [`LegacyProjector`] instance converts one +//! session file: it owns the per-session `seq` counter, the approval +//! request/decision fold state, and the session cwd learned from the +//! SessionMeta line (used as the `CommandExecution` cwd fallback, which +//! legacy payloads never recorded). + +use std::collections::HashMap; +use std::fmt::Display; +use std::path::PathBuf; + +use devo_protocol::canonical::error::AgentError; +use devo_protocol::canonical::ids::{ItemId, SessionId, TurnId}; +use devo_protocol::canonical::item::{ + ApprovalDecision, ApprovalDecisionKind, ApprovalScope, ApprovalTarget, CompactionTrigger, + ContextUsage, ExecOrigin, ExecutionMode, InternalEntry, Item, ItemEnvelope, ItemState, + PlanEntry, PlanStepStatus, ToolSource, UserInput, UserMessageEntry, +}; +use devo_protocol::canonical::model::{ModelBinding, PermissionProfile}; +use devo_protocol::canonical::session::{ + GitInfo, Session, SessionParent, SessionSettings, SessionStatus, +}; +use devo_protocol::canonical::turn::{Turn, TurnKind, TurnStatus}; +use devo_protocol::canonical::usage::{SessionUsage, TurnUsage as CanonicalTurnUsage, UsageTotals}; +use uuid::Uuid; + +use crate::TurnKind as LegacyTurnKind; +use crate::conversation::{ + ApprovalRequestItem, ItemLine, ItemRecord, RolloutLine, SessionMetaLine, TurnItem, TurnLine, + TurnStatus as LegacyTurnStatus, +}; + +use super::rollout_v2::{InternalRecordV2, RolloutLineV2, ROLLOUT_FORMAT_VERSION}; + +/// Errors from projecting a legacy rollout line. Every known legacy shape +/// projects successfully; this exists so genuinely unrecoverable data fails +/// loudly instead of being silently fabricated. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum LegacyProjectError { + /// A legacy identifier did not parse back into the UUID it wraps. + #[error("legacy identifier is not a valid UUID: {0}")] + InvalidLegacyId(String), +} + +/// The state carried between the request and the decision of one approval. +#[derive(Debug)] +struct ApprovalFold { + item_id: ItemId, + seq: u64, + revision: u32, + /// The full original request payload, needed to reconstruct the complete + /// `Item::Approval` when the matching decision arrives. + request: ApprovalRequestItem, +} + +/// Intermediate result of projecting one packed legacy payload: either a +/// normal item (fresh seq assigned by the caller), an approval-fold item +/// (id/seq/revision already fixed by the fold), or a non-item internal +/// record. +#[derive(Debug)] +enum Projected { + Item { + item: Item, + state: ItemState, + }, + FoldedItem { + id: ItemId, + seq: u64, + revision: u32, + item: Item, + state: ItemState, + }, + Internal(Box), +} + +/// Pure legacy (v1) → canonical (v2) rollout converter. One instance per +/// session file being converted; not shared across sessions because `seq`, +/// approval folds, and the cwd fallback are all per-session. +#[derive(Debug)] +pub struct LegacyProjector { + /// Next sequence number to assign on an item's first appearance. Starts + /// at 1 and is strictly increasing within the session. + next_seq: u64, + /// Session cwd learned from SessionMeta; the fallback `CommandExecution` + /// cwd because legacy exec payloads never recorded one. + session_cwd: Option, + /// Approval requests seen so far, keyed by `approval_id`, so a later + /// decision folds into the same item id/seq with a bumped revision. + approvals: HashMap, +} + +impl Default for LegacyProjector { + fn default() -> Self { + Self::new() + } +} + +impl LegacyProjector { + pub fn new() -> Self { + Self { + next_seq: 1, + session_cwd: None, + approvals: HashMap::new(), + } + } + + fn next_seq(&mut self) -> u64 { + let seq = self.next_seq; + self.next_seq += 1; + seq + } + + /// Projects one legacy rollout line into zero or more v2 lines. Most + /// lines map 1:1; an `Item` record expands to one line per packed + /// payload, and internal payloads (HookPrompt/TurnSummary/ToolProgress) + /// become `Internal` lines instead of items. + pub fn project_line( + &mut self, + line: &RolloutLine, + ) -> Result, LegacyProjectError> { + match line { + RolloutLine::SessionMeta(line) => self.project_session_meta(line), + RolloutLine::Turn(line) => self.project_turn(line), + RolloutLine::Item(line) => self.project_item(line), + RolloutLine::SessionTitleUpdated(line) => { + Ok(vec![RolloutLineV2::SessionTitleUpdated { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), + title: line.title.clone(), + previous_title: line.previous_title.clone(), + }]) + } + RolloutLine::SessionContextUpdated(line) => { + Ok(vec![RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + entry: InternalRecordV2::SessionContext(Box::new( + line.session_context.clone(), + )), + }]) + } + RolloutLine::CompactionSnapshot(line) => { + Ok(vec![RolloutLineV2::CompactionSnapshot { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), + turn_id: TurnId::from_legacy_uuid(legacy_uuid(line.turn_id)?), + summary_item_id: ItemId::from_legacy_uuid(legacy_uuid(line.summary_item_id)?), + preserved_item_ids: line + .preserved_item_ids + .iter() + .map(|id| legacy_uuid(id).map(ItemId::from_legacy_uuid)) + .collect::>()?, + }]) + } + RolloutLine::MessageEditRecorded(line) => Ok(vec![RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + entry: InternalRecordV2::MessageEdit(line.record.clone()), + }]), + RolloutLine::TurnSuperseded(line) => Ok(vec![RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + entry: InternalRecordV2::TurnSuperseded(line.record.clone()), + }]), + RolloutLine::TurnWorkspaceCheckpointRecorded(line) => { + Ok(vec![RolloutLineV2::WorkspaceCheckpoint { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + record: line.record.clone(), + }]) + } + RolloutLine::TurnWorkspaceChangeRecorded(line) => { + Ok(vec![RolloutLineV2::WorkspaceChange { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + record: line.record.clone(), + }]) + } + RolloutLine::TurnWorkspaceRestoreStarted(line) => { + Ok(vec![RolloutLineV2::WorkspaceRestoreStarted { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + record: line.record.clone(), + }]) + } + RolloutLine::TurnWorkspaceRestoreCompleted(line) => { + Ok(vec![RolloutLineV2::WorkspaceRestoreCompleted { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + record: line.record.clone(), + }]) + } + RolloutLine::SessionRollback(line) => Ok(vec![RolloutLineV2::SessionRollback { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), + retained_turn_ids: line + .retained_turn_ids + .iter() + .map(|id| legacy_uuid(id).map(TurnId::from_legacy_uuid)) + .collect::>()?, + retained_item_ids: line + .retained_item_ids + .iter() + .map(|id| legacy_uuid(id).map(ItemId::from_legacy_uuid)) + .collect::>()?, + latest_turn_id: line + .latest_turn_id + .map(|id| legacy_uuid(id).map(TurnId::from_legacy_uuid)) + .transpose()?, + }]), + } + } + + fn project_session_meta( + &mut self, + line: &SessionMetaLine, + ) -> Result, LegacyProjectError> { + let record = &line.session; + self.session_cwd = Some(record.cwd.clone()); + + let parent = match record.parent_session_id { + Some(parent_id) + if record.agent_role.is_some() + || record.agent_nickname.is_some() + || record.agent_path.is_some() => + { + Some(SessionParent::Agent { + session_id: SessionId::from_legacy_uuid(legacy_uuid(parent_id)?), + role: record.agent_role.clone(), + }) + } + Some(parent_id) => Some(SessionParent::Fork { + session_id: SessionId::from_legacy_uuid(legacy_uuid(parent_id)?), + at_turn_id: None, + }), + None => None, + }; + + // Legacy approval modes were free-form strings ("on-request", + // "full-auto", ...); map by keyword, defaulting to the safest profile. + let approval_mode = record.approval_mode.to_ascii_lowercase(); + let permission_profile = if approval_mode.contains("auto") { + PermissionProfile::AutoReview + } else if approval_mode.contains("full") { + PermissionProfile::FullAccess + } else { + PermissionProfile::Default + }; + + let git_info = if record.git_sha.is_some() + || record.git_branch.is_some() + || record.git_origin_url.is_some() + { + Some(GitInfo { + sha: record.git_sha.clone(), + branch: record.git_branch.clone(), + origin_url: record.git_origin_url.clone(), + // Legacy never recorded the dirty flag; None = unknown. + dirty: None, + observed_at: record.updated_at, + }) + } else { + None + }; + + // The legacy lump cannot be decomposed into calls: it lands in + // `legacy` and in `total.total_tokens` (total = legacy + ledger, and + // the ledger starts empty). + let legacy_totals = UsageTotals { + total_tokens: record.tokens_used.max(0) as u64, + ..UsageTotals::default() + }; + + let session = Session { + id: SessionId::from_legacy_uuid(legacy_uuid(record.id)?), + version: 1, + cwd: record.cwd.clone(), + parent, + ephemeral: false, + created_at: record.created_at, + status: SessionStatus::Idle, + flags: Vec::new(), + archived: record.archived_at.is_some(), + active_turn_id: None, + queued_count: 0, + title: record.title.clone(), + model: ModelBinding { + provider: record.model_provider.clone(), + // Sessions that never recorded a resolved model keep an + // explicitly empty slug: unknown, not fabricated. + model: record.model.clone().unwrap_or_default(), + reasoning_effort: record + .reasoning_effort_selection + .as_deref() + .and_then(|selection| selection.parse().ok()), + }, + settings: SessionSettings { + permission_profile, + reasoning_effort: None, + mode: None, + }, + git_info, + preview: record.first_user_message.clone().unwrap_or_default(), + last_activity_at: record.last_activity_at.unwrap_or(record.updated_at), + usage: SessionUsage { + total: legacy_totals.clone(), + by_purpose: Vec::new(), + legacy: Some(legacy_totals), + updated_at: record.updated_at, + }, + }; + Ok(vec![RolloutLineV2::SessionMeta { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session, + }]) + } + + fn project_turn(&mut self, line: &TurnLine) -> Result, LegacyProjectError> { + let record = &line.turn; + + let kind = match &record.kind { + // `Review` was dead code with no production data and `Other(_)` + // was an open string; both collapse to Regular. Goal continuations + // are never back-filled from content (05 §2.2): they stay Regular. + LegacyTurnKind::Regular | LegacyTurnKind::Review | LegacyTurnKind::Other(_) => { + TurnKind::Regular + } + LegacyTurnKind::ManualCompaction => TurnKind::Compaction, + }; + + let status = match record.status { + // Waiting on an approval is still part of the turn, not a + // separate state (07 §4.3). + LegacyTurnStatus::Pending + | LegacyTurnStatus::Running + | LegacyTurnStatus::WaitingApproval => TurnStatus::InProgress, + LegacyTurnStatus::Completed => TurnStatus::Completed, + LegacyTurnStatus::Interrupted => TurnStatus::Interrupted, + LegacyTurnStatus::Failed => TurnStatus::Failed, + }; + + let error = record.error.as_ref().map(|error| { + let mut projected = AgentError::new(error.code.clone(), error.message.clone()); + if let Some(hint) = &error.recovery_hint { + projected.details = Some(serde_json::json!({ "recoveryHint": hint })); + } + projected + }); + + let usage = record.usage.as_ref().map(|usage| CanonicalTurnUsage { + query: UsageTotals { + total_tokens: u64::from( + usage + .total_tokens + .unwrap_or(usage.input_tokens + usage.output_tokens), + ), + input_tokens: u64::from(usage.input_tokens), + output_tokens: u64::from(usage.output_tokens), + reasoning_tokens: u64::from(usage.reasoning_output_tokens.unwrap_or(0)), + cache_read_input_tokens: u64::from(usage.cache_read_input_tokens.unwrap_or(0)), + cache_creation_input_tokens: u64::from( + usage.cache_creation_input_tokens.unwrap_or(0), + ), + call_count: 0, + // The provider reported usage, so the turn had at least one + // metered call. + metered_call_count: 1, + ..UsageTotals::default() + }, + overhead: UsageTotals::default(), + }); + + let turn = Turn { + id: TurnId::from_legacy_uuid(legacy_uuid(record.id)?), + session_id: SessionId::from_legacy_uuid(legacy_uuid(record.session_id)?), + sequence: record.sequence, + kind, + status, + model: ModelBinding { + provider: record + .model_binding_id + .clone() + .unwrap_or_else(|| "unknown".into()), + model: if record.request_model.is_empty() { + record.model.clone() + } else { + record.request_model.clone() + }, + reasoning_effort: record + .reasoning_effort_selection + .as_deref() + .and_then(|selection| selection.parse().ok()), + }, + started_at: record.started_at, + completed_at: record.completed_at, + error, + usage, + }; + Ok(vec![RolloutLineV2::Turn { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + turn, + }]) + } + + fn project_item(&mut self, line: &ItemLine) -> Result, LegacyProjectError> { + let record = &line.item; + let session_id = SessionId::from_legacy_uuid(legacy_uuid(record.session_id)?); + let turn_id = TurnId::from_legacy_uuid(legacy_uuid(record.turn_id)?); + let first_item_id = ItemId::from_legacy_uuid(legacy_uuid(record.id)?); + + let mut out = Vec::new(); + for (index, payload) in record + .input_items + .iter() + .chain(&record.output_items) + .enumerate() + { + // A legacy record packs N payloads under a single record id; the + // first payload keeps that id, the rest get fresh canonical ids + // because persistence is one-record-one-item in v2. + let item_id = if index == 0 { + first_item_id.clone() + } else { + ItemId::new() + }; + let (id, seq, revision, state, item) = match self.project_payload( + record, + &item_id, + payload, + )? { + Projected::Item { item, state } => { + (item_id, self.next_seq(), 1, state, item) + } + Projected::FoldedItem { + id, + seq, + revision, + item, + state, + } => (id, seq, revision, state, item), + Projected::Internal(entry) => { + out.push(RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + entry: *entry, + }); + continue; + } + }; + out.push(RolloutLineV2::Item { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + item: ItemEnvelope { + id, + session_id: session_id.clone(), + turn_id: turn_id.clone(), + seq, + revision, + created_at: record.timestamp, + updated_at: record.timestamp, + state, + item, + }, + }); + } + Ok(out) + } + + fn project_payload( + &mut self, + record: &ItemRecord, + item_id: &ItemId, + payload: &TurnItem, + ) -> Result { + let projected = match payload { + TurnItem::UserMessage(item) => Projected::Item { + state: ItemState::Completed, + item: Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: item.text.clone(), + }], + entry: UserMessageEntry::TurnStart, + }, + }, + TurnItem::SteerInput(item) => Projected::Item { + state: ItemState::Completed, + item: Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: item.text.clone(), + }], + entry: UserMessageEntry::Steer, + }, + }, + TurnItem::HookPrompt(item) => Projected::Internal(Box::new(InternalRecordV2::Entry { + entry: InternalEntry::HookPrompt { + text: item.text.clone(), + }, + })), + TurnItem::AgentMessage(item) => Projected::Item { + state: ItemState::Completed, + item: Item::AssistantMessage { + text: item.text.clone(), + phase: None, + }, + }, + TurnItem::Plan(item) => Projected::Item { + state: ItemState::Completed, + // The legacy plan is a plain rendered text blob; one entry + // preserves it verbatim. Cold files are almost always finished + // turns, so the step is marked completed. + item: Item::Plan { + entries: vec![PlanEntry { + step: item.text.clone(), + status: PlanStepStatus::Completed, + }], + }, + }, + TurnItem::Reasoning(item) => Projected::Item { + state: ItemState::Completed, + item: Item::Reasoning { + text: item.text.clone(), + provider_payload_ref: None, + }, + }, + TurnItem::ToolCall(call) => Projected::Item { + state: ItemState::Completed, + item: Item::ToolCall { + call_id: call.tool_call_id.clone(), + tool_name: call.tool_name.clone(), + // Legacy persisted calls all went through the builtin + // dispatcher. + source: ToolSource::Builtin, + server_name: None, + input: Some(call.input.clone()), + }, + }, + TurnItem::ToolProgress(progress) => Projected::Internal(Box::new(InternalRecordV2::Entry { + entry: InternalEntry::ToolProgress { + call_id: progress.tool_call_id.clone(), + message: progress.message.clone(), + }, + })), + TurnItem::ToolResult(result) => Projected::Item { + state: ItemState::Completed, + item: Item::ToolResult { + call_id: result.tool_call_id.clone(), + output: result.output.clone(), + display_content: result.display_content.clone(), + is_error: result.is_error, + truncated: false, + }, + }, + TurnItem::CommandExecution(command) => Projected::Item { + state: ItemState::Completed, + item: Item::CommandExecution { + call_id: command.tool_call_id.clone(), + command: command.command.clone(), + argv: None, + // Legacy exec payloads never recorded a cwd; fall back to + // the session cwd, or an explicitly empty path when the + // SessionMeta line has not been seen yet. + cwd: self.session_cwd.clone().unwrap_or_default(), + input: Some(command.input.clone()), + output: Some(command.output.clone()), + exit_code: None, + execution_handle: None, + is_error: command.is_error, + execution_mode: ExecutionMode::Foreground, + origin: ExecOrigin::AgentTool, + sandbox: None, + }, + }, + TurnItem::WebSearch(item) => Projected::Item { + state: ItemState::Completed, + // Legacy hosted-tool payloads only kept their rendered text; + // no call id was recorded, so the envelope item id stands in + // as a stable identifier. + item: Item::HostedToolCall { + call_id: item_id.as_str().to_owned(), + tool_name: "web_search".into(), + input: None, + output: Some(serde_json::Value::String(item.text.clone())), + }, + }, + TurnItem::ImageGeneration(item) => Projected::Item { + state: ItemState::Completed, + item: Item::HostedToolCall { + call_id: item_id.as_str().to_owned(), + tool_name: "image_generation".into(), + input: None, + output: Some(serde_json::Value::String(item.text.clone())), + }, + }, + TurnItem::ContextCompaction(item) => Projected::Item { + state: ItemState::Completed, + item: Item::ContextCompaction { + // Legacy did not record the trigger; the conservative + // default is the automatic threshold. + trigger: CompactionTrigger::AutoThreshold, + before: ContextUsage { + measured: false, + ..ContextUsage::default() + }, + after: None, + summary: Some(item.text.clone()), + }, + }, + TurnItem::TurnSummary(item) => Projected::Internal(Box::new(InternalRecordV2::Entry { + entry: InternalEntry::TurnSummary { + text: item.text.clone(), + }, + })), + TurnItem::ApprovalRequest(request) => { + let seq = self.next_seq(); + self.approvals.insert( + request.approval_id.clone(), + ApprovalFold { + item_id: item_id.clone(), + seq, + revision: 1, + request: request.clone(), + }, + ); + Projected::FoldedItem { + id: item_id.clone(), + seq, + revision: 1, + state: ItemState::Waiting, + item: approval_request_item(request, None), + } + } + TurnItem::ApprovalDecision(decision) => { + match self.approvals.get_mut(&decision.approval_id) { + Some(fold) => { + fold.revision += 1; + // Legacy decisions were free-form strings; "allow" + // appears in historical files (records.rs tests) and + // anything not clearly approve/deny is cancelled. + let decision_kind = match decision.decision.to_ascii_lowercase().as_str() { + "approve" | "approved" | "allow" => ApprovalDecisionKind::Approved, + "deny" | "denied" => ApprovalDecisionKind::Denied, + _ => ApprovalDecisionKind::Cancelled, + }; + // Unknown legacy scope strings fall back to the + // narrowest scope instead of failing the conversion. + let scope = match decision.scope.to_ascii_lowercase().as_str() { + "once" => ApprovalScope::Once, + "turn" => ApprovalScope::Turn, + "session" => ApprovalScope::Session, + "path_prefix" => ApprovalScope::PathPrefix, + "host" => ApprovalScope::Host, + "tool" => ApprovalScope::Tool, + "command_prefix" => ApprovalScope::CommandPrefix, + "command_prefix_persist" => ApprovalScope::CommandPrefixPersist, + _ => ApprovalScope::Once, + }; + let request = fold.request.clone(); + Projected::FoldedItem { + id: fold.item_id.clone(), + seq: fold.seq, + revision: fold.revision, + state: ItemState::Completed, + item: approval_request_item( + &request, + Some(ApprovalDecision { + decision: decision_kind, + scope, + decided_at: record.timestamp, + }), + ), + } + } + None => { + // Orphan decision (no matching request in this file): + // keep the information as a warning item with a fresh + // id/seq rather than dropping history. + Projected::FoldedItem { + id: ItemId::new(), + seq: self.next_seq(), + revision: 1, + state: ItemState::Completed, + item: Item::Warning { + code: "legacyOrphanApprovalDecision".into(), + message: format!( + "approval decision '{}'/'{}' references unknown approval id {}", + decision.decision, decision.scope, decision.approval_id + ), + retryable: false, + }, + } + } + } + } + }; + Ok(projected) + } +} + +/// Legacy identifiers are `Display`-formatted UUIDs; converting back through +/// the string form keeps the bare-UUID textual representation so ids +/// round-trip unchanged. +fn legacy_uuid(id: impl Display) -> Result { + let text = id.to_string(); + Uuid::parse_str(&text).map_err(|_| LegacyProjectError::InvalidLegacyId(text)) +} + +/// Builds the approval target from the legacy request's optional path, host, +/// or free-form target string, in that priority order. +fn approval_target(request: &ApprovalRequestItem) -> Option { + if let Some(path) = &request.path { + Some(ApprovalTarget::Path { + path: PathBuf::from(path), + }) + } else if let Some(host) = &request.host { + Some(ApprovalTarget::Host { host: host.clone() }) + } else { + request + .target + .clone() + .map(|command| ApprovalTarget::Command { command }) + } +} + +/// Reconstructs a full `Item::Approval` from a stored legacy request payload, +/// with or without the folded-in decision. +fn approval_request_item( + request: &ApprovalRequestItem, + decision: Option, +) -> Item { + Item::Approval { + approval_id: request.approval_id.clone(), + target_item_id: None, + action_summary: request.action_summary.clone(), + justification: request.justification.clone(), + resource: request.resource.clone(), + available_scopes: request.available_scopes.clone(), + target: approval_target(request), + decision, + } +} diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index 4ef57cd9..a61b093c 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -1,6 +1,14 @@ +pub mod legacy_projector; +pub mod rollout_v2; + mod records; pub use devo_protocol::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus, TurnUsage}; +pub use legacy_projector::{LegacyProjectError, LegacyProjector}; +pub use rollout_v2::{ + InternalRecordV2, ParsedRolloutLine, ROLLOUT_FORMAT_VERSION, RolloutLineReadError, + RolloutLineV2, parse_rollout_line, +}; pub use records::{ ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, ItemLine, ItemRecord, MessageEditRecordedLine, RolloutLine, SessionContextUpdatedLine, diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs new file mode 100644 index 00000000..b5652773 --- /dev/null +++ b/crates/core/src/conversation/rollout_v2.rs @@ -0,0 +1,328 @@ +//! The versioned whole-line rollout envelope (v2) and the dual-format reader +//! dispatch. +//! +//! Truth source: `devo-api-design/05-migration.md` §2.2. Rollout files may +//! freely mix legacy v1 lines (no top-level `v` key, frozen `RolloutLine` +//! schema) and v2 lines; the write path only ever appends v2. Reading goes +//! through [`parse_rollout_line`], which dispatches on the top-level `v` key +//! and refuses to silently skip unknown versions. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use devo_protocol::canonical::ids::{ItemId, SessionId, TurnId}; +use devo_protocol::canonical::item::{InternalEntry, ItemEnvelope}; +use devo_protocol::canonical::session::Session; +use devo_protocol::canonical::turn::Turn; + +use crate::{ + MessageEditRecordedRecord, SessionContext, TurnSupersededRecord, + TurnWorkspaceChangeRecordedRecord, TurnWorkspaceCheckpointRecordedRecord, + TurnWorkspaceRestoreCompletedRecord, TurnWorkspaceRestoreStartedRecord, +}; + +use super::records::RolloutLine; + +/// The format version written by the v2 write path. +pub const ROLLOUT_FORMAT_VERSION: u32 = 2; + +/// The v2 whole-line rollout envelope. Every line kind carries the format +/// version, a wall-clock timestamp, and its payload in a stable flat shape, +/// e.g. `{"v":2,"kind":"item","timestamp":"...","item":{...}}`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum RolloutLineV2 { + /// Canonical session metadata. + SessionMeta { + v: u32, + timestamp: DateTime, + session: Session, + }, + /// Canonical turn metadata. + Turn { + v: u32, + timestamp: DateTime, + turn: Turn, + }, + /// One typed item envelope (`{"kind":"item","item":{...}}`). + Item { + v: u32, + timestamp: DateTime, + item: ItemEnvelope, + }, + /// Rollout-only records that are not public items; see + /// [`InternalRecordV2`]. + Internal { + v: u32, + timestamp: DateTime, + entry: InternalRecordV2, + }, + /// Session title change. The legacy `title_state` is dropped: title + /// lifecycle is a derived cache in the new model. + SessionTitleUpdated { + v: u32, + timestamp: DateTime, + session_id: SessionId, + title: String, + previous_title: Option, + }, + /// A compaction snapshot reference: which item summarizes the compacted + /// history and which pre-existing items survive, in prompt order. + CompactionSnapshot { + v: u32, + timestamp: DateTime, + session_id: SessionId, + turn_id: TurnId, + summary_item_id: ItemId, + preserved_item_ids: Vec, + }, + /// An append-only rollback marker: the retained turns/items after the + /// in-memory history was rebuilt. + SessionRollback { + v: u32, + timestamp: DateTime, + session_id: SessionId, + retained_turn_ids: Vec, + retained_item_ids: Vec, + latest_turn_id: Option, + }, + /// A workspace checkpoint captured before a turn; payload unchanged from + /// the legacy record. + WorkspaceCheckpoint { + v: u32, + timestamp: DateTime, + record: TurnWorkspaceCheckpointRecordedRecord, + }, + /// One recorded workspace change; payload unchanged from the legacy + /// record. + WorkspaceChange { + v: u32, + timestamp: DateTime, + record: TurnWorkspaceChangeRecordedRecord, + }, + /// A workspace restore started; payload unchanged from the legacy record. + WorkspaceRestoreStarted { + v: u32, + timestamp: DateTime, + record: TurnWorkspaceRestoreStartedRecord, + }, + /// A workspace restore completed; payload unchanged from the legacy + /// record. + WorkspaceRestoreCompleted { + v: u32, + timestamp: DateTime, + record: TurnWorkspaceRestoreCompletedRecord, + }, +} + +/// Rollout-only records that are not public items. They never appear in +/// `item/*` events or the public schema; the rollout reader hands them +/// straight to the recovery pipeline. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum InternalRecordV2 { + /// A canonical internal replay entry. Kept as a nested payload rather than + /// a flattened newtype variant: both enums use the `type` tag, so + /// flattening would emit a duplicate `type` key and break round-trips. + Entry { entry: InternalEntry }, + /// The locked session context captured for replay, payload unchanged from + /// the legacy record. Boxed to keep the enum small (serde-transparent). + SessionContext(Box), + /// An accepted message edit, payload unchanged from the legacy record. + MessageEdit(MessageEditRecordedRecord), + /// A superseded-turn marker, payload unchanged from the legacy record. + TurnSuperseded(TurnSupersededRecord), +} + +/// A rollout line parsed from disk in either supported format. +#[derive(Debug, Clone, PartialEq)] +pub enum ParsedRolloutLine { + /// A frozen legacy (v1) line, to be converted via + /// `crate::conversation::legacy_projector::LegacyProjector`. Boxed to + /// keep the enum small. + Legacy(Box), + /// A current v2 envelope line. Boxed to keep the enum small. + V2(Box), +} + +/// Errors from reading a single rollout line. Unknown format versions must +/// never be silently skipped (05 §2.2); a parse failure on any non-final line +/// marks the session damaged and stops automatic writes. Only a truncated +/// final line is a tolerable crash tail. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RolloutLineReadError { + /// The line declares a format version this reader does not understand. + #[error("unsupported rollout format version {version}")] + RolloutVersionUnsupported { version: u32 }, + /// The line is neither valid legacy nor valid v2 JSON for its declared + /// version. + #[error("damaged rollout line: {reason}")] + Damaged { reason: String }, + /// The line is cut off mid-JSON. Only tolerable as the final line of the + /// file (crash tail); mid-file it must be treated as + /// [`Self::Damaged`]. + #[error("truncated rollout line (crash tail; only the final line may be ignored)")] + TruncatedTail, +} + +/// Parses one rollout JSONL line, dispatching on the top-level `v` key: +/// absent → frozen legacy `RolloutLine`; `2` → [`RolloutLineV2`]; anything +/// else → [`RolloutLineReadError::RolloutVersionUnsupported`]. +/// +/// A truncated line reports [`RolloutLineReadError::TruncatedTail`]; the +/// caller decides whether it is the file's final line (tolerable crash tail) +/// or mid-file damage. +pub fn parse_rollout_line(line: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(line).map_err(|error| { + if error.is_eof() { + RolloutLineReadError::TruncatedTail + } else { + RolloutLineReadError::Damaged { + reason: error.to_string(), + } + } + })?; + let Some(version) = value.get("v") else { + let legacy = serde_json::from_value::(value).map_err(|error| { + RolloutLineReadError::Damaged { + reason: error.to_string(), + } + })?; + return Ok(ParsedRolloutLine::Legacy(Box::new(legacy))); + }; + let version = version + .as_u64() + .ok_or_else(|| RolloutLineReadError::Damaged { + reason: format!("rollout version is not an unsigned integer: {version}"), + })?; + match version { + 2 => { + let line = serde_json::from_value::(value).map_err(|error| { + RolloutLineReadError::Damaged { + reason: error.to_string(), + } + })?; + Ok(ParsedRolloutLine::V2(Box::new(line))) + } + other => Err(RolloutLineReadError::RolloutVersionUnsupported { + version: u32::try_from(other).unwrap_or(u32::MAX), + }), + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use pretty_assertions::assert_eq; + + use super::*; + use crate::conversation::{SessionTitleState, SessionTitleUpdatedLine}; + use devo_protocol::canonical::item::{Item, ItemState, UserInput, UserMessageEntry}; + use devo_protocol::canonical::ids::ItemId as CanonicalItemId; + + fn fixed_ts() -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap() + } + + fn sample_item_line() -> RolloutLineV2 { + RolloutLineV2::Item { + v: ROLLOUT_FORMAT_VERSION, + timestamp: fixed_ts(), + item: ItemEnvelope { + id: CanonicalItemId::from_string("item_1".into()), + session_id: SessionId::from_string("ses_1".into()), + turn_id: TurnId::from_string("turn_1".into()), + seq: 1, + revision: 1, + created_at: fixed_ts(), + updated_at: fixed_ts(), + state: ItemState::Completed, + item: Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: "hello".into(), + }], + entry: UserMessageEntry::TurnStart, + }, + }, + } + } + + #[test] + fn item_line_serializes_with_flat_v2_envelope_shape() { + let json = serde_json::to_value(sample_item_line()).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ + "v": 2, + "kind": "item", + "timestamp": "2026-08-01T00:00:00Z", + "item": { + "id": "item_1", + "sessionId": "ses_1", + "turnId": "turn_1", + "seq": 1, + "revision": 1, + "createdAt": "2026-08-01T00:00:00Z", + "updatedAt": "2026-08-01T00:00:00Z", + "state": "completed", + "item": { + "type": "userMessage", + "content": [{"type": "text", "text": "hello"}], + "entry": "turnStart" + } + } + }) + ); + } + + #[test] + fn dispatch_parses_v2_line() { + let line = serde_json::to_string(&sample_item_line()).expect("serialize"); + let parsed = parse_rollout_line(&line).expect("parse"); + assert_eq!(parsed, ParsedRolloutLine::V2(Box::new(sample_item_line()))); + } + + #[test] + fn dispatch_detects_legacy_line_by_missing_version() { + let legacy = RolloutLine::SessionTitleUpdated(SessionTitleUpdatedLine { + timestamp: fixed_ts(), + session_id: crate::conversation::SessionId::new(), + title: "New Title".into(), + title_state: SessionTitleState::Provisional, + previous_title: Some("Old Title".into()), + }); + let line = serde_json::to_string(&legacy).expect("serialize"); + let parsed = parse_rollout_line(&line).expect("parse"); + assert_eq!(parsed, ParsedRolloutLine::Legacy(Box::new(legacy))); + } + + #[test] + fn dispatch_rejects_unknown_version() { + let error = parse_rollout_line(r#"{"v":3,"kind":"item"}"#).expect_err("must fail"); + assert_eq!( + error, + RolloutLineReadError::RolloutVersionUnsupported { version: 3 } + ); + } + + #[test] + fn dispatch_rejects_non_integer_version_as_damaged() { + let error = parse_rollout_line(r#"{"v":"2","kind":"item"}"#).expect_err("must fail"); + assert!(matches!(error, RolloutLineReadError::Damaged { .. })); + } + + #[test] + fn dispatch_flags_truncated_line_as_crash_tail() { + let line = serde_json::to_string(&sample_item_line()).expect("serialize"); + let truncated = &line[..line.len() / 2]; + let error = parse_rollout_line(truncated).expect_err("must fail"); + assert_eq!(error, RolloutLineReadError::TruncatedTail); + } + + #[test] + fn dispatch_flags_well_formed_json_with_wrong_shape_as_damaged() { + let error = parse_rollout_line(r#"{"v":2,"kind":"nonsense"}"#).expect_err("must fail"); + assert!(matches!(error, RolloutLineReadError::Damaged { .. })); + } +} diff --git a/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl b/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl new file mode 100644 index 00000000..50a6609f --- /dev/null +++ b/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl @@ -0,0 +1,12 @@ +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000b1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","last_activity_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":null,"agent_role":null,"agent_path":null,"model_provider":"openai","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"on-request","tokens_used":12345,"first_user_message":"Fix the flaky test","archived_at":null,"git_sha":"abc123","git_branch":"main","git_origin_url":"git@github.com:example/repo.git","parent_session_id":null,"schema_version":2}}} +{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000b2","session_id":"00000000-0000-0000-0000-0000000000b1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":"2026-07-01T12:00:03Z","status":"Completed","kind":"regular","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":null,"input_token_estimate":null,"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20,"reasoning_output_tokens":5,"total_tokens":150},"schema_version":4}}} +{"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000b3","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[{"UserMessage":{"text":"Fix the flaky test"}}],"output_items":[{"AgentMessage":{"text":"On it."}},{"Plan":{"text":"1. reproduce\n2. fix"}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:12Z","item":{"id":"00000000-0000-0000-0000-0000000000b4","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":2,"timestamp":"2026-07-01T12:00:12Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ToolCall":{"tool_call_id":"call-1","tool_name":"read_file","input":{"path":"src/lib.rs"}}},{"ToolResult":{"tool_call_id":"call-1","tool_name":"read_file","output":{"content":"fn main() {}"},"display_content":"fn main() {}","is_error":false}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:13Z","item":{"id":"00000000-0000-0000-0000-0000000000b5","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":3,"timestamp":"2026-07-01T12:00:13Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"CommandExecution":{"tool_call_id":"call-2","tool_name":"exec_command","command":"cargo test","input":{"command":"cargo test"},"output":{"stdout":"ok"},"is_error":false}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:14Z","item":{"id":"00000000-0000-0000-0000-0000000000b6","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":4,"timestamp":"2026-07-01T12:00:14Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ApprovalRequest":{"approval_id":"appr-1","action_summary":"Run cargo test","justification":"Need to verify the fix","resource":"ShellExec","available_scopes":["Once","Session"],"target":"cargo test"}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:15Z","item":{"id":"00000000-0000-0000-0000-0000000000b7","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":5,"timestamp":"2026-07-01T12:00:15Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ApprovalDecision":{"approval_id":"appr-1","decision":"Allow","scope":"Once"}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:16Z","item":{"id":"00000000-0000-0000-0000-0000000000b8","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":6,"timestamp":"2026-07-01T12:00:16Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[{"SteerInput":{"text":"also run clippy"}}],"output_items":[{"WebSearch":{"text":"search results"}},{"Reasoning":{"text":"thinking..."}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:17Z","item":{"id":"00000000-0000-0000-0000-0000000000b9","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":7,"timestamp":"2026-07-01T12:00:17Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ContextCompaction":{"text":"compacted summary"}}],"worklog":null,"error":null,"schema_version":1}}} +{"SessionTitleUpdated":{"timestamp":"2026-07-01T12:00:30Z","session_id":"00000000-0000-0000-0000-0000000000b1","title":"Fix flaky test","title_state":{"Final":"UserRename"},"previous_title":"Legacy Session"}} +{"CompactionSnapshot":{"timestamp":"2026-07-01T12:00:31Z","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","summary_item_id":"00000000-0000-0000-0000-0000000000b9","preserved_item_ids":["00000000-0000-0000-0000-0000000000b3","00000000-0000-0000-0000-0000000000b4"]}} +{"SessionRollback":{"timestamp":"2026-07-01T12:00:32Z","session_id":"00000000-0000-0000-0000-0000000000b1","retained_turn_ids":["00000000-0000-0000-0000-0000000000b2"],"retained_item_ids":["00000000-0000-0000-0000-0000000000b3"],"latest_turn_id":"00000000-0000-0000-0000-0000000000b2","schema_version":1}} diff --git a/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl b/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl new file mode 100644 index 00000000..01c1afbd --- /dev/null +++ b/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl @@ -0,0 +1,5 @@ +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000c1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":"scout","agent_role":"explorer","agent_path":null,"model_provider":"openai","model":null,"model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"full-access","tokens_used":12345,"first_user_message":null,"archived_at":null,"git_sha":null,"git_branch":null,"git_origin_url":null,"parent_session_id":"00000000-0000-0000-0000-0000000000c0","schema_version":2}}} +{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000c2","session_id":"00000000-0000-0000-0000-0000000000c1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"Failed","kind":"manual_compaction","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":null,"input_token_estimate":null,"usage":null,"error":{"code":"PROVIDER_SERVER_ERROR","message":"provider request failed","recovery_hint":"retry later"},"schema_version":4}}} +{"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000c3","session_id":"00000000-0000-0000-0000-0000000000c1","turn_id":"00000000-0000-0000-0000-0000000000c2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[{"HookPrompt":{"text":"hook text"}}],"output_items":[{"ToolProgress":{"tool_call_id":"call-9","message":"working"}},{"TurnSummary":{"text":"3"}}],"worklog":null,"error":null,"schema_version":1}}} +{"MessageEditRecorded":{"timestamp":"2026-07-01T12:00:40Z","record":{"schema_version":1,"session_id":"00000000-0000-0000-0000-0000000000c1","edit_id":"00000000-0000-0000-0000-0000000000c4","target_message_id":"00000000-0000-0000-0000-0000000000c5","replacement_message_id":"00000000-0000-0000-0000-0000000000c6","target_turn_id":"00000000-0000-0000-0000-0000000000c2","replacement_turn_id":null,"queue_item_id":null,"edited_content_parts":[{"content_type":"text","value":"edited"}],"edited_mentions":[],"workspace_restore_policy":"skip","edit_state":"accepted","requested_by_client_id":null,"created_at":"2026-07-01T12:00:40Z"}}} +{"SessionContextUpdated":{"timestamp":"2026-07-01T12:00:41Z","session_id":"00000000-0000-0000-0000-0000000000c1","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":1}} diff --git a/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl b/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl new file mode 100644 index 00000000..d804a23b --- /dev/null +++ b/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl @@ -0,0 +1,5 @@ +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000d1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","last_activity_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":null,"agent_role":null,"agent_path":null,"model_provider":"openai","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"untrusted","tokens_used":12345,"first_user_message":"Fix the flaky test","archived_at":null,"git_sha":"abc123","git_branch":"main","git_origin_url":"git@github.com:example/repo.git","parent_session_id":null,"schema_version":2}}} +{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000d2","session_id":"00000000-0000-0000-0000-0000000000d1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"WaitingApproval","kind":"regular","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":null,"input_token_estimate":null,"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20,"reasoning_output_tokens":5,"total_tokens":150},"schema_version":4}}} +{"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000d3","session_id":"00000000-0000-0000-0000-0000000000d1","turn_id":"00000000-0000-0000-0000-0000000000d2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ApprovalDecision":{"approval_id":"appr-orphan","decision":"approve","scope":"session"}}],"worklog":null,"error":null,"schema_version":1}}} +{"Item":{"timestamp":"2026-07-01T12:00:12Z","item":{"id":"00000000-0000-0000-0000-0000000000d4","session_id":"00000000-0000-0000-0000-0000000000d1","turn_id":"00000000-0000-0000-0000-0000000000d2","seq":2,"timestamp":"2026-07-01T12:00:12Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ImageGeneration":{"text":"image result"}}],"worklog":null,"error":null,"schema_version":1}}} +{"SessionRollback":{"timestamp":"2026-07-01T12:00:50Z","session_id":"00000000-0000-0000-0000-0000000000d1","retained_turn_ids":[],"retained_item_ids":[],"latest_turn_id":null,"schema_version":1}} diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs new file mode 100644 index 00000000..5cabc6d0 --- /dev/null +++ b/crates/core/tests/legacy_projector.rs @@ -0,0 +1,781 @@ +//! Integration tests for the v1→v2 rollout migration: fixture files in the +//! frozen legacy format are read through the dual-format dispatch and +//! converted with `LegacyProjector`. +//! +//! The fixtures under `tests/fixtures/rollout_v1/` are generated from real +//! legacy `RolloutLine` values built in Rust (the builders below) and kept +//! permanently in the frozen legacy format (devo-api-design/05 §2.5). The +//! first run writes any missing file; afterwards the file must match the +//! builder byte-for-byte, so a drift failure means the legacy schema changed +//! (which it must never do) — regenerate by deleting the file. + +use std::fs; +use std::path::PathBuf; + +use chrono::{DateTime, TimeZone, Utc}; +use devo_core::{ + ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, + ContentPart, EditId, EditState, EnvironmentContext, ItemId, ItemLine, ItemRecord, + LanguageContext, LegacyProjector, MessageEditRecordedLine, MessageEditRecordedRecord, Model, + ParsedRolloutLine, Persona, RolloutLine, RolloutLineV2, SessionContext, + SessionContextUpdatedLine, SessionId, SessionMetaLine, SessionRecord, SessionRollbackLine, + SessionTitleFinalSource, SessionTitleState, SessionTitleUpdatedLine, SystemPromptMode, + TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, TurnId, TurnItem, + TurnKind, TurnLine, TurnRecord, TurnStatus, TurnUsage, WorkspaceRestorePolicy, + parse_rollout_line, +}; +use devo_protocol::canonical::ids::ItemId as CanonicalItemId; +use devo_protocol::canonical::item::{ + ApprovalDecisionKind, ApprovalScope, ApprovalTarget, ExecOrigin, ExecutionMode, Item, + ItemState, ToolSource, UserInput, UserMessageEntry, +}; +use devo_protocol::canonical::model::PermissionProfile; +use devo_protocol::canonical::session::SessionParent; +use devo_protocol::canonical::turn::{TurnKind as CanonicalTurnKind, TurnStatus as CanonicalTurnStatus}; +use pretty_assertions::assert_eq; +use uuid::Uuid; + +// ── Deterministic fixture data ────────────────────────────────────────── + +fn ts(second: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, second).unwrap() +} + +fn uuid(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +fn session_id(n: u128) -> SessionId { + SessionId::from(uuid(n)) +} + +fn turn_id(n: u128) -> TurnId { + TurnId::from(uuid(n)) +} + +fn item_id(n: u128) -> ItemId { + ItemId::from(uuid(n)) +} + +fn session_record(n: u128) -> SessionRecord { + SessionRecord { + id: session_id(n), + rollout_path: "rollout.jsonl".into(), + created_at: ts(0), + updated_at: ts(1), + last_activity_at: Some(ts(1)), + source: "cli".into(), + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: "openai".into(), + model: Some("gpt-5.2".into()), + model_binding_id: Some("binding-1".into()), + reasoning_effort_selection: Some("high".into()), + cwd: "/tmp/legacy-project".into(), + additional_directories: Vec::new(), + cli_version: "0.1.31".into(), + title: Some("Legacy Session".into()), + title_state: SessionTitleState::Final(SessionTitleFinalSource::ModelGenerated), + sandbox_policy: "workspace-write".into(), + approval_mode: "on-request".into(), + tokens_used: 12345, + first_user_message: Some("Fix the flaky test".into()), + archived_at: None, + git_sha: Some("abc123".into()), + git_branch: Some("main".into()), + git_origin_url: Some("git@github.com:example/repo.git".into()), + parent_session_id: None, + session_context: None, + latest_turn_context: None, + schema_version: 2, + } +} + +fn turn_record(n: u128, session: u128) -> TurnRecord { + TurnRecord { + id: turn_id(n), + session_id: session_id(session), + sequence: 1, + started_at: ts(2), + completed_at: Some(ts(3)), + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "gpt-5.2".into(), + model_binding_id: Some("binding-1".into()), + reasoning_effort_selection: Some("high".into()), + request_model: "gpt-5.2-codex".into(), + request_thinking: None, + input_token_estimate: None, + usage: Some(TurnUsage { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: Some(10), + cache_read_input_tokens: Some(20), + reasoning_output_tokens: Some(5), + total_tokens: Some(150), + }), + latest_query_usage: None, + stop_reason: None, + failure_reason: None, + error: None, + session_context: None, + turn_context: None, + schema_version: 4, + } +} + +fn item_record(n: u128, session: u128, turn: u128, seq: u64) -> ItemRecord { + ItemRecord { + id: item_id(n), + session_id: session_id(session), + turn_id: turn_id(turn), + seq, + timestamp: ts(10 + seq as u32), + attempt_placement: None, + turn_status: Some(TurnStatus::Running), + sibling_turn_ids: Vec::new(), + input_items: Vec::new(), + output_items: Vec::new(), + worklog: None, + error: None, + schema_version: 1, + } +} + +fn item_line(record: ItemRecord) -> RolloutLine { + RolloutLine::Item(ItemLine { + timestamp: record.timestamp, + item: record, + }) +} + +// ── Fixture builders ──────────────────────────────────────────────────── + +/// A typical session: packed multi-payload records, tool call/result pair, +/// command execution, approval request+decision fold, steer input, web +/// search, plan, compaction, title update, snapshot, rollback. +fn basic_session_lines() -> Vec { + let session = 0xb1; + let turn = 0xb2; + let mut conversation = item_record(0xb3, session, turn, 1); + conversation.input_items = vec![TurnItem::UserMessage(TextItem { + text: "Fix the flaky test".into(), + })]; + conversation.output_items = vec![ + TurnItem::AgentMessage(TextItem { text: "On it.".into() }), + TurnItem::Plan(TextItem { + text: "1. reproduce\n2. fix".into(), + }), + ]; + + let mut tool_pair = item_record(0xb4, session, turn, 2); + tool_pair.output_items = vec![ + TurnItem::ToolCall(ToolCallItem { + tool_call_id: "call-1".into(), + tool_name: "read_file".into(), + input: serde_json::json!({"path": "src/lib.rs"}), + }), + TurnItem::ToolResult(ToolResultItem { + tool_call_id: "call-1".into(), + tool_name: Some("read_file".into()), + output: serde_json::json!({"content": "fn main() {}"}), + display_content: Some("fn main() {}".into()), + is_error: false, + }), + ]; + + let mut command = item_record(0xb5, session, turn, 3); + command.output_items = vec![TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: "call-2".into(), + tool_name: "exec_command".into(), + command: "cargo test".into(), + input: serde_json::json!({"command": "cargo test"}), + output: serde_json::json!({"stdout": "ok"}), + is_error: false, + })]; + + let mut approval_request = item_record(0xb6, session, turn, 4); + approval_request.output_items = vec![TurnItem::ApprovalRequest(ApprovalRequestItem { + approval_id: "appr-1".into(), + action_summary: "Run cargo test".into(), + justification: "Need to verify the fix".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["Once".into(), "Session".into()], + path: None, + host: None, + target: Some("cargo test".into()), + })]; + + let mut approval_decision = item_record(0xb7, session, turn, 5); + approval_decision.output_items = vec![TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: "appr-1".into(), + // Pascal-case "Allow" appears in historical files (see records.rs + // tests); it must still map to Approved. + decision: "Allow".into(), + scope: "Once".into(), + })]; + + let mut steer = item_record(0xb8, session, turn, 6); + steer.input_items = vec![TurnItem::SteerInput(TextItem { + text: "also run clippy".into(), + })]; + steer.output_items = vec![ + TurnItem::WebSearch(TextItem { + text: "search results".into(), + }), + TurnItem::Reasoning(TextItem { + text: "thinking...".into(), + }), + ]; + + let mut compaction = item_record(0xb9, session, turn, 7); + compaction.output_items = vec![TurnItem::ContextCompaction(TextItem { + text: "compacted summary".into(), + })]; + + vec![ + RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp: ts(0), + session: session_record(session), + })), + RolloutLine::Turn(Box::new(TurnLine { + timestamp: ts(2), + turn: turn_record(turn, session), + })), + item_line(conversation), + item_line(tool_pair), + item_line(command), + item_line(approval_request), + item_line(approval_decision), + item_line(steer), + item_line(compaction), + RolloutLine::SessionTitleUpdated(SessionTitleUpdatedLine { + timestamp: ts(30), + session_id: session_id(session), + title: "Fix flaky test".into(), + title_state: SessionTitleState::Final(SessionTitleFinalSource::UserRename), + previous_title: Some("Legacy Session".into()), + }), + RolloutLine::CompactionSnapshot(Box::new(CompactionSnapshotLine { + timestamp: ts(31), + session_id: session_id(session), + turn_id: turn_id(turn), + summary_item_id: item_id(0xb9), + preserved_item_ids: vec![item_id(0xb3), item_id(0xb4)], + })), + RolloutLine::SessionRollback(Box::new(SessionRollbackLine { + timestamp: ts(32), + session_id: session_id(session), + retained_turn_ids: vec![turn_id(turn)], + retained_item_ids: vec![item_id(0xb3)], + latest_turn_id: Some(turn_id(turn)), + schema_version: 1, + })), + ] +} + +/// Internal (non-item) payloads, a failed compaction turn on a sub-agent +/// session, a message edit, and a session-context update. +fn internal_lines() -> Vec { + let session = 0xc1; + let turn = 0xc2; + let mut record = session_record(session); + record.parent_session_id = Some(session_id(0xc0)); + record.agent_role = Some("explorer".into()); + record.agent_nickname = Some("scout".into()); + record.approval_mode = "full-access".into(); + record.model = None; + record.git_sha = None; + record.git_branch = None; + record.git_origin_url = None; + record.first_user_message = None; + record.last_activity_at = None; + + let mut turn = turn_record(turn, session); + turn.kind = TurnKind::ManualCompaction; + turn.status = TurnStatus::Failed; + turn.completed_at = None; + turn.usage = None; + turn.error = Some(TurnError { + code: "PROVIDER_SERVER_ERROR".into(), + message: "provider request failed".into(), + recovery_hint: Some("retry later".into()), + }); + + let mut internals = item_record(0xc3, 0xc1, 0xc2, 1); + internals.input_items = vec![TurnItem::HookPrompt(TextItem { + text: "hook text".into(), + })]; + internals.output_items = vec![ + TurnItem::ToolProgress(ToolProgressItem { + tool_call_id: "call-9".into(), + message: "working".into(), + }), + TurnItem::TurnSummary(TextItem { text: "3".into() }), + ]; + + vec![ + RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp: ts(0), + session: record, + })), + RolloutLine::Turn(Box::new(TurnLine { + timestamp: ts(2), + turn, + })), + item_line(internals), + RolloutLine::MessageEditRecorded(Box::new(MessageEditRecordedLine { + timestamp: ts(40), + record: MessageEditRecordedRecord { + schema_version: 1, + session_id: session_id(0xc1), + edit_id: EditId(uuid(0xc4)), + target_message_id: item_id(0xc5), + replacement_message_id: item_id(0xc6), + target_turn_id: Some(turn_id(0xc2)), + replacement_turn_id: None, + queue_item_id: None, + edited_content_parts: vec![ContentPart::Text("edited".into())], + edited_mentions: Vec::new(), + workspace_restore_policy: WorkspaceRestorePolicy::Skip, + edit_state: EditState::Accepted, + requested_by_client_id: None, + created_at: ts(40), + }, + })), + RolloutLine::SessionContextUpdated(Box::new(SessionContextUpdatedLine { + timestamp: ts(41), + session_id: session_id(0xc1), + session_context: SessionContext { + base_instructions: "base".into(), + available_skills: None, + workspace_instructions: None, + locked_agents_snapshot: None, + environment: EnvironmentContext { + cwd: ".".into(), + shell: "bash".into(), + current_date: "2026-07-01".into(), + timezone: "UTC".into(), + }, + language: LanguageContext::default(), + persona: Persona::Default, + model: Model { + slug: "gpt-5.2".into(), + ..Model::default() + }, + reasoning_effort_selection: None, + reasoning_effort: None, + system_prompt_mode: SystemPromptMode::CodingAgent, + }, + schema_version: 1, + })), + ] +} + +/// An orphan approval decision (no request in the file), a hosted image +/// generation, and a rollback that retains nothing. +fn orphan_decision_lines() -> Vec { + let session = 0xd1; + let turn = 0xd2; + let mut record = session_record(session); + record.approval_mode = "untrusted".into(); + + let mut turn = turn_record(turn, session); + turn.status = TurnStatus::WaitingApproval; + turn.completed_at = None; + + let mut orphan = item_record(0xd3, 0xd1, 0xd2, 1); + orphan.output_items = vec![TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: "appr-orphan".into(), + decision: "approve".into(), + scope: "session".into(), + })]; + + let mut image = item_record(0xd4, 0xd1, 0xd2, 2); + image.output_items = vec![TurnItem::ImageGeneration(TextItem { + text: "image result".into(), + })]; + + vec![ + RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp: ts(0), + session: record, + })), + RolloutLine::Turn(Box::new(TurnLine { + timestamp: ts(2), + turn, + })), + item_line(orphan), + item_line(image), + RolloutLine::SessionRollback(Box::new(SessionRollbackLine { + timestamp: ts(50), + session_id: session_id(0xd1), + retained_turn_ids: Vec::new(), + retained_item_ids: Vec::new(), + latest_turn_id: None, + schema_version: 1, + })), + ] +} + +// ── Fixture loading + projection driver ───────────────────────────────── + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rollout_v1") +} + +/// Returns the fixture content, writing it on first run and asserting the +/// on-disk file still matches the builder afterwards. +fn fixture_content(name: &str, lines: &[RolloutLine]) -> String { + let path = fixture_dir().join(name); + let mut expected = lines + .iter() + .map(|line| serde_json::to_string(line).expect("serialize legacy line")) + .collect::>() + .join("\n"); + expected.push('\n'); + match fs::read_to_string(&path) { + Ok(existing) => { + assert_eq!( + existing, expected, + "fixture {name} drifted from its builder; the legacy schema must not change" + ); + existing + } + Err(_) => { + fs::create_dir_all(path.parent().expect("fixture dir parent")).expect("create dir"); + fs::write(&path, &expected).expect("write fixture"); + expected + } + } +} + +/// Reads one fixture through the v-version dispatch and projects every line; +/// asserts that every line converts without error. +fn project_fixture(name: &str, lines: &[RolloutLine]) -> Vec { + let content = fixture_content(name, lines); + let mut projector = LegacyProjector::new(); + let mut out = Vec::new(); + for raw_line in content.lines() { + match parse_rollout_line(raw_line).expect("fixture line parses") { + ParsedRolloutLine::Legacy(line) => { + out.extend( + projector + .project_line(&line) + .expect("legacy line projects without error"), + ); + } + ParsedRolloutLine::V2(_) => panic!("fixture {name} must contain only legacy lines"), + } + } + out +} + +/// The full v2 JSONL output re-parses as `RolloutLineV2` and deep-compares +/// equal to the projected values. +fn assert_v2_roundtrip(projected: &[RolloutLineV2]) { + for line in projected { + let raw = serde_json::to_string(line).expect("serialize v2 line"); + match parse_rollout_line(&raw).expect("v2 line re-parses") { + ParsedRolloutLine::V2(parsed) => assert_eq!(parsed.as_ref(), line), + ParsedRolloutLine::Legacy(_) => panic!("v2 line parsed as legacy"), + } + } +} + +fn item_envelopes(lines: &[RolloutLineV2]) -> Vec<&devo_protocol::canonical::item::ItemEnvelope> { + lines + .iter() + .filter_map(|line| match line { + RolloutLineV2::Item { item, .. } => Some(item), + _ => None, + }) + .collect() +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[test] +fn basic_session_projects_all_lines_in_order() { + let projected = project_fixture("basic_session.jsonl", &basic_session_lines()); + + // 1 SessionMeta + 1 Turn + 12 items (3+2+1+1+1+3+1 payloads, approval + // decision reuses the request's seq) + title + snapshot + rollback. + assert_eq!(projected.len(), 17); + + assert!( + matches!(&projected[0], RolloutLineV2::SessionMeta { session, .. } + if session.cwd.as_os_str() == "/tmp/legacy-project") + ); + assert!( + matches!(&projected[1], RolloutLineV2::Turn { turn, .. } + if turn.kind == CanonicalTurnKind::Regular + && turn.status == CanonicalTurnStatus::Completed) + ); + + let envelopes = item_envelopes(&projected); + assert_eq!(envelopes.len(), 12); + // First-appearance seqs are assigned in payload order; the folded + // decision repeats the request's seq (7) with revision 2. + let seqs: Vec = envelopes.iter().map(|envelope| envelope.seq).collect(); + assert_eq!(seqs, vec![1, 2, 3, 4, 5, 6, 7, 7, 8, 9, 10, 11]); + + // The first payload of the packed conversation record keeps the legacy + // record id; the sibling payloads get fresh canonical ids. + assert_eq!(envelopes[0].id.as_str(), uuid(0xb3).to_string()); + assert!(envelopes[1].id.as_str().starts_with("item_")); + assert!(envelopes[2].id.as_str().starts_with("item_")); + + assert!( + matches!(&envelopes[0].item, Item::UserMessage { content, entry: UserMessageEntry::TurnStart, .. } + if matches!(content.as_slice(), [UserInput::Text { text }] if text == "Fix the flaky test")) + ); + assert!( + matches!(&envelopes[1].item, Item::AssistantMessage { text, phase: None } if text == "On it.") + ); + assert!( + matches!(&envelopes[3].item, Item::ToolCall { call_id, tool_name, source: ToolSource::Builtin, .. } + if call_id == "call-1" && tool_name == "read_file") + ); + assert!( + matches!(&envelopes[4].item, Item::ToolResult { call_id, is_error: false, truncated: false, .. } + if call_id == "call-1") + ); + // Command execution picks up the session cwd learned from SessionMeta. + assert!( + matches!(&envelopes[5].item, Item::CommandExecution { command, cwd, execution_mode: ExecutionMode::Foreground, origin: ExecOrigin::AgentTool, .. } + if command == "cargo test" && cwd.as_os_str() == "/tmp/legacy-project") + ); + assert!( + matches!(&envelopes[8].item, Item::UserMessage { entry: UserMessageEntry::Steer, .. }) + ); + assert!( + matches!(&envelopes[9].item, Item::HostedToolCall { tool_name, .. } if tool_name == "web_search") + ); + assert!( + matches!(&envelopes[11].item, Item::ContextCompaction { summary: Some(summary), .. } + if summary == "compacted summary") + ); + + assert_v2_roundtrip(&projected); +} + +#[test] +fn approval_request_and_decision_fold_into_one_item() { + let projected = project_fixture("basic_session.jsonl", &basic_session_lines()); + let envelopes = item_envelopes(&projected); + let approvals: Vec<_> = envelopes + .iter() + .filter(|envelope| matches!(envelope.item, Item::Approval { .. })) + .collect(); + + assert_eq!(approvals.len(), 2); + // One item id, one seq, revisions 1 then 2. + assert_eq!(approvals[0].id, approvals[1].id); + assert_eq!(approvals[0].seq, approvals[1].seq); + assert_eq!((approvals[0].revision, approvals[1].revision), (1, 2)); + assert_eq!(approvals[0].state, ItemState::Waiting); + assert_eq!(approvals[1].state, ItemState::Completed); + + let Item::Approval { + approval_id, + target, + decision: None, + .. + } = &approvals[0].item + else { + panic!("first revision is the undecided request"); + }; + assert_eq!(approval_id, "appr-1"); + assert_eq!( + target, + &Some(ApprovalTarget::Command { + command: "cargo test".into() + }) + ); + + let Item::Approval { + decision: Some(decision), + .. + } = &approvals[1].item + else { + panic!("second revision carries the folded decision"); + }; + assert_eq!(decision.decision, ApprovalDecisionKind::Approved); + assert_eq!(decision.scope, ApprovalScope::Once); + assert_eq!(decision.decided_at, ts(15)); +} + +#[test] +fn legacy_bare_uuid_ids_round_trip_unchanged() { + let projected = project_fixture("basic_session.jsonl", &basic_session_lines()); + + let RolloutLineV2::SessionMeta { session, .. } = &projected[0] else { + panic!("first line is the session meta"); + }; + assert_eq!(session.id.as_str(), uuid(0xb1).to_string()); + // Serializing the canonical id yields the identical bare-UUID string. + assert_eq!( + serde_json::to_string(&session.id).expect("serialize id"), + format!("\"{}\"", uuid(0xb1)) + ); + + let RolloutLineV2::Turn { turn, .. } = &projected[1] else { + panic!("second line is the turn"); + }; + assert_eq!(turn.id.as_str(), uuid(0xb2).to_string()); + assert_eq!(turn.session_id.as_str(), uuid(0xb1).to_string()); + + let RolloutLineV2::CompactionSnapshot { + summary_item_id, + preserved_item_ids, + .. + } = &projected[15] + else { + panic!("compaction snapshot line"); + }; + assert_eq!(summary_item_id.as_str(), uuid(0xb9).to_string()); + assert_eq!( + preserved_item_ids + .iter() + .map(CanonicalItemId::as_str) + .collect::>(), + vec![uuid(0xb3).to_string(), uuid(0xb4).to_string()] + ); +} + +#[test] +fn internal_payloads_become_internal_lines_not_items() { + let projected = project_fixture("internal_lines.jsonl", &internal_lines()); + + assert_eq!(projected.len(), 7); + assert!(item_envelopes(&projected).is_empty()); + + use devo_core::InternalRecordV2; + use devo_protocol::canonical::item::InternalEntry; + + let internal_entries: Vec<_> = projected + .iter() + .filter_map(|line| match line { + RolloutLineV2::Internal { entry, .. } => Some(entry), + _ => None, + }) + .collect(); + assert_eq!(internal_entries.len(), 5); + assert!( + matches!(&internal_entries[0], InternalRecordV2::Entry { entry: InternalEntry::HookPrompt { text } } + if text == "hook text") + ); + assert!( + matches!(&internal_entries[1], InternalRecordV2::Entry { entry: InternalEntry::ToolProgress { call_id, message } } + if call_id == "call-9" && message == "working") + ); + assert!( + matches!(&internal_entries[2], InternalRecordV2::Entry { entry: InternalEntry::TurnSummary { text } } + if text == "3") + ); + assert!( + matches!(&internal_entries[3], InternalRecordV2::MessageEdit(record) + if record.target_message_id == item_id(0xc5)) + ); + assert!( + matches!(&internal_entries[4], InternalRecordV2::SessionContext(context) + if context.base_instructions == "base") + ); + + assert_v2_roundtrip(&projected); +} + +#[test] +fn subagent_session_and_failed_compaction_turn_project() { + let projected = project_fixture("internal_lines.jsonl", &internal_lines()); + + let RolloutLineV2::SessionMeta { session, .. } = &projected[0] else { + panic!("first line is the session meta"); + }; + assert_eq!( + session.parent, + Some(SessionParent::Agent { + session_id: devo_protocol::canonical::ids::SessionId::from_legacy_uuid(uuid(0xc0)), + role: Some("explorer".into()), + }) + ); + assert_eq!( + session.settings.permission_profile, + PermissionProfile::FullAccess + ); + // No resolved model was recorded: the slug is explicitly empty. + assert_eq!(session.model.model, ""); + assert!(session.git_info.is_none()); + + let RolloutLineV2::Turn { turn, .. } = &projected[1] else { + panic!("second line is the turn"); + }; + assert_eq!(turn.kind, CanonicalTurnKind::Compaction); + assert_eq!(turn.status, CanonicalTurnStatus::Failed); + let error = turn.error.as_ref().expect("failed turn carries an error"); + assert_eq!(error.error_code, "PROVIDER_SERVER_ERROR"); + assert_eq!( + error.details, + Some(serde_json::json!({ "recoveryHint": "retry later" })) + ); +} + +#[test] +fn orphan_approval_decision_becomes_warning_item() { + let projected = project_fixture("orphan_decision.jsonl", &orphan_decision_lines()); + + assert_eq!(projected.len(), 5); + let envelopes = item_envelopes(&projected); + assert_eq!(envelopes.len(), 2); + + let warning = &envelopes[0]; + assert_eq!(warning.state, ItemState::Completed); + assert!( + matches!(&warning.item, Item::Warning { code, retryable: false, .. } + if code == "legacyOrphanApprovalDecision") + ); + // The orphan warning gets a fresh id and its own first-appearance seq. + assert!(warning.id.as_str().starts_with("item_")); + assert_eq!((warning.seq, warning.revision), (1, 1)); + + assert!( + matches!(&envelopes[1].item, Item::HostedToolCall { tool_name, output: Some(output), .. } + if tool_name == "image_generation" && output == &serde_json::Value::String("image result".into())) + ); + + let RolloutLineV2::Turn { turn, .. } = &projected[1] else { + panic!("second line is the turn"); + }; + // Waiting on an approval is still InProgress in the canonical model. + assert_eq!(turn.status, CanonicalTurnStatus::InProgress); + + assert!( + matches!(&projected[4], RolloutLineV2::SessionRollback { latest_turn_id: None, retained_turn_ids, .. } + if retained_turn_ids.is_empty()) + ); + + assert_v2_roundtrip(&projected); +} + +#[test] +fn command_execution_falls_back_to_empty_cwd_before_session_meta() { + let mut projector = LegacyProjector::new(); + let mut record = item_record(0xe1, 0xe2, 0xe3, 1); + record.output_items = vec![TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: "call-x".into(), + tool_name: "exec_command".into(), + command: "ls".into(), + input: serde_json::json!({"command": "ls"}), + output: serde_json::json!({}), + is_error: false, + })]; + let projected = projector + .project_line(&item_line(record)) + .expect("projection succeeds"); + let envelopes = item_envelopes(&projected); + assert!( + matches!(&envelopes[0].item, Item::CommandExecution { cwd, .. } if cwd == &PathBuf::new()) + ); +} From e0500f595a4a0d8b48e0be9d0caeedf491f72eb7 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 01:48:42 +0800 Subject: [PATCH 05/17] feat(server): add typed-items wire opt-in via initialize _meta negotiation --- crates/client/src/acp_permissions.rs | 4 + crates/protocol/src/acp.rs | 23 + crates/protocol/src/canonical/mod.rs | 1 + .../protocol/src/canonical/wire_projector.rs | 787 ++++++++++++++++++ crates/protocol/src/conversation.rs | 6 + crates/protocol/src/event.rs | 16 + crates/server/src/runtime/connection.rs | 217 ++++- crates/server/src/runtime/handlers/acp.rs | 13 + .../server/src/runtime/handlers/compaction.rs | 2 + .../src/runtime/handlers/message_edit.rs | 4 + crates/server/src/runtime/items.rs | 28 +- .../runtime/turn_exec/context_compaction.rs | 1 + .../src/runtime/turn_exec/event_stream.rs | 2 + .../src/runtime/turn_exec/item_stream.rs | 2 + crates/server/src/runtime/turn_exec/tests.rs | 3 + crates/server/tests/protocol_contract.rs | 2 + crates/tui/src/chatwidget_tests.rs | 2 + crates/tui/src/worker.rs | 9 + .../tui/src/worker_queue_compaction_tests.rs | 1 + 19 files changed, 1117 insertions(+), 6 deletions(-) create mode 100644 crates/protocol/src/canonical/wire_projector.rs diff --git a/crates/client/src/acp_permissions.rs b/crates/client/src/acp_permissions.rs index 142edb0d..aa9e516f 100644 --- a/crates/client/src/acp_permissions.rs +++ b/crates/client/src/acp_permissions.rs @@ -218,6 +218,7 @@ fn acp_approval_request_notification( turn_id: Some(pending.turn_id), item_id: Some(pending.item_id), seq: 0, + item_seq: None, }, item: ItemEnvelope { item_id: pending.item_id, @@ -245,6 +246,7 @@ fn acp_approval_decision_notification( turn_id: Some(pending.turn_id), item_id: Some(pending.item_id), seq: 0, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -491,6 +493,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, } ); assert_eq!(request_item.item.item_id, item_id); @@ -559,6 +562,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, } ); assert_eq!(decision_item.item.item_kind, ItemKind::ApprovalDecision); diff --git a/crates/protocol/src/acp.rs b/crates/protocol/src/acp.rs index c9004ee2..8fcb9b57 100644 --- a/crates/protocol/src/acp.rs +++ b/crates/protocol/src/acp.rs @@ -36,12 +36,28 @@ pub const DEVO_HISTORY_INDEX_META: &str = "devo/historyIndex"; pub const DEVO_PARENT_MESSAGE_ID_META: &str = "devo/parentMessageId"; pub const DEVO_ITEM_KIND_META: &str = "devo/itemKind"; pub const DEVO_TURN_USAGE_META: &str = "devo/turnUsage"; +/// Top-level `_meta` object key that carries devo extension capabilities as +/// a nested object, e.g. `_meta: { "devo": { "typedItems": true } }`. +pub const DEVO_EXTENSION_META: &str = "devo"; +/// Capability key inside the `devo` extension meta object: the client opts +/// in to native typed `item/started` / `item/completed` notifications +/// carrying the canonical `ItemEnvelope` (P2, 06-item-model step 2). +pub const DEVO_TYPED_ITEMS_META: &str = "typedItems"; pub type AcpMeta = serde_json::Map; pub use crate::acp_event_to_update::acp_notification_from_server_event; pub use crate::acp_event_to_update::original_event_from_acp_notification; +/// Returns whether the given `_meta` map opts in to typed item +/// notifications (`{ "devo": { "typedItems": true } }`). +pub fn devo_typed_items_opted_in(meta: Option<&AcpMeta>) -> bool { + meta.and_then(|meta| meta.get(DEVO_EXTENSION_META)) + .and_then(|devo| devo.get(DEVO_TYPED_ITEMS_META)) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + pub fn devo_extension_method(method: &str) -> String { format!("{DEVO_EXTENSION_METHOD_PREFIX}{method}") } @@ -785,6 +801,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 1, + item_seq: None, }, item: crate::ItemEnvelope { item_id: ItemId::new(), @@ -957,6 +974,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 1, + item_seq: None, }, item: crate::ItemEnvelope { item_id: ItemId::new(), @@ -1018,6 +1036,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 1, + item_seq: None, }, item: crate::ItemEnvelope { item_id: ItemId::new(), @@ -1093,6 +1112,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, item: crate::ItemEnvelope { item_id, @@ -1141,6 +1161,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, item: crate::ItemEnvelope { item_id, @@ -1248,6 +1269,7 @@ mod tests { turn_id: None, item_id: Some(item_id), seq: 7, + item_seq: None, }, delta: "hello".to_string(), stream_index: None, @@ -1289,6 +1311,7 @@ mod tests { turn_id: None, item_id: Some(reasoning_item_id), seq: 8, + item_seq: None, }, delta: "thinking".to_string(), stream_index: None, diff --git a/crates/protocol/src/canonical/mod.rs b/crates/protocol/src/canonical/mod.rs index 16d06b6f..34daec1a 100644 --- a/crates/protocol/src/canonical/mod.rs +++ b/crates/protocol/src/canonical/mod.rs @@ -23,3 +23,4 @@ pub mod rpc_turn; pub mod session; pub mod turn; pub mod usage; +pub mod wire_projector; diff --git a/crates/protocol/src/canonical/wire_projector.rs b/crates/protocol/src/canonical/wire_projector.rs new file mode 100644 index 00000000..788bd6f0 --- /dev/null +++ b/crates/protocol/src/canonical/wire_projector.rs @@ -0,0 +1,787 @@ +//! Wire-level projector from the legacy item envelope (`ItemKind` + untyped +//! `serde_json::Value` payload bag) to the canonical typed `Item`. +//! +//! Truth source: `devo-api-design/06-item-model.md` migration step 2 (P2): +//! the live protocol switches to typed items *before* persistence does, so +//! this projector is the wire-side counterpart of the core `LegacyProjector` +//! (which converts rollout files). It is used only for connections that +//! opted in to typed items; on any payload mismatch it returns `None` and +//! the caller falls back to the legacy envelope. + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::ids::{ItemId, SessionId, TurnId}; +use super::item::{ + ApprovalDecision, ApprovalDecisionKind, ApprovalScope, ApprovalTarget, CompactionTrigger, + ContextUsage, ExecOrigin, ExecutionMode, FileChangeEntry, FileChangeKind, Item, ItemEnvelope, + ItemState, PlanEntry, PlanStepStatus, ToolSource, UserInput, UserMessageEntry, +}; +use crate::{ + ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, EventContext, + FileChangePayload, ItemKind, ServerEvent, ToolCallPayload, ToolResultPayload, + TypedItemEventPayload, +}; +use crate::protocol::ExecCommandSource; + +/// Projects one legacy wire payload into the canonical `Item` for its kind. +/// +/// `decided_at` fills `ApprovalDecision.decided_at`: legacy decision events +/// carry no timestamp, so the caller supplies one (the fan-out stamps the +/// projection time; there is no honest earlier value). +/// +/// Returns `None` on any payload that does not match the expected legacy +/// shape — the caller must then keep the legacy envelope for that event. +pub fn project_wire_item( + kind: &ItemKind, + payload: &serde_json::Value, + decided_at: DateTime, +) -> Option { + match kind { + ItemKind::UserMessage => { + let text = payload_text(payload)?; + Some(Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { text }], + // Steered messages are indistinguishable on the wire (there + // is no SteerInput kind; `steer/accepted` is a separate + // event), so every wire user message projects as TurnStart. + entry: UserMessageEntry::TurnStart, + }) + } + ItemKind::AgentMessage => { + let text = payload_text(payload)?; + Some(Item::AssistantMessage { text, phase: None }) + } + ItemKind::Reasoning => { + let text = payload_text(payload)?; + Some(Item::Reasoning { + text, + provider_payload_ref: None, + }) + } + ItemKind::Plan => { + let text = payload_text(payload)?; + // Same caveat as the rollout projector: the legacy plan is one + // rendered text blob, preserved verbatim in a single entry. + Some(Item::Plan { + entries: vec![PlanEntry { + step: text, + status: PlanStepStatus::Completed, + }], + }) + } + ItemKind::ToolCall => { + let call = serde_json::from_value::(payload.clone()).ok()?; + Some(Item::ToolCall { + call_id: call.tool_call_id, + tool_name: call.tool_name, + // Legacy wire calls all went through the builtin dispatcher. + source: ToolSource::Builtin, + server_name: None, + // `command_actions` (UI parse info) is intentionally dropped. + input: Some(call.parameters), + }) + } + ItemKind::McpToolCall => { + // Never emitted by the server (dead wire variant); the payload + // shape follows `ToolCallPayload`, which carries no server name. + let call = serde_json::from_value::(payload.clone()).ok()?; + Some(Item::ToolCall { + call_id: call.tool_call_id, + tool_name: call.tool_name, + source: ToolSource::Mcp, + server_name: None, + input: Some(call.parameters), + }) + } + ItemKind::ToolResult => { + let result = serde_json::from_value::(payload.clone()).ok()?; + Some(Item::ToolResult { + call_id: result.tool_call_id, + output: result.content, + display_content: result.display_content, + is_error: result.is_error, + truncated: false, + }) + } + ItemKind::CommandExecution => { + let command = serde_json::from_value::(payload.clone()).ok()?; + let origin = match command.source { + ExecCommandSource::Agent + | ExecCommandSource::UnifiedExecStartup + | ExecCommandSource::UnifiedExecInteraction => ExecOrigin::AgentTool, + ExecCommandSource::UserShell => ExecOrigin::UserShell, + }; + Some(Item::CommandExecution { + call_id: command.tool_call_id, + command: command.command, + argv: None, + // The cwd is not carried on the wire. + cwd: PathBuf::new(), + input: command.input, + output: command.output, + exit_code: None, + execution_handle: None, + is_error: command.is_error, + execution_mode: ExecutionMode::Foreground, + origin, + sandbox: None, + }) + } + ItemKind::FileChange => { + let change = serde_json::from_value::(payload.clone()).ok()?; + let changes = change + .changes + .into_iter() + .map(|(path, change)| { + let change = match change { + crate::protocol::FileChange::Add { content } => { + FileChangeKind::Add { content } + } + crate::protocol::FileChange::Delete { content } => { + FileChangeKind::Delete { content } + } + crate::protocol::FileChange::Update { + unified_diff, + move_path, + // `old_text`/`new_text` are UI diff material; the + // unified diff is the canonical form. + .. + } => FileChangeKind::Update { + unified_diff, + move_path, + }, + }; + FileChangeEntry { path, change } + }) + .collect(); + Some(Item::FileChange { + call_id: change.tool_call_id, + changes, + sandbox: None, + }) + } + ItemKind::WebSearch => { + // Never emitted by the server (dead wire variant); hosted tool + // payloads carry no call id, so the id stays explicitly empty. + Some(Item::HostedToolCall { + call_id: String::new(), + tool_name: "web_search".into(), + input: None, + output: Some(hosted_tool_output(payload)), + }) + } + ItemKind::ImageView => { + // Never emitted by the server (dead wire variant); named after + // the wire kind (the persisted sibling is `image_generation`). + Some(Item::HostedToolCall { + call_id: String::new(), + tool_name: "image_view".into(), + input: None, + output: Some(hosted_tool_output(payload)), + }) + } + ItemKind::ContextCompaction => Some(Item::ContextCompaction { + // The wire payload carries only a display title, never the + // trigger or the summary text. + trigger: CompactionTrigger::AutoThreshold, + before: ContextUsage { + measured: false, + ..ContextUsage::default() + }, + after: None, + summary: payload.get("text").and_then(serde_json::Value::as_str).map(str::to_owned), + }), + ItemKind::ApprovalRequest => { + let request = serde_json::from_value::(payload.clone()).ok()?; + Some(Item::Approval { + approval_id: request.approval_id.to_string(), + target_item_id: None, + action_summary: request.action_summary, + justification: request.justification, + resource: request.resource, + available_scopes: request.available_scopes, + target: approval_target(request.path, request.host, request.target), + decision: None, + }) + } + ItemKind::ApprovalDecision => { + let decision = serde_json::from_value::(payload.clone()) + .ok()?; + // The wire decision event carries only the id + decision/scope + // strings; the request fields are not repeated, so they stay + // empty here (clients fold by `approval_id`). + Some(Item::Approval { + approval_id: decision.approval_id.to_string(), + target_item_id: None, + action_summary: String::new(), + justification: String::new(), + resource: None, + available_scopes: Vec::new(), + target: None, + decision: Some(ApprovalDecision { + // Same string mapping as the rollout projector: legacy + // decisions were free-form ("Allow" appears in + // historical data), anything not clearly approve/deny is + // cancelled. + decision: match decision.decision.to_ascii_lowercase().as_str() { + "approve" | "approved" | "allow" => ApprovalDecisionKind::Approved, + "deny" | "denied" => ApprovalDecisionKind::Denied, + _ => ApprovalDecisionKind::Cancelled, + }, + // Unknown legacy scope strings fall back to the + // narrowest scope. + scope: match decision.scope.to_ascii_lowercase().as_str() { + "once" => ApprovalScope::Once, + "turn" => ApprovalScope::Turn, + "session" => ApprovalScope::Session, + "path_prefix" => ApprovalScope::PathPrefix, + "host" => ApprovalScope::Host, + "tool" => ApprovalScope::Tool, + "command_prefix" => ApprovalScope::CommandPrefix, + "command_prefix_persist" => ApprovalScope::CommandPrefixPersist, + _ => ApprovalScope::Once, + }, + decided_at, + }), + }) + } + } +} + +/// Builds the canonical typed envelope for one legacy item event. +/// +/// Returns `None` when the payload does not project (caller falls back to +/// the legacy envelope) or when the event has no turn id (the canonical +/// envelope requires one). `projected_at` stamps `created_at`/`updated_at`: +/// legacy item events carry no timestamp, so the fan-out time is the only +/// honest value. +pub fn typed_item_envelope( + context: &EventContext, + item: &crate::ItemEnvelope, + state: ItemState, + projected_at: DateTime, +) -> Option { + let canonical_item = project_wire_item(&item.item_kind, &item.payload, projected_at)?; + Some(ItemEnvelope { + id: ItemId::from_legacy_uuid(Uuid::from(item.item_id)), + session_id: SessionId::from_legacy_uuid(Uuid::from(context.session_id)), + turn_id: TurnId::from_legacy_uuid(Uuid::from(context.turn_id?)), + // The item's own sequence when the emitter threaded it through; + // otherwise the connection event sequence is the only ordering left. + seq: context.item_seq.unwrap_or(context.seq), + revision: 1, + created_at: projected_at, + updated_at: projected_at, + state, + item: canonical_item, + }) +} + +/// Projects an `item/started` / `item/completed` server event into its +/// native typed notification (`{"context": ..., "item": }`) for connections that opted in to typed items. All other +/// events — and item events whose payload does not project — return `None` +/// and keep the legacy ACP-wrapped path. +pub fn typed_item_notification_from_server_event( + event: &ServerEvent, +) -> Option<(String, serde_json::Value)> { + let (payload, state) = match event { + ServerEvent::ItemStarted(payload) => (payload, ItemState::Running), + ServerEvent::ItemCompleted(payload) => (payload, ItemState::Completed), + _ => return None, + }; + // No timestamp travels with legacy item events; the envelope is stamped + // with the fan-out time (see `typed_item_envelope`). + let envelope = typed_item_envelope(&payload.context, &payload.item, state, Utc::now())?; + let value = serde_json::to_value(TypedItemEventPayload { + context: payload.context.clone(), + item: envelope, + }) + .expect("serialize typed item event payload"); + Some((event.method_name().to_string(), value)) +} + +/// Legacy text payloads are `{"title": ..., "text": ...}` display objects; +/// the text is the only semantically meaningful field. +fn payload_text(payload: &serde_json::Value) -> Option { + payload + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) +} + +/// Hosted-tool payloads are text-like display objects; keep the text when +/// present, otherwise pass the raw payload through unchanged. +fn hosted_tool_output(payload: &serde_json::Value) -> serde_json::Value { + payload + .get("text") + .cloned() + .unwrap_or_else(|| payload.clone()) +} + +/// Builds the approval target from the legacy request's optional path, host, +/// or free-form target string, in that priority order. +fn approval_target( + path: Option, + host: Option, + target: Option, +) -> Option { + if let Some(path) = path { + Some(ApprovalTarget::Path { + path: PathBuf::from(path), + }) + } else if let Some(host) = host { + Some(ApprovalTarget::Host { host }) + } else { + target.map(|command| ApprovalTarget::Command { command }) + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use pretty_assertions::assert_eq; + use smol_str::SmolStr; + + use super::*; + use crate::parse_command::ParsedCommand; + use crate::{ApprovalRequestPayload, PendingServerRequestContext, ServerRequestKind}; + + fn decided_at() -> DateTime { + Utc.with_ymd_and_hms(2026, 8, 1, 12, 0, 0).unwrap() + } + + fn project(kind: ItemKind, payload: serde_json::Value) -> Option { + project_wire_item(&kind, &payload, decided_at()) + } + + #[test] + fn user_message_projects_as_turn_start_text() { + let item = project( + ItemKind::UserMessage, + serde_json::json!({ "title": "You", "text": "hello" }), + ); + assert_eq!( + item, + Some(Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: "hello".into() + }], + entry: UserMessageEntry::TurnStart, + }) + ); + } + + #[test] + fn agent_message_projects_with_no_phase() { + let item = project( + ItemKind::AgentMessage, + serde_json::json!({ "title": "Assistant", "text": "done" }), + ); + assert_eq!( + item, + Some(Item::AssistantMessage { + text: "done".into(), + phase: None, + }) + ); + } + + #[test] + fn reasoning_projects_without_provider_payload() { + let item = project( + ItemKind::Reasoning, + serde_json::json!({ "title": "Reasoning", "text": "thinking" }), + ); + assert_eq!( + item, + Some(Item::Reasoning { + text: "thinking".into(), + provider_payload_ref: None, + }) + ); + } + + #[test] + fn plan_projects_as_single_completed_entry() { + let item = project( + ItemKind::Plan, + serde_json::json!({ "title": "Plan", "text": "1. do\n2. done" }), + ); + assert_eq!( + item, + Some(Item::Plan { + entries: vec![PlanEntry { + step: "1. do\n2. done".into(), + status: PlanStepStatus::Completed, + }], + }) + ); + } + + #[test] + fn tool_call_projects_as_builtin_and_drops_command_actions() { + let payload = serde_json::to_value(ToolCallPayload { + tool_call_id: "call-1".into(), + tool_name: "read_file".into(), + parameters: serde_json::json!({ "path": "src/lib.rs" }), + command_actions: vec![ParsedCommand::Unknown { cmd: "ls".into() }], + }) + .expect("serialize payload"); + let item = project(ItemKind::ToolCall, payload); + assert_eq!( + item, + Some(Item::ToolCall { + call_id: "call-1".into(), + tool_name: "read_file".into(), + source: ToolSource::Builtin, + server_name: None, + input: Some(serde_json::json!({ "path": "src/lib.rs" })), + }) + ); + } + + #[test] + fn mcp_tool_call_projects_with_mcp_source() { + let payload = serde_json::to_value(ToolCallPayload { + tool_call_id: "call-2".into(), + tool_name: "mcp__docs__search".into(), + parameters: serde_json::json!({ "query": "serde" }), + command_actions: Vec::new(), + }) + .expect("serialize payload"); + let item = project(ItemKind::McpToolCall, payload); + assert_eq!( + item, + Some(Item::ToolCall { + call_id: "call-2".into(), + tool_name: "mcp__docs__search".into(), + source: ToolSource::Mcp, + server_name: None, + input: Some(serde_json::json!({ "query": "serde" })), + }) + ); + } + + #[test] + fn tool_result_projects_content_and_display() { + let payload = serde_json::to_value(ToolResultPayload { + tool_call_id: "call-1".into(), + tool_name: Some("read_file".into()), + input: None, + content: serde_json::json!({ "content": "fn main() {}" }), + display_content: Some("fn main() {}".into()), + is_error: false, + summary: String::new(), + }) + .expect("serialize payload"); + let item = project(ItemKind::ToolResult, payload); + assert_eq!( + item, + Some(Item::ToolResult { + call_id: "call-1".into(), + output: serde_json::json!({ "content": "fn main() {}" }), + display_content: Some("fn main() {}".into()), + is_error: false, + truncated: false, + }) + ); + } + + #[test] + fn command_execution_projects_agent_tool_origin() { + let payload = serde_json::to_value(CommandExecutionPayload { + tool_call_id: "call-3".into(), + tool_name: "exec_command".into(), + command: "cargo test".into(), + input: Some(serde_json::json!({ "command": "cargo test" })), + source: ExecCommandSource::Agent, + command_actions: Vec::new(), + output: Some(serde_json::json!({ "stdout": "ok" })), + is_error: false, + }) + .expect("serialize payload"); + let item = project(ItemKind::CommandExecution, payload); + assert_eq!( + item, + Some(Item::CommandExecution { + call_id: "call-3".into(), + command: "cargo test".into(), + argv: None, + cwd: PathBuf::new(), + input: Some(serde_json::json!({ "command": "cargo test" })), + output: Some(serde_json::json!({ "stdout": "ok" })), + exit_code: None, + execution_handle: None, + is_error: false, + execution_mode: ExecutionMode::Foreground, + origin: ExecOrigin::AgentTool, + sandbox: None, + }) + ); + } + + #[test] + fn command_execution_projects_user_shell_origin() { + let payload = serde_json::json!({ + "tool_call_id": "call-4", + "tool_name": "exec_command", + "command": "ls", + "source": "user_shell", + }); + let item = project(ItemKind::CommandExecution, payload); + assert!( + matches!(item, Some(Item::CommandExecution { origin: ExecOrigin::UserShell, .. })) + ); + } + + #[test] + fn file_change_projects_all_change_kinds() { + let payload = serde_json::to_value(FileChangePayload { + tool_call_id: "call-5".into(), + tool_name: Some("apply_patch".into()), + input: None, + changes: vec![ + ( + PathBuf::from("a.rs"), + crate::protocol::FileChange::Add { + content: "new".into(), + }, + ), + ( + PathBuf::from("b.rs"), + crate::protocol::FileChange::Delete { + content: "old".into(), + }, + ), + ( + PathBuf::from("c.rs"), + crate::protocol::FileChange::Update { + unified_diff: "@@".into(), + old_text: Some("o".into()), + new_text: Some("n".into()), + move_path: Some(PathBuf::from("d.rs")), + }, + ), + ], + is_error: false, + }) + .expect("serialize payload"); + let item = project(ItemKind::FileChange, payload); + assert_eq!( + item, + Some(Item::FileChange { + call_id: "call-5".into(), + changes: vec![ + FileChangeEntry { + path: PathBuf::from("a.rs"), + change: FileChangeKind::Add { + content: "new".into() + }, + }, + FileChangeEntry { + path: PathBuf::from("b.rs"), + change: FileChangeKind::Delete { + content: "old".into() + }, + }, + FileChangeEntry { + path: PathBuf::from("c.rs"), + change: FileChangeKind::Update { + unified_diff: "@@".into(), + move_path: Some(PathBuf::from("d.rs")), + }, + }, + ], + sandbox: None, + }) + ); + } + + #[test] + fn web_search_and_image_view_project_as_hosted_tool_calls() { + let search = project( + ItemKind::WebSearch, + serde_json::json!({ "title": "Web Search", "text": "results" }), + ); + assert_eq!( + search, + Some(Item::HostedToolCall { + call_id: String::new(), + tool_name: "web_search".into(), + input: None, + output: Some(serde_json::Value::String("results".into())), + }) + ); + + let image = project( + ItemKind::ImageView, + serde_json::json!({ "title": "Image", "text": "artifact://1" }), + ); + assert_eq!( + image, + Some(Item::HostedToolCall { + call_id: String::new(), + tool_name: "image_view".into(), + input: None, + output: Some(serde_json::Value::String("artifact://1".into())), + }) + ); + } + + #[test] + fn context_compaction_projects_without_summary_on_wire() { + let item = project( + ItemKind::ContextCompaction, + serde_json::json!({ "title": "Context compacted" }), + ); + assert_eq!( + item, + Some(Item::ContextCompaction { + trigger: CompactionTrigger::AutoThreshold, + before: ContextUsage { + measured: false, + ..ContextUsage::default() + }, + after: None, + summary: None, + }) + ); + } + + #[test] + fn approval_request_projects_undecided_approval() { + let payload = serde_json::to_value(ApprovalRequestPayload { + request: PendingServerRequestContext { + request_id: SmolStr::new("req-1"), + request_kind: ServerRequestKind::ItemCommandExecutionRequestApproval, + session_id: crate::SessionId::new(), + turn_id: None, + item_id: None, + }, + approval_id: SmolStr::new("appr-1"), + action_summary: "Run cargo test".into(), + justification: "Need to verify".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["Once".into()], + path: None, + host: None, + target: Some("cargo test".into()), + command_pattern: None, + command_prefix: None, + }) + .expect("serialize payload"); + let item = project(ItemKind::ApprovalRequest, payload); + assert_eq!( + item, + Some(Item::Approval { + approval_id: "appr-1".into(), + target_item_id: None, + action_summary: "Run cargo test".into(), + justification: "Need to verify".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["Once".into()], + target: Some(ApprovalTarget::Command { + command: "cargo test".into() + }), + decision: None, + }) + ); + } + + #[test] + fn approval_decision_projects_with_supplied_decided_at() { + let payload = serde_json::to_value(ApprovalDecisionPayload { + approval_id: SmolStr::new("appr-1"), + decision: "Allow".into(), + scope: "Session".into(), + }) + .expect("serialize payload"); + let item = project(ItemKind::ApprovalDecision, payload); + assert_eq!( + item, + Some(Item::Approval { + approval_id: "appr-1".into(), + target_item_id: None, + action_summary: String::new(), + justification: String::new(), + resource: None, + available_scopes: Vec::new(), + target: None, + decision: Some(ApprovalDecision { + decision: ApprovalDecisionKind::Approved, + scope: ApprovalScope::Session, + decided_at: decided_at(), + }), + }) + ); + } + + #[test] + fn malformed_payload_returns_none_for_fallback() { + assert_eq!( + project(ItemKind::ToolCall, serde_json::json!({ "bogus": true })), + None + ); + assert_eq!( + project(ItemKind::UserMessage, serde_json::json!({ "title": "You" })), + None + ); + } + + #[test] + fn typed_notification_projects_item_started_and_completed() { + let session_id = crate::SessionId::new(); + let turn_id = crate::TurnId::new(); + let item_id = crate::ItemId::new(); + let payload = crate::ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(item_id), + seq: 0, + item_seq: Some(7), + }, + item: crate::ItemEnvelope { + item_id, + item_kind: ItemKind::AgentMessage, + payload: serde_json::json!({ "title": "Assistant", "text": "hi" }), + }, + }; + + let (method, value) = + typed_item_notification_from_server_event(&ServerEvent::ItemCompleted(payload)) + .expect("projects"); + assert_eq!(method, "item/completed"); + let notification: TypedItemEventPayload = + serde_json::from_value(value).expect("deserialize typed payload"); + let envelope = notification.item; + assert_eq!(envelope.id.as_str(), item_id.to_string()); + assert_eq!(envelope.session_id.as_str(), session_id.to_string()); + assert_eq!(envelope.turn_id.as_str(), turn_id.to_string()); + assert_eq!((envelope.seq, envelope.revision), (7, 1)); + assert_eq!(envelope.state, ItemState::Completed); + assert_eq!( + envelope.item, + Item::AssistantMessage { + text: "hi".into(), + phase: None + } + ); + } + + #[test] + fn typed_notification_skips_non_item_events() { + let event = ServerEvent::InputQueueUpdated(crate::InputQueueUpdatedPayload { + session_id: crate::SessionId::new(), + pending_count: 0, + pending_texts: Vec::new(), + }); + assert_eq!(typed_item_notification_from_server_event(&event), None); + } +} diff --git a/crates/protocol/src/conversation.rs b/crates/protocol/src/conversation.rs index f7b2111e..58a47e42 100644 --- a/crates/protocol/src/conversation.rs +++ b/crates/protocol/src/conversation.rs @@ -67,6 +67,12 @@ macro_rules! define_id { } } + impl From<$name> for Uuid { + fn from(value: $name) -> Self { + value.0 + } + } + impl TryFrom<&str> for $name { type Error = uuid::Error; diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 1ead79cf..5585d9e9 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -20,6 +20,11 @@ pub struct EventContext { pub turn_id: Option, pub item_id: Option, pub seq: u64, + /// The item's own sequence number within the session, when the emitter + /// allocated one. Additive (P2): absent for older emitters and for + /// events without an allocated item sequence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item_seq: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -90,6 +95,17 @@ pub struct ItemEventPayload { pub item: ItemEnvelope, } +/// Opt-in typed item event payload (P2, 06-item-model migration step 2): +/// the same context as [`ItemEventPayload`], but the item is the canonical +/// typed envelope — a typed `Item` instead of the legacy `ItemKind` + +/// `serde_json::Value` payload bag. Only emitted to connections that set +/// `_meta.devo.typedItems` on initialize. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TypedItemEventPayload { + pub context: EventContext, + pub item: crate::canonical::item::ItemEnvelope, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ItemDeltaPayload { pub context: EventContext, diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 96158ee2..5495a69d 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -23,6 +23,7 @@ use crate::ACP_SESSION_SET_MODE_METHOD; use crate::acp_auth_required_response; use crate::acp_notification_from_server_event; use crate::devo_extension_inner_method; +use devo_protocol::canonical::wire_projector::typed_item_notification_from_server_event; use super::outbound::OutboundDeliveryPolicy; use super::outbound::OutboundFrame; @@ -142,6 +143,7 @@ impl ServerRuntime { state: ConnectionState::Connected, acp_authenticated: false, acp_client_capabilities: crate::AcpClientCapabilities::default(), + typed_items: false, outbound_tx, opt_out_notification_methods: HashSet::new(), subscriptions: Vec::new(), @@ -716,7 +718,7 @@ impl ServerRuntime { } let event_seq = connection.next_seq(); let event = event.with_seq(event_seq); - let (method, value) = acp_notification_from_server_event(method, &event); + let (method, value) = connection.notification_for(method, &event); Some(( connection.outbound_tx.clone(), OutboundFrame::notification(connection_id, method, event_seq, value), @@ -765,7 +767,7 @@ impl ServerRuntime { } let event_seq = connection.next_seq(); let event = event.clone().with_seq(event_seq); - let (method, value) = acp_notification_from_server_event(method, &event); + let (method, value) = connection.notification_for(method, &event); Some(( connection.outbound_tx.clone(), OutboundFrame::notification(*connection_id, method, event_seq, value), @@ -1198,6 +1200,9 @@ pub(crate) struct ConnectionRuntime { pub(crate) state: ConnectionState, pub(crate) acp_authenticated: bool, pub(crate) acp_client_capabilities: crate::AcpClientCapabilities, + /// Whether the client opted in to native typed `item/*` notifications + /// via `_meta.devo.typedItems` on ACP initialize (P2). + pub(crate) typed_items: bool, pub(crate) outbound_tx: mpsc::Sender, pub(crate) opt_out_notification_methods: HashSet, pub(crate) subscriptions: Vec, @@ -1223,6 +1228,18 @@ impl ConnectionRuntime { !self.opt_out_notification_methods.contains(method) } + /// Routes one server event to its wire notification for this connection: + /// native typed `item/*` when the connection opted in and the payload + /// projects, otherwise the legacy ACP-wrapped shape (P2 fallback). + pub(super) fn notification_for(&self, method: &str, event: &ServerEvent) -> (String, serde_json::Value) { + if self.typed_items + && let Some(typed) = typed_item_notification_from_server_event(event) + { + return typed; + } + acp_notification_from_server_event(method, event) + } + pub(super) fn should_deliver( &self, method: &str, @@ -1392,6 +1409,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }; let events = [ ServerEvent::ItemCompleted(ItemEventPayload { @@ -1702,6 +1720,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, delta: "hello".to_string(), stream_index: None, @@ -1760,6 +1779,7 @@ mod tests { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, delta: "hello".to_string(), stream_index: None, @@ -1848,4 +1868,197 @@ mod tests { assert!(!subscription.session_matches(None, &child_parent_by_session)); assert!(subscription.session_matches(Some(subscribed_session), &child_parent_by_session)); } + + /// Verifies (P2): an opted-in connection receives native typed + /// `item/completed` while a legacy connection on the same session keeps + /// the ACP-wrapped shape for the same event. + #[tokio::test] + async fn typed_items_opt_in_receives_native_item_notification() -> Result<()> { + use devo_protocol::TypedItemEventPayload; + use devo_protocol::canonical::item::{Item, ItemState}; + + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = SessionId::new(); + let (typed_outbound, mut typed_receiver) = super::outbound::test_outbound_channel(1); + let (legacy_outbound, mut legacy_receiver) = super::outbound::test_outbound_channel(1); + let typed_connection_id = runtime + .register_connection(ClientTransportKind::Stdio, typed_outbound) + .await; + let legacy_connection_id = runtime + .register_connection(ClientTransportKind::Stdio, legacy_outbound) + .await; + runtime + .subscribe_connection_to_session(typed_connection_id, session_id, None) + .await; + runtime + .subscribe_connection_to_session(legacy_connection_id, session_id, None) + .await; + runtime + .connections + .lock() + .await + .get_mut(&typed_connection_id) + .expect("typed connection") + .typed_items = true; + + let turn_id = TurnId::new(); + let item_id = ItemId::new(); + runtime + .broadcast_event(ServerEvent::ItemCompleted(ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(item_id), + seq: 0, + item_seq: Some(3), + }, + item: ItemEnvelope { + item_id, + item_kind: ItemKind::AgentMessage, + payload: serde_json::json!({ "title": "Assistant", "text": "hello" }), + }, + })) + .await; + + let typed = tokio::time::timeout(Duration::from_secs(1), typed_receiver.recv()) + .await? + .expect("typed connection receives notification"); + assert_eq!(typed["method"], serde_json::json!("item/completed")); + let payload: TypedItemEventPayload = + serde_json::from_value(typed["params"].clone()).expect("typed item payload"); + assert_eq!(payload.item.id.as_str(), item_id.to_string()); + assert_eq!(payload.item.session_id.as_str(), session_id.to_string()); + assert_eq!(payload.item.turn_id.as_str(), turn_id.to_string()); + assert_eq!((payload.item.seq, payload.item.revision), (3, 1)); + assert_eq!(payload.item.state, ItemState::Completed); + assert_eq!( + payload.item.item, + Item::AssistantMessage { + text: "hello".into(), + phase: None, + } + ); + + let legacy = tokio::time::timeout(Duration::from_secs(1), legacy_receiver.recv()) + .await? + .expect("legacy connection receives notification"); + assert_eq!( + legacy["method"], + serde_json::json!(crate::ACP_SESSION_UPDATE_METHOD) + ); + + Ok(()) + } + + /// Verifies (P2): an opted-in connection falls back to the legacy + /// ACP-wrapped shape when the payload does not project. + #[tokio::test] + async fn typed_items_falls_back_to_legacy_shape_on_unprojectable_payload() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = SessionId::new(); + let (outbound, mut receiver) = super::outbound::test_outbound_channel(1); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, outbound) + .await; + runtime + .subscribe_connection_to_session(connection_id, session_id, None) + .await; + runtime + .connections + .lock() + .await + .get_mut(&connection_id) + .expect("connection") + .typed_items = true; + + runtime + .broadcast_event(ServerEvent::ItemStarted(ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(TurnId::new()), + item_id: Some(ItemId::new()), + seq: 0, + item_seq: Some(1), + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::ToolCall, + // Missing tool_call_id/tool_name: cannot project. + payload: serde_json::json!({ "bogus": true }), + }, + })) + .await; + + let notification = tokio::time::timeout(Duration::from_secs(1), receiver.recv()) + .await? + .expect("connection receives notification"); + assert_eq!( + notification["method"], + serde_json::json!(crate::ACP_SESSION_UPDATE_METHOD) + ); + + Ok(()) + } + + /// Verifies (P2): `_meta.devo.typedItems` on initialize is stored on the + /// connection and echoed in the initialize result; absence defaults off. + #[tokio::test] + async fn initialize_typed_items_opt_in_is_stored_and_echoed() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let (typed_outbound, _typed_receiver) = super::outbound::test_outbound_channel(1); + let (plain_outbound, _plain_receiver) = super::outbound::test_outbound_channel(1); + let typed_connection_id = runtime + .register_connection(ClientTransportKind::Stdio, typed_outbound) + .await; + let plain_connection_id = runtime + .register_connection(ClientTransportKind::Stdio, plain_outbound) + .await; + + let response = runtime + .handle_acp_initialize( + typed_connection_id, + Some(serde_json::json!(1)), + serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": { "terminal": false }, + "_meta": { "devo": { "typedItems": true } }, + }), + ) + .await; + assert_eq!( + response["result"]["_meta"]["devo"]["typedItems"], + serde_json::json!(true) + ); + + let response = runtime + .handle_acp_initialize( + plain_connection_id, + Some(serde_json::json!(2)), + serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": { "terminal": false }, + }), + ) + .await; + assert!(response["result"]["_meta"].get("devo").is_none()); + + let connections = runtime.connections.lock().await; + assert!( + connections + .get(&typed_connection_id) + .expect("typed connection") + .typed_items + ); + assert!( + !connections + .get(&plain_connection_id) + .expect("plain connection") + .typed_items + ); + + Ok(()) + } } diff --git a/crates/server/src/runtime/handlers/acp.rs b/crates/server/src/runtime/handlers/acp.rs index 62758985..fb059c96 100644 --- a/crates/server/src/runtime/handlers/acp.rs +++ b/crates/server/src/runtime/handlers/acp.rs @@ -116,15 +116,22 @@ impl ServerRuntime { } }; let acp_auth_config = self.acp_auth_config(); + // Typed-items opt-in (P2): accepted from the initialize params `_meta` + // or from `clientCapabilities._meta`, both in the + // `{ "devo": { "typedItems": true } }` shape. + let typed_items = devo_protocol::devo_typed_items_opted_in(params.meta.as_ref()) + || devo_protocol::devo_typed_items_opted_in(params.client_capabilities.meta.as_ref()); if let Some(connection) = self.connections.lock().await.get_mut(&connection_id) { connection.state = ConnectionState::Ready; connection.acp_authenticated = !acp_auth_config.enabled; connection.acp_client_capabilities = params.client_capabilities.clone(); + connection.typed_items = typed_items; } tracing::info!( connection_id, protocol_version = params.protocol_version, client = ?params.client_info.as_ref().map(|info| info.name.as_str()), + typed_items, "accepted ACP initialize request" ); let mut meta = serde_json::Map::new(); @@ -142,6 +149,12 @@ impl ServerRuntime { serde_json::Value::String(self.metadata.server_home.display().to_string()), ); } + if typed_items { + meta.insert( + devo_protocol::DEVO_EXTENSION_META.to_string(), + serde_json::json!({ devo_protocol::DEVO_TYPED_ITEMS_META: true }), + ); + } acp_success_response( request_id, AcpInitializeResult { diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index 1164baf2..9da28aa4 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -228,6 +228,7 @@ impl ServerRuntime { turn_id: Some(turn_id), item_id: Some(item_id), seq: item_seq, + item_seq: Some(item_seq), }, item: ItemEnvelope { item_id, @@ -243,6 +244,7 @@ impl ServerRuntime { turn_id: Some(turn_id), item_id: Some(item_id), seq: item_seq, + item_seq: Some(item_seq), }, item: ItemEnvelope { item_id, diff --git a/crates/server/src/runtime/handlers/message_edit.rs b/crates/server/src/runtime/handlers/message_edit.rs index 8c18b4e0..4e51dbf4 100644 --- a/crates/server/src/runtime/handlers/message_edit.rs +++ b/crates/server/src/runtime/handlers/message_edit.rs @@ -548,6 +548,9 @@ impl ServerRuntime { params.session_id, replacement_turn_id, replacement_message_id, + // The replacement message id is reused as-is; no new item + // sequence is allocated on this path. + None, ItemKind::UserMessage, serde_json::json!({ "title": "You", "text": display_input.clone() }), ) @@ -556,6 +559,7 @@ impl ServerRuntime { params.session_id, replacement_turn_id, replacement_message_id, + None, ItemKind::UserMessage, serde_json::json!({ "title": "You", "text": display_input }), ) diff --git a/crates/server/src/runtime/items.rs b/crates/server/src/runtime/items.rs index 69768e7d..28e0c5f6 100644 --- a/crates/server/src/runtime/items.rs +++ b/crates/server/src/runtime/items.rs @@ -328,16 +328,25 @@ impl ServerRuntime { ) -> (ItemId, u64) { let item_id = ItemId::new(); let item_seq = self.allocate_item_sequence(session_id).await; - self.emit_item_started(session_id, turn_id, item_id, item_kind, payload) - .await; + self.emit_item_started( + session_id, + turn_id, + item_id, + Some(item_seq), + item_kind, + payload, + ) + .await; (item_id, item_seq) } + #[allow(clippy::too_many_arguments)] pub(super) async fn emit_item_started( &self, session_id: SessionId, turn_id: TurnId, item_id: ItemId, + item_seq: Option, item_kind: ItemKind, payload: serde_json::Value, ) { @@ -347,6 +356,7 @@ impl ServerRuntime { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq, }, item: ItemEnvelope { item_id, @@ -357,11 +367,13 @@ impl ServerRuntime { .await; } + #[allow(clippy::too_many_arguments)] pub(super) async fn emit_item_completed( &self, session_id: SessionId, turn_id: TurnId, item_id: ItemId, + item_seq: Option, item_kind: ItemKind, payload: serde_json::Value, ) { @@ -371,6 +383,7 @@ impl ServerRuntime { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq, }, item: ItemEnvelope { item_id, @@ -402,8 +415,15 @@ impl ServerRuntime { None, ) .await; - self.emit_item_completed(session_id, turn_id, item_id, item_kind, payload) - .await; + self.emit_item_completed( + session_id, + turn_id, + item_id, + Some(item_seq), + item_kind, + payload, + ) + .await; } #[allow(clippy::too_many_arguments)] diff --git a/crates/server/src/runtime/turn_exec/context_compaction.rs b/crates/server/src/runtime/turn_exec/context_compaction.rs index 748f8020..492f9331 100644 --- a/crates/server/src/runtime/turn_exec/context_compaction.rs +++ b/crates/server/src/runtime/turn_exec/context_compaction.rs @@ -157,6 +157,7 @@ fn item_event( turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, item: ItemEnvelope { item_id, diff --git a/crates/server/src/runtime/turn_exec/event_stream.rs b/crates/server/src/runtime/turn_exec/event_stream.rs index b1670590..d5df8a7f 100644 --- a/crates/server/src/runtime/turn_exec/event_stream.rs +++ b/crates/server/src/runtime/turn_exec/event_stream.rs @@ -437,6 +437,7 @@ async fn handle_reasoning_delta( turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, delta: text, stream_index: None, @@ -744,6 +745,7 @@ async fn handle_tool_progress( turn_id: Some(turn_id), item_id, seq: 0, + item_seq: None, }, delta: serde_json::json!({ "tool_use_id": tool_use_id, diff --git a/crates/server/src/runtime/turn_exec/item_stream.rs b/crates/server/src/runtime/turn_exec/item_stream.rs index 2b69739d..12acd071 100644 --- a/crates/server/src/runtime/turn_exec/item_stream.rs +++ b/crates/server/src/runtime/turn_exec/item_stream.rs @@ -102,6 +102,7 @@ impl ProposedPlanStreamItem { turn_id: Some(turn_id), item_id: self.item_id, seq: 0, + item_seq: None, }, delta, stream_index: None, @@ -177,6 +178,7 @@ pub(super) async fn push_assistant_text_delta( turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, delta: text, stream_index: None, diff --git a/crates/server/src/runtime/turn_exec/tests.rs b/crates/server/src/runtime/turn_exec/tests.rs index bb385744..239966ba 100644 --- a/crates/server/src/runtime/turn_exec/tests.rs +++ b/crates/server/src/runtime/turn_exec/tests.rs @@ -69,6 +69,7 @@ fn context_compaction_events_share_stable_item_lifecycle() { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, item: ItemEnvelope { item_id, @@ -82,6 +83,7 @@ fn context_compaction_events_share_stable_item_lifecycle() { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, item: ItemEnvelope { item_id, @@ -109,6 +111,7 @@ fn context_compaction_failure_closes_item_and_reports_visible_error() { turn_id: Some(turn_id), item_id: Some(item_id), seq: 0, + item_seq: None, }, item: ItemEnvelope { item_id, diff --git a/crates/server/tests/protocol_contract.rs b/crates/server/tests/protocol_contract.rs index a76aa3dd..1a5f7ef4 100644 --- a/crates/server/tests/protocol_contract.rs +++ b/crates/server/tests/protocol_contract.rs @@ -119,6 +119,7 @@ fn event_context_keeps_correlation_ids() { turn_id: Some(TurnId::new()), item_id: None, seq: 7, + item_seq: None, }; assert_eq!(context.seq, 7); @@ -297,6 +298,7 @@ fn event_enum_carries_delta_kind() { turn_id: Some(TurnId::new()), item_id: Some(ItemId::new()), seq: 5, + item_seq: None, }, delta: "hi".into(), stream_index: None, diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 1a039692..c1c9f1d6 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -6472,6 +6472,7 @@ fn context_compaction_item_lifecycle_emits_worker_events() { turn_id: Some(turn_id), item_id: Some(item_id), seq: 1, + item_seq: None, }; let item = devo_server::ItemEnvelope { item_id, @@ -6515,6 +6516,7 @@ fn failed_context_compaction_item_emits_failure_event() { turn_id: Some(TurnId::new()), item_id: None, seq: 1, + item_seq: None, }, item: devo_server::ItemEnvelope { item_id: ItemId::new(), diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index 52c0122b..ad1cae7a 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -4956,6 +4956,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5129,6 +5130,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5181,6 +5183,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5234,6 +5237,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5277,6 +5281,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5895,6 +5900,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5945,6 +5951,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -5995,6 +6002,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), @@ -6050,6 +6058,7 @@ mod tests { turn_id: None, item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), diff --git a/crates/tui/src/worker_queue_compaction_tests.rs b/crates/tui/src/worker_queue_compaction_tests.rs index 90478157..7b0c744b 100644 --- a/crates/tui/src/worker_queue_compaction_tests.rs +++ b/crates/tui/src/worker_queue_compaction_tests.rs @@ -115,6 +115,7 @@ fn completed_context_compaction_item_emits_worker_event() { turn_id: Some(TurnId::new()), item_id: None, seq: 1, + item_seq: None, }, item: ItemEnvelope { item_id: ItemId::new(), From a87b77da66f6f206947f953cb983800f88ba5107 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 02:31:43 +0800 Subject: [PATCH 06/17] feat(core): add v2 inverse projector and persistence extras for lossless replay --- .../core/src/conversation/legacy_projector.rs | 38 +- crates/core/src/conversation/mod.rs | 4 +- crates/core/src/conversation/rollout_v2.rs | 55 +- crates/core/src/conversation/v2_inverse.rs | 732 ++++++++++++++++++ .../fixtures/rollout_v1/basic_session.jsonl | 2 +- .../fixtures/rollout_v1/internal_lines.jsonl | 4 +- .../fixtures/rollout_v1/orphan_decision.jsonl | 2 +- crates/core/tests/legacy_projector.rs | 107 ++- crates/core/tests/v2_roundtrip.rs | 604 +++++++++++++++ crates/protocol/src/canonical/session.rs | 7 + 10 files changed, 1505 insertions(+), 50 deletions(-) create mode 100644 crates/core/src/conversation/v2_inverse.rs create mode 100644 crates/core/tests/v2_roundtrip.rs diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index 652b8265..0744d950 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -33,7 +33,10 @@ use crate::conversation::{ TurnStatus as LegacyTurnStatus, }; -use super::rollout_v2::{InternalRecordV2, RolloutLineV2, ROLLOUT_FORMAT_VERSION}; +use super::rollout_v2::{ + InternalRecordV2, RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, + ROLLOUT_FORMAT_VERSION, +}; /// Errors from projecting a legacy rollout line. Every known legacy shape /// projects successfully; this exists so genuinely unrecoverable data fails @@ -281,6 +284,7 @@ impl LegacyProjector { id: SessionId::from_legacy_uuid(legacy_uuid(record.id)?), version: 1, cwd: record.cwd.clone(), + additional_directories: record.additional_directories.clone(), parent, ephemeral: false, created_at: record.created_at, @@ -304,6 +308,8 @@ impl LegacyProjector { permission_profile, reasoning_effort: None, mode: None, + sandbox_profile: (!record.sandbox_policy.is_empty()) + .then(|| record.sandbox_policy.clone()), }, git_info, preview: record.first_user_message.clone().unwrap_or_default(), @@ -318,7 +324,12 @@ impl LegacyProjector { Ok(vec![RolloutLineV2::SessionMeta { v: ROLLOUT_FORMAT_VERSION, timestamp: line.timestamp, - session, + session: Box::new(session), + extras: Some(Box::new(SessionPersistenceExtras { + session_context: record.session_context.clone(), + cli_version: record.cli_version.clone(), + source: record.source.clone(), + })), }]) } @@ -407,6 +418,15 @@ impl LegacyProjector { v: ROLLOUT_FORMAT_VERSION, timestamp: line.timestamp, turn, + extras: Some(Box::new(TurnPersistenceExtras { + session_context: record.session_context.clone(), + turn_context: record.turn_context.clone(), + request_thinking: record.request_thinking.clone(), + input_token_estimate: record.input_token_estimate, + latest_query_usage: record.latest_query_usage.clone(), + stop_reason: record.stop_reason.clone(), + failure_reason: record.failure_reason, + })), }]) } @@ -425,11 +445,15 @@ impl LegacyProjector { { // A legacy record packs N payloads under a single record id; the // first payload keeps that id, the rest get fresh canonical ids - // because persistence is one-record-one-item in v2. + // because persistence is one-record-one-item in v2. Fresh ids are + // bare UUIDs (not prefixed) so they still round-trip into legacy + // UUID newtypes via the inverse projector; prefixed ids only + // appear once the runtime natively creates canonical resources, + // at which point the legacy replay path is gone. let item_id = if index == 0 { first_item_id.clone() } else { - ItemId::new() + ItemId::from_legacy_uuid(Uuid::now_v7()) }; let (id, seq, revision, state, item) = match self.project_payload( record, @@ -683,9 +707,11 @@ impl LegacyProjector { None => { // Orphan decision (no matching request in this file): // keep the information as a warning item with a fresh - // id/seq rather than dropping history. + // id/seq rather than dropping history. The fresh id is + // a bare UUID for the same round-trip reason as the + // expansion ids above. Projected::FoldedItem { - id: ItemId::new(), + id: ItemId::from_legacy_uuid(Uuid::now_v7()), seq: self.next_seq(), revision: 1, state: ItemState::Completed, diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index a61b093c..b66be823 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -1,5 +1,6 @@ pub mod legacy_projector; pub mod rollout_v2; +pub mod v2_inverse; mod records; @@ -7,8 +8,9 @@ pub use devo_protocol::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus pub use legacy_projector::{LegacyProjectError, LegacyProjector}; pub use rollout_v2::{ InternalRecordV2, ParsedRolloutLine, ROLLOUT_FORMAT_VERSION, RolloutLineReadError, - RolloutLineV2, parse_rollout_line, + RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, parse_rollout_line, }; +pub use v2_inverse::{V2InverseError, V2InverseProjector}; pub use records::{ ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, ItemLine, ItemRecord, MessageEditRecordedLine, RolloutLine, SessionContextUpdatedLine, diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs index b5652773..0e3693eb 100644 --- a/crates/core/src/conversation/rollout_v2.rs +++ b/crates/core/src/conversation/rollout_v2.rs @@ -16,7 +16,7 @@ use devo_protocol::canonical::session::Session; use devo_protocol::canonical::turn::Turn; use crate::{ - MessageEditRecordedRecord, SessionContext, TurnSupersededRecord, + MessageEditRecordedRecord, SessionContext, TurnContext, TurnSupersededRecord, TurnWorkspaceChangeRecordedRecord, TurnWorkspaceCheckpointRecordedRecord, TurnWorkspaceRestoreCompletedRecord, TurnWorkspaceRestoreStartedRecord, }; @@ -26,23 +26,72 @@ use super::records::RolloutLine; /// The format version written by the v2 write path. pub const ROLLOUT_FORMAT_VERSION: u32 = 2; +/// Persistence-only extras on the v2 SessionMeta line: internal +/// implementation details that canonical `Session` deliberately omits from +/// the public surface but legacy replay needs at resume time. Never enters +/// the public schema — `RolloutLineV2` is a core type. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionPersistenceExtras { + /// The locked session context captured for prompt replay. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_context: Option, + /// The CLI version that created the session (audit field). + pub cli_version: String, + /// The session source kind, such as `cli` or `api` (audit field). + pub source: String, +} + +/// Persistence-only extras on the v2 Turn line, same rationale as +/// [`SessionPersistenceExtras`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TurnPersistenceExtras { + /// The locked session context used to build the stable request prefix. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_context: Option, + /// The turn context snapshot used for this turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_context: Option, + /// The concrete request thinking parameter used to execute the turn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_thinking: Option, + /// The estimated input-token count at turn start, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_token_estimate: Option, + /// Provider usage of the latest model query (excludes tool/retry calls). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_query_usage: Option, + /// The terminal provider/model stop reason, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + /// The typed terminal failure reason, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_reason: Option, +} + /// The v2 whole-line rollout envelope. Every line kind carries the format /// version, a wall-clock timestamp, and its payload in a stable flat shape, /// e.g. `{"v":2,"kind":"item","timestamp":"...","item":{...}}`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum RolloutLineV2 { - /// Canonical session metadata. + /// Canonical session metadata. Session and extras are boxed to keep the + /// enum small (serde-transparent). SessionMeta { v: u32, timestamp: DateTime, - session: Session, + session: Box, + /// Replay-only fields the canonical session does not model. + #[serde(default, skip_serializing_if = "Option::is_none")] + extras: Option>, }, /// Canonical turn metadata. Turn { v: u32, timestamp: DateTime, turn: Turn, + /// Replay-only fields the canonical turn does not model. + #[serde(default, skip_serializing_if = "Option::is_none")] + extras: Option>, }, /// One typed item envelope (`{"kind":"item","item":{...}}`). Item { diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs new file mode 100644 index 00000000..be5a961e --- /dev/null +++ b/crates/core/src/conversation/v2_inverse.rs @@ -0,0 +1,732 @@ +//! Inverse projector: canonical v2 rollout lines → frozen legacy +//! `RolloutLine`s. +//! +//! Exists so rollout files written in the v2 envelope keep replaying through +//! the legacy `ReplayState` pipeline (`crates/server/src/persistence.rs`) +//! until that pipeline is retired. Companion of +//! [`crate::conversation::legacy_projector::LegacyProjector`]; the tested +//! invariant is: for every record shape the v2 writer can produce, +//! `inverse(forward(x))` replays equivalently to `x`. +//! +//! Honest-loss points are commented inline; each one is a field the +//! canonical model deliberately does not carry. + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use devo_protocol::canonical::error::AgentError; +use devo_protocol::canonical::ids::{ + ItemId as CanonicalItemId, SessionId as CanonicalSessionId, TurnId as CanonicalTurnId, +}; +use devo_protocol::canonical::item::{ + ApprovalDecisionKind, ApprovalScope, ApprovalTarget, InternalEntry, Item, ItemEnvelope, + UserInput, UserMessageEntry, +}; +use devo_protocol::canonical::model::PermissionProfile; +use devo_protocol::canonical::session::{Session, SessionParent}; +use devo_protocol::canonical::turn::{Turn, TurnKind, TurnStatus}; +use uuid::Uuid; + +use crate::conversation::rollout_v2::{ + InternalRecordV2, RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, +}; +use crate::conversation::{ + ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, + ItemId, ItemLine, ItemRecord, RolloutLine, SessionContextUpdatedLine, SessionId, + SessionMetaLine, SessionRecord, SessionRollbackLine, SessionTitleState, + SessionTitleUpdatedLine, TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, + TurnId, TurnItem, TurnLine, TurnRecord, TurnStatus as LegacyTurnStatus, +}; +use crate::{SessionTitleFinalSource, TurnKind as LegacyTurnKind, TurnUsage}; + +/// Schema versions the live legacy write path stamps today +/// (`crates/server/src/persistence.rs`). The inverse writes the CURRENT +/// versions, not whatever the original record carried: replay only +/// understands the current layout, and the legacy schema is frozen. +const CURRENT_SESSION_SCHEMA_VERSION: u32 = 2; +const CURRENT_TURN_SCHEMA_VERSION: u32 = 4; +const CURRENT_ITEM_SCHEMA_VERSION: u32 = 1; +const CURRENT_SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +/// Errors from projecting a v2 line back into the legacy format. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum V2InverseError { + /// A canonical id is not a bare UUID (e.g. `item_…`-prefixed). Only + /// reachable for resources created natively by the v2 runtime — and by + /// then the legacy replay path this projector feeds is gone. + #[error("canonical id is not a legacy bare UUID: {0}")] + NonLegacyId(String), + /// An internal or session-context line arrived before the SessionMeta + /// line established the session identity (internal lines carry no + /// ids of their own). + #[error("line arrived before the session meta line: {0}")] + MissingSessionMeta(&'static str), +} + +/// Stateful v2 → legacy line converter, one instance per session file. The +/// state exists because v2 `Internal` lines carry no session/turn identity +/// or sequence of their own; both are approximated from the surrounding +/// lines (see the comments at the use sites). +#[derive(Debug, Default)] +pub struct V2InverseProjector { + /// Session id learned from the SessionMeta line. + session_id: Option, + /// Turn id learned from the most recent Turn line; internal entries are + /// attributed to it (their original owning turn is not recorded on the + /// v2 internal line). + current_turn_id: Option, + /// Highest item envelope seq seen so far; synthesized records for + /// internal lines sort at this position. + max_seq_seen: u64, +} + +impl V2InverseProjector { + pub fn new() -> Self { + Self::default() + } + + /// Projects one v2 line into zero or more legacy lines. Most lines map + /// 1:1; items the legacy format cannot represent at all (`Warning` and + /// the new-only canonical variants) produce no line — see the comment on + /// the item mapping. + pub fn project_line(&mut self, line: &RolloutLineV2) -> Result, V2InverseError> { + match line { + RolloutLineV2::SessionMeta { + timestamp, + session, + extras, + .. + } => self.project_session_meta(*timestamp, session.as_ref(), extras.as_deref()), + RolloutLineV2::Turn { + timestamp, + turn, + extras, + .. + } => self.project_turn(*timestamp, turn, extras.as_deref()), + RolloutLineV2::Item { item, .. } => { + self.max_seq_seen = self.max_seq_seen.max(item.seq); + Ok(self.project_item_envelope(item)?.into_iter().collect()) + } + RolloutLineV2::Internal { + timestamp, entry, .. + } => self.project_internal(*timestamp, entry), + RolloutLineV2::SessionTitleUpdated { + timestamp, + session_id, + title, + previous_title, + .. + } => Ok(vec![RolloutLine::SessionTitleUpdated(SessionTitleUpdatedLine { + timestamp: *timestamp, + session_id: legacy_session_id(session_id)?, + title: title.clone(), + // The title lifecycle is a derived cache in the canonical + // model; any Final variant is honest here because it only + // suppresses later regeneration of a recorded title. + title_state: SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate), + previous_title: previous_title.clone(), + })]), + RolloutLineV2::CompactionSnapshot { + timestamp, + session_id, + turn_id, + summary_item_id, + preserved_item_ids, + .. + } => Ok(vec![RolloutLine::CompactionSnapshot(Box::new( + CompactionSnapshotLine { + timestamp: *timestamp, + session_id: legacy_session_id(session_id)?, + turn_id: legacy_turn_id(turn_id)?, + summary_item_id: legacy_item_id(summary_item_id)?, + preserved_item_ids: preserved_item_ids + .iter() + .map(legacy_item_id) + .collect::>()?, + }, + ))]), + RolloutLineV2::SessionRollback { + timestamp, + session_id, + retained_turn_ids, + retained_item_ids, + latest_turn_id, + .. + } => Ok(vec![RolloutLine::SessionRollback(Box::new( + SessionRollbackLine { + timestamp: *timestamp, + session_id: legacy_session_id(session_id)?, + retained_turn_ids: retained_turn_ids + .iter() + .map(legacy_turn_id) + .collect::>()?, + retained_item_ids: retained_item_ids + .iter() + .map(legacy_item_id) + .collect::>()?, + latest_turn_id: latest_turn_id.as_ref().map(legacy_turn_id).transpose()?, + schema_version: CURRENT_SNAPSHOT_SCHEMA_VERSION, + }, + ))]), + RolloutLineV2::WorkspaceCheckpoint { + timestamp, record, .. + } => Ok(vec![RolloutLine::TurnWorkspaceCheckpointRecorded(Box::new( + crate::conversation::TurnWorkspaceCheckpointRecordedLine { + timestamp: *timestamp, + record: record.clone(), + }, + ))]), + RolloutLineV2::WorkspaceChange { + timestamp, record, .. + } => Ok(vec![RolloutLine::TurnWorkspaceChangeRecorded(Box::new( + crate::conversation::TurnWorkspaceChangeRecordedLine { + timestamp: *timestamp, + record: record.clone(), + }, + ))]), + RolloutLineV2::WorkspaceRestoreStarted { + timestamp, record, .. + } => Ok(vec![RolloutLine::TurnWorkspaceRestoreStarted(Box::new( + crate::conversation::TurnWorkspaceRestoreStartedLine { + timestamp: *timestamp, + record: record.clone(), + }, + ))]), + RolloutLineV2::WorkspaceRestoreCompleted { + timestamp, record, .. + } => Ok(vec![RolloutLine::TurnWorkspaceRestoreCompleted(Box::new( + crate::conversation::TurnWorkspaceRestoreCompletedLine { + timestamp: *timestamp, + record: record.clone(), + }, + ))]), + } + } + + fn project_session_meta( + &mut self, + timestamp: DateTime, + session: &Session, + extras: Option<&SessionPersistenceExtras>, + ) -> Result, V2InverseError> { + let id = legacy_session_id(&session.id)?; + self.session_id = Some(id); + + let (parent_session_id, agent_role) = match &session.parent { + Some(SessionParent::Fork { session_id, .. }) => { + (Some(legacy_session_id(session_id)?), None) + } + Some(SessionParent::Agent { session_id, role }) => { + (Some(legacy_session_id(session_id)?), role.clone()) + } + None => (None, None), + }; + + // Lossy: the legacy approval mode was a free-form string + // ("on-request", "untrusted", "never", ...); only the mapped profile + // survives, so it maps back to the canonical spellings. + let approval_mode = match session.settings.permission_profile { + PermissionProfile::Default => "on-request", + PermissionProfile::AutoReview => "auto-review", + PermissionProfile::FullAccess => "full-access", + }; + + let (git_sha, git_branch, git_origin_url) = session.git_info.as_ref().map_or( + (None, None, None), + |git| (git.sha.clone(), git.branch.clone(), git.origin_url.clone()), + ); + + let record = SessionRecord { + id, + // Unknown at this layer: replay takes the real rollout path from + // the file it is reading. + rollout_path: PathBuf::new(), + created_at: session.created_at, + // The canonical model keeps only `last_activity_at`; it stands in + // for the metadata update time as well. + updated_at: session.last_activity_at, + last_activity_at: Some(session.last_activity_at), + source: extras.map(|extras| extras.source.clone()).unwrap_or_default(), + // Nickname/path are not modeled on canonical `SessionParent`. + agent_nickname: None, + agent_role, + agent_path: None, + model_provider: session.model.provider.clone(), + model: (!session.model.model.is_empty()).then(|| session.model.model.clone()), + // Not modeled canonically; only the provider string survives. + model_binding_id: None, + reasoning_effort_selection: session + .model + .reasoning_effort + .map(|effort| effort.to_string()), + cwd: session.cwd.clone(), + additional_directories: session.additional_directories.clone(), + cli_version: extras + .map(|extras| extras.cli_version.clone()) + .unwrap_or_default(), + title: session.title.clone(), + title_state: if session.title.is_some() { + SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate) + } else { + SessionTitleState::Unset + }, + sandbox_policy: session + .settings + .sandbox_profile + .clone() + .unwrap_or_default(), + approval_mode: approval_mode.into(), + tokens_used: session + .usage + .legacy + .as_ref() + .map_or(session.usage.total.total_tokens, |legacy| { + legacy.total_tokens + }) as i64, + first_user_message: (!session.preview.is_empty()).then(|| session.preview.clone()), + // The exact archive time is not modeled; the creation time is + // the only timestamp known to precede it. + archived_at: session.archived.then_some(session.created_at), + git_sha, + git_branch, + git_origin_url, + parent_session_id, + session_context: extras.and_then(|extras| extras.session_context.clone()), + // Internal prefix-cache cache, not carried even in the extras. + latest_turn_context: None, + schema_version: CURRENT_SESSION_SCHEMA_VERSION, + }; + Ok(vec![RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp, + session: record, + }))]) + } + + fn project_turn( + &mut self, + timestamp: DateTime, + turn: &Turn, + extras: Option<&TurnPersistenceExtras>, + ) -> Result, V2InverseError> { + let id = legacy_turn_id(&turn.id)?; + self.current_turn_id = Some(id); + + let status = match turn.status { + // Pending/WaitingApproval are not distinguishable after the + // forward mapping collapsed them into InProgress; Running is the + // honest in-flight value. + TurnStatus::InProgress => LegacyTurnStatus::Running, + TurnStatus::Completed => LegacyTurnStatus::Completed, + TurnStatus::Interrupted => LegacyTurnStatus::Interrupted, + TurnStatus::Failed => LegacyTurnStatus::Failed, + }; + let kind = match turn.kind { + TurnKind::Compaction => LegacyTurnKind::ManualCompaction, + // GoalContinuation is new-only (never produced from legacy data), + // and legacy Regular is the honest fallback for both. + TurnKind::Regular | TurnKind::GoalContinuation => LegacyTurnKind::Regular, + }; + let usage = turn.usage.as_ref().map(|usage| TurnUsage { + input_tokens: saturating_u32(usage.query.input_tokens), + output_tokens: saturating_u32(usage.query.output_tokens), + // Forward mapped None → 0; 0 maps back to None. A genuine Some(0) + // is indistinguishable and also becomes None. + cache_creation_input_tokens: (usage.query.cache_creation_input_tokens > 0) + .then(|| saturating_u32(usage.query.cache_creation_input_tokens)), + cache_read_input_tokens: (usage.query.cache_read_input_tokens > 0) + .then(|| saturating_u32(usage.query.cache_read_input_tokens)), + reasoning_output_tokens: (usage.query.reasoning_tokens > 0) + .then(|| saturating_u32(usage.query.reasoning_tokens)), + // Always recorded in the canonical totals (forward fills it with + // input+output when the legacy total was absent). + total_tokens: Some(saturating_u32(usage.query.total_tokens)), + }); + let error = turn.error.as_ref().map(|error| TurnError { + code: error.error_code.clone(), + message: error.message.clone(), + recovery_hint: recovery_hint_from_details(error), + }); + + let record = TurnRecord { + id, + session_id: legacy_session_id(&turn.session_id)?, + sequence: turn.sequence, + started_at: turn.started_at, + completed_at: turn.completed_at, + status, + kind, + // The canonical snapshot keeps one model slug (the request model + // when both existed); the logical model is not separately + // recoverable. + model: turn.model.model.clone(), + model_binding_id: (turn.model.provider != "unknown") + .then(|| turn.model.provider.clone()), + reasoning_effort_selection: turn + .model + .reasoning_effort + .map(|effort| effort.to_string()), + request_model: turn.model.model.clone(), + request_thinking: extras.and_then(|extras| extras.request_thinking.clone()), + input_token_estimate: extras.and_then(|extras| extras.input_token_estimate), + usage, + latest_query_usage: extras.and_then(|extras| extras.latest_query_usage.clone()), + stop_reason: extras.and_then(|extras| extras.stop_reason.clone()), + failure_reason: extras.and_then(|extras| extras.failure_reason), + error, + session_context: extras.and_then(|extras| extras.session_context.clone()), + turn_context: extras.and_then(|extras| extras.turn_context.clone()), + schema_version: CURRENT_TURN_SCHEMA_VERSION, + }; + Ok(vec![RolloutLine::Turn(Box::new(TurnLine { + timestamp, + turn: record, + }))]) + } + + /// Projects one item envelope into one legacy `ItemRecord` (the v2 + /// writer emits exactly one payload per envelope), or `None` when the + /// canonical variant has no legacy representation: + /// + /// - `Warning` is itself a migration artifact (orphan approval + /// decisions), so re-emitting it would fabricate a legacy kind that + /// was never written; + /// - `FileChange`/`UserInputRequest`/`SubAgent`/`BackgroundTask`/ + /// `GoalProgress` are new-only variants the forward projector never + /// produces from legacy records (and `FileChange` famously has no + /// legacy `TurnItem` — the persistence hole this redesign closes). + fn project_item_envelope( + &self, + envelope: &ItemEnvelope, + ) -> Result, V2InverseError> { + let payload = match &envelope.item { + Item::UserMessage { content, entry, .. } => { + let text = text_from_user_content(content); + match entry { + UserMessageEntry::TurnStart | UserMessageEntry::Queue => { + TurnItem::UserMessage(TextItem { text }) + } + UserMessageEntry::Steer => TurnItem::SteerInput(TextItem { text }), + } + } + Item::AssistantMessage { text, .. } => TurnItem::AgentMessage(TextItem { + text: text.clone(), + }), + Item::Reasoning { text, .. } => TurnItem::Reasoning(TextItem { + text: text.clone(), + }), + Item::Plan { entries } => TurnItem::Plan(TextItem { + // The v2 writer produces exactly one entry (the legacy + // rendered text); multiple entries join lossily with + // newlines because the legacy plan is one text blob. + text: entries + .iter() + .map(|entry| entry.step.as_str()) + .collect::>() + .join("\n"), + }), + Item::ToolCall { + call_id, + tool_name, + input, + .. + } => TurnItem::ToolCall(ToolCallItem { + tool_call_id: call_id.clone(), + tool_name: tool_name.clone(), + input: input.clone().unwrap_or(serde_json::Value::Null), + }), + Item::ToolResult { + call_id, + output, + display_content, + is_error, + .. + } => TurnItem::ToolResult(ToolResultItem { + tool_call_id: call_id.clone(), + // The legacy tool name is not carried by the canonical + // result variant. + tool_name: None, + output: output.clone(), + display_content: display_content.clone(), + is_error: *is_error, + }), + Item::CommandExecution { + call_id, + command, + input, + output, + is_error, + .. + } => TurnItem::CommandExecution(CommandExecutionItem { + tool_call_id: call_id.clone(), + // The legacy tool name was dropped by the forward mapping + // (canonical carries origin/mode instead); replay only uses + // it for display, and the exec family is the only producer + // of this variant. + tool_name: "exec_command".into(), + command: command.clone(), + input: input.clone().unwrap_or(serde_json::Value::Null), + output: output.clone().unwrap_or(serde_json::Value::Null), + is_error: *is_error, + }), + Item::HostedToolCall { + tool_name, output, .. + } => { + let text = output + .as_ref() + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + match tool_name.as_str() { + "image_generation" | "image_view" => { + TurnItem::ImageGeneration(TextItem { text }) + } + // "web_search" and any other hosted tool name: the + // legacy format has no generic hosted variant, and the + // writer never produces others. + _ => TurnItem::WebSearch(TextItem { text }), + } + } + Item::ContextCompaction { summary, .. } => TurnItem::ContextCompaction(TextItem { + text: summary.clone().unwrap_or_default(), + }), + Item::Approval { + approval_id, + action_summary, + justification, + resource, + available_scopes, + target, + decision, + .. + } => { + if let Some(decision) = decision { + // A decided approval becomes the legacy decision record. + // Legacy decisions lived in their own record with their + // own id, so a fresh bare UUID stands in (replay only + // needs uniqueness); the seq stays the shared fold seq so + // it sorts with its request. + let record = self.item_record( + ItemId::new(), + envelope, + TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: approval_id.clone(), + decision: legacy_decision_string(decision.decision).into(), + scope: legacy_scope_string(decision.scope).into(), + }), + )?; + return Ok(Some(RolloutLine::Item(record))); + } + let (path, host, target) = target.as_ref().map_or((None, None, None), |t| match t { + ApprovalTarget::Path { path } => { + (Some(path.display().to_string()), None, None) + } + ApprovalTarget::Host { host } => (None, Some(host.clone()), None), + ApprovalTarget::Command { command } => (None, None, Some(command.clone())), + }); + TurnItem::ApprovalRequest(ApprovalRequestItem { + approval_id: approval_id.clone(), + action_summary: action_summary.clone(), + justification: justification.clone(), + resource: resource.clone(), + available_scopes: available_scopes.clone(), + path, + host, + target, + }) + } + Item::FileChange { .. } + | Item::UserInputRequest { .. } + | Item::SubAgent { .. } + | Item::BackgroundTask { .. } + | Item::GoalProgress { .. } + | Item::Warning { .. } => return Ok(None), + }; + + let record = self.item_record(legacy_item_id(&envelope.id)?, envelope, payload)?; + Ok(Some(RolloutLine::Item(record))) + } + + /// Builds one legacy `ItemRecord` mirroring the live write path + /// (`build_item_record`): exactly one payload, placed in `output_items` + /// (the live writer never fills `input_items`; replay reads both + /// buckets). + fn item_record( + &self, + id: ItemId, + envelope: &ItemEnvelope, + payload: TurnItem, + ) -> Result { + Ok(ItemLine { + timestamp: envelope.updated_at, + item: ItemRecord { + id, + session_id: legacy_session_id(&envelope.session_id)?, + turn_id: legacy_turn_id(&envelope.turn_id)?, + seq: envelope.seq, + timestamp: envelope.updated_at, + // Not modeled on the canonical envelope: orchestration + // placement, the turn status at append time, sibling turns, + // worklog and per-item errors. + attempt_placement: None, + turn_status: None, + sibling_turn_ids: Vec::new(), + input_items: Vec::new(), + output_items: vec![payload], + worklog: None, + error: None, + schema_version: CURRENT_ITEM_SCHEMA_VERSION, + }, + }) + } + + fn project_internal( + &self, + timestamp: DateTime, + entry: &InternalRecordV2, + ) -> Result, V2InverseError> { + match entry { + InternalRecordV2::Entry { entry } => { + let payload = match entry { + InternalEntry::TurnSummary { text } => TurnItem::TurnSummary(TextItem { + text: text.clone(), + }), + InternalEntry::ToolProgress { call_id, message } => { + TurnItem::ToolProgress(ToolProgressItem { + tool_call_id: call_id.clone(), + message: message.clone(), + }) + } + InternalEntry::HookPrompt { text } => TurnItem::HookPrompt(TextItem { + text: text.clone(), + }), + }; + // Internal v2 lines carry no ids or seq. The id is a fresh + // bare UUID (replay only needs uniqueness); the seq is the + // highest envelope seq seen so far, so the record sorts at + // the position where the internal line appeared. + let envelope_seq = self.max_seq_seen; + let session_id = self + .session_id + .ok_or(V2InverseError::MissingSessionMeta("internal entry"))?; + let turn_id = self + .current_turn_id + .ok_or(V2InverseError::MissingSessionMeta("internal entry"))?; + Ok(vec![RolloutLine::Item(ItemLine { + timestamp, + item: ItemRecord { + id: ItemId::new(), + session_id, + turn_id, + seq: envelope_seq, + timestamp, + attempt_placement: None, + turn_status: None, + sibling_turn_ids: Vec::new(), + input_items: Vec::new(), + output_items: vec![payload], + worklog: None, + error: None, + schema_version: CURRENT_ITEM_SCHEMA_VERSION, + }, + })]) + } + InternalRecordV2::SessionContext(context) => { + let session_id = self + .session_id + .ok_or(V2InverseError::MissingSessionMeta("session context"))?; + Ok(vec![RolloutLine::SessionContextUpdated(Box::new( + SessionContextUpdatedLine { + timestamp, + session_id, + session_context: (**context).clone(), + schema_version: CURRENT_SNAPSHOT_SCHEMA_VERSION, + }, + ))]) + } + InternalRecordV2::MessageEdit(record) => { + Ok(vec![RolloutLine::MessageEditRecorded(Box::new( + crate::conversation::MessageEditRecordedLine { + timestamp, + record: record.clone(), + }, + ))]) + } + InternalRecordV2::TurnSuperseded(record) => { + Ok(vec![RolloutLine::TurnSuperseded(Box::new( + crate::conversation::TurnSupersededLine { + timestamp, + record: record.clone(), + }, + ))]) + } + } + } +} + +/// Parses a canonical id back into the legacy UUID newtype. Prefixed +/// canonical ids (`ses_…`/`turn_…`/`item_…`) fail here — see +/// [`V2InverseError::NonLegacyId`]. +fn legacy_uuid(id: &str) -> Result { + Uuid::parse_str(id).map_err(|_| V2InverseError::NonLegacyId(id.to_owned())) +} + +fn legacy_session_id(id: &CanonicalSessionId) -> Result { + Ok(SessionId::from(legacy_uuid(id.as_str())?)) +} + +fn legacy_turn_id(id: &CanonicalTurnId) -> Result { + Ok(TurnId::from(legacy_uuid(id.as_str())?)) +} + +fn legacy_item_id(id: &CanonicalItemId) -> Result { + Ok(ItemId::from(legacy_uuid(id.as_str())?)) +} + +/// The v2 writer only ever produces a single `Text` part (the legacy user +/// message was plain text); multiple text parts join with newlines and +/// non-text parts are dropped because legacy `TextItem` cannot represent +/// them. +fn text_from_user_content(content: &[UserInput]) -> String { + content + .iter() + .filter_map(|part| match part { + UserInput::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +fn legacy_decision_string(decision: ApprovalDecisionKind) -> &'static str { + match decision { + ApprovalDecisionKind::Approved => "approve", + ApprovalDecisionKind::Denied => "deny", + ApprovalDecisionKind::Cancelled => "cancel", + } +} + +fn legacy_scope_string(scope: ApprovalScope) -> &'static str { + match scope { + ApprovalScope::Once => "once", + ApprovalScope::Turn => "turn", + ApprovalScope::Session => "session", + ApprovalScope::PathPrefix => "path_prefix", + ApprovalScope::Host => "host", + ApprovalScope::Tool => "tool", + ApprovalScope::CommandPrefix => "command_prefix", + ApprovalScope::CommandPrefixPersist => "command_prefix_persist", + } +} + +fn recovery_hint_from_details(error: &AgentError) -> Option { + error + .details + .as_ref()? + .get("recoveryHint")? + .as_str() + .map(str::to_owned) +} + +fn saturating_u32(value: u64) -> u32 { + u32::try_from(value).unwrap_or(u32::MAX) +} diff --git a/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl b/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl index 50a6609f..60690302 100644 --- a/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl +++ b/crates/core/tests/fixtures/rollout_v1/basic_session.jsonl @@ -1,4 +1,4 @@ -{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000b1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","last_activity_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":null,"agent_role":null,"agent_path":null,"model_provider":"openai","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"on-request","tokens_used":12345,"first_user_message":"Fix the flaky test","archived_at":null,"git_sha":"abc123","git_branch":"main","git_origin_url":"git@github.com:example/repo.git","parent_session_id":null,"schema_version":2}}} +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000b1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","last_activity_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":null,"agent_role":null,"agent_path":null,"model_provider":"openai","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","additional_directories":["/tmp/legacy-extra"],"cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"on-request","tokens_used":12345,"first_user_message":"Fix the flaky test","archived_at":null,"git_sha":"abc123","git_branch":"main","git_origin_url":"git@github.com:example/repo.git","parent_session_id":null,"schema_version":2}}} {"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000b2","session_id":"00000000-0000-0000-0000-0000000000b1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":"2026-07-01T12:00:03Z","status":"Completed","kind":"regular","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":null,"input_token_estimate":null,"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20,"reasoning_output_tokens":5,"total_tokens":150},"schema_version":4}}} {"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000b3","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[{"UserMessage":{"text":"Fix the flaky test"}}],"output_items":[{"AgentMessage":{"text":"On it."}},{"Plan":{"text":"1. reproduce\n2. fix"}}],"worklog":null,"error":null,"schema_version":1}}} {"Item":{"timestamp":"2026-07-01T12:00:12Z","item":{"id":"00000000-0000-0000-0000-0000000000b4","session_id":"00000000-0000-0000-0000-0000000000b1","turn_id":"00000000-0000-0000-0000-0000000000b2","seq":2,"timestamp":"2026-07-01T12:00:12Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ToolCall":{"tool_call_id":"call-1","tool_name":"read_file","input":{"path":"src/lib.rs"}}},{"ToolResult":{"tool_call_id":"call-1","tool_name":"read_file","output":{"content":"fn main() {}"},"display_content":"fn main() {}","is_error":false}}],"worklog":null,"error":null,"schema_version":1}}} diff --git a/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl b/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl index 01c1afbd..fef05da2 100644 --- a/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl +++ b/crates/core/tests/fixtures/rollout_v1/internal_lines.jsonl @@ -1,5 +1,5 @@ -{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000c1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":"scout","agent_role":"explorer","agent_path":null,"model_provider":"openai","model":null,"model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"full-access","tokens_used":12345,"first_user_message":null,"archived_at":null,"git_sha":null,"git_branch":null,"git_origin_url":null,"parent_session_id":"00000000-0000-0000-0000-0000000000c0","schema_version":2}}} -{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000c2","session_id":"00000000-0000-0000-0000-0000000000c1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"Failed","kind":"manual_compaction","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":null,"input_token_estimate":null,"usage":null,"error":{"code":"PROVIDER_SERVER_ERROR","message":"provider request failed","recovery_hint":"retry later"},"schema_version":4}}} +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000c1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":"scout","agent_role":"explorer","agent_path":null,"model_provider":"openai","model":null,"model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","additional_directories":["/tmp/legacy-extra"],"cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"full-access","tokens_used":12345,"first_user_message":null,"archived_at":null,"git_sha":null,"git_branch":null,"git_origin_url":null,"parent_session_id":"00000000-0000-0000-0000-0000000000c0","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":2}}} +{"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000c2","session_id":"00000000-0000-0000-0000-0000000000c1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"Failed","kind":"manual_compaction","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":"enabled","input_token_estimate":42,"usage":null,"latest_query_usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":null,"cache_read_input_tokens":null,"total_tokens":15},"error":{"code":"PROVIDER_SERVER_ERROR","message":"provider request failed","recovery_hint":"retry later"},"session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"turn_context":{"environment":{"cwd":"/tmp/legacy-project","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"observed_agents_snapshot":null,"collaboration_mode":"build"},"schema_version":4}}} {"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000c3","session_id":"00000000-0000-0000-0000-0000000000c1","turn_id":"00000000-0000-0000-0000-0000000000c2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[{"HookPrompt":{"text":"hook text"}}],"output_items":[{"ToolProgress":{"tool_call_id":"call-9","message":"working"}},{"TurnSummary":{"text":"3"}}],"worklog":null,"error":null,"schema_version":1}}} {"MessageEditRecorded":{"timestamp":"2026-07-01T12:00:40Z","record":{"schema_version":1,"session_id":"00000000-0000-0000-0000-0000000000c1","edit_id":"00000000-0000-0000-0000-0000000000c4","target_message_id":"00000000-0000-0000-0000-0000000000c5","replacement_message_id":"00000000-0000-0000-0000-0000000000c6","target_turn_id":"00000000-0000-0000-0000-0000000000c2","replacement_turn_id":null,"queue_item_id":null,"edited_content_parts":[{"content_type":"text","value":"edited"}],"edited_mentions":[],"workspace_restore_policy":"skip","edit_state":"accepted","requested_by_client_id":null,"created_at":"2026-07-01T12:00:40Z"}}} {"SessionContextUpdated":{"timestamp":"2026-07-01T12:00:41Z","session_id":"00000000-0000-0000-0000-0000000000c1","session_context":{"base_instructions":"base","available_skills":null,"workspace_instructions":null,"locked_agents_snapshot":null,"environment":{"cwd":".","shell":"bash","current_date":"2026-07-01","timezone":"UTC"},"language":{"language_preference":"Reply in the same natural language as the user's latest message. If the latest user message mixes languages, use the primary language of that message. Preserve technical terms, code identifiers, file paths, commands, API names, and quoted text in their original form unless the user explicitly asks to translate them. This language rule also applies to Proposed Plan and Goal: any content inside and must follow the same natural language as the user's latest message."},"persona":"default","model":{"slug":"gpt-5.2","display_name":"","provider":"openai_chat_completions","description":null,"reasoning_capability":"unsupported","default_reasoning_effort":"medium","reasoning_implementation":null,"base_instructions":"","context_window":200000,"effective_context_window_percent":null,"truncation_policy":{"mode":"bytes","limit":8000},"input_modalities":["text"],"supports_image_detail_original":false,"channel":null,"temperature":null,"top_p":null,"top_k":null,"max_tokens":null},"reasoning_effort_selection":null,"reasoning_effort":null,"system_prompt_mode":"coding_agent"},"schema_version":1}} diff --git a/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl b/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl index d804a23b..caa0aca1 100644 --- a/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl +++ b/crates/core/tests/fixtures/rollout_v1/orphan_decision.jsonl @@ -1,4 +1,4 @@ -{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000d1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","last_activity_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":null,"agent_role":null,"agent_path":null,"model_provider":"openai","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"untrusted","tokens_used":12345,"first_user_message":"Fix the flaky test","archived_at":null,"git_sha":"abc123","git_branch":"main","git_origin_url":"git@github.com:example/repo.git","parent_session_id":null,"schema_version":2}}} +{"SessionMeta":{"timestamp":"2026-07-01T12:00:00Z","session":{"id":"00000000-0000-0000-0000-0000000000d1","rollout_path":"rollout.jsonl","created_at":"2026-07-01T12:00:00Z","updated_at":"2026-07-01T12:00:01Z","last_activity_at":"2026-07-01T12:00:01Z","source":"cli","agent_nickname":null,"agent_role":null,"agent_path":null,"model_provider":"openai","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","cwd":"/tmp/legacy-project","additional_directories":["/tmp/legacy-extra"],"cli_version":"0.1.31","title":"Legacy Session","title_state":{"Final":"ModelGenerated"},"sandbox_policy":"workspace-write","approval_mode":"untrusted","tokens_used":12345,"first_user_message":"Fix the flaky test","archived_at":null,"git_sha":"abc123","git_branch":"main","git_origin_url":"git@github.com:example/repo.git","parent_session_id":null,"schema_version":2}}} {"Turn":{"timestamp":"2026-07-01T12:00:02Z","turn":{"id":"00000000-0000-0000-0000-0000000000d2","session_id":"00000000-0000-0000-0000-0000000000d1","sequence":1,"started_at":"2026-07-01T12:00:02Z","completed_at":null,"status":"WaitingApproval","kind":"regular","model":"gpt-5.2","model_binding_id":"binding-1","reasoning_effort_selection":"high","request_model":"gpt-5.2-codex","request_thinking":null,"input_token_estimate":null,"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":10,"cache_read_input_tokens":20,"reasoning_output_tokens":5,"total_tokens":150},"schema_version":4}}} {"Item":{"timestamp":"2026-07-01T12:00:11Z","item":{"id":"00000000-0000-0000-0000-0000000000d3","session_id":"00000000-0000-0000-0000-0000000000d1","turn_id":"00000000-0000-0000-0000-0000000000d2","seq":1,"timestamp":"2026-07-01T12:00:11Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ApprovalDecision":{"approval_id":"appr-orphan","decision":"approve","scope":"session"}}],"worklog":null,"error":null,"schema_version":1}}} {"Item":{"timestamp":"2026-07-01T12:00:12Z","item":{"id":"00000000-0000-0000-0000-0000000000d4","session_id":"00000000-0000-0000-0000-0000000000d1","turn_id":"00000000-0000-0000-0000-0000000000d2","seq":2,"timestamp":"2026-07-01T12:00:12Z","attempt_placement":null,"turn_status":"Running","sibling_turn_ids":[],"input_items":[],"output_items":[{"ImageGeneration":{"text":"image result"}}],"worklog":null,"error":null,"schema_version":1}}} diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs index 5cabc6d0..8451e1bc 100644 --- a/crates/core/tests/legacy_projector.rs +++ b/crates/core/tests/legacy_projector.rs @@ -14,15 +14,15 @@ use std::path::PathBuf; use chrono::{DateTime, TimeZone, Utc}; use devo_core::{ - ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, - ContentPart, EditId, EditState, EnvironmentContext, ItemId, ItemLine, ItemRecord, - LanguageContext, LegacyProjector, MessageEditRecordedLine, MessageEditRecordedRecord, Model, - ParsedRolloutLine, Persona, RolloutLine, RolloutLineV2, SessionContext, - SessionContextUpdatedLine, SessionId, SessionMetaLine, SessionRecord, SessionRollbackLine, - SessionTitleFinalSource, SessionTitleState, SessionTitleUpdatedLine, SystemPromptMode, - TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, TurnId, TurnItem, - TurnKind, TurnLine, TurnRecord, TurnStatus, TurnUsage, WorkspaceRestorePolicy, - parse_rollout_line, + ApprovalDecisionItem, ApprovalRequestItem, CollaborationMode, CommandExecutionItem, + CompactionSnapshotLine, ContentPart, EditId, EditState, EnvironmentContext, ItemId, ItemLine, + ItemRecord, LanguageContext, LegacyProjector, MessageEditRecordedLine, + MessageEditRecordedRecord, Model, ParsedRolloutLine, Persona, RolloutLine, RolloutLineV2, + SessionContext, SessionContextUpdatedLine, SessionId, SessionMetaLine, SessionRecord, + SessionRollbackLine, SessionTitleFinalSource, SessionTitleState, SessionTitleUpdatedLine, + SystemPromptMode, TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnContext, + TurnError, TurnId, TurnItem, TurnKind, TurnLine, TurnRecord, TurnStatus, TurnUsage, + WorkspaceRestorePolicy, parse_rollout_line, }; use devo_protocol::canonical::ids::ItemId as CanonicalItemId; use devo_protocol::canonical::item::{ @@ -73,7 +73,7 @@ fn session_record(n: u128) -> SessionRecord { model_binding_id: Some("binding-1".into()), reasoning_effort_selection: Some("high".into()), cwd: "/tmp/legacy-project".into(), - additional_directories: Vec::new(), + additional_directories: vec!["/tmp/legacy-extra".into()], cli_version: "0.1.31".into(), title: Some("Legacy Session".into()), title_state: SessionTitleState::Final(SessionTitleFinalSource::ModelGenerated), @@ -150,6 +150,30 @@ fn item_line(record: ItemRecord) -> RolloutLine { }) } +fn sample_session_context() -> SessionContext { + SessionContext { + base_instructions: "base".into(), + available_skills: None, + workspace_instructions: None, + locked_agents_snapshot: None, + environment: EnvironmentContext { + cwd: ".".into(), + shell: "bash".into(), + current_date: "2026-07-01".into(), + timezone: "UTC".into(), + }, + language: LanguageContext::default(), + persona: Persona::Default, + model: Model { + slug: "gpt-5.2".into(), + ..Model::default() + }, + reasoning_effort_selection: None, + reasoning_effort: None, + system_prompt_mode: SystemPromptMode::CodingAgent, + } +} + // ── Fixture builders ──────────────────────────────────────────────────── /// A typical session: packed multi-payload records, tool call/result pair, @@ -291,6 +315,7 @@ fn internal_lines() -> Vec { record.git_origin_url = None; record.first_user_message = None; record.last_activity_at = None; + record.session_context = Some(sample_session_context()); let mut turn = turn_record(turn, session); turn.kind = TurnKind::ManualCompaction; @@ -302,6 +327,34 @@ fn internal_lines() -> Vec { message: "provider request failed".into(), recovery_hint: Some("retry later".into()), }); + turn.session_context = Some(sample_session_context()); + turn.turn_context = Some(TurnContext { + environment: EnvironmentContext { + cwd: "/tmp/legacy-project".into(), + shell: "bash".into(), + current_date: "2026-07-01".into(), + timezone: "UTC".into(), + }, + persona: Persona::Default, + model: Model { + slug: "gpt-5.2".into(), + ..Model::default() + }, + reasoning_effort_selection: None, + reasoning_effort: None, + observed_agents_snapshot: None, + collaboration_mode: CollaborationMode::default(), + }); + turn.request_thinking = Some("enabled".into()); + turn.input_token_estimate = Some(42); + turn.latest_query_usage = Some(TurnUsage { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: None, + cache_read_input_tokens: None, + reasoning_output_tokens: None, + total_tokens: Some(15), + }); let mut internals = item_record(0xc3, 0xc1, 0xc2, 1); internals.input_items = vec![TurnItem::HookPrompt(TextItem { @@ -347,27 +400,7 @@ fn internal_lines() -> Vec { RolloutLine::SessionContextUpdated(Box::new(SessionContextUpdatedLine { timestamp: ts(41), session_id: session_id(0xc1), - session_context: SessionContext { - base_instructions: "base".into(), - available_skills: None, - workspace_instructions: None, - locked_agents_snapshot: None, - environment: EnvironmentContext { - cwd: ".".into(), - shell: "bash".into(), - current_date: "2026-07-01".into(), - timezone: "UTC".into(), - }, - language: LanguageContext::default(), - persona: Persona::Default, - model: Model { - slug: "gpt-5.2".into(), - ..Model::default() - }, - reasoning_effort_selection: None, - reasoning_effort: None, - system_prompt_mode: SystemPromptMode::CodingAgent, - }, + session_context: sample_session_context(), schema_version: 1, })), ] @@ -522,10 +555,11 @@ fn basic_session_projects_all_lines_in_order() { assert_eq!(seqs, vec![1, 2, 3, 4, 5, 6, 7, 7, 8, 9, 10, 11]); // The first payload of the packed conversation record keeps the legacy - // record id; the sibling payloads get fresh canonical ids. + // record id; the sibling payloads get fresh bare-UUID ids (prefixed ids + // could not round-trip into legacy UUID newtypes). assert_eq!(envelopes[0].id.as_str(), uuid(0xb3).to_string()); - assert!(envelopes[1].id.as_str().starts_with("item_")); - assert!(envelopes[2].id.as_str().starts_with("item_")); + assert!(uuid::Uuid::parse_str(envelopes[1].id.as_str()).is_ok()); + assert!(uuid::Uuid::parse_str(envelopes[2].id.as_str()).is_ok()); assert!( matches!(&envelopes[0].item, Item::UserMessage { content, entry: UserMessageEntry::TurnStart, .. } @@ -736,8 +770,9 @@ fn orphan_approval_decision_becomes_warning_item() { matches!(&warning.item, Item::Warning { code, retryable: false, .. } if code == "legacyOrphanApprovalDecision") ); - // The orphan warning gets a fresh id and its own first-appearance seq. - assert!(warning.id.as_str().starts_with("item_")); + // The orphan warning gets a fresh bare-UUID id and its own + // first-appearance seq. + assert!(uuid::Uuid::parse_str(warning.id.as_str()).is_ok()); assert_eq!((warning.seq, warning.revision), (1, 1)); assert!( diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs new file mode 100644 index 00000000..269179f7 --- /dev/null +++ b/crates/core/tests/v2_roundtrip.rs @@ -0,0 +1,604 @@ +//! Round-trip property tests (P3a): legacy `RolloutLine` → `LegacyProjector` +//! → v2 JSON → `parse_rollout_line` → `V2InverseProjector` → legacy +//! `RolloutLine` must replay equivalently to the original. +//! +//! Equivalence is full-object equality after `normalize_expected` applies +//! the documented allow-list of unrecoverable fields (see the comments in +//! `v2_inverse.rs` and the normalize function below) and reshapes packed +//! records the way the one-record-one-item v2 writer does. + +use std::collections::HashMap; +use std::path::PathBuf; + +use chrono::{DateTime, TimeZone, Utc}; +use devo_core::{ + ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, ItemId, ItemLine, ItemRecord, + LegacyProjector, ParsedRolloutLine, RolloutLine, RolloutLineV2, SessionRecord, + SessionTitleFinalSource, SessionTitleState, TextItem, ToolCallItem, ToolResultItem, TurnItem, + TurnKind, TurnRecord, TurnStatus, V2InverseError, V2InverseProjector, parse_rollout_line, +}; +use pretty_assertions::assert_eq; +use uuid::Uuid; + +/// Marker id for positions where the round-trip legitimately produces a +/// fresh random id (approval decision records, synthesized internal-entry +/// records, expanded packed payloads). The comparator skips id equality +/// there. +fn sentinel_id() -> ItemId { + ItemId::from(Uuid::nil()) +} + +fn ts(second: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, second).unwrap() +} + +fn fixture_lines(name: &str) -> Vec { + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("tests/fixtures/rollout_v1/{name}")); + std::fs::read_to_string(&path) + .expect("read fixture") + .lines() + .map(|line| match parse_rollout_line(line).expect("fixture line parses") { + ParsedRolloutLine::Legacy(line) => *line, + ParsedRolloutLine::V2(_) => panic!("fixture {name} must contain only legacy lines"), + }) + .collect() +} + +/// The full round-trip: forward project, serialize to v2 JSONL, re-parse +/// through the version dispatch (proving the v2 output is well-formed), then +/// inverse project back to legacy lines. +fn round_trip(lines: &[RolloutLine]) -> Vec { + let mut forward = LegacyProjector::new(); + let mut inverse = V2InverseProjector::new(); + let mut out = Vec::new(); + for line in lines { + let v2_lines = forward.project_line(line).expect("forward projection"); + for v2 in &v2_lines { + let raw = serde_json::to_string(v2).expect("serialize v2 line"); + let parsed = match parse_rollout_line(&raw).expect("v2 line re-parses") { + ParsedRolloutLine::V2(parsed) => *parsed, + ParsedRolloutLine::Legacy(_) => panic!("v2 line parsed as legacy"), + }; + out.extend(inverse.project_line(&parsed).expect("inverse projection")); + } + } + out +} + +// ── Expected-shape normalization (the explicit allow-list) ───────────── + +struct Normalizer { + next_seq: u64, + approval_seqs: HashMap, +} + +impl Normalizer { + fn new() -> Self { + Self { + next_seq: 1, + approval_seqs: HashMap::new(), + } + } + + fn normalize(&mut self, lines: &[RolloutLine]) -> Vec { + let mut out = Vec::new(); + for line in lines { + match line { + RolloutLine::SessionMeta(line) => { + out.push(RolloutLine::SessionMeta(Box::new( + devo_core::SessionMetaLine { + timestamp: line.timestamp, + session: normalize_session(&line.session), + }, + ))); + } + RolloutLine::Turn(line) => { + out.push(RolloutLine::Turn(Box::new(devo_core::TurnLine { + timestamp: line.timestamp, + turn: normalize_turn(&line.turn), + }))); + } + RolloutLine::Item(line) => self.normalize_item_record(line, &mut out), + RolloutLine::SessionTitleUpdated(line) => { + out.push(RolloutLine::SessionTitleUpdated( + devo_core::SessionTitleUpdatedLine { + title_state: SessionTitleState::Final( + SessionTitleFinalSource::ExplicitCreate, + ), + ..line.clone() + }, + )); + } + other => out.push(other.clone()), + } + } + out + } + + /// Expands one packed legacy record into the one-payload-per-record + /// shape the v2 writer produces, applying the item-level allow-list: + /// + /// - internal payloads (HookPrompt/ToolProgress/TurnSummary) become + /// synthesized records with an unstable id and the seq of the position + /// where they appeared; + /// - approval decisions fold onto their request's id/seq in v2, so the + /// inverse decision record gets an unstable id, the request's seq, and + /// normalized decision/scope strings; + /// - orphan decisions (Warning in v2) are dropped; + /// - `ToolResult.tool_name` and `CommandExecution.tool_name` do not + /// survive the canonical variants; + /// - `attempt_placement`/`turn_status` are not modeled on the canonical + /// envelope, and the inverse stamps the current schema version. + fn normalize_item_record(&mut self, line: &ItemLine, out: &mut Vec) { + let record = &line.item; + for (index, payload) in record + .input_items + .iter() + .chain(&record.output_items) + .enumerate() + { + // Payloads after the first get a fresh bare-UUID id in v2; their + // round-tripped id is unstable. + let stable_id = (index == 0).then_some(record.id); + match payload { + TurnItem::HookPrompt(_) | TurnItem::ToolProgress(_) | TurnItem::TurnSummary(_) => { + out.push(item_line( + sentinel_id(), + record, + self.next_seq.saturating_sub(1), + payload.clone(), + )); + } + TurnItem::ApprovalRequest(request) => { + let seq = self.next_seq; + self.next_seq += 1; + self.approval_seqs.insert(request.approval_id.clone(), seq); + out.push(item_line( + stable_id.unwrap_or_else(sentinel_id), + record, + seq, + payload.clone(), + )); + } + TurnItem::ApprovalDecision(decision) => { + // The Warning a v2 orphan decision becomes is skipped by + // the inverse: no line survives, but the seq counter + // still advanced. + let Some(seq) = self.approval_seqs.get(&decision.approval_id) else { + self.next_seq += 1; + continue; + }; + out.push(item_line( + sentinel_id(), + record, + *seq, + TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: decision.approval_id.clone(), + decision: normalize_decision_string(&decision.decision), + scope: normalize_scope_string(&decision.scope), + }), + )); + } + payload => { + let seq = self.next_seq; + self.next_seq += 1; + out.push(item_line( + stable_id.unwrap_or_else(sentinel_id), + record, + seq, + normalize_payload(payload), + )); + } + } + } + } +} + +fn item_line(id: ItemId, original: &ItemRecord, seq: u64, payload: TurnItem) -> RolloutLine { + RolloutLine::Item(ItemLine { + timestamp: original.timestamp, + item: ItemRecord { + id, + session_id: original.session_id, + turn_id: original.turn_id, + seq, + timestamp: original.timestamp, + attempt_placement: None, + turn_status: None, + sibling_turn_ids: Vec::new(), + input_items: Vec::new(), + output_items: vec![payload], + worklog: None, + error: None, + schema_version: 1, + }, + }) +} + +fn normalize_payload(payload: &TurnItem) -> TurnItem { + match payload { + // The canonical result variants do not carry the legacy tool name. + TurnItem::ToolResult(result) => TurnItem::ToolResult(ToolResultItem { + tool_name: None, + ..result.clone() + }), + TurnItem::CommandExecution(command) => TurnItem::CommandExecution(CommandExecutionItem { + tool_name: "exec_command".into(), + ..command.clone() + }), + other => other.clone(), + } +} + +fn normalize_decision_string(decision: &str) -> String { + match decision.to_ascii_lowercase().as_str() { + "approve" | "approved" | "allow" => "approve", + "deny" | "denied" => "deny", + _ => "cancel", + } + .into() +} + +fn normalize_scope_string(scope: &str) -> String { + match scope.to_ascii_lowercase().as_str() { + "once" => "once", + "turn" => "turn", + "session" => "session", + "path_prefix" => "path_prefix", + "host" => "host", + "tool" => "tool", + "command_prefix" => "command_prefix", + "command_prefix_persist" => "command_prefix_persist", + _ => "once", + } + .into() +} + +/// SessionRecord fields the canonical model does not carry (the allow-list, +/// each with its justification in `v2_inverse.rs`). +fn normalize_session(session: &SessionRecord) -> SessionRecord { + let last_activity_at = session.last_activity_at.unwrap_or(session.updated_at); + SessionRecord { + rollout_path: PathBuf::new(), + updated_at: last_activity_at, + last_activity_at: Some(last_activity_at), + agent_nickname: None, + agent_path: None, + model_binding_id: None, + title_state: if session.title.is_some() { + SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate) + } else { + SessionTitleState::Unset + }, + approval_mode: { + let mode = session.approval_mode.to_ascii_lowercase(); + if mode.contains("auto") { + "auto-review".into() + } else if mode.contains("full") { + "full-access".into() + } else { + "on-request".into() + } + }, + archived_at: session.archived_at.map(|_| session.created_at), + latest_turn_context: None, + schema_version: 2, + ..session.clone() + } +} + +/// TurnRecord fields the canonical model does not carry. +fn normalize_turn(turn: &TurnRecord) -> TurnRecord { + TurnRecord { + status: match turn.status.clone() { + TurnStatus::Pending | TurnStatus::Running | TurnStatus::WaitingApproval => { + TurnStatus::Running + } + status => status, + }, + kind: match &turn.kind { + TurnKind::Regular | TurnKind::Review | TurnKind::Other(_) => TurnKind::Regular, + TurnKind::ManualCompaction => TurnKind::ManualCompaction, + }, + model: if turn.request_model.is_empty() { + turn.model.clone() + } else { + turn.request_model.clone() + }, + usage: turn.usage.as_ref().map(|usage| devo_core::TurnUsage { + cache_creation_input_tokens: usage.cache_creation_input_tokens.filter(|v| *v > 0), + cache_read_input_tokens: usage.cache_read_input_tokens.filter(|v| *v > 0), + reasoning_output_tokens: usage.reasoning_output_tokens.filter(|v| *v > 0), + total_tokens: Some( + usage + .total_tokens + .unwrap_or(usage.input_tokens + usage.output_tokens), + ), + ..usage.clone() + }), + schema_version: 4, + ..turn.clone() + } +} + +// ── Comparison with unstable-id wildcards ─────────────────────────────── + +fn assert_lines_equivalent(actual: &[RolloutLine], expected: &[RolloutLine]) { + assert_eq!(actual.len(), expected.len(), "line count mismatch"); + for (index, (actual, expected)) in actual.iter().zip(expected).enumerate() { + let context = format!("line {index}"); + match (actual, expected) { + (RolloutLine::Item(actual), RolloutLine::Item(expected)) => { + assert_eq!(actual.timestamp, expected.timestamp, "{context}: timestamp"); + // Align the id so unstable positions (sentinel) compare equal; + // every other field must match exactly. + let actual_aligned = ItemRecord { + id: expected.item.id, + ..actual.item.clone() + }; + assert_eq!(&actual_aligned, &expected.item, "{context}: record"); + } + _ => assert_eq!(actual, expected, "{context}"), + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[test] +fn basic_session_fixture_round_trips_through_v2() { + let original = fixture_lines("basic_session.jsonl"); + let expected = Normalizer::new().normalize(&original); + assert_lines_equivalent(&round_trip(&original), &expected); +} + +#[test] +fn internal_lines_fixture_round_trips_through_v2() { + let original = fixture_lines("internal_lines.jsonl"); + let expected = Normalizer::new().normalize(&original); + assert_lines_equivalent(&round_trip(&original), &expected); +} + +#[test] +fn orphan_decision_fixture_round_trips_with_warning_dropped() { + let original = fixture_lines("orphan_decision.jsonl"); + let expected = Normalizer::new().normalize(&original); + let actual = round_trip(&original); + // The orphan decision became a v2 Warning, which the inverse skips. + assert!( + actual + .iter() + .all(|line| !matches!(line, RolloutLine::Item(line) if line.item.output_items.iter().any(|item| matches!(item, TurnItem::ApprovalDecision(_))))) + ); + assert_lines_equivalent(&actual, &expected); +} + +/// Live-write shapes: one payload per record in `output_items`, the approval +/// pair, contexts in the extras, and a hook prompt. +fn live_write_lines() -> Vec { + let session_id = devo_core::SessionId::new(); + let turn_id = devo_core::TurnId::new(); + let item = |seq: u64, payload: TurnItem| { + RolloutLine::Item(ItemLine { + timestamp: ts(10 + seq as u32), + item: ItemRecord { + id: ItemId::new(), + session_id, + turn_id, + seq, + timestamp: ts(10 + seq as u32), + attempt_placement: None, + turn_status: Some(TurnStatus::Running), + sibling_turn_ids: Vec::new(), + input_items: Vec::new(), + output_items: vec![payload], + worklog: None, + error: None, + schema_version: 1, + }, + }) + }; + vec![ + RolloutLine::SessionMeta(Box::new(devo_core::SessionMetaLine { + timestamp: ts(0), + session: SessionRecord { + id: session_id, + rollout_path: "rollout.jsonl".into(), + created_at: ts(0), + updated_at: ts(1), + last_activity_at: Some(ts(1)), + source: "cli".into(), + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: "openai".into(), + model: Some("gpt-5.2".into()), + model_binding_id: None, + reasoning_effort_selection: Some("medium".into()), + cwd: "/tmp/live".into(), + additional_directories: vec!["/tmp/live-extra".into()], + cli_version: "0.1.31".into(), + title: None, + title_state: SessionTitleState::Unset, + sandbox_policy: "workspace-write".into(), + approval_mode: "on-request".into(), + tokens_used: 0, + first_user_message: None, + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + parent_session_id: None, + session_context: None, + latest_turn_context: None, + schema_version: 2, + }, + })), + RolloutLine::Turn(Box::new(devo_core::TurnLine { + timestamp: ts(2), + turn: TurnRecord { + id: turn_id, + session_id, + sequence: 1, + started_at: ts(2), + completed_at: None, + status: TurnStatus::Running, + kind: TurnKind::Regular, + model: "gpt-5.2".into(), + model_binding_id: Some("binding-1".into()), + reasoning_effort_selection: Some("medium".into()), + request_model: "gpt-5.2".into(), + request_thinking: None, + input_token_estimate: None, + usage: None, + latest_query_usage: None, + stop_reason: None, + failure_reason: None, + error: None, + session_context: None, + turn_context: None, + schema_version: 4, + }, + })), + item( + 1, + TurnItem::UserMessage(TextItem { + text: "hello".into(), + }), + ), + item( + 2, + TurnItem::ToolCall(ToolCallItem { + tool_call_id: "call-1".into(), + tool_name: "exec_command".into(), + input: serde_json::json!({ "command": "ls" }), + }), + ), + item( + 3, + TurnItem::ApprovalRequest(ApprovalRequestItem { + approval_id: "appr-1".into(), + action_summary: "Run ls".into(), + justification: "listing".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["once".into()], + path: Some("/tmp/live".into()), + host: None, + target: None, + }), + ), + item( + 4, + TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: "appr-1".into(), + decision: "approve".into(), + scope: "once".into(), + }), + ), + item( + 5, + TurnItem::HookPrompt(TextItem { + text: "hook".into(), + }), + ), + ] +} + +#[test] +fn live_write_shapes_round_trips_through_v2() { + let original = live_write_lines(); + let expected = Normalizer::new().normalize(&original); + assert_lines_equivalent(&round_trip(&original), &expected); +} + +#[test] +fn mixed_v1_v2_file_dispatches_per_line() { + let original = fixture_lines("basic_session.jsonl"); + let mut forward = LegacyProjector::new(); + let mut raw_lines = Vec::new(); + for line in &original[..2] { + raw_lines.push(serde_json::to_string(line).expect("serialize legacy")); + } + for line in &original[2..4] { + for v2 in forward.project_line(line).expect("forward projection") { + raw_lines.push(serde_json::to_string(&v2).expect("serialize v2")); + } + } + + let kinds: Vec<&'static str> = raw_lines + .iter() + .map(|raw| match parse_rollout_line(raw).expect("line parses") { + ParsedRolloutLine::Legacy(_) => "legacy", + ParsedRolloutLine::V2(_) => "v2", + }) + .collect(); + // Two legacy lines, then the two item records expand to 3 + 2 v2 lines. + assert_eq!(kinds, vec!["legacy", "legacy", "v2", "v2", "v2", "v2", "v2"]); +} + +#[test] +fn inverse_rejects_prefixed_canonical_ids() { + let line = RolloutLineV2::SessionMeta { + v: devo_core::ROLLOUT_FORMAT_VERSION, + timestamp: ts(0), + session: Box::new(devo_protocol::canonical::session::Session { + id: devo_protocol::canonical::ids::SessionId::new(), + version: 1, + cwd: PathBuf::from("/tmp"), + additional_directories: Vec::new(), + parent: None, + ephemeral: false, + created_at: ts(0), + status: devo_protocol::canonical::session::SessionStatus::Idle, + flags: Vec::new(), + archived: false, + active_turn_id: None, + queued_count: 0, + title: None, + model: devo_protocol::canonical::model::ModelBinding { + provider: "openai".into(), + model: "gpt-5.2".into(), + reasoning_effort: None, + }, + settings: devo_protocol::canonical::session::SessionSettings { + permission_profile: devo_protocol::canonical::model::PermissionProfile::Default, + reasoning_effort: None, + mode: None, + sandbox_profile: None, + }, + git_info: None, + preview: String::new(), + last_activity_at: ts(0), + usage: devo_protocol::canonical::usage::SessionUsage { + total: devo_protocol::canonical::usage::UsageTotals::default(), + by_purpose: Vec::new(), + legacy: None, + updated_at: ts(0), + }, + }), + extras: None, + }; + let mut inverse = V2InverseProjector::new(); + let error = inverse.project_line(&line).expect_err("prefixed id must fail"); + assert!(matches!(error, V2InverseError::NonLegacyId(_))); +} + +#[test] +fn inverse_rejects_internal_lines_before_session_meta() { + let line = RolloutLineV2::Internal { + v: devo_core::ROLLOUT_FORMAT_VERSION, + timestamp: ts(0), + entry: devo_core::InternalRecordV2::Entry { + entry: devo_protocol::canonical::item::InternalEntry::TurnSummary { + text: "1".into(), + }, + }, + }; + let mut inverse = V2InverseProjector::new(); + let error = inverse.project_line(&line).expect_err("missing session meta"); + assert_eq!( + error, + V2InverseError::MissingSessionMeta("internal entry") + ); +} diff --git a/crates/protocol/src/canonical/session.rs b/crates/protocol/src/canonical/session.rs index aacb2310..caf8dca5 100644 --- a/crates/protocol/src/canonical/session.rs +++ b/crates/protocol/src/canonical/session.rs @@ -33,6 +33,9 @@ pub struct Session { /// of the session's identity, not the client's location; resuming from a /// different cwd does not change it. pub cwd: PathBuf, + /// Additional absolute workspace roots associated with the session. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub additional_directories: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent: Option, pub ephemeral: bool, @@ -123,6 +126,10 @@ pub struct SessionSettings { /// ACP-style session mode id, if one is active. #[serde(default, skip_serializing_if = "Option::is_none")] pub mode: Option, + /// Active sandbox profile description; needed to restore the sandbox when + /// resuming the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox_profile: Option, } /// Snapshot semantics: the current value is *copied* into the record at From ef30fd0dab71851592410f9ca98386f27d494125 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 03:16:50 +0800 Subject: [PATCH 07/17] feat(server): switch rollout writes to v2 with dual-format reads and fail-closed damage policy --- .../core/src/conversation/legacy_projector.rs | 139 +++- crates/core/src/conversation/rollout_v2.rs | 10 +- crates/core/src/conversation/v2_inverse.rs | 77 +- crates/core/tests/v2_roundtrip.rs | 31 +- crates/server/src/db.rs | 58 ++ crates/server/src/persistence.rs | 695 +++++++++++++++++- crates/server/tests/persistence_resume.rs | 34 +- .../tests/provider_failure_reporting.rs | 8 +- crates/server/tests/support/rollout.rs | 29 + crates/server/tests/turn_start_persistence.rs | 10 +- 10 files changed, 960 insertions(+), 131 deletions(-) create mode 100644 crates/server/tests/support/rollout.rs diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index 0744d950..7f93a600 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -116,6 +116,70 @@ impl LegacyProjector { seq } + /// Re-syncs the write-path state (seq counter, approval folds, cwd) with + /// a v2 line that is already on disk. Stores hydrating a projector for a + /// pre-existing file replay legacy lines through [`Self::project_line`] + /// and feed v2 lines through this method so subsequent appends never + /// collide with or orphan the on-disk history. + pub fn observe_v2_line(&mut self, line: &RolloutLineV2) { + match line { + RolloutLineV2::SessionMeta { session, .. } => { + self.session_cwd = Some(session.cwd.clone()); + } + RolloutLineV2::Item { item, .. } => { + self.next_seq = self.next_seq.max(item.seq + 1); + if let Item::Approval { + approval_id, + action_summary, + justification, + resource, + available_scopes, + target, + decision, + .. + } = &item.item + { + match decision { + None => { + self.approvals.insert( + approval_id.clone(), + ApprovalFold { + item_id: item.id.clone(), + seq: item.seq, + revision: item.revision, + request: approval_request_from_parts( + approval_id, + action_summary, + justification, + resource, + available_scopes, + target, + ), + }, + ); + } + Some(_) => { + if let Some(fold) = self.approvals.get_mut(approval_id) { + fold.revision = fold.revision.max(item.revision); + } + } + } + } + } + RolloutLineV2::Internal { seq, .. } => { + self.next_seq = self.next_seq.max(seq + 1); + } + RolloutLineV2::Turn { .. } + | RolloutLineV2::SessionTitleUpdated { .. } + | RolloutLineV2::CompactionSnapshot { .. } + | RolloutLineV2::SessionRollback { .. } + | RolloutLineV2::WorkspaceCheckpoint { .. } + | RolloutLineV2::WorkspaceChange { .. } + | RolloutLineV2::WorkspaceRestoreStarted { .. } + | RolloutLineV2::WorkspaceRestoreCompleted { .. } => {} + } + } + /// Projects one legacy rollout line into zero or more v2 lines. Most /// lines map 1:1; an `Item` record expands to one line per packed /// payload, and internal payloads (HookPrompt/TurnSummary/ToolProgress) @@ -141,6 +205,9 @@ impl LegacyProjector { Ok(vec![RolloutLineV2::Internal { v: ROLLOUT_FORMAT_VERSION, timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), + turn_id: None, + seq: 0, entry: InternalRecordV2::SessionContext(Box::new( line.session_context.clone(), )), @@ -160,16 +227,34 @@ impl LegacyProjector { .collect::>()?, }]) } - RolloutLine::MessageEditRecorded(line) => Ok(vec![RolloutLineV2::Internal { - v: ROLLOUT_FORMAT_VERSION, - timestamp: line.timestamp, - entry: InternalRecordV2::MessageEdit(line.record.clone()), - }]), - RolloutLine::TurnSuperseded(line) => Ok(vec![RolloutLineV2::Internal { - v: ROLLOUT_FORMAT_VERSION, - timestamp: line.timestamp, - entry: InternalRecordV2::TurnSuperseded(line.record.clone()), - }]), + RolloutLine::MessageEditRecorded(line) => { + let record = &line.record; + Ok(vec![RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(record.session_id)?), + turn_id: record + .replacement_turn_id + .or(record.target_turn_id) + .map(|id| legacy_uuid(id).map(TurnId::from_legacy_uuid)) + .transpose()?, + seq: 0, + entry: InternalRecordV2::MessageEdit(line.record.clone()), + }]) + } + RolloutLine::TurnSuperseded(line) => { + let record = &line.record; + Ok(vec![RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(record.session_id)?), + turn_id: Some(TurnId::from_legacy_uuid(legacy_uuid( + record.replacement_turn_id, + )?)), + seq: 0, + entry: InternalRecordV2::TurnSuperseded(line.record.clone()), + }]) + } RolloutLine::TurnWorkspaceCheckpointRecorded(line) => { Ok(vec![RolloutLineV2::WorkspaceCheckpoint { v: ROLLOUT_FORMAT_VERSION, @@ -471,9 +556,15 @@ impl LegacyProjector { state, } => (id, seq, revision, state, item), Projected::Internal(entry) => { + // Internal entries consume one sequence position, shared + // with the item stream, so their order among items is + // exactly recoverable by the inverse projector. out.push(RolloutLineV2::Internal { v: ROLLOUT_FORMAT_VERSION, timestamp: line.timestamp, + session_id: session_id.clone(), + turn_id: Some(turn_id.clone()), + seq: self.next_seq(), entry: *entry, }); continue; @@ -740,6 +831,34 @@ fn legacy_uuid(id: impl Display) -> Result { Uuid::parse_str(&text).map_err(|_| LegacyProjectError::InvalidLegacyId(text)) } +/// Rebuilds a legacy approval request payload from the canonical approval +/// parts (the inverse of [`approval_request_item`]); used when hydrating the +/// fold map from an on-disk v2 approval envelope. +fn approval_request_from_parts( + approval_id: &str, + action_summary: &str, + justification: &str, + resource: &Option, + available_scopes: &[String], + target: &Option, +) -> ApprovalRequestItem { + let (path, host, target) = target.as_ref().map_or((None, None, None), |target| match target { + ApprovalTarget::Path { path } => (Some(path.display().to_string()), None, None), + ApprovalTarget::Host { host } => (None, Some(host.clone()), None), + ApprovalTarget::Command { command } => (None, None, Some(command.clone())), + }); + ApprovalRequestItem { + approval_id: approval_id.into(), + action_summary: action_summary.into(), + justification: justification.into(), + resource: resource.clone(), + available_scopes: available_scopes.into(), + path, + host, + target, + } +} + /// Builds the approval target from the legacy request's optional path, host, /// or free-form target string, in that priority order. fn approval_target(request: &ApprovalRequestItem) -> Option { diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs index 0e3693eb..26a5dd31 100644 --- a/crates/core/src/conversation/rollout_v2.rs +++ b/crates/core/src/conversation/rollout_v2.rs @@ -100,10 +100,18 @@ pub enum RolloutLineV2 { item: ItemEnvelope, }, /// Rollout-only records that are not public items; see - /// [`InternalRecordV2`]. + /// [`InternalRecordV2`]. Identity and position travel on the line so + /// internal entries are exactly recoverable: `Entry` records are + /// turn-scoped and consume one sequence position (shared with the item + /// stream); the other records are session-scoped markers that carry + /// their own identity inside the payload, so `turn_id` is `None` and + /// `seq` is 0 for them. Internal { v: u32, timestamp: DateTime, + session_id: SessionId, + turn_id: Option, + seq: u64, entry: InternalRecordV2, }, /// Session title change. The legacy `title_state` is dropped: title diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs index be5a961e..d0a575eb 100644 --- a/crates/core/src/conversation/v2_inverse.rs +++ b/crates/core/src/conversation/v2_inverse.rs @@ -56,40 +56,29 @@ pub enum V2InverseError { /// then the legacy replay path this projector feeds is gone. #[error("canonical id is not a legacy bare UUID: {0}")] NonLegacyId(String), - /// An internal or session-context line arrived before the SessionMeta - /// line established the session identity (internal lines carry no - /// ids of their own). - #[error("line arrived before the session meta line: {0}")] - MissingSessionMeta(&'static str), + /// A turn-scoped internal entry arrived without its turn id (the v2 + /// writer always sets one for `Entry` records). + #[error("internal entry line is missing its turn id")] + MissingTurnId, } -/// Stateful v2 → legacy line converter, one instance per session file. The -/// state exists because v2 `Internal` lines carry no session/turn identity -/// or sequence of their own; both are approximated from the surrounding -/// lines (see the comments at the use sites). +/// v2 → legacy line converter. Stateless: every v2 line carries the +/// identity the inverse needs (internal lines got explicit +/// session/turn/seq fields in the same change that introduced the write +/// switch). #[derive(Debug, Default)] -pub struct V2InverseProjector { - /// Session id learned from the SessionMeta line. - session_id: Option, - /// Turn id learned from the most recent Turn line; internal entries are - /// attributed to it (their original owning turn is not recorded on the - /// v2 internal line). - current_turn_id: Option, - /// Highest item envelope seq seen so far; synthesized records for - /// internal lines sort at this position. - max_seq_seen: u64, -} +pub struct V2InverseProjector; impl V2InverseProjector { pub fn new() -> Self { - Self::default() + Self } /// Projects one v2 line into zero or more legacy lines. Most lines map /// 1:1; items the legacy format cannot represent at all (`Warning` and /// the new-only canonical variants) produce no line — see the comment on /// the item mapping. - pub fn project_line(&mut self, line: &RolloutLineV2) -> Result, V2InverseError> { + pub fn project_line(&self, line: &RolloutLineV2) -> Result, V2InverseError> { match line { RolloutLineV2::SessionMeta { timestamp, @@ -104,12 +93,16 @@ impl V2InverseProjector { .. } => self.project_turn(*timestamp, turn, extras.as_deref()), RolloutLineV2::Item { item, .. } => { - self.max_seq_seen = self.max_seq_seen.max(item.seq); Ok(self.project_item_envelope(item)?.into_iter().collect()) } RolloutLineV2::Internal { - timestamp, entry, .. - } => self.project_internal(*timestamp, entry), + timestamp, + session_id, + turn_id, + seq, + entry, + .. + } => self.project_internal(*timestamp, session_id, turn_id.as_ref(), *seq, entry), RolloutLineV2::SessionTitleUpdated { timestamp, session_id, @@ -204,13 +197,12 @@ impl V2InverseProjector { } fn project_session_meta( - &mut self, + &self, timestamp: DateTime, session: &Session, extras: Option<&SessionPersistenceExtras>, ) -> Result, V2InverseError> { let id = legacy_session_id(&session.id)?; - self.session_id = Some(id); let (parent_session_id, agent_role) = match &session.parent { Some(SessionParent::Fork { session_id, .. }) => { @@ -303,13 +295,12 @@ impl V2InverseProjector { } fn project_turn( - &mut self, + &self, timestamp: DateTime, turn: &Turn, extras: Option<&TurnPersistenceExtras>, ) -> Result, V2InverseError> { let id = legacy_turn_id(&turn.id)?; - self.current_turn_id = Some(id); let status = match turn.status { // Pending/WaitingApproval are not distinguishable after the @@ -582,6 +573,9 @@ impl V2InverseProjector { fn project_internal( &self, timestamp: DateTime, + session_id: &CanonicalSessionId, + turn_id: Option<&CanonicalTurnId>, + seq: u64, entry: &InternalRecordV2, ) -> Result, V2InverseError> { match entry { @@ -600,24 +594,16 @@ impl V2InverseProjector { text: text.clone(), }), }; - // Internal v2 lines carry no ids or seq. The id is a fresh - // bare UUID (replay only needs uniqueness); the seq is the - // highest envelope seq seen so far, so the record sorts at - // the position where the internal line appeared. - let envelope_seq = self.max_seq_seen; - let session_id = self - .session_id - .ok_or(V2InverseError::MissingSessionMeta("internal entry"))?; - let turn_id = self - .current_turn_id - .ok_or(V2InverseError::MissingSessionMeta("internal entry"))?; + // Identity and position travel on the line (exact); only the + // record id is synthesized, since internal entries have no + // item id of their own (replay only needs uniqueness). Ok(vec![RolloutLine::Item(ItemLine { timestamp, item: ItemRecord { id: ItemId::new(), - session_id, - turn_id, - seq: envelope_seq, + session_id: legacy_session_id(session_id)?, + turn_id: legacy_turn_id(turn_id.ok_or(V2InverseError::MissingTurnId)?)?, + seq, timestamp, attempt_placement: None, turn_status: None, @@ -631,13 +617,10 @@ impl V2InverseProjector { })]) } InternalRecordV2::SessionContext(context) => { - let session_id = self - .session_id - .ok_or(V2InverseError::MissingSessionMeta("session context"))?; Ok(vec![RolloutLine::SessionContextUpdated(Box::new( SessionContextUpdatedLine { timestamp, - session_id, + session_id: legacy_session_id(session_id)?, session_context: (**context).clone(), schema_version: CURRENT_SNAPSHOT_SCHEMA_VERSION, }, diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs index 269179f7..d91d43bf 100644 --- a/crates/core/tests/v2_roundtrip.rs +++ b/crates/core/tests/v2_roundtrip.rs @@ -50,7 +50,7 @@ fn fixture_lines(name: &str) -> Vec { /// inverse project back to legacy lines. fn round_trip(lines: &[RolloutLine]) -> Vec { let mut forward = LegacyProjector::new(); - let mut inverse = V2InverseProjector::new(); + let inverse = V2InverseProjector::new(); let mut out = Vec::new(); for line in lines { let v2_lines = forward.project_line(line).expect("forward projection"); @@ -143,12 +143,13 @@ impl Normalizer { let stable_id = (index == 0).then_some(record.id); match payload { TurnItem::HookPrompt(_) | TurnItem::ToolProgress(_) | TurnItem::TurnSummary(_) => { - out.push(item_line( - sentinel_id(), - record, - self.next_seq.saturating_sub(1), - payload.clone(), - )); + // Internal entries consume one sequence position in v2 + // and the inverse restores it verbatim; the record id + // is still synthesized (internal entries have no item + // id), so it stays unstable. + let seq = self.next_seq; + self.next_seq += 1; + out.push(item_line(sentinel_id(), record, seq, payload.clone())); } TurnItem::ApprovalRequest(request) => { let seq = self.next_seq; @@ -579,26 +580,26 @@ fn inverse_rejects_prefixed_canonical_ids() { }), extras: None, }; - let mut inverse = V2InverseProjector::new(); + let inverse = V2InverseProjector::new(); let error = inverse.project_line(&line).expect_err("prefixed id must fail"); assert!(matches!(error, V2InverseError::NonLegacyId(_))); } #[test] -fn inverse_rejects_internal_lines_before_session_meta() { +fn inverse_rejects_turn_scoped_internal_line_without_turn_id() { let line = RolloutLineV2::Internal { v: devo_core::ROLLOUT_FORMAT_VERSION, timestamp: ts(0), + session_id: devo_protocol::canonical::ids::SessionId::from_legacy_uuid(Uuid::nil()), + turn_id: None, + seq: 1, entry: devo_core::InternalRecordV2::Entry { entry: devo_protocol::canonical::item::InternalEntry::TurnSummary { text: "1".into(), }, }, }; - let mut inverse = V2InverseProjector::new(); - let error = inverse.project_line(&line).expect_err("missing session meta"); - assert_eq!( - error, - V2InverseError::MissingSessionMeta("internal entry") - ); + let inverse = V2InverseProjector::new(); + let error = inverse.project_line(&line).expect_err("missing turn id"); + assert_eq!(error, V2InverseError::MissingTurnId); } diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 94d280b7..4a6452bc 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -60,6 +60,11 @@ pub struct SessionStats { pub prompt_token_estimate: usize, } +/// Current index schema version recorded in `schema_meta` (05 §2.3). +/// Bump when a migration changes the index layout; the rollout files are +/// the rebuildable source of truth on any mismatch. +const CURRENT_SCHEMA_VERSION: u32 = 1; + /// SQLite database for session metadata, token stats, and pending queues. pub struct Database { conn: Arc>, @@ -239,9 +244,45 @@ impl Database { conn.execute("ALTER TABLE sessions ADD COLUMN agent_path TEXT", []) .context("failed to add agent_path column")?; } + // Schema version table (05 §2.3): the new authority going forward. + // The ad-hoc column probes above are the v0 baseline and keep working + // for databases created before this table existed. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", + ) + .context("failed to create schema_meta table")?; + conn.execute( + "INSERT INTO schema_meta (key, value) VALUES ('schema_version', ?1) + ON CONFLICT(key) DO NOTHING", + [CURRENT_SCHEMA_VERSION.to_string()], + ) + .context("failed to record schema version")?; Ok(()) } + /// The recorded schema version, if any. `None` means the database + /// predates the `schema_meta` table (implicitly version 0). + pub fn schema_version(&self) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let value: Option = conn + .query_row( + "SELECT value FROM schema_meta WHERE key = 'schema_version'", + [], + |row| row.get(0), + ) + .ok(); + value + .map(|value| { + value + .parse::() + .context("invalid schema_version in schema_meta") + }) + .transpose() + } + // === Session CRUD === /// Inserts or updates a session's metadata and optional rollout index fields. @@ -843,6 +884,23 @@ mod tests { (db, dir) } + #[test] + fn schema_meta_records_current_schema_version() { + let (db, _dir) = test_db(); + assert_eq!( + db.schema_version().expect("read schema version"), + Some(CURRENT_SCHEMA_VERSION) + ); + // Re-opening an existing database keeps the recorded version. + let (db, dir) = test_db(); + drop(db); + let db = Database::open(dir.path().join("test.db")).expect("reopen database"); + assert_eq!( + db.schema_version().expect("read schema version"), + Some(CURRENT_SCHEMA_VERSION) + ); + } + #[test] fn migration_backfills_legacy_session_stats_total_tokens() { let dir = TempDir::new().expect("create temp dir"); diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index ea9f50b6..de3d4732 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -24,8 +24,10 @@ use devo_core::ItemRecord; use devo_core::Message; use devo_core::MessageEditRecordedLine; use devo_core::MessageEditRecordedRecord; +use devo_core::ParsedRolloutLine; use devo_core::Role; use devo_core::RolloutLine; +use devo_core::RolloutLineReadError; use devo_core::SessionContext; use devo_core::SessionContextUpdatedLine; use devo_core::SessionId; @@ -54,7 +56,10 @@ use devo_core::TurnWorkspaceRestoreCompletedLine; use devo_core::TurnWorkspaceRestoreCompletedRecord; use devo_core::TurnWorkspaceRestoreStartedLine; use devo_core::TurnWorkspaceRestoreStartedRecord; +use devo_core::V2InverseProjector; use devo_core::Worklog; +use devo_core::parse_rollout_line; +use devo_core::legacy_projector::LegacyProjector; use crate::execution::PersistedTurnItem; use crate::execution::RuntimeSession; @@ -71,6 +76,10 @@ pub(crate) struct RolloutStore { /// Per-file locks that serialise concurrent writes to the same rollout file, /// preventing interleaved JSON lines. file_locks: Arc>>>>, + /// Per-file write-path projectors (v2 single-write, 05 §2.2). One + /// instance per rollout path, hydrated from the on-disk history on first + /// append so item seqs and approval folds never collide with it. + projectors: Arc>>, } impl std::fmt::Debug for RolloutStore { @@ -86,6 +95,7 @@ impl Clone for RolloutStore { Self { data_root: self.data_root.clone(), file_locks: Arc::clone(&self.file_locks), + projectors: Arc::clone(&self.projectors), } } } @@ -96,6 +106,7 @@ impl RolloutStore { Self { data_root, file_locks: Arc::new(StdMutex::new(HashMap::new())), + projectors: Arc::new(StdMutex::new(HashMap::new())), } } @@ -524,6 +535,9 @@ impl RolloutStore { .with_context(|| format!("open rollout file {}", rollout_path.display()))?; let reader = BufReader::new(file); let mut replay = ReplayState::default(); + // Dual read (05 §2.2): legacy lines replay directly, v2 lines are + // projected back into legacy lines by the per-load inverse. + let inverse = V2InverseProjector::new(); let mut lines = reader.lines().enumerate().peekable(); while let Some((line_index, line)) = lines.next() { @@ -532,26 +546,50 @@ impl RolloutStore { if line.trim().is_empty() { continue; } - match serde_json::from_str::(&line) { - Ok(parsed) => replay.apply_line(parsed)?, + let parsed = match parse_rollout_line(&line) { + Ok(parsed) => parsed, + // A truncated final line is a crash tail: the write never + // completed, nothing was acknowledged. + Err(RolloutLineReadError::TruncatedTail) if lines.peek().is_none() => break, + // Fail closed: a damaged or unsupported mid-file line means + // the session's history is unreadable past this point; the + // session refuses to resume rather than silently dropping + // history and appending onto a fork. Err(error) => { - if lines.peek().is_none() { - break; + return Err(error).with_context(|| { + format!( + "rollout {} is damaged at line {}; refusing to resume", + rollout_path.display(), + line_index + 1 + ) + }); + } + }; + match parsed { + ParsedRolloutLine::Legacy(legacy) => replay.apply_line(*legacy)?, + ParsedRolloutLine::V2(v2) => { + for legacy_line in inverse + .project_line(&v2) + .with_context(|| format!("project v2 line from {}", rollout_path.display()))? + { + replay.apply_line(legacy_line)?; } - tracing::warn!( - rollout_path = %rollout_path.display(), - line_number = line_index + 1, - error = %error, - "skipping corrupt rollout line" - ); } } } - replay + let mut recovered = replay .into_runtime_session(deps) .await - .with_context(|| format!("replay rollout {}", rollout_path.display())) + .with_context(|| format!("replay rollout {}", rollout_path.display()))?; + // Inverse-projected (v2) session records carry an empty rollout_path + // by design; the real location is always the file being read. + if let Some(record) = recovered.record.as_mut() + && record.rollout_path.as_os_str().is_empty() + { + record.rollout_path = rollout_path.to_path_buf(); + } + Ok(recovered) } pub(crate) fn rollout_paths(&self) -> Result> { @@ -578,6 +616,10 @@ impl RolloutStore { partition.join(format!("rollout-{timestamp}-{session_id}.jsonl")) } + /// Appends one legacy-shaped line to the rollout file. The write path is + /// single-write v2 (05 §2.2): the line is projected through the per-file + /// [`LegacyProjector`] and every resulting v2 line becomes its own JSONL + /// row. Legacy callers (the typed wrappers above) are unchanged. fn append_line(&self, rollout_path: &Path, line: &RolloutLine) -> Result<()> { if let Some(parent) = rollout_path.parent() { std::fs::create_dir_all(parent) @@ -596,21 +638,92 @@ impl RolloutStore { .clone() }; let _guard = file_lock.lock().expect("rollout per-file lock poisoned"); + + let mut projectors = self + .projectors + .lock() + .expect("rollout projector table poisoned"); + let projector = match projectors.get_mut(rollout_path) { + Some(projector) => projector, + None => { + let projector = hydrate_projector(rollout_path)?; + projectors.entry(rollout_path.to_path_buf()).or_insert(projector) + } + }; + let v2_lines = projector + .project_line(line) + .with_context(|| format!("project rollout line for {}", rollout_path.display()))?; + let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(rollout_path) .with_context(|| format!("open rollout file {}", rollout_path.display()))?; - serde_json::to_writer(&mut file, line) - .with_context(|| format!("serialize rollout line {}", rollout_path.display()))?; - file.write_all(b"\n") - .with_context(|| format!("write rollout newline {}", rollout_path.display()))?; + for v2_line in &v2_lines { + serde_json::to_writer(&mut file, v2_line) + .with_context(|| format!("serialize rollout line {}", rollout_path.display()))?; + file.write_all(b"\n") + .with_context(|| format!("write rollout newline {}", rollout_path.display()))?; + } file.flush() .with_context(|| format!("flush rollout file {}", rollout_path.display()))?; + // The rollout is the event log: an acknowledged write must survive a + // crash, so every append ends in fsync (file data only, not the + // directory entry — matches the pre-v2 durability floor plus the + // event-log requirement). + file.sync_data() + .with_context(|| format!("fsync rollout file {}", rollout_path.display()))?; Ok(()) } } +/// Builds the write-path projector for an existing rollout file by replaying +/// its current contents: legacy lines go through the forward projector (so +/// the seq counter and approval folds advance exactly as if the file had +/// been written through the v2 path), v2 lines re-sync that state via +/// [`LegacyProjector::observe_v2_line`]. Bounded per path: runs once, on the +/// first append, and the result is cached in the store. +/// +/// Fails closed on any damaged or unsupported line: appending onto history +/// the projector could not fully read would fork the session's history. +fn hydrate_projector(rollout_path: &Path) -> Result { + let mut projector = LegacyProjector::new(); + let file = match File::open(rollout_path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(projector), + Err(error) => { + return Err(error).with_context(|| format!("open rollout file {}", rollout_path.display())); + } + }; + let reader = BufReader::new(file); + let mut lines = reader.lines().enumerate().peekable(); + while let Some((line_index, line)) = lines.next() { + let line = line.with_context(|| format!("read line from {}", rollout_path.display()))?; + if line.trim().is_empty() { + continue; + } + match parse_rollout_line(&line) { + Ok(ParsedRolloutLine::Legacy(legacy)) => { + projector + .project_line(&legacy) + .with_context(|| format!("hydrate projector from {}", rollout_path.display()))?; + } + Ok(ParsedRolloutLine::V2(v2)) => projector.observe_v2_line(&v2), + Err(RolloutLineReadError::TruncatedTail) if lines.peek().is_none() => break, + Err(error) => { + return Err(error).with_context(|| { + format!( + "rollout {} is damaged at line {}; refusing to append", + rollout_path.display(), + line_index + 1 + ) + }); + } + } + } + Ok(projector) +} + #[derive(Default)] struct ReplayState { session: Option, @@ -1700,34 +1813,43 @@ fn read_rollout_index_fields(path: &Path) -> Result<(SessionRecord, chrono::Date let reader = BufReader::new(file); let mut session: Option = None; let mut last_activity_at: Option> = None; + // Dual read for the index path (05 §2.2/§2.3). The index is a rebuildable + // cache, so — unlike resume — unreadable lines are skipped, not fatal. + let inverse = V2InverseProjector::new(); for line in reader.lines() { let line = line.with_context(|| format!("read line from {}", path.display()))?; if line.trim().is_empty() { continue; } - let parsed = match serde_json::from_str::(&line) { - Ok(parsed) => parsed, + let legacy_lines: Vec = match parse_rollout_line(&line) { + Ok(ParsedRolloutLine::Legacy(legacy)) => vec![*legacy], + Ok(ParsedRolloutLine::V2(v2)) => match inverse.project_line(&v2) { + Ok(lines) => lines, + Err(_) => continue, + }, Err(_) => continue, }; - match parsed { - RolloutLine::SessionMeta(meta_line) => { - let mut record = meta_line.session; - if record.last_activity_at.is_none() { - record.last_activity_at = Some(record.created_at); + for parsed in legacy_lines { + match parsed { + RolloutLine::SessionMeta(meta_line) => { + let mut record = meta_line.session; + if record.last_activity_at.is_none() { + record.last_activity_at = Some(record.created_at); + } + last_activity_at = record.last_activity_at; + session = Some(record); } - last_activity_at = record.last_activity_at; - session = Some(record); - } - RolloutLine::SessionTitleUpdated(line) => { - if let Some(record) = session.as_mut() { - record.title = Some(line.title); - record.title_state = line.title_state; - record.updated_at = line.timestamp; - last_activity_at = Some(line.timestamp); + RolloutLine::SessionTitleUpdated(line) => { + if let Some(record) = session.as_mut() { + record.title = Some(line.title); + record.title_state = line.title_state; + record.updated_at = line.timestamp; + last_activity_at = Some(line.timestamp); + } } + _ => {} } - _ => {} } } @@ -1883,15 +2005,19 @@ pub(crate) fn build_item_record( mod tests { use std::collections::HashMap; use std::path::PathBuf; + use std::sync::Arc; use chrono::TimeZone; use chrono::Utc; use pretty_assertions::assert_eq; + use super::ParsedRolloutLine; use super::ReplayHistoryItemPayload; use super::ReplayState; use super::build_prompt_messages_from_snapshot; + use super::parse_rollout_line; use crate::execution::PersistedTurnItem; + use crate::execution::ServerRuntimeDependencies; use crate::persistence::apply_turn_item; use devo_core::CompactionSnapshotLine; use devo_core::ContentPart; @@ -3124,6 +3250,507 @@ mod tests { assert!(session_context_recorded); let rollout = std::fs::read_to_string(&record.rollout_path).expect("read rollout"); assert_eq!(rollout.matches("unique-base-instruction-marker").count(), 1); - assert!(rollout.contains("SessionContextUpdated")); + // v2 write path: the locked context travels as an internal line. + assert!(rollout.contains("\"sessionContext\"")); + } + + // ── v2 write switch / dual read (P3b) ───────────────────────────── + + struct NoopProvider; + + #[async_trait::async_trait] + impl devo_provider::ModelProviderSDK for NoopProvider { + async fn completion( + &self, + _request: devo_protocol::ModelRequest, + ) -> anyhow::Result { + anyhow::bail!("noop provider does not support completion") + } + + async fn completion_stream( + &self, + _request: devo_protocol::ModelRequest, + ) -> anyhow::Result< + std::pin::Pin< + Box> + Send>, + >, + > { + anyhow::bail!("noop provider does not support streaming") + } + + fn name(&self) -> &str { + "noop-provider" + } + } + + fn test_deps(data_root: &std::path::Path) -> ServerRuntimeDependencies { + let provider: Arc = Arc::new(NoopProvider); + ServerRuntimeDependencies::new( + Arc::clone(&provider), + Arc::new(devo_provider::SingleProviderRouter::new(provider)), + Arc::new(devo_core::tools::ToolRegistry::new()), + "test-model".to_string(), + Arc::new(devo_core::PresetModelCatalog::default()), + Arc::new(devo_core::ProviderVendorCatalog::default()), + Box::new(devo_core::FileSystemSkillCatalog::new( + devo_core::SkillsConfig { + bundled: Some(devo_core::BundledSkillsConfig { enabled: false }), + ..devo_core::SkillsConfig::default() + }, + )), + devo_core::AgentsMdConfig::default(), + Arc::new( + crate::db::Database::open(data_root.join("test.db")).expect("open test database"), + ), + Arc::new(std::sync::Mutex::new( + devo_core::AppConfigStore::load(data_root.to_path_buf(), None) + .expect("load app config store"), + )), + ) + } + + fn write_raw_lines(path: &std::path::Path, raw_lines: &[String]) { + use std::io::Write; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create rollout directory"); + } + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("open rollout for raw append"); + for raw in raw_lines { + file.write_all(raw.as_bytes()).expect("write raw line"); + file.write_all(b"\n").expect("write newline"); + } + } + + fn raw_rollout_lines(path: &std::path::Path) -> Vec { + std::fs::read_to_string(path) + .expect("read rollout") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(str::to_owned) + .collect() + } + + fn test_turn_metadata(session_id: SessionId, turn_id: TurnId) -> crate::turn::TurnMetadata { + crate::turn::TurnMetadata { + turn_id, + session_id, + sequence: 1, + status: TurnStatus::Completed, + kind: TurnKind::Regular, + model: "test-model".into(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".into(), + request_thinking: None, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + usage: None, + stop_reason: None, + failure_reason: None, + } + } + + #[test] + fn write_path_appends_only_v2_lines() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + let metadata = test_turn_metadata(record.id, TurnId::new()); + let turn = super::build_turn_record(&metadata, None, None, None); + rollout_store.append_turn(&record, turn).expect("append turn"); + let item = super::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + 1, + TurnItem::AgentMessage(TextItem { text: "hi".into() }), + Some(TurnStatus::Running), + None, + ); + rollout_store.append_item(&record, item).expect("append item"); + + let raw_lines = raw_rollout_lines(&record.rollout_path); + assert_eq!(raw_lines.len(), 3); + for raw in &raw_lines { + assert!(raw.contains("\"v\":2"), "line is v2: {raw}"); + match parse_rollout_line(raw).expect("line parses") { + ParsedRolloutLine::V2(_) => {} + ParsedRolloutLine::Legacy(_) => panic!("freshly written line parsed as legacy"), + } + } + } + + #[test] + fn hydration_folds_approval_decision_onto_request_across_restart() { + use devo_core::ApprovalDecisionItem; + use devo_core::ApprovalRequestItem; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + let metadata = test_turn_metadata(record.id, TurnId::new()); + let turn = super::build_turn_record(&metadata, None, None, None); + rollout_store.append_turn(&record, turn).expect("append turn"); + let request_record_id = ItemId::new(); + rollout_store + .append_item( + &record, + super::build_item_record( + record.id, + metadata.turn_id, + request_record_id, + 1, + TurnItem::ApprovalRequest(ApprovalRequestItem { + approval_id: "appr-1".into(), + action_summary: "Run ls".into(), + justification: "listing".into(), + resource: Some("ShellExec".into()), + available_scopes: vec!["once".into()], + path: None, + host: None, + target: Some("ls".into()), + }), + Some(TurnStatus::Running), + None, + ), + ) + .expect("append approval request"); + + // "Restart": a brand-new store must hydrate its projector from the + // on-disk v2 history before appending. + let restarted_store = super::RolloutStore::new(dir.path().to_path_buf()); + restarted_store + .append_item( + &record, + super::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + 2, + TurnItem::ApprovalDecision(ApprovalDecisionItem { + approval_id: "appr-1".into(), + decision: "approve".into(), + scope: "once".into(), + }), + Some(TurnStatus::Running), + None, + ), + ) + .expect("append approval decision"); + + let approvals: Vec = raw_rollout_lines(&record.rollout_path) + .iter() + .map(|raw| match parse_rollout_line(raw).expect("line parses") { + ParsedRolloutLine::V2(line) => *line, + ParsedRolloutLine::Legacy(_) => panic!("line parsed as legacy"), + }) + .filter(|line| { + matches!( + line, + devo_core::RolloutLineV2::Item { item, .. } + if matches!(item.item, devo_protocol::canonical::item::Item::Approval { .. }) + ) + }) + .collect(); + assert_eq!(approvals.len(), 2); + let devo_core::RolloutLineV2::Item { item: request, .. } = &approvals[0] else { + panic!("request line"); + }; + let devo_core::RolloutLineV2::Item { item: decision, .. } = &approvals[1] else { + panic!("decision line"); + }; + // The decision folded onto the request's item id and seq — not an + // orphan Warning with a fresh id. + assert_eq!(request.id.as_str(), request_record_id.to_string()); + assert_eq!(decision.id, request.id); + assert_eq!(decision.seq, request.seq); + assert_eq!((request.revision, decision.revision), (1, 2)); + assert_eq!( + decision.state, + devo_protocol::canonical::item::ItemState::Completed + ); + assert!( + matches!(&decision.item, devo_protocol::canonical::item::Item::Approval { decision: Some(d), .. } + if d.decision == devo_protocol::canonical::item::ApprovalDecisionKind::Approved + && d.scope == devo_protocol::canonical::item::ApprovalScope::Once) + ); + } + + #[test] + fn hydration_fails_closed_on_damaged_history() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + write_raw_lines(&record.rollout_path, &[r#"{"v":2,"kind":"nope"}"#.to_string()]); + + let restarted_store = super::RolloutStore::new(dir.path().to_path_buf()); + let metadata = test_turn_metadata(record.id, TurnId::new()); + let turn = super::build_turn_record(&metadata, None, None, None); + let error = restarted_store + .append_turn(&record, turn) + .expect_err("append onto damaged history must fail"); + assert!( + format!("{error:#}").contains("refusing to append"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn hydration_tolerates_truncated_final_line() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + write_raw_lines( + &record.rollout_path, + &[r#"{"v":2,"kind":"item","timestamp":"2026"#.to_string()], + ); + + let restarted_store = super::RolloutStore::new(dir.path().to_path_buf()); + let metadata = test_turn_metadata(record.id, TurnId::new()); + let turn = super::build_turn_record(&metadata, None, None, None); + restarted_store + .append_turn(&record, turn) + .expect("crash tail is tolerated"); + } + + #[tokio::test] + async fn dual_read_fails_closed_on_mid_file_damage_but_tolerates_crash_tail() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let deps = test_deps(dir.path()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + // Damaged middle line, then a valid line after it. + write_raw_lines(&record.rollout_path, &[r#"{"v":2,"kind":"nope"}"#.to_string()]); + let metadata = test_turn_metadata(record.id, TurnId::new()); + let turn = super::build_turn_record(&metadata, None, None, None); + rollout_store.append_turn(&record, turn).expect("append turn"); + + let error = rollout_store + .load_session_from_rollout(&record.rollout_path, &deps) + .await + .err() + .expect("damaged mid-file line must fail the load"); + assert!( + format!("{error:#}").contains("refusing to resume"), + "unexpected error: {error:#}" + ); + + // A truncated final line (crash tail) is tolerated instead. + let tail_record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&tail_record) + .expect("append session meta"); + write_raw_lines( + &tail_record.rollout_path, + &[r#"{"v":2,"kind":"item","timestamp":"2026"#.to_string()], + ); + let recovered = rollout_store + .load_session_from_rollout(&tail_record.rollout_path, &deps) + .await + .expect("crash tail is tolerated"); + assert_eq!(recovered.summary.session_id, tail_record.id); + } + + #[tokio::test] + async fn mixed_v1_v2_file_resumes_with_reconciled_next_seq() { + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let deps = test_deps(dir.path()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + Some("Mixed session".into()), + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + let metadata = test_turn_metadata(record.id, TurnId::new()); + + // v1 portion: hand-written legacy lines, as produced before the v2 + // write switch (never rewritten afterwards). + let legacy_lines = [ + RolloutLine::SessionMeta(Box::new(SessionMetaLine { + timestamp: Utc::now(), + session: record.clone(), + })), + RolloutLine::Turn(Box::new(TurnLine { + timestamp: Utc::now(), + turn: super::build_turn_record(&metadata, None, None, None), + })), + RolloutLine::Item(ItemLine { + timestamp: Utc::now(), + item: super::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + 1, + TurnItem::UserMessage(TextItem { + text: "legacy hello".into(), + }), + Some(TurnStatus::Running), + None, + ), + }), + ]; + write_raw_lines( + &record.rollout_path, + &legacy_lines + .iter() + .map(|line| serde_json::to_string(line).expect("serialize legacy line")) + .collect::>(), + ); + + // The v2 write path appends onto the legacy file (hydrating first). + rollout_store + .append_item( + &record, + super::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + 2, + TurnItem::AgentMessage(TextItem { + text: "v2 reply".into(), + }), + Some(TurnStatus::Running), + None, + ), + ) + .expect("append v2 item"); + + // The file mixes both formats; every line dispatches cleanly. + let raw_lines = raw_rollout_lines(&record.rollout_path); + assert_eq!(raw_lines.len(), 4); + for (index, raw) in raw_lines.iter().enumerate() { + let parsed = parse_rollout_line(raw).expect("line dispatches"); + if index < 3 { + assert!( + matches!(parsed, ParsedRolloutLine::Legacy(_)), + "line {index} must be legacy" + ); + } else { + assert!(matches!(parsed, ParsedRolloutLine::V2(_)), "line {index} must be v2"); + } + } + + // Dual read: the resumed session holds the union of both histories, + // and the next runtime seq matches the write-path projector's (the + // v1 item took seq 1, the v2 item seq 2 — no collision). + let recovered = rollout_store + .load_session_from_rollout(&record.rollout_path, &deps) + .await + .expect("mixed file resumes"); + assert_eq!(recovered.summary.session_id, record.id); + assert_eq!(recovered.summary.title.as_deref(), Some("Mixed session")); + assert_eq!(recovered.loaded_item_count, 2); + assert_eq!(recovered.next_item_seq, 3); + let texts: Vec<&str> = recovered + .history_items + .iter() + .map(|item| item.body.as_str()) + .collect(); + assert!(texts.contains(&"legacy hello"), "history: {texts:?}"); + assert!(texts.contains(&"v2 reply"), "history: {texts:?}"); } } diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index 3c5dc74c..f98f1102 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -4,6 +4,9 @@ use std::sync::Arc; use std::sync::Mutex; use std::task; +#[path = "support/rollout.rs"] +mod support; + use anyhow::Context; use anyhow::Result; use async_trait::async_trait; @@ -543,9 +546,12 @@ async fn runtime_generates_final_title_and_persists_explicit_rename() -> Result< rebuilt_result.session.title.as_deref(), Some("Rollout persistence follow-up") ); + // v2 title-update lines deliberately drop the title lifecycle (a derived + // cache in the canonical model); any Final variant is preserved, which + // keeps suppressing regeneration of the recorded title. assert_eq!( rebuilt_result.session.title_state, - devo_core::SessionTitleState::Final(devo_core::SessionTitleFinalSource::UserRename) + devo_core::SessionTitleState::Final(devo_core::SessionTitleFinalSource::ExplicitCreate) ); Ok(()) } @@ -1180,6 +1186,9 @@ async fn runtime_recovers_session_when_middle_rollout_line_is_corrupted() -> Res lines[2] = "{\"Turn\":{\"timestamp\":\"broken\"".to_string(); std::fs::write(&rollout_path, format!("{}\n", lines.join("\n")))?; + // Fail closed (05 §2.2): a damaged mid-file line marks the session + // damaged — it is skipped at load and refuses to resume, rather than + // silently dropping the history after the damage. let rebuilt_runtime = build_runtime(data_root.path())?; rebuilt_runtime.load_persisted_sessions().await?; let (rebuilt_connection_id, _notifications_rx) = @@ -1198,17 +1207,10 @@ async fn runtime_recovers_session_when_middle_rollout_line_is_corrupted() -> Res ) .await .context("session/resume response")?; - let resume_result = serde_json::from_value::< - devo_server::SuccessResponse, - >(resume_response)? - .result; - - assert_eq!(resume_result.session.session_id, session_id); - assert_eq!( - resume_result.session.title.as_deref(), - Some("Recoverable session") + assert!( + resume_response.get("error").is_some(), + "damaged session must refuse resume: {resume_response}" ); - assert!(resume_result.loaded_item_count >= 1); Ok(()) } @@ -2213,11 +2215,10 @@ async fn rollout_writes_base_instructions_once_across_multiple_turns() -> Result let db = devo_server::db::Database::open(data_root.path().join("test_persistence.db"))?; let index = db.get_session_index(&session_id)?.expect("indexed session"); let rollout_path = index.rollout_path.expect("rollout path"); - let rollout = std::fs::read_to_string(&rollout_path)?; + let rollout_lines = support::read_rollout_lines_dual(&rollout_path)?; let mut session_context_lines = 0usize; let mut turn_lines_with_session_context = 0usize; - for line in rollout.lines().filter(|line| !line.trim().is_empty()) { - let rollout_line: RolloutLine = serde_json::from_str(line)?; + for rollout_line in rollout_lines { match rollout_line { RolloutLine::SessionContextUpdated(_) => session_context_lines += 1, RolloutLine::Turn(turn_line) if turn_line.turn.session_context.is_some() => { @@ -2307,11 +2308,10 @@ async fn turn_start_persists_session_context_before_turn_completes() -> Result<( let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let (session_context_lines, turn_lines) = loop { - let rollout = std::fs::read_to_string(&rollout_path)?; + let rollout_lines = support::read_rollout_lines_dual(&rollout_path)?; let mut session_context_lines = 0usize; let mut turn_lines = 0usize; - for line in rollout.lines().filter(|line| !line.trim().is_empty()) { - let rollout_line: RolloutLine = serde_json::from_str(line)?; + for rollout_line in rollout_lines { match rollout_line { RolloutLine::SessionContextUpdated(_) => session_context_lines += 1, RolloutLine::Turn(_) => turn_lines += 1, diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index 1ce616c7..579013f7 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -3,6 +3,9 @@ use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; +#[path = "support/rollout.rs"] +mod support; + use anyhow::Context; use anyhow::Result; use async_trait::async_trait; @@ -213,9 +216,8 @@ async fn exhausted_provider_retries_persist_for_history_but_do_not_enter_context wait_for_original_event(&mut notifications_rx, "turn/completed").await?; let rollout = std::fs::read_to_string(rollout_path(data_root.path(), &session))?; assert!(rollout.contains(PROVIDER_ERROR_TEXT)); - let persisted_error = rollout - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) + let persisted_error = support::read_rollout_lines_dual(&rollout_path(data_root.path(), &session))? + .into_iter() .find_map(|line| match line { devo_core::RolloutLine::Turn(line) if line.turn.id == failed_turn_id => line.turn.error, _ => None, diff --git a/crates/server/tests/support/rollout.rs b/crates/server/tests/support/rollout.rs new file mode 100644 index 00000000..acf285e2 --- /dev/null +++ b/crates/server/tests/support/rollout.rs @@ -0,0 +1,29 @@ +#![allow(dead_code)] + +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use devo_core::ParsedRolloutLine; +use devo_core::RolloutLine; +use devo_core::V2InverseProjector; +use devo_core::parse_rollout_line; + +/// Reads a rollout file that may freely mix legacy (v1) and v2 lines into +/// the legacy line stream the replay pipeline consumes. Dual-read mirror of +/// the server's `load_session_from_rollout` for tests. +pub fn read_rollout_lines_dual(path: &Path) -> Result> { + let text = std::fs::read_to_string(path) + .with_context(|| format!("read rollout {}", path.display()))?; + let inverse = V2InverseProjector::new(); + let mut out = Vec::new(); + for raw in text.lines().filter(|line| !line.trim().is_empty()) { + match parse_rollout_line(raw) + .with_context(|| format!("parse line in {}", path.display()))? + { + ParsedRolloutLine::Legacy(line) => out.push(*line), + ParsedRolloutLine::V2(line) => out.extend(inverse.project_line(&line)?), + } + } + Ok(out) +} diff --git a/crates/server/tests/turn_start_persistence.rs b/crates/server/tests/turn_start_persistence.rs index 60d95a5b..4c13c6c9 100644 --- a/crates/server/tests/turn_start_persistence.rs +++ b/crates/server/tests/turn_start_persistence.rs @@ -237,8 +237,9 @@ async fn message_edit_previous_accepts_skip_restore_and_replaces_prompt_branch() ); let rollout = std::fs::read_to_string(rollout_path_for_session(data_root.path(), &session))?; - assert!(rollout.contains("MessageEditRecorded")); - assert!(rollout.contains("TurnSuperseded")); + // v2 write path: edit markers travel as internal lines. + assert!(rollout.contains(r#""type":"messageEdit""#)); + assert!(rollout.contains(r#""type":"turnSuperseded""#)); assert!(rollout.contains(&edit_response.result.replacement_message_id.to_string())); assert!(rollout.contains(&replacement_turn_id.to_string())); @@ -329,8 +330,9 @@ async fn message_edit_previous_default_safe_restore_records_and_broadcasts() -> ); let rollout = std::fs::read_to_string(rollout_path_for_session(data_root.path(), &session))?; - assert!(rollout.contains("TurnWorkspaceRestoreStarted")); - assert!(rollout.contains("TurnWorkspaceRestoreCompleted")); + // v2 write path: workspace restore lines carry camelCase kinds. + assert!(rollout.contains(r#""kind":"workspaceRestoreStarted""#)); + assert!(rollout.contains(r#""kind":"workspaceRestoreCompleted""#)); assert!(rollout.contains("\"policy\":\"safe\"")); let methods = collect_notification_methods(&mut notifications_rx).await; From 2796e8a29f53d85d8847d4554d0ff3cdc96c33d8 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 09:00:12 +0800 Subject: [PATCH 08/17] feat(server): add SQLite event_log outbox with startup reconciliation --- .../core/src/conversation/event_projection.rs | 260 +++++++++++++++ crates/core/src/conversation/mod.rs | 5 + crates/protocol/src/canonical/item.rs | 2 +- crates/protocol/src/canonical/patch.rs | 9 +- crates/server/src/bootstrap.rs | 23 ++ crates/server/src/db.rs | 151 ++++++++- crates/server/src/event_reconcile.rs | 279 ++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/src/persistence.rs | 309 ++++++++++++++++-- crates/server/src/runtime.rs | 13 +- 10 files changed, 1010 insertions(+), 42 deletions(-) create mode 100644 crates/core/src/conversation/event_projection.rs create mode 100644 crates/server/src/event_reconcile.rs diff --git a/crates/core/src/conversation/event_projection.rs b/crates/core/src/conversation/event_projection.rs new file mode 100644 index 00000000..bacde80c --- /dev/null +++ b/crates/core/src/conversation/event_projection.rs @@ -0,0 +1,260 @@ +//! Derivation of persisted delivery-log events from v2 rollout facts. +//! +//! Truth source: `devo-api-design/08-events-subscription.md` §5/§7. The +//! rollout JSONL is the canonical recovery log; every persisted event in the +//! SQLite `event_log` is derived from a v2 line by this pure mapping, so +//! crash recovery only ever *re-derives* the same rows (idempotent by source +//! fact) — a crash may delay an event, never lose or duplicate it. +//! +//! Only v2 lines produce events: legacy lines are first projected forward by +//! `LegacyProjector` during hydration/reconciliation, so all log rows come +//! from v2 facts. + +use sha2::Digest; +use sha2::Sha256; + +use devo_protocol::canonical::event::ServerNotification; +use devo_protocol::canonical::ids::RestorePlanId; +use devo_protocol::canonical::ids::SessionId; +use devo_protocol::canonical::item::ItemState; +use devo_protocol::canonical::turn::TurnStatus; + +use super::rollout_v2::RolloutLineV2; + +/// One derived persisted event (pre-sequencing). `event_kind` is the +/// notification method string (`item/started`, ...). +#[derive(Debug, Clone, PartialEq)] +pub struct DerivedEvent { + pub event_kind: &'static str, + pub stream_id: String, + pub notification: ServerNotification, +} + +/// The schema version stamped into `EventMeta.schema_version` for events +/// derived by this module. +pub const EVENT_SCHEMA_VERSION: u32 = 1; + +/// Stable identity of a rollout fact: `#[.]`. +/// The line index counts physical JSONL rows; `sub_index` distinguishes +/// multiple v2 facts projected from one legacy row (packed item expansion). +/// The event log is idempotent by this key (paired with event kind and +/// stream), so re-deriving the same fact after a crash is always a no-op. +pub fn source_fact_id(rollout_path: &std::path::Path, line_index: u64, sub_index: u64) -> String { + if sub_index == 0 { + format!("{}#{line_index}", rollout_path.to_string_lossy()) + } else { + format!("{}#{line_index}.{sub_index}", rollout_path.to_string_lossy()) + } +} + +/// Stream id of a session stream (`session:`). +pub fn session_stream_id(session_id: &SessionId) -> String { + format!("session:{session_id}") +} + +/// Stream id of the per-cwd session-list stream (`sessions:`). The +/// hash is the first 16 hex chars of SHA-256 over the normalized cwd string; +/// P4's subscription selector must use this exact function. +pub fn sessions_stream_id(cwd: &str) -> String { + let digest = Sha256::digest(cwd.as_bytes()); + format!("sessions:{}", hex_prefix(&digest, 8)) +} + +fn hex_prefix(bytes: &[u8], len: usize) -> String { + bytes[..len].iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn is_terminal_item_state(state: ItemState) -> bool { + match state { + ItemState::Completed | ItemState::Failed | ItemState::Interrupted | ItemState::Lost => true, + ItemState::Running | ItemState::Waiting => false, + } +} + +/// Derives the persisted events carried by one v2 rollout line. Line kinds +/// without a faithful canonical notification are skipped (each with its +/// reason on the match arm); they remain recoverable from the rollout itself. +pub fn events_from_v2_line(line: &RolloutLineV2) -> Vec { + match line { + RolloutLineV2::Item { item, .. } => { + let stream_id = session_stream_id(&item.session_id); + let envelope = Box::new(item.clone()); + let (event_kind, notification) = if is_terminal_item_state(item.state) { + // All terminal states share `item/completed` (08 §3). + ("item/completed", ServerNotification::ItemCompleted { item: envelope }) + } else if item.revision > 1 { + ("item/updated", ServerNotification::ItemUpdated { item: envelope }) + } else { + ("item/started", ServerNotification::ItemStarted { item: envelope }) + }; + vec![DerivedEvent { + event_kind, + stream_id, + notification, + }] + } + RolloutLineV2::Turn { turn, .. } => { + let stream_id = session_stream_id(&turn.session_id); + let (event_kind, notification) = match turn.status { + TurnStatus::InProgress => ( + "turn/started", + ServerNotification::TurnStarted { + turn: Box::new(turn.clone()), + }, + ), + TurnStatus::Completed | TurnStatus::Interrupted | TurnStatus::Failed => ( + "turn/completed", + ServerNotification::TurnCompleted { + turn: Box::new(turn.clone()), + }, + ), + }; + vec![DerivedEvent { + event_kind, + stream_id, + notification, + }] + } + RolloutLineV2::SessionMeta { session, .. } => { + // One fact, two streams: the session stream and the per-cwd + // session-list stream. The log PK includes stream_id, so both + // rows are idempotent independently. + let session = session.as_ref().clone(); + let mut events = Vec::with_capacity(2); + for stream_id in [ + session_stream_id(&session.id), + sessions_stream_id(&session.cwd.to_string_lossy()), + ] { + events.push(DerivedEvent { + event_kind: "session/created", + stream_id, + notification: ServerNotification::SessionCreated { + session: Box::new(session.clone()), + }, + }); + } + events + } + RolloutLineV2::WorkspaceRestoreStarted { record, .. } => { + vec![DerivedEvent { + event_kind: "workspace/restoreStarted", + stream_id: session_stream_id(&SessionId::from_string( + record.session_id.to_string(), + )), + notification: ServerNotification::WorkspaceRestoreStarted { + session_id: SessionId::from_string(record.session_id.to_string()), + restore_plan_id: RestorePlanId::from_string(record.restore_id.0.to_string()), + }, + }] + } + RolloutLineV2::WorkspaceRestoreCompleted { record, .. } => { + let succeeded = record + .outcomes + .iter() + .all(|outcome| { + matches!( + outcome.status, + crate::durable_record::RestoreFileStatus::Restored + | crate::durable_record::RestoreFileStatus::Skipped + ) + }); + vec![DerivedEvent { + event_kind: "workspace/restoreCompleted", + stream_id: session_stream_id(&SessionId::from_string( + record.session_id.to_string(), + )), + notification: ServerNotification::WorkspaceRestoreCompleted { + session_id: SessionId::from_string(record.session_id.to_string()), + restore_plan_id: RestorePlanId::from_string(record.restore_id.0.to_string()), + succeeded, + error: None, + }, + }] + } + // No faithful canonical notification exists for these kinds; they are + // replay concerns, not delivery-log events: + // - SessionTitleUpdated: `session/metadataUpdated` needs a full + // Session snapshot, unavailable from the line alone; the live path + // emits it. + // - CompactionSnapshot: the compaction item itself yields item events. + // - SessionRollback: P4's rollback preview/commit flow emits live + // events; the marker is replay state. + // - Internal / WorkspaceCheckpoint / WorkspaceChange: rollout-only. + RolloutLineV2::SessionTitleUpdated { .. } + | RolloutLineV2::CompactionSnapshot { .. } + | RolloutLineV2::SessionRollback { .. } + | RolloutLineV2::Internal { .. } + | RolloutLineV2::WorkspaceCheckpoint { .. } + | RolloutLineV2::WorkspaceChange { .. } => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use devo_protocol::canonical::ids::ItemId; + use devo_protocol::canonical::ids::TurnId; + use devo_protocol::canonical::item::Item; + use devo_protocol::canonical::item::ItemEnvelope; + use devo_protocol::canonical::item::ItemState; + use devo_protocol::canonical::item::UserInput; + use devo_protocol::canonical::item::UserMessageEntry; + use pretty_assertions::assert_eq; + + use super::*; + + fn item_envelope(state: ItemState, revision: u32) -> ItemEnvelope { + ItemEnvelope { + id: ItemId::new(), + session_id: SessionId::new(), + turn_id: TurnId::new(), + seq: 1, + revision, + created_at: Utc::now(), + updated_at: Utc::now(), + state, + item: Item::UserMessage { + client_user_message_id: None, + content: vec![UserInput::Text { + text: "hi".to_owned(), + }], + entry: UserMessageEntry::TurnStart, + }, + } + } + + #[test] + fn item_line_maps_lifecycle_to_event_kinds() { + for (state, revision, expected) in [ + (ItemState::Running, 1, "item/started"), + (ItemState::Waiting, 1, "item/started"), + (ItemState::Running, 2, "item/updated"), + (ItemState::Completed, 1, "item/completed"), + (ItemState::Completed, 3, "item/completed"), + (ItemState::Interrupted, 1, "item/completed"), + (ItemState::Lost, 2, "item/completed"), + ] { + let line = RolloutLineV2::Item { + v: 2, + timestamp: Utc::now(), + item: item_envelope(state, revision), + }; + let events = events_from_v2_line(&line); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_kind, expected, "{state:?} rev {revision}"); + } + } + + #[test] + fn sessions_stream_id_is_stable() { + assert_eq!( + sessions_stream_id("/Users/dev/project"), + sessions_stream_id("/Users/dev/project") + ); + assert_ne!( + sessions_stream_id("/Users/dev/project"), + sessions_stream_id("/Users/dev/other") + ); + assert!(sessions_stream_id("/x").starts_with("sessions:")); + } +} diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index b66be823..bbdfc2b3 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -1,3 +1,4 @@ +pub mod event_projection; pub mod legacy_projector; pub mod rollout_v2; pub mod v2_inverse; @@ -5,6 +6,10 @@ pub mod v2_inverse; mod records; pub use devo_protocol::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus, TurnUsage}; +pub use event_projection::{ + DerivedEvent, EVENT_SCHEMA_VERSION, events_from_v2_line, session_stream_id, + sessions_stream_id, source_fact_id, +}; pub use legacy_projector::{LegacyProjectError, LegacyProjector}; pub use rollout_v2::{ InternalRecordV2, ParsedRolloutLine, ROLLOUT_FORMAT_VERSION, RolloutLineReadError, diff --git a/crates/protocol/src/canonical/item.rs b/crates/protocol/src/canonical/item.rs index 8fad600c..bcf37e00 100644 --- a/crates/protocol/src/canonical/item.rs +++ b/crates/protocol/src/canonical/item.rs @@ -260,7 +260,7 @@ pub enum Item { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum ItemOrUnknown { - Known(Item), + Known(Box), Unknown(JsonValue), } diff --git a/crates/protocol/src/canonical/patch.rs b/crates/protocol/src/canonical/patch.rs index b32ba877..5d909984 100644 --- a/crates/protocol/src/canonical/patch.rs +++ b/crates/protocol/src/canonical/patch.rs @@ -16,20 +16,15 @@ use serde::Deserializer; use serde::Serialize; use serde::Serializer; -#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(untagged)] pub enum PatchField { + #[default] Missing, Null, Value(T), } -impl Default for PatchField { - fn default() -> Self { - Self::Missing - } -} - impl PatchField { pub fn is_missing(&self) -> bool { matches!(self, Self::Missing) diff --git a/crates/server/src/bootstrap.rs b/crates/server/src/bootstrap.rs index 53c9d73b..f161bf54 100644 --- a/crates/server/src/bootstrap.rs +++ b/crates/server/src/bootstrap.rs @@ -254,6 +254,29 @@ pub async fn run_server_process( if runtime.backfill_session_index_if_required()? { tracing::info!("rollout metadata index backfill completed"); } + // Delivery-log reconciliation (08 §7): backfill event_log rows a crash + // prevented the append path from writing. Runs in the background; + // session/list correctness never depends on it. + { + let rollout_store = runtime.rollout_store(); + let db = runtime.deps_db(); + tokio::task::spawn_blocking(move || { + match crate::event_reconcile::reconcile_event_log(&rollout_store, &db) { + Ok(stats) => { + if stats.rows_inserted > 0 || stats.files_damaged > 0 { + tracing::info!( + rows_inserted = stats.rows_inserted, + files_damaged = stats.files_damaged, + "event_log reconciliation completed" + ); + } + } + Err(error) => { + tracing::warn!(%error, "event_log reconciliation failed"); + } + } + }); + } let shutdown_signal = tokio_util::sync::CancellationToken::new(); let internal_proxy_control = InternalProxyControl::new(shutdown_signal.clone()); diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 4a6452bc..be92c4f2 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -1,3 +1,4 @@ +use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -60,10 +61,37 @@ pub struct SessionStats { pub prompt_token_estimate: usize, } +/// One derived event row before per-stream sequencing (08 §5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewEventLogRow { + /// Stable identity of the rollout fact: `#`. + pub source_fact_id: String, + /// Notification method, e.g. `item/started`. + pub event_kind: String, + pub stream_id: String, + pub event_id: String, + /// `EventEnvelope` JSON (meta + notification). + pub payload: String, + pub created_at: String, +} + +/// A stored event row including its per-stream sequence number. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventLogRow { + pub source_fact_id: String, + pub event_kind: String, + pub stream_id: String, + pub event_id: String, + pub seq: u64, + pub payload: String, + pub created_at: String, +} + /// Current index schema version recorded in `schema_meta` (05 §2.3). /// Bump when a migration changes the index layout; the rollout files are /// the rebuildable source of truth on any mismatch. -const CURRENT_SCHEMA_VERSION: u32 = 1; +/// v2: adds `event_log` + `projection_watermark` (08 §5/§7). +const CURRENT_SCHEMA_VERSION: u32 = 2; /// SQLite database for session metadata, token stats, and pending queues. pub struct Database { @@ -256,10 +284,35 @@ impl Database { .context("failed to create schema_meta table")?; conn.execute( "INSERT INTO schema_meta (key, value) VALUES ('schema_version', ?1) - ON CONFLICT(key) DO NOTHING", + ON CONFLICT(key) DO UPDATE SET value = excluded.value", [CURRENT_SCHEMA_VERSION.to_string()], ) .context("failed to record schema version")?; + // Persisted event log (08 §5/§7): the rollout JSONL is the canonical + // recovery log; this table is the delivery log used for cursor replay. + // Rows are idempotent by (source_fact_id, event_kind, stream_id); + // `seq` is strictly increasing per stream. A database rebuild expires + // all cursors and forces re-snapshot — rows are never modified. + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS event_log ( + source_fact_id TEXT NOT NULL, + event_kind TEXT NOT NULL, + stream_id TEXT NOT NULL, + event_id TEXT NOT NULL, + seq INTEGER NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (source_fact_id, event_kind, stream_id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS event_log_stream_seq + ON event_log(stream_id, seq); + + CREATE TABLE IF NOT EXISTS projection_watermark ( + rollout_path TEXT PRIMARY KEY, + last_line_index INTEGER NOT NULL + );", + ) + .context("failed to create event_log tables")?; Ok(()) } @@ -283,6 +336,100 @@ impl Database { .transpose() } + // === Event log (08 §5/§7) === + + /// Idempotently inserts derived event rows. `seq` is allocated per stream + /// inside the same statement, and the `(source_fact_id, event_kind, + /// stream_id)` primary key makes re-projection of the same rollout fact a + /// no-op — reconciliation never duplicates, only backfills. Returns the + /// number of rows actually inserted. + pub fn insert_event_log_rows(&self, rows: &[NewEventLogRow]) -> Result { + let conn = self.conn.lock().expect("database mutex poisoned"); + let mut inserted = 0usize; + for row in rows { + let changes = conn + .execute( + "INSERT OR IGNORE INTO event_log + (source_fact_id, event_kind, stream_id, event_id, seq, payload, created_at) + SELECT ?1, ?2, ?3, ?4, + (SELECT COALESCE(MAX(seq), 0) + 1 FROM event_log WHERE stream_id = ?3), + ?5, ?6", + params![ + row.source_fact_id, + row.event_kind, + row.stream_id, + row.event_id, + row.payload, + row.created_at, + ], + ) + .context("failed to insert event_log row")?; + inserted += changes; + } + Ok(inserted) + } + + /// Reads stored events of one stream after `after_seq`, ordered by seq + /// (cursor replay, 08 §4). + pub fn event_log_rows(&self, stream_id: &str, after_seq: u64) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let mut stmt = conn + .prepare( + "SELECT source_fact_id, event_kind, stream_id, event_id, seq, payload, created_at + FROM event_log WHERE stream_id = ?1 AND seq > ?2 ORDER BY seq", + ) + .context("failed to prepare event_log read")?; + let rows = stmt + .query_map(params![stream_id, after_seq as i64], |row| { + Ok(EventLogRow { + source_fact_id: row.get(0)?, + event_kind: row.get(1)?, + stream_id: row.get(2)?, + event_id: row.get(3)?, + seq: row.get::<_, i64>(4)? as u64, + payload: row.get(5)?, + created_at: row.get(6)?, + }) + }) + .context("failed to read event_log rows")?; + rows.collect::, _>>() + .context("failed to decode event_log rows") + } + + /// Total number of stored event rows (reconciliation tests). + pub fn event_log_len(&self) -> Result { + let conn = self.conn.lock().expect("database mutex poisoned"); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM event_log", [], |row| row.get(0)) + .context("failed to count event_log rows")?; + Ok(count as u64) + } + + /// The last rollout line index projected into `event_log` for a file. + pub fn projection_watermark(&self, rollout_path: &Path) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let value: Option = conn + .query_row( + "SELECT last_line_index FROM projection_watermark WHERE rollout_path = ?1", + params![rollout_path.to_string_lossy().as_ref()], + |row| row.get(0), + ) + .ok(); + Ok(value.map(|value| value as u64)) + } + + /// Advances the projection watermark for a rollout file. + pub fn set_projection_watermark(&self, rollout_path: &Path, last_line_index: u64) -> Result<()> { + let conn = self.conn.lock().expect("database mutex poisoned"); + conn.execute( + "INSERT INTO projection_watermark (rollout_path, last_line_index) VALUES (?1, ?2) + ON CONFLICT(rollout_path) DO UPDATE SET last_line_index = excluded.last_line_index", + params![rollout_path.to_string_lossy().as_ref(), last_line_index as i64], + ) + .context("failed to update projection watermark")?; + Ok(()) + } + // === Session CRUD === /// Inserts or updates a session's metadata and optional rollout index fields. diff --git a/crates/server/src/event_reconcile.rs b/crates/server/src/event_reconcile.rs new file mode 100644 index 00000000..c54f1bf7 --- /dev/null +++ b/crates/server/src/event_reconcile.rs @@ -0,0 +1,279 @@ +//! Startup reconciliation of the delivery log (08 §7). +//! +//! The rollout JSONL and SQLite cannot share a transaction, so the event log +//! uses outbox/reconciliation semantics: every persisted event row is derived +//! from a v2 rollout fact and idempotent by `(source_fact_id, event_kind, +//! stream_id)`. On startup this reconciler replays each rollout file from its +//! projection watermark and backfills any rows a crash prevented the append +//! path from writing. Crash windows covered: +//! +//! 1. crash before rollout fsync — no fact, no event (consistent); +//! 2. crash after fsync, before event insert — this reconciler backfills; +//! 3. crash after insert, before delivery — the row is replayed to clients +//! from the log (P4 subscription replay). + +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; + +use devo_core::legacy_projector::LegacyProjector; +use devo_core::parse_rollout_line; +use devo_core::{ParsedRolloutLine, RolloutLineV2}; + +use crate::db::Database; +use crate::persistence::RolloutStore; + +/// Aggregate outcome of one reconciliation pass. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ReconcileStats { + pub files_scanned: u64, + /// Files skipped because a damaged line stopped projection; their + /// watermarks are left untouched so the next startup retries. + pub files_damaged: u64, + pub rows_inserted: u64, +} + +/// Replays every rollout file from its projection watermark and backfills +/// missing `event_log` rows. Idempotent: re-running inserts nothing. +pub(crate) fn reconcile_event_log(store: &RolloutStore, db: &Database) -> Result { + let mut stats = ReconcileStats::default(); + for path in store.rollout_paths()? { + stats.files_scanned += 1; + match reconcile_file(&path, db) { + Ok(outcome) => { + stats.rows_inserted += outcome.inserted; + if outcome.damaged { + stats.files_damaged += 1; + tracing::warn!( + rollout = %path.display(), + reason = outcome.reason.as_deref().unwrap_or("unknown"), + "event_log reconciliation stopped at damaged line; watermark preserved" + ); + } + } + Err(error) => { + stats.files_damaged += 1; + tracing::warn!( + rollout = %path.display(), + %error, + "event_log reconciliation skipped damaged rollout" + ); + } + } + } + Ok(stats) +} + +/// Outcome of reconciling one rollout file: rows actually inserted plus +/// whether a damaged line stopped the scan (watermark stays behind so the +/// next startup retries from there). +struct FileOutcome { + inserted: u64, + damaged: bool, + reason: Option, +} + +fn reconcile_file(rollout_path: &Path, db: &Database) -> Result { + let watermark = db.projection_watermark(rollout_path)?; + let file = std::fs::File::open(rollout_path) + .with_context(|| format!("open rollout file {}", rollout_path.display()))?; + let reader = std::io::BufReader::new(file); + // Legacy rows are projected forward so every log row derives from v2 + // facts only, exactly like the write path. + let mut projector = LegacyProjector::new(); + let mut inserted = 0u64; + // Rows are flushed per line and the watermark advances with them, so + // progress up to a damaged line survives for the next startup. + + let mut lines = std::io::BufRead::lines(reader).enumerate().peekable(); + while let Some((line_index, line)) = lines.next() { + let line_index = line_index as u64; + let line = line.with_context(|| format!("read line from {}", rollout_path.display()))?; + if line.trim().is_empty() { + continue; + } + if watermark.is_some_and(|watermark| line_index <= watermark) { + continue; + } + let v2_lines: Vec = match parse_rollout_line(&line) { + Ok(ParsedRolloutLine::Legacy(legacy)) => projector + .project_line(&legacy) + .with_context(|| format!("project legacy line in {}", rollout_path.display()))?, + Ok(ParsedRolloutLine::V2(v2)) => vec![*v2], + Err(devo_core::RolloutLineReadError::TruncatedTail) if lines.peek().is_none() => break, + Err(error) => { + return Ok(FileOutcome { + inserted, + damaged: true, + reason: Some(format!( + "rollout {} is damaged at line {}: {error}", + rollout_path.display(), + line_index + 1 + )), + }); + } + }; + let mut rows = Vec::new(); + for (sub_index, v2_line) in v2_lines.iter().enumerate() { + rows.extend(crate::persistence::event_log_rows_for_v2_line( + rollout_path, + line_index, + sub_index as u64, + v2_line, + )?); + } + inserted += db.insert_event_log_rows(&rows)? as u64; + db.set_projection_watermark(rollout_path, line_index)?; + } + + Ok(FileOutcome { + inserted, + damaged: false, + reason: None, + }) +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use devo_core::{ItemId, SessionId, TextItem, TurnId, TurnItem, TurnStatus}; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + use super::*; + use crate::persistence::{RolloutStore, build_item_record, build_turn_record}; + + /// Writes one session file (meta + turn + item = 3 physical rows) with a + /// store that has NO event-log sink, simulating facts that were fsynced + /// while the event projection never ran (crash window 2). + fn write_session_file(data_root: &std::path::Path) -> (RolloutStore, std::path::PathBuf) { + let store = RolloutStore::new(data_root.to_path_buf(), None); + let record = store.create_session_record( + SessionId::new(), + Utc::now(), + data_root.to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + store.append_session_meta(&record).expect("append meta"); + let turn_id = TurnId::new(); + let turn = build_turn_record( + &crate::turn::TurnMetadata { + turn_id, + session_id: record.id, + sequence: 1, + status: TurnStatus::Completed, + kind: devo_core::TurnKind::Regular, + model: "test-model".into(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".into(), + request_thinking: None, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + usage: None, + stop_reason: None, + failure_reason: None, + }, + None, + None, + None, + ); + store.append_turn(&record, turn).expect("append turn"); + let item = build_item_record( + record.id, + turn_id, + ItemId::new(), + 1, + TurnItem::AgentMessage(TextItem { text: "hi".into() }), + Some(TurnStatus::Running), + None, + ); + store.append_item(&record, item).expect("append item"); + (store, record.rollout_path) + } + + #[test] + fn reconcile_backfills_rows_and_is_idempotent() { + let dir = TempDir::new().expect("temp dir"); + let (store, _path) = write_session_file(dir.path()); + let db = Database::open(dir.path().join("devo.db")).expect("open db"); + + let stats = reconcile_event_log(&store, &db).expect("first reconcile"); + assert_eq!(stats.files_scanned, 1); + assert_eq!(stats.files_damaged, 0); + assert_eq!(stats.rows_inserted, 4); + assert_eq!(db.event_log_len().expect("count"), 4); + + // Re-running is a no-op (watermark + primary-key idempotency). + let stats = reconcile_event_log(&store, &db).expect("second reconcile"); + assert_eq!(stats.rows_inserted, 0); + assert_eq!(db.event_log_len().expect("count"), 4); + } + + #[test] + fn reconcile_respects_watermark() { + let dir = TempDir::new().expect("temp dir"); + let (store, path) = write_session_file(dir.path()); + let db = Database::open(dir.path().join("devo.db")).expect("open db"); + + // Crash after projecting only the first row (session meta at index 0). + db.set_projection_watermark(&path, 0).expect("set watermark"); + let stats = reconcile_event_log(&store, &db).expect("reconcile"); + // Lines 1 (turn) and 2 (item) backfill; session/created does not. + assert_eq!(stats.rows_inserted, 2); + assert_eq!(db.event_log_len().expect("count"), 2); + assert_eq!( + db.projection_watermark(&path).expect("watermark"), + Some(2) + ); + } + + #[test] + fn reconcile_skips_damaged_file_and_keeps_watermark() { + let dir = TempDir::new().expect("temp dir"); + let (store, path) = write_session_file(dir.path()); + let db = Database::open(dir.path().join("devo.db")).expect("open db"); + // Corrupt the tail of the file beyond the intact facts. + { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open"); + file.write_all(b"{not json\n").expect("write garbage"); + } + let stats = reconcile_event_log(&store, &db).expect("reconcile tolerates"); + assert_eq!(stats.files_damaged, 1); + // All intact rows before the damage were still backfilled. + assert_eq!(stats.rows_inserted, 4); + assert_eq!( + db.projection_watermark(&path).expect("watermark"), + Some(2) + ); + } + + #[test] + fn stream_seq_is_monotonic_per_stream() { + let dir = TempDir::new().expect("temp dir"); + let (store, _path) = write_session_file(dir.path()); + let db = Database::open(dir.path().join("devo.db")).expect("open db"); + reconcile_event_log(&store, &db).expect("reconcile"); + + // The per-cwd sessions stream holds exactly one session/created row. + let sessions_stream = devo_core::sessions_stream_id(&dir.path().to_string_lossy()); + let sessions_rows = db + .event_log_rows(&sessions_stream, 0) + .expect("sessions stream"); + assert_eq!(sessions_rows.len(), 1); + assert_eq!(sessions_rows[0].seq, 1); + assert_eq!(sessions_rows[0].event_kind, "session/created"); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index d2d14115..05833a51 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -5,6 +5,7 @@ mod client; mod connection; pub mod db; mod event; +mod event_reconcile; mod exec_policy_store; mod execution; pub mod goal; diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index de3d4732..59ef9aad 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -60,7 +60,12 @@ use devo_core::V2InverseProjector; use devo_core::Worklog; use devo_core::parse_rollout_line; use devo_core::legacy_projector::LegacyProjector; +use devo_core::rollout_v2::RolloutLineV2; +use devo_core::{EVENT_SCHEMA_VERSION, events_from_v2_line, source_fact_id}; +use devo_protocol::canonical::event::{EventEnvelope, EventMeta}; +use devo_protocol::canonical::ids::EventId; +use crate::db::{Database, NewEventLogRow}; use crate::execution::PersistedTurnItem; use crate::execution::RuntimeSession; use crate::execution::ServerRuntimeDependencies; @@ -76,10 +81,22 @@ pub(crate) struct RolloutStore { /// Per-file locks that serialise concurrent writes to the same rollout file, /// preventing interleaved JSON lines. file_locks: Arc>>>>, - /// Per-file write-path projectors (v2 single-write, 05 §2.2). One - /// instance per rollout path, hydrated from the on-disk history on first - /// append so item seqs and approval folds never collide with it. - projectors: Arc>>, + /// Per-file write-path state (v2 single-write, 05 §2.2). One instance per + /// rollout path, hydrated from the on-disk history on first append so + /// item seqs and approval folds never collide with it. + write_states: Arc>>, + /// Delivery-log sink (08 §5/§7): after each fsynced append, derived + /// events are projected into the SQLite `event_log` (best effort; the + /// startup reconciler backfills anything missed). `None` in tests that + /// do not exercise the event log. + event_log: Option>, +} + +/// Per-file write-path state: the forward projector plus the index of the +/// next JSONL row to be written (used as the `source_fact_id` line index). +pub(crate) struct WritePathState { + projector: LegacyProjector, + next_line_index: u64, } impl std::fmt::Debug for RolloutStore { @@ -95,18 +112,20 @@ impl Clone for RolloutStore { Self { data_root: self.data_root.clone(), file_locks: Arc::clone(&self.file_locks), - projectors: Arc::clone(&self.projectors), + write_states: Arc::clone(&self.write_states), + event_log: self.event_log.as_ref().map(Arc::clone), } } } impl RolloutStore { /// Creates a rollout store rooted at the supplied server home directory. - pub(crate) fn new(data_root: PathBuf) -> Self { + pub(crate) fn new(data_root: PathBuf, event_log: Option>) -> Self { Self { data_root, file_locks: Arc::new(StdMutex::new(HashMap::new())), - projectors: Arc::new(StdMutex::new(HashMap::new())), + write_states: Arc::new(StdMutex::new(HashMap::new())), + event_log, } } @@ -639,18 +658,19 @@ impl RolloutStore { }; let _guard = file_lock.lock().expect("rollout per-file lock poisoned"); - let mut projectors = self - .projectors + let mut write_states = self + .write_states .lock() - .expect("rollout projector table poisoned"); - let projector = match projectors.get_mut(rollout_path) { - Some(projector) => projector, + .expect("rollout write-state table poisoned"); + let state = match write_states.get_mut(rollout_path) { + Some(state) => state, None => { - let projector = hydrate_projector(rollout_path)?; - projectors.entry(rollout_path.to_path_buf()).or_insert(projector) + let state = hydrate_write_state(rollout_path)?; + write_states.entry(rollout_path.to_path_buf()).or_insert(state) } }; - let v2_lines = projector + let v2_lines = state + .projector .project_line(line) .with_context(|| format!("project rollout line for {}", rollout_path.display()))?; @@ -659,6 +679,7 @@ impl RolloutStore { .append(true) .open(rollout_path) .with_context(|| format!("open rollout file {}", rollout_path.display()))?; + let first_line_index = state.next_line_index; for v2_line in &v2_lines { serde_json::to_writer(&mut file, v2_line) .with_context(|| format!("serialize rollout line {}", rollout_path.display()))?; @@ -673,10 +694,108 @@ impl RolloutStore { // event-log requirement). file.sync_data() .with_context(|| format!("fsync rollout file {}", rollout_path.display()))?; + state.next_line_index += v2_lines.len() as u64; + + // Outbox projection (08 §5/§7): derive delivery-log events from the + // fsynced facts. Best effort — a failure here is backfilled by the + // startup reconciler, so a crash may delay an event but never lose + // or duplicate it. + if let Some(db) = &self.event_log + && let Err(error) = + project_events_into_log(db, rollout_path, first_line_index, &v2_lines) + { + tracing::warn!( + rollout = %rollout_path.display(), + %error, + "failed to project events into event_log; reconciliation will backfill" + ); + } Ok(()) } } +/// Derives delivery-log rows from freshly written v2 lines and inserts them +/// idempotently, then advances the projection watermark. +fn project_events_into_log( + db: &Database, + rollout_path: &Path, + first_line_index: u64, + v2_lines: &[RolloutLineV2], +) -> Result<()> { + let mut rows = Vec::new(); + let mut last_line_index = first_line_index; + for (offset, v2_line) in v2_lines.iter().enumerate() { + let line_index = first_line_index + offset as u64; + last_line_index = line_index; + rows.extend(event_log_rows_for_v2_line( + rollout_path, + line_index, + 0, + v2_line, + )?); + } + db.insert_event_log_rows(&rows)?; + if !v2_lines.is_empty() { + db.set_projection_watermark(rollout_path, last_line_index)?; + } + Ok(()) +} + +/// Builds the delivery-log rows derived from one v2 rollout fact (also used +/// by the startup reconciler, which passes a nonzero `sub_index` for v2 +/// lines expanded from a packed legacy row). +pub(crate) fn event_log_rows_for_v2_line( + rollout_path: &Path, + line_index: u64, + sub_index: u64, + v2_line: &RolloutLineV2, +) -> Result> { + let timestamp = v2_line_timestamp(v2_line); + let mut rows = Vec::new(); + for derived in events_from_v2_line(v2_line) { + let envelope = EventEnvelope { + meta: EventMeta { + event_id: EventId::new(), + stream_id: derived.stream_id.clone(), + // Allocated by the event_log insert (per-stream monotonic); + // replay hydrates meta.seq from the stored row. + seq: None, + emitted_at: timestamp, + persisted: true, + schema_version: EVENT_SCHEMA_VERSION, + actor_client_id: None, + }, + notification: derived.notification, + }; + rows.push(NewEventLogRow { + source_fact_id: source_fact_id(rollout_path, line_index, sub_index), + event_kind: derived.event_kind.to_owned(), + stream_id: derived.stream_id, + event_id: envelope.meta.event_id.to_string(), + payload: serde_json::to_string(&envelope).context("serialize event envelope")?, + created_at: timestamp.to_rfc3339(), + }); + } + Ok(rows) +} + +/// The wall-clock timestamp carried by any v2 line variant. +fn v2_line_timestamp(line: &RolloutLineV2) -> chrono::DateTime { + match line { + RolloutLineV2::SessionMeta { timestamp, .. } + | RolloutLineV2::Turn { timestamp, .. } + | RolloutLineV2::Item { timestamp, .. } + | RolloutLineV2::Internal { timestamp, .. } + | RolloutLineV2::SessionTitleUpdated { timestamp, .. } + | RolloutLineV2::CompactionSnapshot { timestamp, .. } + | RolloutLineV2::SessionRollback { timestamp, .. } + | RolloutLineV2::WorkspaceCheckpoint { timestamp, .. } + | RolloutLineV2::WorkspaceChange { timestamp, .. } + | RolloutLineV2::WorkspaceRestoreStarted { timestamp, .. } + | RolloutLineV2::WorkspaceRestoreCompleted { timestamp, .. } => *timestamp, + } +} + /// Builds the write-path projector for an existing rollout file by replaying /// its current contents: legacy lines go through the forward projector (so /// the seq counter and approval folds advance exactly as if the file had @@ -686,11 +805,28 @@ impl RolloutStore { /// /// Fails closed on any damaged or unsupported line: appending onto history /// the projector could not fully read would fork the session's history. -fn hydrate_projector(rollout_path: &Path) -> Result { +/// Builds the write-path state for an existing rollout file by replaying its +/// current contents: legacy lines go through the forward projector (so the +/// seq counter and approval folds advance exactly as if the file had been +/// written through the v2 path), v2 lines re-sync that state via +/// [`LegacyProjector::observe_v2_line`]. Bounded per path: runs once, on the +/// first append, and the result is cached in the store. Also returns the next +/// JSONL row index, which becomes the `source_fact_id` line index of every +/// subsequent append. +/// +/// Fails closed on any damaged or unsupported line: appending onto history +/// the projector could not fully read would fork the session's history. +fn hydrate_write_state(rollout_path: &Path) -> Result { let mut projector = LegacyProjector::new(); + let mut next_line_index = 0u64; let file = match File::open(rollout_path) { Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(projector), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(WritePathState { + projector, + next_line_index, + }); + } Err(error) => { return Err(error).with_context(|| format!("open rollout file {}", rollout_path.display())); } @@ -720,8 +856,13 @@ fn hydrate_projector(rollout_path: &Path) -> Result { }); } } + // The line index counts physical JSONL rows regardless of format. + next_line_index += 1; } - Ok(projector) + Ok(WritePathState { + projector, + next_line_index, + }) } #[derive(Default)] @@ -2448,7 +2589,7 @@ mod tests { let data_root = dir.path().to_path_buf(); let session_id = SessionId::new(); let now = Utc::now(); - let rollout_store = super::RolloutStore::new(data_root.clone()); + let rollout_store = super::RolloutStore::new(data_root.clone(), None); let record = rollout_store.create_session_record( session_id, now, @@ -2502,7 +2643,7 @@ mod tests { let data_root = dir.path().to_path_buf(); let session_id = SessionId::new(); let now = Utc::now(); - let rollout_store = super::RolloutStore::new(data_root.clone()); + let rollout_store = super::RolloutStore::new(data_root.clone(), None); let record = rollout_store.create_session_record( session_id, now, @@ -2589,7 +2730,7 @@ mod tests { let data_root = dir.path().to_path_buf(); let session_id = SessionId::new(); let now = Utc::now(); - let rollout_store = super::RolloutStore::new(data_root.clone()); + let rollout_store = super::RolloutStore::new(data_root.clone(), None); let record = rollout_store.create_session_record( session_id, now, @@ -3197,7 +3338,7 @@ mod tests { let data_root = dir.path().to_path_buf(); let session_id = SessionId::new(); let now = Utc::now(); - let rollout_store = super::RolloutStore::new(data_root.clone()); + let rollout_store = super::RolloutStore::new(data_root.clone(), None); let record = rollout_store.create_session_record( session_id, now, @@ -3360,7 +3501,7 @@ mod tests { use tempfile::TempDir; let dir = TempDir::new().expect("temp dir"); - let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -3408,7 +3549,7 @@ mod tests { use tempfile::TempDir; let dir = TempDir::new().expect("temp dir"); - let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -3454,7 +3595,7 @@ mod tests { // "Restart": a brand-new store must hydrate its projector from the // on-disk v2 history before appending. - let restarted_store = super::RolloutStore::new(dir.path().to_path_buf()); + let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None); restarted_store .append_item( &record, @@ -3517,7 +3658,7 @@ mod tests { use tempfile::TempDir; let dir = TempDir::new().expect("temp dir"); - let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -3535,7 +3676,7 @@ mod tests { .expect("append session meta"); write_raw_lines(&record.rollout_path, &[r#"{"v":2,"kind":"nope"}"#.to_string()]); - let restarted_store = super::RolloutStore::new(dir.path().to_path_buf()); + let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let metadata = test_turn_metadata(record.id, TurnId::new()); let turn = super::build_turn_record(&metadata, None, None, None); let error = restarted_store @@ -3552,7 +3693,7 @@ mod tests { use tempfile::TempDir; let dir = TempDir::new().expect("temp dir"); - let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -3573,7 +3714,7 @@ mod tests { &[r#"{"v":2,"kind":"item","timestamp":"2026"#.to_string()], ); - let restarted_store = super::RolloutStore::new(dir.path().to_path_buf()); + let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let metadata = test_turn_metadata(record.id, TurnId::new()); let turn = super::build_turn_record(&metadata, None, None, None); restarted_store @@ -3587,7 +3728,7 @@ mod tests { let dir = TempDir::new().expect("temp dir"); let deps = test_deps(dir.path()); - let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -3652,7 +3793,7 @@ mod tests { let dir = TempDir::new().expect("temp dir"); let deps = test_deps(dir.path()); - let rollout_store = super::RolloutStore::new(dir.path().to_path_buf()); + let rollout_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -3753,4 +3894,110 @@ mod tests { assert!(texts.contains(&"legacy hello"), "history: {texts:?}"); assert!(texts.contains(&"v2 reply"), "history: {texts:?}"); } + + fn append_basic_session_lines( + rollout_store: &super::RolloutStore, + data_root: &std::path::Path, + ) -> devo_core::SessionRecord { + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + data_root.to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + let metadata = test_turn_metadata(record.id, TurnId::new()); + let turn = super::build_turn_record(&metadata, None, None, None); + rollout_store.append_turn(&record, turn).expect("append turn"); + let item = super::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + 1, + TurnItem::AgentMessage(TextItem { text: "hi".into() }), + Some(TurnStatus::Running), + None, + ); + rollout_store.append_item(&record, item).expect("append item"); + record + } + + #[test] + fn append_projects_events_into_event_log() { + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let db = std::sync::Arc::new( + crate::db::Database::open(dir.path().join("devo.db")).expect("open db"), + ); + let rollout_store = + super::RolloutStore::new(dir.path().to_path_buf(), Some(std::sync::Arc::clone(&db))); + let record = append_basic_session_lines(&rollout_store, dir.path()); + + // session/created lands on both the session stream and the per-cwd + // sessions stream; turn and item facts land on the session stream. + assert_eq!(db.event_log_len().expect("count"), 4); + let session_stream = + devo_core::session_stream_id(&devo_protocol::canonical::ids::SessionId::from_string( + record.id.to_string(), + )); + let rows = db.event_log_rows(&session_stream, 0).expect("session stream"); + let kinds: Vec<&str> = rows.iter().map(|row| row.event_kind.as_str()).collect(); + assert_eq!(kinds, vec!["session/created", "turn/completed", "item/completed"]); + let seqs: Vec = rows.iter().map(|row| row.seq).collect(); + assert_eq!(seqs, vec![1, 2, 3]); + // Three physical rows written; watermark is the last line index. + assert_eq!( + db.projection_watermark(&record.rollout_path).expect("watermark"), + Some(2) + ); + + // The stored envelope payload parses as a typed EventEnvelope whose + // meta.seq is hydrated from the row at replay time. + let envelope: devo_protocol::canonical::event::EventEnvelope = + serde_json::from_str(&rows[2].payload).expect("envelope payload parses"); + assert_eq!(envelope.meta.seq, None); + assert!(envelope.meta.persisted); + } + + #[test] + fn event_log_insert_is_idempotent_by_source_fact() { + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + let dir = TempDir::new().expect("temp dir"); + let db = std::sync::Arc::new( + crate::db::Database::open(dir.path().join("devo.db")).expect("open db"), + ); + let rollout_store = + super::RolloutStore::new(dir.path().to_path_buf(), Some(std::sync::Arc::clone(&db))); + let record = append_basic_session_lines(&rollout_store, dir.path()); + assert_eq!(db.event_log_len().expect("count"), 4); + + // Re-deriving the same facts (simulated crash recovery) inserts nothing. + let raw_lines = raw_rollout_lines(&record.rollout_path); + let mut rows = Vec::new(); + for (index, raw) in raw_lines.iter().enumerate() { + let ParsedRolloutLine::V2(v2) = parse_rollout_line(raw).expect("parse") else { + panic!("v2 line expected"); + }; + rows.extend( + super::event_log_rows_for_v2_line(&record.rollout_path, index as u64, 0, &v2) + .expect("derive rows"), + ); + } + assert_eq!(rows.len(), 4); + let inserted = db.insert_event_log_rows(&rows).expect("re-insert"); + assert_eq!(inserted, 0); + assert_eq!(db.event_log_len().expect("count"), 4); + } } diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 972b9aef..0a1cc709 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -302,7 +302,8 @@ pub(super) fn subagent_usage_owner_pending_metadata( impl ServerRuntime { pub fn new(server_home: PathBuf, deps: ServerRuntimeDependencies) -> Arc { - let rollout_store = RolloutStore::new(server_home.clone()); + let rollout_store = + RolloutStore::new(server_home.clone(), Some(Arc::clone(&deps.db))); let goal_durable_store = GoalDurableStore::new(server_home.clone()); let sandbox_network_proxy = std::sync::Arc::new(std::sync::Mutex::new(None)); // Proxy startup is async; ports are published via the thread-safe @@ -383,6 +384,16 @@ impl ServerRuntime { .ok() .and_then(|slot| slot.clone()) } + + /// The rollout store, for the startup event-log reconciler (08 §7). + pub(crate) fn rollout_store(&self) -> RolloutStore { + self.rollout_store.clone() + } + + /// The shared SQLite handle (session index, queues, event log). + pub(crate) fn deps_db(&self) -> Arc { + Arc::clone(&self.deps.db) + } } fn permission_mode_from_approval_policy(policy: &str) -> Option { From 018d36de8f79cbf9d7e8f2aa91c724b463d8b13a Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 09:27:23 +0800 Subject: [PATCH 09/17] feat(server): add paged session/turns/list and session/items/list history APIs --- crates/core/src/conversation/history.rs | 248 ++++++++++++ .../core/src/conversation/legacy_projector.rs | 165 ++++---- crates/core/src/conversation/mod.rs | 4 +- crates/protocol/src/protocol.rs | 8 + crates/server/src/runtime/connection.rs | 364 ++++++++++++++++++ crates/server/src/runtime/handlers.rs | 1 + crates/server/src/runtime/handlers/history.rs | 253 ++++++++++++ 7 files changed, 961 insertions(+), 82 deletions(-) create mode 100644 crates/core/src/conversation/history.rs create mode 100644 crates/server/src/runtime/handlers/history.rs diff --git a/crates/core/src/conversation/history.rs b/crates/core/src/conversation/history.rs new file mode 100644 index 00000000..0a55a32c --- /dev/null +++ b/crates/core/src/conversation/history.rs @@ -0,0 +1,248 @@ +//! Canonical history reader: loads a session's effective history from its +//! rollout file in canonical form, regardless of the on-disk line format. +//! +//! Used by the paged history read API (`session/turns/list`, +//! `session/items/list`). The in-memory runtime model deliberately does not +//! retain turn records or item envelopes, so the rollout — dual-read and +//! forward-projected — is the only complete source. A read re-parses the +//! whole file; history reads are infrequent enough that this beats keeping +//! a second in-memory copy in sync (a cache can be added later behind the +//! same function). + +use std::collections::HashSet; +use std::path::Path; + +use devo_protocol::canonical::item::ItemEnvelope; +use devo_protocol::canonical::session::Session; +use devo_protocol::canonical::turn::Turn; + +use super::legacy_projector::{LegacyProjectError, LegacyProjector}; +use super::rollout_v2::{ParsedRolloutLine, RolloutLineReadError, RolloutLineV2, parse_rollout_line}; + +/// A session's effective canonical history, in file order. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CanonicalHistory { + /// The session metadata line, when the file has one (files always do for + /// durable sessions; `None` only for truncated reads). + pub session: Option>, + /// Turn records in ascending `sequence` order. + pub turns: Vec, + /// Item envelopes in ascending `seq` order, approval folds applied. + pub items: Vec, +} + +/// Errors from reading a rollout file as canonical history. +#[derive(Debug, thiserror::Error)] +pub enum HistoryReadError { + /// The file could not be read. + #[error("read rollout history: {0}")] + Io(#[from] std::io::Error), + /// A line failed the version dispatch. History reads are fail-closed, + /// like resume: a damaged file errors rather than silently truncating + /// the returned history. + #[error("rollout history line {line_index} is unreadable: {error}")] + DamagedLine { + line_index: usize, + error: RolloutLineReadError, + }, + /// A legacy line failed to project forward. + #[error("project legacy line: {0}")] + Projection(#[from] LegacyProjectError), +} + +/// Reads one rollout file into canonical history form. Legacy (v1) lines +/// are projected through a file-scoped [`LegacyProjector`] (so packed +/// records expand and approvals fold); v2 lines are used directly. A +/// truncated final line is tolerated as a crash tail, matching resume. +/// +/// Rollback markers are honored at turn granularity: the last +/// `SessionRollback` line drops already-read turns (and their items) that +/// are not in its retained set. Item-level retention ids are not matched +/// because packed-record sibling ids cannot be recovered after projection; +/// rollback truncates at turn boundaries in practice, so turn granularity +/// is exact for the real use case. +pub fn read_canonical_history(path: &Path) -> Result { + let text = std::fs::read_to_string(path)?; + let mut projector = LegacyProjector::new(); + let mut history = CanonicalHistory::default(); + let lines: Vec<&str> = text.lines().collect(); + for (index, raw) in lines.iter().enumerate() { + if raw.trim().is_empty() { + continue; + } + let parsed = match parse_rollout_line(raw) { + Ok(parsed) => parsed, + // A truncated final line is a crash tail: the write never + // completed, nothing was acknowledged. + Err(RolloutLineReadError::TruncatedTail) if index + 1 == lines.len() => break, + Err(error) => { + return Err(HistoryReadError::DamagedLine { + line_index: index, + error, + }); + } + }; + let v2_lines = match parsed { + ParsedRolloutLine::Legacy(line) => projector.project_line(&line)?, + ParsedRolloutLine::V2(line) => vec![*line], + }; + for line in v2_lines { + apply_v2_line(&mut history, line); + } + } + Ok(history) +} + +fn apply_v2_line(history: &mut CanonicalHistory, line: RolloutLineV2) { + match line { + RolloutLineV2::SessionMeta { session, .. } => history.session = Some(session), + RolloutLineV2::Turn { turn, .. } => history.turns.push(turn), + RolloutLineV2::Item { item, .. } => history.items.push(item), + RolloutLineV2::SessionRollback { + retained_turn_ids, .. + } => { + let retained: HashSet<&str> = retained_turn_ids + .iter() + .map(|id| id.as_str()) + .collect(); + history.turns.retain(|turn| retained.contains(turn.id.as_str())); + history + .items + .retain(|item| retained.contains(item.turn_id.as_str())); + } + // Internal entries are not items; title updates are folded into the + // session snapshot by callers that need them; compaction snapshots + // shape the prompt, not the displayed history; workspace lines are + // not part of the conversational timeline. + RolloutLineV2::Internal { .. } + | RolloutLineV2::SessionTitleUpdated { .. } + | RolloutLineV2::CompactionSnapshot { .. } + | RolloutLineV2::WorkspaceCheckpoint { .. } + | RolloutLineV2::WorkspaceChange { .. } + | RolloutLineV2::WorkspaceRestoreStarted { .. } + | RolloutLineV2::WorkspaceRestoreCompleted { .. } => {} + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use pretty_assertions::assert_eq; + + use super::*; + use crate::conversation::records::{ + ItemLine, ItemRecord, RolloutLine, SessionRollbackLine, TextItem, TurnItem, + }; + use crate::conversation::{ItemId, SessionId, TurnId, TurnStatus}; + use devo_protocol::canonical::item::ItemState; + + fn write_lines(path: &Path, lines: &[RolloutLine]) { + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(line).expect("serialize")); + text.push('\n'); + } + std::fs::write(path, text).expect("write fixture"); + } + + fn item_record(seq: u64, session_id: SessionId, turn_id: TurnId, text: &str) -> ItemRecord { + ItemRecord { + id: ItemId::new(), + session_id, + turn_id, + seq, + timestamp: Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, 0).unwrap(), + attempt_placement: None, + turn_status: Some(TurnStatus::Running), + sibling_turn_ids: Vec::new(), + input_items: Vec::new(), + output_items: vec![TurnItem::AgentMessage(TextItem { text: text.into() })], + worklog: None, + error: None, + schema_version: 1, + } + } + + #[test] + fn rollback_truncates_turns_and_their_items() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let session_id = SessionId::new(); + let kept_turn = TurnId::new(); + let dropped_turn = TurnId::new(); + let kept_item = item_record(1, session_id, kept_turn, "kept"); + let dropped_item = item_record(2, session_id, dropped_turn, "dropped"); + write_lines( + &dir.path().join("rollout.jsonl"), + &[ + RolloutLine::Item(ItemLine { + timestamp: kept_item.timestamp, + item: kept_item, + }), + RolloutLine::Item(ItemLine { + timestamp: dropped_item.timestamp, + item: dropped_item, + }), + RolloutLine::SessionRollback(Box::new(SessionRollbackLine { + timestamp: Utc.with_ymd_and_hms(2026, 7, 1, 12, 1, 0).unwrap(), + session_id, + retained_turn_ids: vec![kept_turn], + retained_item_ids: Vec::new(), + latest_turn_id: Some(kept_turn), + schema_version: 1, + })), + ], + ); + + let history = read_canonical_history(&dir.path().join("rollout.jsonl")).expect("read"); + assert_eq!(history.items.len(), 1); + assert_eq!(history.items[0].state, ItemState::Completed); + assert!( + matches!(&history.items[0].item, devo_protocol::canonical::item::Item::AssistantMessage { text, .. } if text == "kept") + ); + } + + #[test] + fn truncated_final_line_is_tolerated() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let item = item_record(1, session_id, turn_id, "ok"); + let mut text = String::new(); + text.push_str( + &serde_json::to_string(&RolloutLine::Item(ItemLine { + timestamp: item.timestamp, + item, + })) + .expect("serialize"), + ); + text.push('\n'); + text.push_str(r#"{"v":2,"kind":"item","timestamp":"2026"#); + std::fs::write(dir.path().join("rollout.jsonl"), text).expect("write fixture"); + + let history = read_canonical_history(&dir.path().join("rollout.jsonl")).expect("read"); + assert_eq!(history.items.len(), 1); + } + + #[test] + fn damaged_middle_line_fails_closed() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let item = item_record(1, session_id, turn_id, "ok"); + let mut text = String::new(); + text.push_str( + &serde_json::to_string(&RolloutLine::Item(ItemLine { + timestamp: item.timestamp, + item, + })) + .expect("serialize"), + ); + text.push('\n'); + text.push_str("{\"v\":2,\"kind\":\"nope\"}\n"); + std::fs::write(dir.path().join("rollout.jsonl"), text).expect("write fixture"); + + let error = read_canonical_history(&dir.path().join("rollout.jsonl")) + .expect_err("damaged line must fail"); + assert!(matches!(error, HistoryReadError::DamagedLine { .. })); + } +} diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index 7f93a600..f022fbef 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -30,7 +30,7 @@ use uuid::Uuid; use crate::TurnKind as LegacyTurnKind; use crate::conversation::{ ApprovalRequestItem, ItemLine, ItemRecord, RolloutLine, SessionMetaLine, TurnItem, TurnLine, - TurnStatus as LegacyTurnStatus, + TurnRecord, TurnStatus as LegacyTurnStatus, }; use super::rollout_v2::{ @@ -420,89 +420,10 @@ impl LegacyProjector { fn project_turn(&mut self, line: &TurnLine) -> Result, LegacyProjectError> { let record = &line.turn; - - let kind = match &record.kind { - // `Review` was dead code with no production data and `Other(_)` - // was an open string; both collapse to Regular. Goal continuations - // are never back-filled from content (05 §2.2): they stay Regular. - LegacyTurnKind::Regular | LegacyTurnKind::Review | LegacyTurnKind::Other(_) => { - TurnKind::Regular - } - LegacyTurnKind::ManualCompaction => TurnKind::Compaction, - }; - - let status = match record.status { - // Waiting on an approval is still part of the turn, not a - // separate state (07 §4.3). - LegacyTurnStatus::Pending - | LegacyTurnStatus::Running - | LegacyTurnStatus::WaitingApproval => TurnStatus::InProgress, - LegacyTurnStatus::Completed => TurnStatus::Completed, - LegacyTurnStatus::Interrupted => TurnStatus::Interrupted, - LegacyTurnStatus::Failed => TurnStatus::Failed, - }; - - let error = record.error.as_ref().map(|error| { - let mut projected = AgentError::new(error.code.clone(), error.message.clone()); - if let Some(hint) = &error.recovery_hint { - projected.details = Some(serde_json::json!({ "recoveryHint": hint })); - } - projected - }); - - let usage = record.usage.as_ref().map(|usage| CanonicalTurnUsage { - query: UsageTotals { - total_tokens: u64::from( - usage - .total_tokens - .unwrap_or(usage.input_tokens + usage.output_tokens), - ), - input_tokens: u64::from(usage.input_tokens), - output_tokens: u64::from(usage.output_tokens), - reasoning_tokens: u64::from(usage.reasoning_output_tokens.unwrap_or(0)), - cache_read_input_tokens: u64::from(usage.cache_read_input_tokens.unwrap_or(0)), - cache_creation_input_tokens: u64::from( - usage.cache_creation_input_tokens.unwrap_or(0), - ), - call_count: 0, - // The provider reported usage, so the turn had at least one - // metered call. - metered_call_count: 1, - ..UsageTotals::default() - }, - overhead: UsageTotals::default(), - }); - - let turn = Turn { - id: TurnId::from_legacy_uuid(legacy_uuid(record.id)?), - session_id: SessionId::from_legacy_uuid(legacy_uuid(record.session_id)?), - sequence: record.sequence, - kind, - status, - model: ModelBinding { - provider: record - .model_binding_id - .clone() - .unwrap_or_else(|| "unknown".into()), - model: if record.request_model.is_empty() { - record.model.clone() - } else { - record.request_model.clone() - }, - reasoning_effort: record - .reasoning_effort_selection - .as_deref() - .and_then(|selection| selection.parse().ok()), - }, - started_at: record.started_at, - completed_at: record.completed_at, - error, - usage, - }; Ok(vec![RolloutLineV2::Turn { v: ROLLOUT_FORMAT_VERSION, timestamp: line.timestamp, - turn, + turn: canonical_turn_from_record(record)?, extras: Some(Box::new(TurnPersistenceExtras { session_context: record.session_context.clone(), turn_context: record.turn_context.clone(), @@ -823,6 +744,88 @@ impl LegacyProjector { } } +/// Converts a legacy `TurnRecord` into the canonical `Turn`. This is the +/// exact mapping the rollout forward projector uses for Turn lines, shared +/// with the history read API (`session/turns/list`). +pub fn canonical_turn_from_record(record: &TurnRecord) -> Result { + let kind = match &record.kind { + // `Review` was dead code with no production data and `Other(_)` + // was an open string; both collapse to Regular. Goal continuations + // are never back-filled from content (05 §2.2): they stay Regular. + LegacyTurnKind::Regular | LegacyTurnKind::Review | LegacyTurnKind::Other(_) => { + TurnKind::Regular + } + LegacyTurnKind::ManualCompaction => TurnKind::Compaction, + }; + + let status = match record.status { + // Waiting on an approval is still part of the turn, not a + // separate state (07 §4.3). + LegacyTurnStatus::Pending | LegacyTurnStatus::Running | LegacyTurnStatus::WaitingApproval => { + TurnStatus::InProgress + } + LegacyTurnStatus::Completed => TurnStatus::Completed, + LegacyTurnStatus::Interrupted => TurnStatus::Interrupted, + LegacyTurnStatus::Failed => TurnStatus::Failed, + }; + + let error = record.error.as_ref().map(|error| { + let mut projected = AgentError::new(error.code.clone(), error.message.clone()); + if let Some(hint) = &error.recovery_hint { + projected.details = Some(serde_json::json!({ "recoveryHint": hint })); + } + projected + }); + + let usage = record.usage.as_ref().map(|usage| CanonicalTurnUsage { + query: UsageTotals { + total_tokens: u64::from( + usage + .total_tokens + .unwrap_or(usage.input_tokens + usage.output_tokens), + ), + input_tokens: u64::from(usage.input_tokens), + output_tokens: u64::from(usage.output_tokens), + reasoning_tokens: u64::from(usage.reasoning_output_tokens.unwrap_or(0)), + cache_read_input_tokens: u64::from(usage.cache_read_input_tokens.unwrap_or(0)), + cache_creation_input_tokens: u64::from(usage.cache_creation_input_tokens.unwrap_or(0)), + call_count: 0, + // The provider reported usage, so the turn had at least one + // metered call. + metered_call_count: 1, + ..UsageTotals::default() + }, + overhead: UsageTotals::default(), + }); + + Ok(Turn { + id: TurnId::from_legacy_uuid(legacy_uuid(record.id)?), + session_id: SessionId::from_legacy_uuid(legacy_uuid(record.session_id)?), + sequence: record.sequence, + kind, + status, + model: ModelBinding { + provider: record + .model_binding_id + .clone() + .unwrap_or_else(|| "unknown".into()), + model: if record.request_model.is_empty() { + record.model.clone() + } else { + record.request_model.clone() + }, + reasoning_effort: record + .reasoning_effort_selection + .as_deref() + .and_then(|selection| selection.parse().ok()), + }, + started_at: record.started_at, + completed_at: record.completed_at, + error, + usage, + }) +} + /// Legacy identifiers are `Display`-formatted UUIDs; converting back through /// the string form keeps the bare-UUID textual representation so ids /// round-trip unchanged. diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index bbdfc2b3..4f47a666 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -1,4 +1,5 @@ pub mod event_projection; +pub mod history; pub mod legacy_projector; pub mod rollout_v2; pub mod v2_inverse; @@ -10,7 +11,8 @@ pub use event_projection::{ DerivedEvent, EVENT_SCHEMA_VERSION, events_from_v2_line, session_stream_id, sessions_stream_id, source_fact_id, }; -pub use legacy_projector::{LegacyProjectError, LegacyProjector}; +pub use history::{CanonicalHistory, HistoryReadError, read_canonical_history}; +pub use legacy_projector::{LegacyProjectError, LegacyProjector, canonical_turn_from_record}; pub use rollout_v2::{ InternalRecordV2, ParsedRolloutLine, ROLLOUT_FORMAT_VERSION, RolloutLineReadError, RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, parse_rollout_line, diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 7fa8f6f7..3f1d0b90 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -105,6 +105,10 @@ pub enum ClientMethod { ProviderVendorList, ProviderValidate, ProviderVendorUpsert, + // New Native API methods (canonical types; not part of the legacy + // `_devo/*` alias surface). + SessionTurnsList, + SessionItemsList, } impl ClientMethod { @@ -159,6 +163,8 @@ impl ClientMethod { Self::ProviderVendorList => "provider/list", Self::ProviderValidate => "provider/validate", Self::ProviderVendorUpsert => "provider/upsert", + Self::SessionTurnsList => "session/turns/list", + Self::SessionItemsList => "session/items/list", } } @@ -213,6 +219,8 @@ impl ClientMethod { "provider/list" => Self::ProviderVendorList, "provider/validate" => Self::ProviderValidate, "provider/upsert" => Self::ProviderVendorUpsert, + "session/turns/list" => Self::SessionTurnsList, + "session/items/list" => Self::SessionItemsList, _ => return None, }) } diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 5495a69d..f0b29cb3 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -451,6 +451,13 @@ impl ServerRuntime { Some(ClientMethod::ProviderVendorUpsert) => { Some(self.handle_provider_vendor_upsert(id?, params).await) } + // Paged history reads of the new Native API (canonical types). + Some(ClientMethod::SessionTurnsList) => { + Some(self.handle_session_turns_list(id?, params).await) + } + Some(ClientMethod::SessionItemsList) => { + Some(self.handle_session_items_list(id?, params).await) + } // TODO: add endpoint to kill background process opened by unified exec command. // TODO: add endpoint to list current background processes. None => Some(self.error_response( @@ -2061,4 +2068,361 @@ mod tests { Ok(()) } + + // ── Paged history reads (P4a: session/turns/list, session/items/list) ── + + /// Writes a session rollout (3 turns, 5 agent-message items across two + /// turns) through the real v2 write path and returns its session id. + async fn write_history_rollout(data_root: &std::path::Path) -> SessionId { + use devo_core::{TextItem, TurnItem}; + + let rollout_store = crate::persistence::RolloutStore::new(data_root.to_path_buf(), None); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + data_root.to_path_buf(), + Vec::new(), + Some("history session".into()), + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + let mut item_seq = 1u64; + for turn_index in 1..=3u32 { + let metadata = crate::turn::TurnMetadata { + turn_id: TurnId::new(), + session_id: record.id, + sequence: turn_index, + status: TurnStatus::Completed, + kind: devo_core::TurnKind::Regular, + model: "test-model".into(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".into(), + request_thinking: None, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + usage: None, + stop_reason: None, + failure_reason: None, + }; + let turn = crate::persistence::build_turn_record(&metadata, None, None, None); + rollout_store.append_turn(&record, turn).expect("append turn"); + // Turns 1 and 2 get two items each, turn 3 gets one. + for text in match turn_index { + 1 | 2 => vec!["first", "second"], + _ => vec!["third"], + } { + let item = crate::persistence::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + item_seq, + TurnItem::AgentMessage(TextItem { + text: format!("{text}-t{turn_index}"), + }), + Some(TurnStatus::Running), + None, + ); + rollout_store.append_item(&record, item).expect("append item"); + item_seq += 1; + } + } + record.id + } + + async fn initialized_connection(runtime: &Arc) -> u64 { + let (outbound, _rx) = super::outbound::test_outbound_channel(16); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, outbound) + .await; + runtime + .handle_acp_initialize( + connection_id, + Some(serde_json::json!(1)), + serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": { "terminal": false }, + }), + ) + .await; + connection_id + } + + async fn history_request( + runtime: &Arc, + connection_id: u64, + id: u64, + method: &str, + params: serde_json::Value, + ) -> serde_json::Value { + runtime + .handle_incoming( + connection_id, + serde_json::json!({ "id": id, "method": method, "params": params }), + ) + .await + .expect("history response") + } + + #[tokio::test] + async fn turns_and_items_list_paginate_without_gaps_or_duplicates() -> Result<()> { + use devo_protocol::canonical::page::Page; + use devo_protocol::canonical::turn::Turn; + + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = write_history_rollout(data_root.path()).await; + let connection_id = initialized_connection(&runtime).await; + + let first = history_request( + &runtime, + connection_id, + 1, + "session/turns/list", + serde_json::json!({ "sessionId": session_id.to_string(), "limit": 2 }), + ) + .await; + let first: Page = + serde_json::from_value(first["result"].clone()).expect("page 1 result"); + assert_eq!(first.data.len(), 2); + assert_eq!( + first.data.iter().map(|turn| turn.sequence).collect::>(), + vec![1, 2] + ); + assert_eq!(first.next_cursor.as_deref(), Some("2")); + let first_turn = &first.data[0]; + assert_eq!(first_turn.session_id.as_str(), session_id.to_string()); + assert_eq!( + first_turn.status, + devo_protocol::canonical::turn::TurnStatus::Completed + ); + assert_eq!( + first_turn.kind, + devo_protocol::canonical::turn::TurnKind::Regular + ); + + let second = history_request( + &runtime, + connection_id, + 2, + "session/turns/list", + serde_json::json!({ + "sessionId": session_id.to_string(), + "limit": 2, + "cursor": first.next_cursor.expect("cursor"), + }), + ) + .await; + let second: Page = + serde_json::from_value(second["result"].clone()).expect("page 2 result"); + assert_eq!(second.data.len(), 1); + assert_eq!(second.data[0].sequence, 3); + assert_eq!(second.next_cursor, None); + + let first_items = history_request( + &runtime, + connection_id, + 3, + "session/items/list", + serde_json::json!({ "sessionId": session_id.to_string(), "limit": 3 }), + ) + .await; + let first_items: Page = + serde_json::from_value(first_items["result"].clone()).expect("items page 1"); + assert_eq!(first_items.data.len(), 3); + assert_eq!( + first_items + .data + .iter() + .map(|item| item.seq) + .collect::>(), + vec![1, 2, 3] + ); + assert_eq!(first_items.next_cursor.as_deref(), Some("3")); + let envelope = &first_items.data[0]; + assert_eq!(envelope.session_id.as_str(), session_id.to_string()); + assert_eq!(envelope.revision, 1); + assert_eq!( + envelope.state, + devo_protocol::canonical::item::ItemState::Completed + ); + assert!( + matches!(&envelope.item, devo_protocol::canonical::item::Item::AssistantMessage { text, .. } if text == "first-t1") + ); + + let second_items = history_request( + &runtime, + connection_id, + 4, + "session/items/list", + serde_json::json!({ + "sessionId": session_id.to_string(), + "limit": 3, + "cursor": first_items.next_cursor.expect("cursor"), + }), + ) + .await; + let second_items: Page = + serde_json::from_value(second_items["result"].clone()).expect("items page 2"); + assert_eq!( + second_items + .data + .iter() + .map(|item| item.seq) + .collect::>(), + vec![4, 5] + ); + assert_eq!(second_items.next_cursor, None); + + Ok(()) + } + + #[tokio::test] + async fn items_list_filters_by_turn_id() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = write_history_rollout(data_root.path()).await; + let connection_id = initialized_connection(&runtime).await; + + let turns = history_request( + &runtime, + connection_id, + 1, + "session/turns/list", + serde_json::json!({ "sessionId": session_id.to_string() }), + ) + .await; + let turns: devo_protocol::canonical::page::Page = + serde_json::from_value(turns["result"].clone()).expect("turns result"); + let turn_two = &turns.data[1]; + + let items = history_request( + &runtime, + connection_id, + 2, + "session/items/list", + serde_json::json!({ + "sessionId": session_id.to_string(), + "turnId": turn_two.id.as_str(), + }), + ) + .await; + let items: devo_protocol::canonical::page::Page< + devo_protocol::canonical::item::ItemEnvelope, + > = serde_json::from_value(items["result"].clone()).expect("items result"); + assert_eq!(items.data.len(), 2); + assert!( + items + .data + .iter() + .all(|item| item.turn_id == turn_two.id) + ); + assert!( + matches!(&items.data[0].item, devo_protocol::canonical::item::Item::AssistantMessage { text, .. } if text == "first-t2") + ); + + Ok(()) + } + + #[tokio::test] + async fn items_list_reads_cold_session_without_resume() -> Result<()> { + let data_root = TempDir::new()?; + let session_id = write_history_rollout(data_root.path()).await; + // A brand-new runtime that never loaded the session: the read must + // resolve the rollout from disk, not from the session map or index. + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + + let items = history_request( + &runtime, + connection_id, + 1, + "session/items/list", + serde_json::json!({ "sessionId": session_id.to_string() }), + ) + .await; + let items: devo_protocol::canonical::page::Page< + devo_protocol::canonical::item::ItemEnvelope, + > = serde_json::from_value(items["result"].clone()).expect("items result"); + assert_eq!(items.data.len(), 5); + assert_eq!(items.next_cursor, None); + + Ok(()) + } + + #[tokio::test] + async fn history_lists_handle_empty_session_bad_cursor_and_unknown_session() -> Result<()> { + use devo_protocol::canonical::page::Page; + use devo_protocol::canonical::turn::Turn; + + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + // A session with only its meta line (no turns, no items). + let rollout_store = crate::persistence::RolloutStore::new(data_root.path().to_path_buf(), None); + let record = rollout_store.create_session_record( + SessionId::new(), + Utc::now(), + data_root.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + let connection_id = initialized_connection(&runtime).await; + + let empty = history_request( + &runtime, + connection_id, + 1, + "session/turns/list", + serde_json::json!({ "sessionId": record.id.to_string() }), + ) + .await; + let empty: Page = serde_json::from_value(empty["result"].clone()).expect("empty"); + assert_eq!( + empty, + Page { + data: Vec::new(), + next_cursor: None, + } + ); + + let bad_cursor = history_request( + &runtime, + connection_id, + 2, + "session/items/list", + serde_json::json!({ "sessionId": record.id.to_string(), "cursor": "not-a-cursor" }), + ) + .await; + assert!( + bad_cursor.get("error").is_some(), + "malformed cursor must error: {bad_cursor}" + ); + + let unknown = history_request( + &runtime, + connection_id, + 3, + "session/turns/list", + serde_json::json!({ "sessionId": SessionId::new().to_string() }), + ) + .await; + assert!(unknown.get("error").is_some(), "unknown session must error"); + + Ok(()) + } } diff --git a/crates/server/src/runtime/handlers.rs b/crates/server/src/runtime/handlers.rs index 99fb2273..93e4f048 100644 --- a/crates/server/src/runtime/handlers.rs +++ b/crates/server/src/runtime/handlers.rs @@ -4,6 +4,7 @@ pub(crate) mod acp_config_options; mod acp_slash_commands; mod compaction; pub mod goal; +mod history; mod message_edit; mod message_edit_restore; mod session; diff --git a/crates/server/src/runtime/handlers/history.rs b/crates/server/src/runtime/handlers/history.rs new file mode 100644 index 00000000..ed8e0492 --- /dev/null +++ b/crates/server/src/runtime/handlers/history.rs @@ -0,0 +1,253 @@ +//! Handlers for the paged history read methods of the new Native API: +//! `session/turns/list` and `session/items/list` (01 §4.2, 07). +//! +//! The in-memory runtime model does not retain turn records or item +//! envelopes, so both methods read the session's rollout file through +//! `devo_core::read_canonical_history` (dual-format, fail-closed). That also +//! makes cold sessions work without a resume: resolving the rollout path is +//! enough. + +use std::path::PathBuf; + +use devo_core::read_canonical_history; +use devo_protocol::canonical::item::ItemEnvelope; +use devo_protocol::canonical::page::{Page, PageParams}; +use devo_protocol::canonical::rpc_session::{SessionItemsListParams, SessionTurnsListParams}; + +use super::super::*; + +/// Default page size for the history read methods. +const DEFAULT_PAGE_LIMIT: u32 = 50; +/// Maximum page size; larger requests are clamped, not rejected (01 §4.2). +const MAX_PAGE_LIMIT: u32 = 200; + +impl ServerRuntime { + pub(crate) async fn handle_session_turns_list( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionTurnsListParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/turns/list params: {error}"), + ); + } + }; + let history = match self + .load_canonical_history(&request_id, params.session_id) + .await + { + Ok(history) => history, + Err(response) => return response, + }; + let page = match paginate(&history.turns, ¶ms.page, |turn| u64::from(turn.sequence)) + { + Ok(page) => page, + Err(message) => { + return self.error_response(request_id, ProtocolErrorCode::InvalidParams, message); + } + }; + serde_json::to_value(SuccessResponse { + id: request_id, + result: page, + }) + .expect("serialize session/turns/list response") + } + + pub(crate) async fn handle_session_items_list( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionItemsListParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/items/list params: {error}"), + ); + } + }; + let history = match self + .load_canonical_history(&request_id, params.session_id) + .await + { + Ok(history) => history, + Err(response) => return response, + }; + let items: Vec = match ¶ms.turn_id { + Some(turn_id) => history + .items + .into_iter() + .filter(|item| item.turn_id == *turn_id) + .collect(), + None => history.items, + }; + let page = match paginate(&items, ¶ms.page, |item| item.seq) { + Ok(page) => page, + Err(message) => { + return self.error_response(request_id, ProtocolErrorCode::InvalidParams, message); + } + }; + serde_json::to_value(SuccessResponse { + id: request_id, + result: page, + }) + .expect("serialize session/items/list response") + } + + /// Resolves the session's rollout path and reads its canonical history. + /// The error variant is a ready-made JSON-RPC error response (session + /// not found, or a damaged/unreadable file). + async fn load_canonical_history( + &self, + request_id: &serde_json::Value, + session_id: devo_protocol::canonical::ids::SessionId, + ) -> Result { + let Some(rollout_path) = self.resolve_rollout_path(&session_id).await else { + return Err(self.error_response( + request_id.clone(), + ProtocolErrorCode::SessionNotFound, + "session does not exist", + )); + }; + read_canonical_history(&rollout_path).map_err(|error| { + self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to read session history: {error}"), + ) + }) + } + + /// Finds the rollout file for a session, loaded or cold: a resumed/live + /// session knows its path; otherwise the SQLite index, then the + /// file-name scan. Ephemeral sessions have no persisted history and + /// resolve to `None` (reported as not found — they have no history to + /// page). + async fn resolve_rollout_path( + &self, + session_id: &devo_protocol::canonical::ids::SessionId, + ) -> Option { + let legacy_id = SessionId::try_from(session_id.as_str()).ok()?; + if let Some(handle) = self.session(legacy_id).await + && let Some(record) = handle.record().await.flatten() + { + return Some(record.rollout_path); + } + if let Ok(Some(index)) = self.deps.db.get_session_index(&legacy_id) + && let Some(path) = index.rollout_path + { + return Some(path); + } + self.rollout_store + .find_rollout_by_session_id(&legacy_id) + .ok() + .flatten() + } +} + +/// Slices `items` (ascending by position) into one page. +/// +/// Cursor encoding: the decimal position of the previous page's last item +/// (`sequence` for turns, `seq` for items); clients must treat it as +/// opaque. `nextCursor` is the last returned item's position and is present +/// iff more data remains. The limit defaults to 50 and clamps into +/// `1..=200` — out-of-range limits never error (01 §4.2). +fn paginate( + items: &[T], + params: &PageParams, + position: impl Fn(&T) -> u64, +) -> Result, String> { + let after = match ¶ms.cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| "malformed cursor".to_string())?, + None => 0, + }; + let limit = params + .limit + .unwrap_or(DEFAULT_PAGE_LIMIT) + .clamp(1, MAX_PAGE_LIMIT) as usize; + let data: Vec = items + .iter() + .filter(|item| position(item) > after) + .take(limit) + .cloned() + .collect(); + let last_position = data.last().map(&position).unwrap_or(after); + let next_cursor = items + .iter() + .any(|item| position(item) > last_position) + .then(|| last_position.to_string()); + Ok(Page { data, next_cursor }) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn params(cursor: Option<&str>, limit: Option) -> PageParams { + PageParams { + cursor: cursor.map(str::to_owned), + limit, + } + } + + #[test] + fn paginate_walks_all_pages_without_gaps_or_duplicates() { + let items: Vec = (1..=5).collect(); + let first = paginate(&items, ¶ms(None, Some(2)), |item| *item).expect("page 1"); + assert_eq!(first.data, vec![1, 2]); + assert_eq!(first.next_cursor.as_deref(), Some("2")); + let second = paginate(&items, ¶ms(Some("2"), Some(2)), |item| *item).expect("page 2"); + assert_eq!(second.data, vec![3, 4]); + assert_eq!(second.next_cursor.as_deref(), Some("4")); + let third = paginate(&items, ¶ms(Some("4"), Some(2)), |item| *item).expect("page 3"); + assert_eq!( + third, + Page { + data: vec![5], + next_cursor: None, + } + ); + } + + #[test] + fn paginate_defaults_and_clamps_the_limit() { + let items: Vec = (1..=250).collect(); + let defaulted = paginate(&items, ¶ms(None, None), |item| *item).expect("default"); + assert_eq!(defaulted.data.len(), 50); + let clamped = paginate(&items, ¶ms(None, Some(1000)), |item| *item).expect("clamp"); + assert_eq!(clamped.data.len(), 200); + assert_eq!(clamped.next_cursor.as_deref(), Some("200")); + let zero = paginate(&items, ¶ms(None, Some(0)), |item| *item).expect("zero"); + assert_eq!(zero.data.len(), 1); + } + + #[test] + fn paginate_rejects_malformed_cursor() { + let error = paginate(&[1u64], ¶ms(Some("not-a-cursor"), None), |item| *item) + .expect_err("must fail"); + assert_eq!(error, "malformed cursor"); + } + + #[test] + fn paginate_empty_input_has_no_cursor() { + let page = paginate(&Vec::::new(), ¶ms(None, None), |item| *item).expect("empty"); + assert_eq!( + page, + Page { + data: Vec::new(), + next_cursor: None, + } + ); + } +} From 389de401255bd5e06707a2af0b24ecbeb388e556 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 12:10:38 +0800 Subject: [PATCH 10/17] feat(server): add subscription API with barrier-consistent cursor replay --- crates/client/src/client_core.rs | 1 + crates/protocol/src/protocol.rs | 16 + crates/server/src/db.rs | 175 +++-- crates/server/src/execution.rs | 4 + crates/server/src/runtime.rs | 8 + crates/server/src/runtime/approval.rs | 5 +- crates/server/src/runtime/connection.rs | 432 +++++++++- crates/server/src/runtime/handlers.rs | 1 + crates/server/src/runtime/handlers/history.rs | 2 +- .../src/runtime/handlers/subscription.rs | 739 ++++++++++++++++++ .../runtime/session_actor/approval_scope.rs | 5 + .../server/src/runtime/session_interactive.rs | 57 ++ crates/server/src/runtime/user_input.rs | 6 +- 13 files changed, 1392 insertions(+), 59 deletions(-) create mode 100644 crates/server/src/runtime/handlers/subscription.rs diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 0d8a4328..c79c20ce 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -949,6 +949,7 @@ fn format_protocol_error_code(code: &ProtocolErrorCode) -> &'static str { ProtocolErrorCode::ForkTurnNotFound => "fork_turn_not_found", ProtocolErrorCode::ForkTurnNotStable => "fork_turn_not_stable", ProtocolErrorCode::PermissionDenied => "permission_denied", + ProtocolErrorCode::CursorExpired => "cursor_expired", ProtocolErrorCode::WorkspaceUnavailable => "workspace_unavailable", ProtocolErrorCode::InheritedSegmentWriteFailed => "inherited_segment_write_failed", ProtocolErrorCode::ForkRetentionRequired => "fork_retention_required", diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 3f1d0b90..285d6a2a 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -109,6 +109,10 @@ pub enum ClientMethod { // `_devo/*` alias surface). SessionTurnsList, SessionItemsList, + SubscriptionCreate, + SubscriptionUpdate, + SubscriptionAck, + SubscriptionUnsubscribe, } impl ClientMethod { @@ -165,6 +169,10 @@ impl ClientMethod { Self::ProviderVendorUpsert => "provider/upsert", Self::SessionTurnsList => "session/turns/list", Self::SessionItemsList => "session/items/list", + Self::SubscriptionCreate => "subscription/create", + Self::SubscriptionUpdate => "subscription/update", + Self::SubscriptionAck => "subscription/ack", + Self::SubscriptionUnsubscribe => "subscription/unsubscribe", } } @@ -221,6 +229,10 @@ impl ClientMethod { "provider/upsert" => Self::ProviderVendorUpsert, "session/turns/list" => Self::SessionTurnsList, "session/items/list" => Self::SessionItemsList, + "subscription/create" => Self::SubscriptionCreate, + "subscription/update" => Self::SubscriptionUpdate, + "subscription/ack" => Self::SubscriptionAck, + "subscription/unsubscribe" => Self::SubscriptionUnsubscribe, _ => return None, }) } @@ -262,6 +274,10 @@ pub enum ProtocolErrorCode { ForkTurnNotStable, #[error("PermissionDenied")] PermissionDenied, + /// The ack/replay cursor is outside the stored log (regression, future + /// value, or unknown stream); the client must re-snapshot (08 §4). + #[error("CursorExpired")] + CursorExpired, #[error("WorkspaceUnavailable")] WorkspaceUnavailable, #[error("InheritedSegmentWriteFailed")] diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index be92c4f2..7bde7f8c 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -405,6 +405,20 @@ impl Database { Ok(count as u64) } + /// The highest stored seq of one stream — the subscription barrier seq + /// (08 §4). `None` when the stream has no rows yet. + pub fn event_log_max_seq(&self, stream_id: &str) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let value: Option = conn + .query_row( + "SELECT MAX(seq) FROM event_log WHERE stream_id = ?1", + params![stream_id], + |row| row.get(0), + ) + .context("failed to read stream barrier seq")?; + Ok(value.map(|value| value as u64)) + } + /// The last rollout line index projected into `event_log` for a file. pub fn projection_watermark(&self, rollout_path: &Path) -> Result> { let conn = self.conn.lock().expect("database mutex poisoned"); @@ -765,6 +779,43 @@ impl Database { Ok(()) } + /// Lists pending messages of one queue without draining them + /// (subscription snapshots, 08 §4). + pub fn list_pending( + &self, + session_id: &SessionId, + queue: QueueType, + ) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let mut stmt = conn + .prepare( + "SELECT kind, content, pending_input_id, metadata, created_at + FROM pending_messages + WHERE session_id = ?1 AND queue_type = ?2 + ORDER BY id ASC", + ) + .context("failed to prepare list_pending statement")?; + let items = stmt + .query_map(params![session_id.to_string(), queue.as_str()], |row| { + let kind_str: String = row.get(0)?; + let content: String = row.get(1)?; + let pending_input_id: Option = row.get(2)?; + let metadata_str: Option = row.get(3)?; + let created_at: i64 = row.get(4)?; + Ok(pending_input_from_row( + &kind_str, + &content, + pending_input_id, + metadata_str, + created_at, + )) + }) + .context("failed to query pending messages")? + .collect::, _>>() + .context("failed to decode pending messages")?; + Ok(items) + } + /// Drains all pending messages from the specified queue, deleting them in the process. pub fn drain_pending( &self, @@ -794,61 +845,13 @@ impl Database { let metadata_str: Option = row.get(3)?; let created_at: i64 = row.get(4)?; - let kind = match kind_str.as_str() { - "user_text" => PendingInputKind::UserText { text: content }, - "user_input" => serde_json::from_str::(&content) - .ok() - .and_then(|value| { - Some(PendingInputKind::UserInput { - input: serde_json::from_value(value.get("input")?.clone()) - .ok()?, - display_text: value - .get("display_text")? - .as_str() - .unwrap_or_default() - .to_string(), - prompt_text: value - .get("prompt_text")? - .as_str() - .unwrap_or_default() - .to_string(), - prompt_messages: value - .get("prompt_messages") - .and_then(|messages| { - serde_json::from_value(messages.clone()).ok() - }) - .unwrap_or_default(), - }) - }) - .unwrap_or(PendingInputKind::UserText { text: content }), - "tool_call_blocked" => { - let parsed: serde_json::Value = - serde_json::from_str(&content).unwrap_or_default(); - PendingInputKind::ToolCallBlockedByHook { - tool_use_id: parsed["tool_use_id"] - .as_str() - .unwrap_or_default() - .to_string(), - reason: parsed["reason"].as_str().unwrap_or_default().to_string(), - } - } - "budget_limit" => PendingInputKind::BudgetLimitSteering, - _ => PendingInputKind::UserText { text: content }, - }; - - let metadata = metadata_str.and_then(|s| serde_json::from_str(&s).ok()); - - Ok(PendingInputItem { - id: pending_input_id - .and_then(|id| PendingInputId::try_from(id).ok()) - .unwrap_or_default(), - kind, - metadata, - created_at: Utc - .timestamp_opt(created_at, 0) - .single() - .unwrap_or_else(Utc::now), - }) + Ok(pending_input_from_row( + &kind_str, + &content, + pending_input_id, + metadata_str, + created_at, + )) }) .context("failed to query pending messages")?; @@ -1018,6 +1021,68 @@ fn parse_additional_directories_column( }) } +/// Maps one `pending_messages` row to its `PendingInputItem` (shared by +/// `drain_pending` and `list_pending`). +fn pending_input_from_row( + kind_str: &str, + content: &str, + pending_input_id: Option, + metadata_str: Option, + created_at: i64, +) -> PendingInputItem { + let kind = match kind_str { + "user_text" => PendingInputKind::UserText { + text: content.to_string(), + }, + "user_input" => serde_json::from_str::(content) + .ok() + .and_then(|value| { + Some(PendingInputKind::UserInput { + input: serde_json::from_value(value.get("input")?.clone()).ok()?, + display_text: value + .get("display_text")? + .as_str() + .unwrap_or_default() + .to_string(), + prompt_text: value + .get("prompt_text")? + .as_str() + .unwrap_or_default() + .to_string(), + prompt_messages: value + .get("prompt_messages") + .and_then(|messages| serde_json::from_value(messages.clone()).ok()) + .unwrap_or_default(), + }) + }) + .unwrap_or(PendingInputKind::UserText { + text: content.to_string(), + }), + "tool_call_blocked" => { + let parsed: serde_json::Value = serde_json::from_str(content).unwrap_or_default(); + PendingInputKind::ToolCallBlockedByHook { + tool_use_id: parsed["tool_use_id"].as_str().unwrap_or_default().to_string(), + reason: parsed["reason"].as_str().unwrap_or_default().to_string(), + } + } + "budget_limit" => PendingInputKind::BudgetLimitSteering, + _ => PendingInputKind::UserText { + text: content.to_string(), + }, + }; + PendingInputItem { + id: pending_input_id + .and_then(|id| PendingInputId::try_from(id).ok()) + .unwrap_or_default(), + kind, + metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()), + created_at: Utc + .timestamp_opt(created_at, 0) + .single() + .unwrap_or_else(Utc::now), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index ebf91c28..4eb04f09 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -66,6 +66,7 @@ pub(crate) fn sandbox_bypass_key_from_pending( pub(crate) struct PendingApproval { pub(crate) owner_session_id: devo_protocol::SessionId, + pub(crate) turn_id: TurnId, pub(crate) tool_name: String, pub(crate) resource: Option, pub(crate) path: Option, @@ -81,6 +82,9 @@ pub(crate) struct PendingApproval { pub(crate) struct PendingUserInput { pub(crate) turn_id: TurnId, + /// The questions the tool asked; kept so subscription snapshots can + /// rebuild the waiting `UserInputRequest` item (08 §4). + pub(crate) questions: Vec, pub(crate) tx: oneshot::Sender, } diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 0a1cc709..4011bcce 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -189,6 +189,12 @@ pub struct ServerRuntime { sessions: Mutex>, /// Interactive approval and user-input waits outside session actors. session_interactive: SessionInteractiveLanes, + /// New-style (`subscription/*`) subscriptions keyed by subscription id + /// (08 §4). Lock order: `connections` → `event_subscriptions`. + event_subscriptions: Mutex>, + /// Count of active `SessionsByCwd` selectors; gates per-event cwd + /// resolution during broadcast (0 = skip the lookup entirely). + sessions_by_cwd_subscriptions: std::sync::atomic::AtomicUsize, /// In-flight turn execution handles keyed by session id. active_turns: active_turn::ActiveTurnRegistry, connections: Arc>>, @@ -346,6 +352,8 @@ impl ServerRuntime { goal_durable_store, sessions: Mutex::new(HashMap::new()), session_interactive: SessionInteractiveLanes::default(), + event_subscriptions: Mutex::new(HashMap::new()), + sessions_by_cwd_subscriptions: std::sync::atomic::AtomicUsize::new(0), active_turns: active_turn::ActiveTurnRegistry::default(), connections: Arc::new(Mutex::new(HashMap::new())), terminal_turn_statuses: Mutex::new(VecDeque::new()), diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index f11fe2d2..8ce87749 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -145,7 +145,7 @@ impl ServerRuntime { } } let result = self - .request_tool_approval(session_id, request.clone()) + .request_tool_approval(session_id, turn_id, request.clone()) .await; if let Err(reason) = &result { self.run_permission_denied_hook(session_id, &request, reason) @@ -560,6 +560,7 @@ impl ServerRuntime { async fn request_tool_approval( &self, session_id: SessionId, + turn_id: TurnId, request: ToolPermissionRequest, ) -> Result { let host_session_id = self.permission_host_session_id(session_id).await; @@ -586,6 +587,7 @@ impl ServerRuntime { let (tx, rx) = oneshot::channel(); let pending = PendingApproval { owner_session_id: session_id, + turn_id, tool_name: request.tool_name.clone(), resource: Some(request.resource.clone()), path: request.path.clone(), @@ -658,6 +660,7 @@ impl ServerRuntime { let (scope_tx, _) = oneshot::channel(); let pending_for_scope = PendingApproval { owner_session_id: pending.owner_session_id, + turn_id: pending.turn_id, tool_name: pending.tool_name, resource: pending.resource, path: pending.path, diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index f0b29cb3..481e6c71 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -144,6 +144,7 @@ impl ServerRuntime { acp_authenticated: false, acp_client_capabilities: crate::AcpClientCapabilities::default(), typed_items: false, + event_selectors: Vec::new(), outbound_tx, opt_out_notification_methods: HashSet::new(), subscriptions: Vec::new(), @@ -174,6 +175,8 @@ impl ServerRuntime { } } self.active_turns.drop_connection_id(connection_id).await; + self.drop_event_subscriptions_for_connection(connection_id) + .await; self.reference_searches .lock() .await @@ -458,6 +461,19 @@ impl ServerRuntime { Some(ClientMethod::SessionItemsList) => { Some(self.handle_session_items_list(id?, params).await) } + // Durable event subscriptions (08 §4). + Some(ClientMethod::SubscriptionCreate) => { + Some(self.handle_subscription_create(connection_id, id?, params).await) + } + Some(ClientMethod::SubscriptionUpdate) => { + Some(self.handle_subscription_update(connection_id, id?, params).await) + } + Some(ClientMethod::SubscriptionAck) => { + Some(self.handle_subscription_ack(connection_id, id?, params).await) + } + Some(ClientMethod::SubscriptionUnsubscribe) => { + Some(self.handle_subscription_unsubscribe(connection_id, id?, params).await) + } // TODO: add endpoint to kill background process opened by unified exec command. // TODO: add endpoint to list current background processes. None => Some(self.error_response( @@ -756,6 +772,21 @@ impl ServerRuntime { let delivery_policy = outbound_delivery_policy(&event); let child_parent_by_session = self.child_parent_by_session().await; let active_turn_connections = self.active_turns.connection_map().await; + // New-style SessionsByCwd selectors match on the event session's cwd; + // resolve it once per event, and only when such a selector exists. + let event_cwd = if self + .sessions_by_cwd_subscriptions + .load(std::sync::atomic::Ordering::Relaxed) + > 0 + && let Some(session_id) = session_id + { + match self.session(session_id).await { + Some(handle) => handle.summary().await.map(|summary| summary.cwd), + None => None, + } + } else { + None + }; let notifications = { let mut connections = self.connections.lock().await; connections @@ -769,7 +800,15 @@ impl ServerRuntime { ) { return None; } - if !connection.should_deliver(method, session_id, &child_parent_by_session) { + // Union of the legacy events/subscribe filter and the new + // subscription/* selectors (legacy clients unaffected). + if !connection.should_deliver(method, session_id, &child_parent_by_session) + && !crate::runtime::handlers::subscription::event_matches_selectors( + &connection.event_selectors, + &event, + event_cwd.as_deref(), + ) + { return None; } let event_seq = connection.next_seq(); @@ -1210,6 +1249,10 @@ pub(crate) struct ConnectionRuntime { /// Whether the client opted in to native typed `item/*` notifications /// via `_meta.devo.typedItems` on ACP initialize (P2). pub(crate) typed_items: bool, + /// Cached union of this connection's new-style (`subscription/*`) + /// selector sets; rebuilt on every create/update/unsubscribe. Delivery + /// reads only this cache (the registry is authoritative for ack state). + pub(crate) event_selectors: Vec, pub(crate) outbound_tx: mpsc::Sender, pub(crate) opt_out_notification_methods: HashSet, pub(crate) subscriptions: Vec, @@ -2425,4 +2468,391 @@ mod tests { Ok(()) } + + // ── Durable event subscriptions (P4b: subscription/*) ───────────── + + /// Writes a session rollout (meta + one completed turn + two agent + /// items) through the runtime's own store, so the outbox projects the + /// derived events into `event_log`. Expected stream rows on + /// `session:`: session/created seq 1, turn/completed seq 2, + /// item/completed seq 3..4. + async fn write_subscribed_rollout(runtime: &Arc) -> SessionId { + use devo_core::{TextItem, TurnItem}; + + let store = &runtime.rollout_store; + let record = store.create_session_record( + SessionId::new(), + Utc::now(), + std::path::PathBuf::from("/tmp/subscription-test"), + Vec::new(), + Some("subscribed session".into()), + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + store.append_session_meta(&record).expect("append meta"); + let metadata = crate::turn::TurnMetadata { + turn_id: TurnId::new(), + session_id: record.id, + sequence: 1, + status: TurnStatus::Completed, + kind: devo_core::TurnKind::Regular, + model: "test-model".into(), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + request_model: "test-model".into(), + request_thinking: None, + started_at: Utc::now(), + completed_at: Some(Utc::now()), + usage: None, + stop_reason: None, + failure_reason: None, + }; + let turn = crate::persistence::build_turn_record(&metadata, None, None, None); + store.append_turn(&record, turn).expect("append turn"); + for (seq, text) in [(1u64, "one"), (2, "two")] { + let item = crate::persistence::build_item_record( + record.id, + metadata.turn_id, + ItemId::new(), + seq, + TurnItem::AgentMessage(TextItem { text: text.into() }), + Some(TurnStatus::Running), + None, + ); + store.append_item(&record, item).expect("append item"); + } + record.id + } + + #[tokio::test] + async fn subscription_create_returns_barrier_consistent_replay_and_snapshot() -> Result<()> { + use devo_protocol::canonical::event::{SnapshotData, SubscriptionCreateResult}; + + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = write_subscribed_rollout(&runtime).await; + let stream_id = devo_core::session_stream_id( + &devo_protocol::canonical::ids::SessionId::from_string(session_id.to_string()), + ); + let connection_id = initialized_connection(&runtime).await; + + let response = history_request( + &runtime, + connection_id, + 1, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": true, + }), + ) + .await; + let result: SubscriptionCreateResult = + serde_json::from_value(response["result"].clone()).expect("create result"); + + // Barrier = 4 (created/completed/completed/completed); cursors are + // barrier-consistent and replay rows carry hydrated log seqs. + assert_eq!(result.cursors.len(), 1); + assert_eq!(result.cursors[0].stream_id, stream_id); + assert_eq!(result.cursors[0].seq, 4); + assert_eq!(result.replay.len(), 4); + assert_eq!( + result + .replay + .iter() + .map(|event| event.meta.seq.expect("hydrated seq")) + .collect::>(), + vec![1, 2, 3, 4] + ); + assert!( + matches!( + &result.replay[0].notification, + devo_protocol::canonical::event::ServerNotification::SessionCreated { .. } + ) + ); + assert!(result.replay.iter().all(|event| event.meta.persisted)); + + // Snapshot: the session from the rollout history, no active turn. + assert_eq!(result.snapshots.len(), 1); + let SnapshotData::Session { + session, + active_turn, + queue, + } = &result.snapshots[0].data + else { + panic!("expected session snapshot"); + }; + assert_eq!(session.id.as_str(), session_id.to_string()); + assert_eq!(active_turn, &None); + assert!(queue.is_empty()); + assert_eq!(result.snapshots[0].barrier_seq, 4); + assert!(result.pending_control_requests.is_empty()); + assert!(result.recovery_snapshots.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn subscription_create_future_cursor_is_expired() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = write_subscribed_rollout(&runtime).await; + let stream_id = devo_core::session_stream_id( + &devo_protocol::canonical::ids::SessionId::from_string(session_id.to_string()), + ); + let connection_id = initialized_connection(&runtime).await; + + let response = history_request( + &runtime, + connection_id, + 1, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": false, + "after": [{ "streamId": stream_id, "seq": 999 }], + }), + ) + .await; + assert_eq!( + response["error"]["code"], + serde_json::json!("CursorExpired") + ); + assert_eq!( + response["error"]["data"]["errorCode"], + serde_json::json!("CURSOR_EXPIRED") + ); + assert_eq!( + response["error"]["data"]["requiresSnapshot"], + serde_json::json!(true) + ); + + Ok(()) + } + + #[tokio::test] + async fn subscription_live_delivery_reaches_new_style_subscriber() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = write_subscribed_rollout(&runtime).await; + let (outbound, mut receiver) = super::outbound::test_outbound_channel(4); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, outbound) + .await; + runtime + .handle_acp_initialize( + connection_id, + Some(serde_json::json!(1)), + serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": { "terminal": false }, + }), + ) + .await; + // The connection has NO legacy events/subscribe filter; only the + // new-style selector can deliver. + let created = history_request( + &runtime, + connection_id, + 2, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": false, + }), + ) + .await; + assert!(created.get("error").is_none(), "create failed: {created}"); + + let turn_id = TurnId::new(); + runtime + .broadcast_event(ServerEvent::ItemCompleted(ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(ItemId::new()), + seq: 0, + item_seq: Some(5), + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::AgentMessage, + payload: serde_json::json!({ "title": "Assistant", "text": "live" }), + }, + })) + .await; + + let frame = tokio::time::timeout(Duration::from_secs(1), receiver.recv()) + .await? + .expect("new-style subscriber receives live event"); + assert_eq!( + frame["method"], + serde_json::json!(crate::ACP_SESSION_UPDATE_METHOD) + ); + assert!( + frame["params"]["_meta"]["devo/originalEvent"] + .to_string() + .contains("live"), + "frame carries the original item event: {frame}" + ); + + // A connection without any selector sees nothing. + let (other_outbound, mut other_receiver) = super::outbound::test_outbound_channel(4); + let other_connection_id = runtime + .register_connection(ClientTransportKind::Stdio, other_outbound) + .await; + runtime + .handle_acp_initialize( + other_connection_id, + Some(serde_json::json!(1)), + serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": { "terminal": false }, + }), + ) + .await; + runtime + .broadcast_event(ServerEvent::ItemCompleted(ItemEventPayload { + context: EventContext { + session_id, + turn_id: Some(turn_id), + item_id: Some(ItemId::new()), + seq: 0, + item_seq: Some(6), + }, + item: ItemEnvelope { + item_id: ItemId::new(), + item_kind: ItemKind::AgentMessage, + payload: serde_json::json!({ "title": "Assistant", "text": "second" }), + }, + })) + .await; + assert!( + tokio::time::timeout(Duration::from_millis(100), other_receiver.recv()) + .await + .is_err(), + "connection without selectors must not receive the event" + ); + // The subscribed connection gets the second event too. + let second = tokio::time::timeout(Duration::from_secs(1), receiver.recv()) + .await? + .expect("second live event"); + assert!(second["params"]["_meta"]["devo/originalEvent"] + .to_string() + .contains("second")); + + Ok(()) + } + + #[tokio::test] + async fn subscription_ack_is_monotonic_and_unsubscribe_removes() -> Result<()> { + use devo_protocol::canonical::event::SubscriptionCreateResult; + + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let session_id = write_subscribed_rollout(&runtime).await; + let stream_id = devo_core::session_stream_id( + &devo_protocol::canonical::ids::SessionId::from_string(session_id.to_string()), + ); + let connection_id = initialized_connection(&runtime).await; + + let created = history_request( + &runtime, + connection_id, + 1, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": false, + }), + ) + .await; + let created: SubscriptionCreateResult = + serde_json::from_value(created["result"].clone()).expect("create result"); + let subscription_id = created.subscription_id.as_str().to_owned(); + + // Future ack → expired. + let future = history_request( + &runtime, + connection_id, + 2, + "subscription/ack", + serde_json::json!({ + "subscriptionId": subscription_id, + "cursors": [{ "streamId": stream_id, "seq": 99 }], + }), + ) + .await; + assert_eq!(future["error"]["code"], serde_json::json!("CursorExpired")); + // Barrier ack → ok. + let ok = history_request( + &runtime, + connection_id, + 3, + "subscription/ack", + serde_json::json!({ + "subscriptionId": subscription_id, + "cursors": [{ "streamId": stream_id, "seq": 4 }], + }), + ) + .await; + assert!(ok.get("error").is_none(), "barrier ack must succeed: {ok}"); + // Regression → expired. + let regression = history_request( + &runtime, + connection_id, + 4, + "subscription/ack", + serde_json::json!({ + "subscriptionId": subscription_id, + "cursors": [{ "streamId": stream_id, "seq": 2 }], + }), + ) + .await; + assert_eq!( + regression["error"]["code"], + serde_json::json!("CursorExpired") + ); + // Unknown stream → expired. + let unknown_stream = history_request( + &runtime, + connection_id, + 5, + "subscription/ack", + serde_json::json!({ + "subscriptionId": subscription_id, + "cursors": [{ "streamId": "session:00000000-0000-0000-0000-000000000000", "seq": 1 }], + }), + ) + .await; + assert_eq!( + unknown_stream["error"]["code"], + serde_json::json!("CursorExpired") + ); + + let removed = history_request( + &runtime, + connection_id, + 6, + "subscription/unsubscribe", + serde_json::json!({ "subscriptionId": subscription_id }), + ) + .await; + assert!(removed.get("error").is_none(), "unsubscribe must succeed"); + let removed_again = history_request( + &runtime, + connection_id, + 7, + "subscription/unsubscribe", + serde_json::json!({ "subscriptionId": subscription_id }), + ) + .await; + assert!(removed_again.get("error").is_some()); + + Ok(()) + } } diff --git a/crates/server/src/runtime/handlers.rs b/crates/server/src/runtime/handlers.rs index 93e4f048..ce293df1 100644 --- a/crates/server/src/runtime/handlers.rs +++ b/crates/server/src/runtime/handlers.rs @@ -8,6 +8,7 @@ mod history; mod message_edit; mod message_edit_restore; mod session; +pub(crate) mod subscription; mod turn; mod turn_interrupt; mod workspace_changes; diff --git a/crates/server/src/runtime/handlers/history.rs b/crates/server/src/runtime/handlers/history.rs index ed8e0492..cac12232 100644 --- a/crates/server/src/runtime/handlers/history.rs +++ b/crates/server/src/runtime/handlers/history.rs @@ -130,7 +130,7 @@ impl ServerRuntime { /// file-name scan. Ephemeral sessions have no persisted history and /// resolve to `None` (reported as not found — they have no history to /// page). - async fn resolve_rollout_path( + pub(crate) async fn resolve_rollout_path( &self, session_id: &devo_protocol::canonical::ids::SessionId, ) -> Option { diff --git a/crates/server/src/runtime/handlers/subscription.rs b/crates/server/src/runtime/handlers/subscription.rs new file mode 100644 index 00000000..0ba272cb --- /dev/null +++ b/crates/server/src/runtime/handlers/subscription.rs @@ -0,0 +1,739 @@ +//! Handlers for the `subscription/*` API (devo-api-design/08 §4). +//! +//! The core invariant is the barrier/snapshot critical section (documented +//! on `handle_subscription_create`): snapshot + replay covers everything up +//! to the barrier seq, live delivery covers everything after it. The v1 +//! notification set is full-snapshot replace-by-id, so the narrow race +//! window left (event committed just before the barrier read, delivered +//! right after registration) is at worst a benign redelivery. + +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use devo_core::event_projection::{session_stream_id, sessions_stream_id}; +use devo_protocol::canonical::error::AgentError; +use devo_protocol::canonical::error::codes; +use devo_protocol::canonical::event::{ + ControlRequestKind, EventCursor, EventEnvelope, PendingControlRequest, SnapshotData, + StreamSelector, StreamSnapshot, SubscriptionAckParams, SubscriptionCreateParams, + SubscriptionCreateResult, SubscriptionUnsubscribeParams, SubscriptionUpdateParams, +}; +use devo_protocol::canonical::ids::{ + ItemId as CanonicalItemId, QueueItemId, SessionId as CanonicalSessionId, SubscriptionId, + TurnId as CanonicalTurnId, +}; +use devo_protocol::canonical::item::{ApprovalTarget, Item, ItemEnvelope, ItemState, UserInput}; +use devo_protocol::canonical::queue::QueueEntry; +use devo_protocol::canonical::rpc_admin::RuntimePingResult; +use devo_protocol::canonical::turn::TurnStatus; +use uuid::Uuid; + +use super::super::*; +use crate::db::QueueType; + +/// One server-side subscription record (registry entry, 08 §4). +#[derive(Debug)] +pub(crate) struct EventSubscription { + pub(crate) connection_id: u64, + pub(crate) selectors: Vec, + /// Highest acked seq per stream; monotonic. ack is also the future + /// basis for truncating the persisted log (no truncation in v1) and for + /// lease expiry (`last_ack_at`). + pub(crate) acked: HashMap, + pub(crate) last_ack_at: Option>, +} + +/// Computes the whitelisted stream id for one selector (08 §2). P4 clients +/// use the same helpers (`devo_core::conversation::event_projection`). +pub(crate) fn selector_stream_id(selector: &StreamSelector) -> String { + match selector { + StreamSelector::SessionsByCwd { cwd } => sessions_stream_id(&cwd.to_string_lossy()), + StreamSelector::Session { session_id } => session_stream_id(session_id), + StreamSelector::BackgroundTask { item_id } => format!("task:{item_id}"), + } +} + +/// Whether a live server event matches any of the connection's new-style +/// subscription selectors. Unioned with the legacy subscription filter at +/// the fan-out (legacy clients are unaffected). +pub(crate) fn event_matches_selectors( + selectors: &[StreamSelector], + event: &ServerEvent, + event_cwd: Option<&std::path::Path>, +) -> bool { + selectors.iter().any(|selector| match selector { + StreamSelector::Session { session_id } => event + .session_id() + .is_some_and(|id| id.to_string() == session_id.as_str()), + StreamSelector::SessionsByCwd { cwd } => { + // The sessions: stream carries session-list changes only + // (08 §2): created/archived/deleted/metadata updates. + matches!( + event, + ServerEvent::SessionStarted(_) + | ServerEvent::SessionArchived(_) + | ServerEvent::SessionUnarchived(_) + | ServerEvent::SessionDeleted(_) + | ServerEvent::SessionTitleUpdated(_) + ) && event_cwd == Some(cwd.as_path()) + } + // No legacy wire event is task-scoped yet; `task:*` stream events + // are derived from unified-exec items in a later phase. + StreamSelector::BackgroundTask { .. } => false, + }) +} + +impl ServerRuntime { + pub(crate) async fn handle_subscription_create( + &self, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SubscriptionCreateParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid subscription/create params: {error}"), + ); + } + }; + let subscription_id = SubscriptionId::new(); + + // CRITICAL SECTION (08 §4): the connections lock is held across the + // barrier read, replay collection, and registration. Live delivery + // takes the same lock, so no event reaches this connection between + // the barrier read and the registration — events with seq ≤ barrier + // are covered by snapshot+replay, later events arrive live. + let mut connections = self.connections.lock().await; + let Some(connection) = connections.get_mut(&connection_id) else { + return self.error_response( + request_id, + ProtocolErrorCode::NotInitialized, + "connection is not registered", + ); + }; + let mut result = match self + .prepare_subscription(&request_id, ¶ms.selectors, ¶ms.after) + { + Ok(result) => result, + Err(response) => return response, + }; + if params.include_snapshot { + for selector in ¶ms.selectors { + let barrier = result + .cursors + .iter() + .find(|cursor| cursor.stream_id == selector_stream_id(selector)) + .map(|cursor| cursor.seq) + .unwrap_or(0); + match self.build_snapshot(selector, barrier).await { + Ok(Some(snapshot)) => result.snapshots.push(snapshot), + Ok(None) => {} + Err(message) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + message, + ); + } + } + } + } + result.pending_control_requests = self + .pending_control_requests(¶ms.selectors) + .await; + // TODO(P4-followup): recovery_snapshots for in-flight items. The + // runtime tracks accumulated stream text (deferred_assistant / + // deferred_reasoning) but not per-channel chunk indices, so an + // honest `nextChunkIndex` cannot be produced yet; v1 returns none + // (clients refetch items via session/items/list when in doubt). + + self.event_subscriptions.lock().await.insert( + subscription_id.as_str().to_owned(), + EventSubscription { + connection_id, + selectors: params.selectors.clone(), + acked: HashMap::new(), + last_ack_at: None, + }, + ); + connection.event_selectors = params.selectors; + self.refresh_cwd_selector_count().await; + drop(connections); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: SubscriptionCreateResult { + subscription_id, + ..result + }, + }) + .expect("serialize subscription/create response") + } + + pub(crate) async fn handle_subscription_update( + &self, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SubscriptionUpdateParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid subscription/update params: {error}"), + ); + } + }; + // Same critical section as create: newly added streams get + // barrier-consistent cursors atomically with the selector swap. + let mut connections = self.connections.lock().await; + let mut subscriptions = self.event_subscriptions.lock().await; + let Some(subscription) = subscriptions.get_mut(params.subscription_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "unknown subscription id", + ); + }; + if subscription.connection_id != connection_id { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "unknown subscription id", + ); + } + let mut result = match self + .prepare_subscription(&request_id, ¶ms.selectors, &[]) + { + Ok(result) => result, + Err(response) => return response, + }; + result.subscription_id = params.subscription_id.clone(); + subscription.selectors = params.selectors.clone(); + if let Some(connection) = connections.get_mut(&connection_id) { + connection.event_selectors = params.selectors; + } + drop(subscriptions); + self.refresh_cwd_selector_count().await; + drop(connections); + + serde_json::to_value(SuccessResponse { + id: request_id, + result, + }) + .expect("serialize subscription/update response") + } + + pub(crate) async fn handle_subscription_ack( + &self, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SubscriptionAckParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid subscription/ack params: {error}"), + ); + } + }; + let mut subscriptions = self.event_subscriptions.lock().await; + let Some(subscription) = subscriptions.get_mut(params.subscription_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "unknown subscription id", + ); + }; + if subscription.connection_id != connection_id { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "unknown subscription id", + ); + } + let selector_streams: Vec = subscription + .selectors + .iter() + .map(selector_stream_id) + .collect(); + for cursor in ¶ms.cursors { + if !selector_streams.contains(&cursor.stream_id) { + return self.cursor_expired_response( + request_id, + format!("stream {} is not part of the subscription", cursor.stream_id), + ); + } + let barrier = match self.deps.db.event_log_max_seq(&cursor.stream_id) { + Ok(barrier) => barrier.unwrap_or(0), + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read stream barrier: {error}"), + ); + } + }; + let acked = subscription.acked.get(&cursor.stream_id).copied().unwrap_or(0); + if cursor.seq < acked || cursor.seq > barrier { + return self.cursor_expired_response( + request_id, + format!( + "cursor {} for stream {} is outside (acked {acked}, barrier {barrier}]", + cursor.seq, cursor.stream_id + ), + ); + } + subscription.acked.insert(cursor.stream_id.clone(), cursor.seq); + } + subscription.last_ack_at = Some(Utc::now()); + drop(subscriptions); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: RuntimePingResult { + server_time_ms: Utc::now().timestamp_millis(), + }, + }) + .expect("serialize subscription/ack response") + } + + pub(crate) async fn handle_subscription_unsubscribe( + &self, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SubscriptionUnsubscribeParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid subscription/unsubscribe params: {error}"), + ); + } + }; + let mut connections = self.connections.lock().await; + let mut subscriptions = self.event_subscriptions.lock().await; + let removed = subscriptions.remove(params.subscription_id.as_str()); + match removed { + Some(subscription) if subscription.connection_id == connection_id => {} + _ => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "unknown subscription id", + ); + } + } + drop(subscriptions); + self.refresh_connection_selectors(&mut connections, connection_id) + .await; + self.refresh_cwd_selector_count().await; + drop(connections); + + serde_json::to_value(SuccessResponse { + id: request_id, + result: RuntimePingResult { + server_time_ms: Utc::now().timestamp_millis(), + }, + }) + .expect("serialize subscription/unsubscribe response") + } + + /// Drops every new-style subscription of a closed connection. + pub(crate) async fn drop_event_subscriptions_for_connection(&self, connection_id: u64) { + self.event_subscriptions + .lock() + .await + .retain(|_, subscription| subscription.connection_id != connection_id); + self.refresh_cwd_selector_count().await; + } + + /// Computes barriers, validates `after` cursors, and collects replay for + /// one selector set. Must be called inside the critical section (see + /// `handle_subscription_create`). The `Err` variant is a ready-made + /// JSON-RPC error response. + fn prepare_subscription( + &self, + request_id: &serde_json::Value, + selectors: &[StreamSelector], + after: &[EventCursor], + ) -> Result { + let mut result = SubscriptionCreateResult { + subscription_id: SubscriptionId::new(), + snapshots: Vec::new(), + replay: Vec::new(), + recovery_snapshots: Vec::new(), + cursors: Vec::new(), + pending_control_requests: Vec::new(), + }; + for selector in selectors { + let stream_id = selector_stream_id(selector); + let barrier = self + .deps + .db + .event_log_max_seq(&stream_id) + .map_err(|error| { + self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to read stream barrier: {error}"), + ) + })? + .unwrap_or(0); + let after_seq = after + .iter() + .find(|cursor| cursor.stream_id == stream_id) + .map(|cursor| cursor.seq) + .unwrap_or(0); + // A cursor from the future means the log was rebuilt (or never + // contained the stream): the client must re-snapshot (08 §4). + if after_seq > barrier { + return Err(self.cursor_expired_response( + request_id.clone(), + format!( + "cursor {after_seq} for stream {stream_id} is past the log barrier {barrier}" + ), + )); + } + let rows = self + .deps + .db + .event_log_rows(&stream_id, after_seq) + .map_err(|error| { + self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to read event log: {error}"), + ) + })?; + for row in rows { + let mut envelope: EventEnvelope = + serde_json::from_str(&row.payload).map_err(|error| { + self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to decode stored event: {error}"), + ) + })?; + // Stored payloads carry meta.seq = null (the outbox does not + // know the log seq at write time); hydrate it from the row. + envelope.meta.seq = Some(row.seq); + result.replay.push(envelope); + } + result.cursors.push(EventCursor { + stream_id, + seq: barrier, + }); + } + Ok(result) + } + + fn cursor_expired_response( + &self, + request_id: serde_json::Value, + message: String, + ) -> serde_json::Value { + let mut agent_error = AgentError::new(codes::CURSOR_EXPIRED, message.clone()); + agent_error.requires_snapshot = true; + serde_json::to_value(ErrorResponse { + id: request_id, + error: ProtocolError { + code: ProtocolErrorCode::CursorExpired, + message, + data: serde_json::to_value(agent_error).expect("serialize agent error"), + }, + }) + .expect("serialize cursor-expired response") + } + + async fn build_snapshot( + &self, + selector: &StreamSelector, + barrier_seq: u64, + ) -> Result, String> { + let stream_id = selector_stream_id(selector); + match selector { + StreamSelector::Session { session_id } => { + let Some(rollout_path) = self.resolve_rollout_path(session_id).await else { + return Ok(None); + }; + let history = devo_core::read_canonical_history(&rollout_path) + .map_err(|error| format!("failed to read session history: {error}"))?; + let Some(session) = history.session else { + return Ok(None); + }; + let active_turn = history + .turns + .iter() + .rev() + .find(|turn| turn.status == TurnStatus::InProgress) + .cloned() + .map(Box::new); + let queue = self + .queue_entries(session_id) + .map_err(|error| format!("failed to read session queue: {error}"))?; + Ok(Some(StreamSnapshot { + stream_id, + barrier_seq, + data: SnapshotData::Session { + session, + active_turn, + queue, + }, + })) + } + StreamSelector::SessionsByCwd { cwd } => { + let sessions = self + .canonical_sessions_for_cwd(cwd) + .await + .map_err(|error| error.to_string())?; + Ok(Some(StreamSnapshot { + stream_id, + barrier_seq, + data: SnapshotData::SessionsList { sessions }, + })) + } + // v1: no standalone task snapshot source; the task item is + // visible through its owning session's history. + StreamSelector::BackgroundTask { .. } => Ok(None), + } + } + + /// Canonical sessions under one cwd: the rollout history reader is the + /// source of truth (the SQLite index is a cache that may lag or lack + /// rows for never-indexed files). + async fn canonical_sessions_for_cwd( + &self, + cwd: &std::path::Path, + ) -> anyhow::Result> { + let mut sessions = Vec::new(); + for rollout_path in self.rollout_store.rollout_paths()? { + let Ok(history) = devo_core::read_canonical_history(&rollout_path) else { + // Damaged files contribute nothing to the list snapshot; + // resume's fail-closed policy reports them separately. + continue; + }; + if let Some(session) = history.session + && session.cwd == cwd + { + sessions.push(*session); + } + } + Ok(sessions) + } + + fn queue_entries( + &self, + session_id: &CanonicalSessionId, + ) -> anyhow::Result> { + let legacy_id = SessionId::try_from(session_id.as_str()) + .map_err(|error| anyhow::anyhow!("invalid session id: {error}"))?; + let pending = self.deps.db.list_pending(&legacy_id, QueueType::Turn)?; + Ok(pending + .into_iter() + .enumerate() + .map(|(index, item)| { + let (input, preview) = queue_entry_content(&item); + QueueEntry { + queue_item_id: QueueItemId::from_legacy_uuid(Uuid::from(item.id)), + position: index as u32, + input, + preview, + enqueued_at: item.created_at, + } + }) + .collect()) + } + + /// Pending approvals/structured questions of the subscribed sessions + /// (08 §4: reconnecting clients must be able to answer them). + async fn pending_control_requests( + &self, + selectors: &[StreamSelector], + ) -> Vec { + let mut out = Vec::new(); + for selector in selectors { + let StreamSelector::Session { session_id } = selector else { + continue; + }; + let Ok(legacy_id) = SessionId::try_from(session_id.as_str()) else { + continue; + }; + let snapshot = self.session_interactive.pending_snapshot(legacy_id).await; + for approval in snapshot.approvals { + let kind = if approval.command.is_some() { + ControlRequestKind::ApprovalCommand + } else if matches!( + approval.resource, + Some(devo_safety::ResourceKind::FileWrite) + ) { + ControlRequestKind::ApprovalFileChange + } else { + ControlRequestKind::ApprovalPermission + }; + let target = if let Some(path) = &approval.path { + Some(ApprovalTarget::Path { path: path.clone() }) + } else if let Some(host) = &approval.host { + Some(ApprovalTarget::Host { + host: host.clone(), + }) + } else { + approval + .command + .clone() + .map(|command| ApprovalTarget::Command { command }) + }; + out.push(PendingControlRequest { + request_id: approval.approval_id.clone(), + kind, + item: waiting_item_envelope( + session_id, + approval.turn_id, + Item::Approval { + approval_id: approval.approval_id.clone(), + target_item_id: None, + action_summary: approval + .command + .clone() + .unwrap_or_else(|| approval.tool_name.clone()), + justification: String::new(), + resource: approval.resource.map(|resource| format!("{resource:?}")), + available_scopes: Vec::new(), + target, + decision: None, + }, + ), + }); + } + for user_input in snapshot.user_inputs { + out.push(PendingControlRequest { + request_id: user_input.request_id.clone(), + kind: ControlRequestKind::UserInput, + item: waiting_item_envelope( + session_id, + user_input.turn_id, + Item::UserInputRequest { + request_id: user_input.request_id.clone(), + target_item_id: None, + questions: user_input + .questions + .into_iter() + .map(|question| { + devo_protocol::canonical::item::UserQuestion { + id: question.id, + header: question.header, + question: question.question, + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| { + devo_protocol::canonical::item::UserQuestionOption { + label: option.label, + description: option.description, + } + }) + .collect() + }), + } + }) + .collect(), + answers: None, + }, + ), + }); + } + } + out + } + + /// Rebuilds one connection's cached selector union from the registry + /// (after unsubscribe or connection close of a sibling). + async fn refresh_connection_selectors( + &self, + connections: &mut HashMap, + connection_id: u64, + ) { + let selectors: Vec = self + .event_subscriptions + .lock() + .await + .values() + .filter(|subscription| subscription.connection_id == connection_id) + .flat_map(|subscription| subscription.selectors.clone()) + .collect(); + if let Some(connection) = connections.get_mut(&connection_id) { + connection.event_selectors = selectors; + } + } + + /// Recomputes the cheap SessionsByCwd gate used by the broadcast path. + async fn refresh_cwd_selector_count(&self) { + let count = self + .event_subscriptions + .lock() + .await + .values() + .flat_map(|subscription| subscription.selectors.iter()) + .filter(|selector| matches!(selector, StreamSelector::SessionsByCwd { .. })) + .count(); + self.sessions_by_cwd_subscriptions + .store(count, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Builds the waiting-state envelope for a pending control request. The +/// approval/question is not a persisted item, so the envelope id is a fresh +/// bare UUID (the fold id is only needed once it materializes as an item); +/// seq 0 marks it as not part of the item stream. +fn waiting_item_envelope( + session_id: &CanonicalSessionId, + turn_id: devo_core::TurnId, + item: Item, +) -> ItemEnvelope { + let now = Utc::now(); + ItemEnvelope { + id: CanonicalItemId::from_legacy_uuid(Uuid::now_v7()), + session_id: session_id.clone(), + turn_id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)), + seq: 0, + revision: 1, + created_at: now, + updated_at: now, + state: ItemState::Waiting, + item, + } +} + +/// Extracts display content and a single-line preview from a pending queue +/// entry. Structured `UserInput` entries keep only their display text for +/// now (the full part list is stored but the canonical `UserInput` mapping +/// for skills/mentions/images lands with `session/queue/*` in P4). +fn queue_entry_content(item: &devo_protocol::PendingInputItem) -> (Vec, String) { + let text = match &item.kind { + devo_protocol::PendingInputKind::UserText { text } => text.clone(), + devo_protocol::PendingInputKind::UserInput { display_text, .. } => display_text.clone(), + // Non-input queue kinds (hook blocks, budget steering) are not + // user-editable inputs; they surface as an empty entry. + _ => String::new(), + }; + let preview = text.lines().next().unwrap_or_default().to_owned(); + if text.is_empty() { + (Vec::new(), preview) + } else { + (vec![UserInput::Text { text }], preview) + } +} diff --git a/crates/server/src/runtime/session_actor/approval_scope.rs b/crates/server/src/runtime/session_actor/approval_scope.rs index d6860910..cf58720d 100644 --- a/crates/server/src/runtime/session_actor/approval_scope.rs +++ b/crates/server/src/runtime/session_actor/approval_scope.rs @@ -166,6 +166,7 @@ mod tests { let (tx, _rx) = tokio::sync::oneshot::channel(); let pending = crate::execution::PendingApproval { owner_session_id: devo_protocol::SessionId::new(), + turn_id: devo_core::TurnId::new(), tool_name: "fetch".to_string(), resource: Some(devo_safety::ResourceKind::Network), path: None, @@ -206,6 +207,7 @@ mod tests { let (tx, _rx) = tokio::sync::oneshot::channel(); crate::execution::PendingApproval { owner_session_id: devo_protocol::SessionId::new(), + turn_id: devo_core::TurnId::new(), tool_name: "shell_command".to_string(), resource: Some(devo_safety::ResourceKind::ShellExec), path: None, @@ -232,6 +234,7 @@ mod tests { let file_path = PathBuf::from("/workspace/src/main.rs"); let pending = crate::execution::PendingApproval { owner_session_id: devo_protocol::SessionId::new(), + turn_id: devo_core::TurnId::new(), tool_name: "write".to_string(), resource: Some(devo_safety::ResourceKind::FileWrite), path: Some(file_path.clone()), @@ -268,6 +271,7 @@ mod tests { let file_path = PathBuf::from("/workspace/src/main.rs"); let pending = crate::execution::PendingApproval { owner_session_id: devo_protocol::SessionId::new(), + turn_id: devo_core::TurnId::new(), tool_name: "read".to_string(), resource: Some(devo_safety::ResourceKind::FileRead), path: Some(file_path), @@ -416,6 +420,7 @@ mod tests { let (tx, _rx) = tokio::sync::oneshot::channel(); let pending = crate::execution::PendingApproval { owner_session_id: devo_protocol::SessionId::new(), + turn_id: devo_core::TurnId::new(), tool_name: "read".to_string(), resource: Some(devo_safety::ResourceKind::FileRead), path: Some(dir.clone()), diff --git a/crates/server/src/runtime/session_interactive.rs b/crates/server/src/runtime/session_interactive.rs index 22a2dc04..8f5fe199 100644 --- a/crates/server/src/runtime/session_interactive.rs +++ b/crates/server/src/runtime/session_interactive.rs @@ -144,6 +144,62 @@ impl SessionInteractiveLanes { pub(crate) async fn clear_session(&self, session_id: SessionId) { self.inner.lock().await.remove(&session_id); } + + /// Clones the pending approval/user-input state of one session for + /// subscription snapshots (08 §4 `pending_control_requests`). The wait + /// channels stay in the lanes; only the descriptive fields are copied. + pub(crate) async fn pending_snapshot(&self, session_id: SessionId) -> PendingSnapshot { + let lanes = self.inner.lock().await; + let Some(state) = lanes.get(&session_id) else { + return PendingSnapshot::default(); + }; + PendingSnapshot { + approvals: state + .pending_approvals + .iter() + .map(|(approval_id, pending)| PendingApprovalSnapshot { + approval_id: approval_id.clone(), + turn_id: pending.turn_id, + tool_name: pending.tool_name.clone(), + resource: pending.resource.clone(), + path: pending.path.clone(), + host: pending.host.clone(), + command: pending.command.clone(), + }) + .collect(), + user_inputs: state + .pending_user_inputs + .iter() + .map(|(request_id, pending)| PendingUserInputSnapshot { + request_id: request_id.clone(), + turn_id: pending.turn_id, + questions: pending.questions.clone(), + }) + .collect(), + } + } +} + +#[derive(Default)] +pub(crate) struct PendingSnapshot { + pub(crate) approvals: Vec, + pub(crate) user_inputs: Vec, +} + +pub(crate) struct PendingApprovalSnapshot { + pub(crate) approval_id: String, + pub(crate) turn_id: devo_core::TurnId, + pub(crate) tool_name: String, + pub(crate) resource: Option, + pub(crate) path: Option, + pub(crate) host: Option, + pub(crate) command: Option, +} + +pub(crate) struct PendingUserInputSnapshot { + pub(crate) request_id: String, + pub(crate) turn_id: devo_core::TurnId, + pub(crate) questions: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -181,6 +237,7 @@ mod tests { "approval-1".to_string(), PendingApproval { owner_session_id: child_session_id, + turn_id: TurnId::new(), tool_name: "exec_command".to_string(), resource: Some(devo_safety::ResourceKind::ShellExec), path: None, diff --git a/crates/server/src/runtime/user_input.rs b/crates/server/src/runtime/user_input.rs index ac4e5224..fdaba2b4 100644 --- a/crates/server/src/runtime/user_input.rs +++ b/crates/server/src/runtime/user_input.rs @@ -88,7 +88,11 @@ impl ServerRuntime { .register_pending_user_input( session_id, request_id.clone(), - PendingUserInput { turn_id, tx }, + PendingUserInput { + turn_id, + questions: args.questions.clone(), + tx, + }, ) .await; From ee745e9200217c4e65d702a9362c74b560fd7356 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 20:05:43 +0800 Subject: [PATCH 11/17] feat(server): add session/queue API with editable pre-items and steer degradation --- crates/client/src/client_core.rs | 1 + crates/core/src/session.rs | 62 +- crates/protocol/src/protocol.rs | 19 + crates/server/src/db.rs | 176 +++- crates/server/src/runtime/connection.rs | 608 +++++++++++- crates/server/src/runtime/handlers.rs | 1 + crates/server/src/runtime/handlers/queue.rs | 934 ++++++++++++++++++ crates/server/src/runtime/handlers/turn.rs | 118 ++- crates/server/src/runtime/lifecycle.rs | 46 +- .../server/src/runtime/turn_exec/finalize.rs | 81 +- .../server/src/runtime/turn_exec/followup.rs | 24 + crates/server/src/runtime/turn_exec/types.rs | 3 + 12 files changed, 1985 insertions(+), 88 deletions(-) create mode 100644 crates/server/src/runtime/handlers/queue.rs diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index c79c20ce..563f5efe 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -950,6 +950,7 @@ fn format_protocol_error_code(code: &ProtocolErrorCode) -> &'static str { ProtocolErrorCode::ForkTurnNotStable => "fork_turn_not_stable", ProtocolErrorCode::PermissionDenied => "permission_denied", ProtocolErrorCode::CursorExpired => "cursor_expired", + ProtocolErrorCode::QueueItemNotFound => "queue_item_not_found", ProtocolErrorCode::WorkspaceUnavailable => "workspace_unavailable", ProtocolErrorCode::InheritedSegmentWriteFailed => "inherited_segment_write_failed", ProtocolErrorCode::ForkRetentionRequired => "fork_retention_required", diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 4d6d897b..eff37ff8 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -438,12 +438,27 @@ impl SessionState { queue.push_front(item); } } - // /btw steer inputs are scoped to the current turn only; discard any - // that arrived too late to be consumed. - self.btw_input_queue - .lock() - .expect("btw input queue mutex should not be poisoned") - .clear(); + // /btw steer inputs that arrived too late to be consumed degrade + // back into the session turn queue (01 §4.3: a steer racing the + // turn end must never lose the message). They append behind + // already-queued inputs — re-queued at turn end, arrival order among + // themselves preserved. + let late_steer: Vec = { + let mut btw = self + .btw_input_queue + .lock() + .expect("btw input queue mutex should not be poisoned"); + btw.drain(..).collect() + }; + if !late_steer.is_empty() { + let mut queue = self + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + for item in late_steer { + queue.push_back(item); + } + } } /// Merge turn-scoped pending input with both cross-thread inboxes. @@ -460,6 +475,7 @@ impl SessionState { #[cfg(test)] mod tests { + use devo_protocol::PendingInputKind; use devo_protocol::ReasoningCapability; use devo_protocol::ReasoningEffort; use devo_protocol::SessionId; @@ -544,6 +560,40 @@ mod tests { ); } + #[test] + fn end_turn_degrades_unconsumed_btw_inputs_into_the_turn_queue() { + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.start_turn(TurnKind::Regular); + let steer = PendingInputItem::new( + PendingInputKind::UserText { + text: "late steer".to_string(), + }, + None, + chrono::Utc::now(), + ); + session + .btw_input_queue + .lock() + .expect("btw lock") + .push_back(steer.clone()); + + session.end_turn(); + + let queue = session.pending_turn_queue.lock().expect("queue lock"); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].id, steer.id); + assert!( + matches!(&queue[0].kind, PendingInputKind::UserText { text } if text == "late steer") + ); + assert!( + session + .btw_input_queue + .lock() + .expect("btw lock") + .is_empty() + ); + } + #[test] fn turn_config_token_budget_uses_model_effective_context() { let model = Model { diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 285d6a2a..71bc28c7 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -113,6 +113,11 @@ pub enum ClientMethod { SubscriptionUpdate, SubscriptionAck, SubscriptionUnsubscribe, + SessionQueuePush, + SessionQueueList, + SessionQueueUpdate, + SessionQueueRemove, + SessionQueueSteer, } impl ClientMethod { @@ -173,6 +178,11 @@ impl ClientMethod { Self::SubscriptionUpdate => "subscription/update", Self::SubscriptionAck => "subscription/ack", Self::SubscriptionUnsubscribe => "subscription/unsubscribe", + Self::SessionQueuePush => "session/queue/push", + Self::SessionQueueList => "session/queue/list", + Self::SessionQueueUpdate => "session/queue/update", + Self::SessionQueueRemove => "session/queue/remove", + Self::SessionQueueSteer => "session/queue/steer", } } @@ -233,6 +243,11 @@ impl ClientMethod { "subscription/update" => Self::SubscriptionUpdate, "subscription/ack" => Self::SubscriptionAck, "subscription/unsubscribe" => Self::SubscriptionUnsubscribe, + "session/queue/push" => Self::SessionQueuePush, + "session/queue/list" => Self::SessionQueueList, + "session/queue/update" => Self::SessionQueueUpdate, + "session/queue/remove" => Self::SessionQueueRemove, + "session/queue/steer" => Self::SessionQueueSteer, _ => return None, }) } @@ -278,6 +293,10 @@ pub enum ProtocolErrorCode { /// value, or unknown stream); the client must re-snapshot (08 §4). #[error("CursorExpired")] CursorExpired, + /// The addressed queue entry is no longer queued (drained, removed, or + /// never existed) — `session/queue/*` (01 §4.3). + #[error("QueueItemNotFound")] + QueueItemNotFound, #[error("WorkspaceUnavailable")] WorkspaceUnavailable, #[error("InheritedSegmentWriteFailed")] diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 7bde7f8c..8e53b00f 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -272,6 +272,15 @@ impl Database { conn.execute("ALTER TABLE sessions ADD COLUMN agent_path TEXT", []) .context("failed to add agent_path column")?; } + // Queue entries have an explicit position so `session/queue/update` + // can reorder without rewriting row ids (P4c); existing rows keep + // their insertion order (position = id). + if !pending_messages_has_column(&conn, "position")? { + conn.execute("ALTER TABLE pending_messages ADD COLUMN position INTEGER", []) + .context("failed to add pending_messages position column")?; + conn.execute("UPDATE pending_messages SET position = id WHERE position IS NULL", []) + .context("failed to backfill pending_messages position")?; + } // Schema version table (05 §2.3): the new authority going forward. // The ad-hoc column probes above are the v0 baseline and keep working // for databases created before this table existed. @@ -733,38 +742,12 @@ impl Database { item: &PendingInputItem, ) -> Result<()> { let conn = self.conn.lock().expect("database mutex poisoned"); - let (kind_str, content) = match &item.kind { - PendingInputKind::UserText { text } => ("user_text", text.clone()), - PendingInputKind::UserInput { - input, - display_text, - prompt_text, - prompt_messages, - } => { - let content = serde_json::json!({ - "input": input, - "display_text": display_text, - "prompt_text": prompt_text, - "prompt_messages": prompt_messages, - }); - ("user_input", content.to_string()) - } - PendingInputKind::ToolCallBlockedByHook { - tool_use_id, - reason, - } => { - let content = serde_json::json!({ - "tool_use_id": tool_use_id, - "reason": reason, - }); - ("tool_call_blocked", content.to_string()) - } - PendingInputKind::BudgetLimitSteering => ("budget_limit", String::new()), - }; + let (kind_str, content) = pending_kind_parts(&item.kind); let metadata_str = item.metadata.as_ref().map(|v| v.to_string()); conn.execute( - "INSERT INTO pending_messages (session_id, queue_type, kind, content, pending_input_id, metadata, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO pending_messages (session_id, queue_type, kind, content, pending_input_id, metadata, created_at, position) + SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, + (SELECT COALESCE(MAX(position), 0) + 1 FROM pending_messages WHERE session_id = ?1 AND queue_type = ?2)", params![ session_id.to_string(), queue.as_str(), @@ -779,6 +762,90 @@ impl Database { Ok(()) } + /// Replaces one pending message's content (kind/content/metadata), keyed + /// by its stable `pending_input_id` (`session/queue/update`). Returns + /// whether the entry still existed. + pub fn update_pending_content( + &self, + session_id: &SessionId, + queue: QueueType, + item: &PendingInputItem, + ) -> Result { + let conn = self.conn.lock().expect("database mutex poisoned"); + let (kind_str, content) = pending_kind_parts(&item.kind); + let metadata_str = item.metadata.as_ref().map(|v| v.to_string()); + let changes = conn + .execute( + "UPDATE pending_messages SET kind = ?4, content = ?5, metadata = ?6 + WHERE session_id = ?1 AND queue_type = ?2 AND pending_input_id = ?3", + params![ + session_id.to_string(), + queue.as_str(), + item.id.to_string(), + kind_str, + content, + metadata_str, + ], + ) + .context("failed to update pending message")?; + Ok(changes == 1) + } + + /// Merges one key into a pending message's metadata JSON (used for the + /// `clientUserMessageId` dedup key, 01 §4.3). Returns whether the entry + /// existed. + pub fn set_pending_metadata_field( + &self, + session_id: &SessionId, + queue: QueueType, + pending_input_id: &PendingInputId, + key: &str, + value: &str, + ) -> Result { + let conn = self.conn.lock().expect("database mutex poisoned"); + let changes = conn + .execute( + "UPDATE pending_messages + SET metadata = json_set(COALESCE(metadata, '{}'), '$.' || ?4, ?5) + WHERE session_id = ?1 AND queue_type = ?2 AND pending_input_id = ?3", + params![ + session_id.to_string(), + queue.as_str(), + pending_input_id.to_string(), + key, + value, + ], + ) + .context("failed to update pending message metadata")?; + Ok(changes == 1) + } + + /// Rewrites queue positions to 1..=N following `ordered_ids` + /// (`session/queue/update` reorder). Ids not listed keep their relative + /// order at the end. + pub fn set_pending_positions( + &self, + session_id: &SessionId, + queue: QueueType, + ordered_ids: &[PendingInputId], + ) -> Result<()> { + let conn = self.conn.lock().expect("database mutex poisoned"); + for (index, id) in ordered_ids.iter().enumerate() { + conn.execute( + "UPDATE pending_messages SET position = ?4 + WHERE session_id = ?1 AND queue_type = ?2 AND pending_input_id = ?3", + params![ + session_id.to_string(), + queue.as_str(), + id.to_string(), + (index + 1) as i64, + ], + ) + .context("failed to reorder pending messages")?; + } + Ok(()) + } + /// Lists pending messages of one queue without draining them /// (subscription snapshots, 08 §4). pub fn list_pending( @@ -792,7 +859,7 @@ impl Database { "SELECT kind, content, pending_input_id, metadata, created_at FROM pending_messages WHERE session_id = ?1 AND queue_type = ?2 - ORDER BY id ASC", + ORDER BY position ASC, id ASC", ) .context("failed to prepare list_pending statement")?; let items = stmt @@ -834,7 +901,7 @@ impl Database { "SELECT kind, content, pending_input_id, metadata, created_at FROM pending_messages WHERE session_id = ?1 AND queue_type = ?2 - ORDER BY id ASC", + ORDER BY position ASC, id ASC", ) .context("failed to prepare drain_pending statement")?; let rows = stmt @@ -920,9 +987,17 @@ impl Database { } fn sessions_has_column(conn: &Connection, column: &str) -> Result { + table_has_column(conn, "sessions", column) +} + +fn pending_messages_has_column(conn: &Connection, column: &str) -> Result { + table_has_column(conn, "pending_messages", column) +} + +fn table_has_column(conn: &Connection, table: &str, column: &str) -> Result { let mut stmt = conn - .prepare("PRAGMA table_info(sessions)") - .context("failed to inspect sessions schema")?; + .prepare(&format!("PRAGMA table_info({table})")) + .with_context(|| format!("failed to inspect {table} schema"))?; let columns = stmt .query_map([], |row| row.get::<_, String>(1)) .context("failed to read sessions schema")?; @@ -1021,6 +1096,39 @@ fn parse_additional_directories_column( }) } +/// Maps a `PendingInputKind` to its `(kind, content)` storage pair (shared +/// by `push_pending` and `update_pending_content`). +fn pending_kind_parts(kind: &PendingInputKind) -> (&'static str, String) { + match kind { + PendingInputKind::UserText { text } => ("user_text", text.clone()), + PendingInputKind::UserInput { + input, + display_text, + prompt_text, + prompt_messages, + } => { + let content = serde_json::json!({ + "input": input, + "display_text": display_text, + "prompt_text": prompt_text, + "prompt_messages": prompt_messages, + }); + ("user_input", content.to_string()) + } + PendingInputKind::ToolCallBlockedByHook { + tool_use_id, + reason, + } => { + let content = serde_json::json!({ + "tool_use_id": tool_use_id, + "reason": reason, + }); + ("tool_call_blocked", content.to_string()) + } + PendingInputKind::BudgetLimitSteering => ("budget_limit", String::new()), + } +} + /// Maps one `pending_messages` row to its `PendingInputItem` (shared by /// `drain_pending` and `list_pending`). fn pending_input_from_row( diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 481e6c71..e2b8220f 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -474,6 +474,22 @@ impl ServerRuntime { Some(ClientMethod::SubscriptionUnsubscribe) => { Some(self.handle_subscription_unsubscribe(connection_id, id?, params).await) } + // Session input queue of the new Native API (01 §4.3). + Some(ClientMethod::SessionQueuePush) => { + Some(self.handle_session_queue_push(connection_id, id?, params).await) + } + Some(ClientMethod::SessionQueueList) => { + Some(self.handle_session_queue_list(id?, params).await) + } + Some(ClientMethod::SessionQueueUpdate) => { + Some(self.handle_session_queue_update(id?, params).await) + } + Some(ClientMethod::SessionQueueRemove) => { + Some(self.handle_session_queue_remove(id?, params).await) + } + Some(ClientMethod::SessionQueueSteer) => { + Some(self.handle_session_queue_steer(connection_id, id?, params).await) + } // TODO: add endpoint to kill background process opened by unified exec command. // TODO: add endpoint to list current background processes. None => Some(self.error_response( @@ -1394,7 +1410,13 @@ mod tests { } fn build_runtime(data_root: &std::path::Path) -> Arc { - let provider: Arc = Arc::new(NoopProvider); + build_runtime_with_provider(data_root, Arc::new(NoopProvider)) + } + + fn build_runtime_with_provider( + data_root: &std::path::Path, + provider: Arc, + ) -> Arc { let db = Arc::new( crate::db::Database::open(data_root.join("connection.db")).expect("open test database"), ); @@ -2855,4 +2877,588 @@ mod tests { Ok(()) } + + // ── Session input queue (P4c: session/queue/*) ──────────────────── + + /// A provider whose stream blocks until `open` flips, so tests can hold + /// a turn open and finish it on demand. The gate is level-triggered: + /// once open, every later call completes immediately (a plain + /// `Notify::notify_waiters` only wakes current waiters and deadlocks + /// follow-up turns). `started` flips once the first stream is requested, + /// letting tests distinguish "model call in flight" from "turn + /// registered but query not started yet". + struct GatedProvider { + open: Arc, + started: Arc, + } + + #[async_trait] + impl ModelProviderSDK for GatedProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + anyhow::bail!("gated provider does not support completion") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> + { + self.started + .store(true, std::sync::atomic::Ordering::SeqCst); + let open = Arc::clone(&self.open); + // Tick like a real provider stream so the session actor keeps + // servicing its mailbox (a perfectly silent stream would stall + // every mailbox round-trip the way no production stream can). + Ok(Box::pin(futures::stream::unfold(false, move |done| { + let open = Arc::clone(&open); + async move { + if done { + return None; + } + let gate_open = async { + while !open.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { + Some(( + Ok(StreamEvent::TextDelta { + index: 0, + text: "tick".into(), + }), + false, + )) + } + _ = gate_open => { + Some(( + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "gated-response".into(), + content: vec![devo_protocol::ResponseContent::Text( + "done".into(), + )], + stop_reason: Some(devo_protocol::StopReason::EndTurn), + usage: devo_protocol::Usage::default(), + metadata: devo_protocol::ResponseMetadata::default(), + }, + }), + true, + )) + } + } + } + }))) + } + + fn name(&self) -> &str { + "gated-provider" + } + } + + async fn start_durable_session( + runtime: &Arc, + connection_id: u64, + data_root: &std::path::Path, + ) -> Result { + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 100, + "method": "session/start", + "params": { + "cwd": data_root, + "ephemeral": false, + "title": "queue session", + "model": "test-model" + } + }), + ) + .await + .expect("session/start response"); + Ok(serde_json::from_value::>( + response, + )? + .result + .session + .session_id) + } + + async fn start_turn( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, + text: &str, + ) -> Result { + let response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 101, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": text }], + "model": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + .expect("turn/start response"); + let result: crate::SuccessResponse = + serde_json::from_value(response)?; + Ok(result.result.turn_id().expect("turn started")) + } + + async fn queue_list( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, + ) -> Vec { + let response = history_request( + runtime, + connection_id, + 1, + "session/queue/list", + serde_json::json!({ "sessionId": session_id.to_string() }), + ) + .await; + let result: devo_protocol::canonical::rpc_turn::SessionQueueListResult = + serde_json::from_value(response["result"].clone()).expect("queue/list result"); + result.entries + } + + async fn session_turns_json( + runtime: &Arc, + connection_id: u64, + session_id: SessionId, + ) -> Vec { + let response = history_request( + runtime, + connection_id, + 90, + "session/turns/list", + serde_json::json!({ "sessionId": session_id.to_string() }), + ) + .await; + response["result"]["data"] + .as_array() + .cloned() + .unwrap_or_default() + } + + #[tokio::test] + async fn queue_push_idle_starts_turn_and_busy_queues_then_update_remove() -> Result<()> { + use devo_protocol::canonical::rpc_turn::{SessionQueuePushResult, SessionQueueUpdateResult}; + use devo_protocol::canonical::turn::TurnStatus as CanonicalTurnStatus; + + let data_root = TempDir::new()?; + let open = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let runtime = build_runtime_with_provider( + data_root.path(), + Arc::new(GatedProvider { + open: Arc::clone(&open), + started: Default::default(), + }), + ); + let (outbound, mut notifications) = super::outbound::test_outbound_channel(64); + let connection_id = runtime + .register_connection(ClientTransportKind::Stdio, outbound) + .await; + runtime + .handle_acp_initialize( + connection_id, + Some(serde_json::json!(1)), + serde_json::json!({ + "protocolVersion": 1, + "clientCapabilities": { "terminal": false }, + }), + ) + .await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + // Subscribe so queue/updated notifications are observable. + let created = history_request( + &runtime, + connection_id, + 2, + "subscription/create", + serde_json::json!({ + "selectors": [{ "kind": "session", "sessionId": session_id.to_string() }], + "includeSnapshot": false, + }), + ) + .await; + assert!(created.get("error").is_none(), "subscribe: {created}"); + + // Idle push → a turn starts immediately. + let pushed = history_request( + &runtime, + connection_id, + 3, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "first" }], + "idempotencyKey": "push-1", + }), + ) + .await; + let pushed: SessionQueuePushResult = + serde_json::from_value(pushed["result"].clone()).expect("push result"); + let SessionQueuePushResult::Started { turn } = pushed else { + panic!("idle push must start a turn"); + }; + assert_eq!(turn.session_id.as_str(), session_id.to_string()); + assert_eq!(turn.status, CanonicalTurnStatus::InProgress); + assert_eq!(turn.sequence, 1); + + // Busy push → queued pre-item. + let queued = history_request( + &runtime, + connection_id, + 4, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "second" }], + "idempotencyKey": "push-2", + }), + ) + .await; + let queued: SessionQueuePushResult = + serde_json::from_value(queued["result"].clone()).expect("push result"); + let SessionQueuePushResult::Queued { entry } = queued else { + panic!("busy push must queue"); + }; + assert_eq!(entry.position, 1); + assert_eq!(entry.preview, "second"); + assert!( + matches!(&entry.input.as_slice(), [devo_protocol::canonical::item::UserInput::Text { text }] if text == "second") + ); + + // Update replaces the content wholesale. + let updated = history_request( + &runtime, + connection_id, + 5, + "session/queue/update", + serde_json::json!({ + "sessionId": session_id.to_string(), + "queueItemId": entry.queue_item_id.as_str(), + "input": [{ "type": "text", "text": "edited" }], + }), + ) + .await; + let updated: SessionQueueUpdateResult = + serde_json::from_value(updated["result"].clone()).expect("update result"); + assert!( + matches!(&updated.entry.input.as_slice(), [devo_protocol::canonical::item::UserInput::Text { text }] if text == "edited") + ); + + // Reorder: push another entry, then move it to position 1. + let third = history_request( + &runtime, + connection_id, + 6, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "third" }], + "idempotencyKey": "push-3", + }), + ) + .await; + let third: SessionQueuePushResult = + serde_json::from_value(third["result"].clone()).expect("push result"); + let SessionQueuePushResult::Queued { entry: third_entry } = third else { + panic!("busy push must queue"); + }; + let reordered = history_request( + &runtime, + connection_id, + 7, + "session/queue/update", + serde_json::json!({ + "sessionId": session_id.to_string(), + "queueItemId": third_entry.queue_item_id.as_str(), + "position": 1, + }), + ) + .await; + let reordered: SessionQueueUpdateResult = + serde_json::from_value(reordered["result"].clone()).expect("reorder result"); + assert_eq!(reordered.entry.position, 1); + let entries = queue_list(&runtime, connection_id, session_id).await; + assert_eq!( + entries + .iter() + .map(|entry| entry.queue_item_id.as_str().to_owned()) + .collect::>(), + vec![ + third_entry.queue_item_id.as_str().to_owned(), + entry.queue_item_id.as_str().to_owned() + ] + ); + // The reorder survives in SQLite too. + let db_entries = runtime + .deps + .db + .list_pending(&session_id, crate::db::QueueType::Turn)?; + assert_eq!(db_entries.len(), 2); + assert_eq!(db_entries[0].id.to_string(), third_entry.queue_item_id.as_str()); + + // Remove works; removing again reports the entry is gone. + let removed = history_request( + &runtime, + connection_id, + 8, + "session/queue/remove", + serde_json::json!({ + "sessionId": session_id.to_string(), + "queueItemId": third_entry.queue_item_id.as_str(), + }), + ) + .await; + assert!(removed.get("error").is_none(), "remove: {removed}"); + let removed_again = history_request( + &runtime, + connection_id, + 9, + "session/queue/remove", + serde_json::json!({ + "sessionId": session_id.to_string(), + "queueItemId": third_entry.queue_item_id.as_str(), + }), + ) + .await; + assert_eq!( + removed_again["error"]["code"], + serde_json::json!("QueueItemNotFound") + ); + + // queue/updated notifications reached the new-style subscriber. + let mut changes = Vec::new(); + while let Ok(Some(frame)) = + tokio::time::timeout(Duration::from_millis(50), notifications.recv()).await + { + if frame["method"] == serde_json::json!("queue/updated") { + changes.push(frame["params"]["change"].clone()); + } + } + assert!( + changes.contains(&serde_json::json!("added")) + && changes.contains(&serde_json::json!("updated")) + && changes.contains(&serde_json::json!("removed")), + "expected added/updated/removed notifications, got {changes:?}" + ); + // Let the held turn settle so teardown does not race the provider. + open.store(true, std::sync::atomic::Ordering::SeqCst); + + Ok(()) + } + + #[tokio::test] + async fn queue_steer_promotes_and_late_steer_degrades_back_to_queue() -> Result<()> { + use devo_protocol::canonical::rpc_turn::SessionQueueSteerResult; + + let data_root = TempDir::new()?; + let open = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let runtime = build_runtime_with_provider( + data_root.path(), + Arc::new(GatedProvider { + open: Arc::clone(&open), + started: Arc::clone(&started), + }), + ); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + let turn_id = start_turn(&runtime, connection_id, session_id, "go").await?; + // Wait until the model call is actually in flight: a steer promoted + // before the query loop's first pending-input drain would be consumed + // into the prompt (the correct injection outcome), which is not the + // degrade path this test exercises. + tokio::time::timeout(Duration::from_secs(5), async { + while !started.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + + let queued = history_request( + &runtime, + connection_id, + 3, + "session/queue/push", + serde_json::json!({ + "sessionId": session_id.to_string(), + "input": [{ "type": "text", "text": "steer me" }], + "idempotencyKey": "push-steer", + }), + ) + .await; + let queued: devo_protocol::canonical::rpc_turn::SessionQueuePushResult = + serde_json::from_value(queued["result"].clone()).expect("push result"); + let devo_protocol::canonical::rpc_turn::SessionQueuePushResult::Queued { entry } = queued + else { + panic!("busy push must queue"); + }; + + let steered = history_request( + &runtime, + connection_id, + 4, + "session/queue/steer", + serde_json::json!({ + "sessionId": session_id.to_string(), + "queueItemId": entry.queue_item_id.as_str(), + "expectedTurnId": turn_id.to_string(), + }), + ) + .await; + let steered: SessionQueueSteerResult = + serde_json::from_value(steered["result"].clone()).expect("steer result"); + assert!(!steered.item_id.as_str().is_empty()); + assert!(queue_list(&runtime, connection_id, session_id).await.is_empty()); + + // Interrupt the turn before the next injection boundary: the + // promoted steer is never consumed; it degrades back into the + // session queue, and the now-idle session drains it into a new + // turn — the message is never lost (01 §4.3). + let interrupted = history_request( + &runtime, + connection_id, + 5, + "turn/interrupt", + serde_json::json!({ + "session_id": session_id.to_string(), + "turn_id": turn_id.to_string(), + }), + ) + .await; + assert!(interrupted.get("error").is_none(), "interrupt: {interrupted}"); + // Open the gate: turn 1 settles as interrupted; the follow-up turn + // started by the queue drain runs to completion immediately. + open.store(true, std::sync::atomic::Ordering::SeqCst); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let turns = session_turns_json(&runtime, connection_id, session_id).await; + let has_completed_followup = turns + .iter() + .any(|turn| turn["status"] == serde_json::json!("completed")); + if has_completed_followup + && queue_list(&runtime, connection_id, session_id) + .await + .is_empty() + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await?; + // Turn 1 is interrupted; the degraded steer drained into a second, + // completed turn (two distinct turn ids, message processed). + let turns = session_turns_json(&runtime, connection_id, session_id).await; + let statuses: Vec<&str> = turns + .iter() + .filter_map(|turn| turn["status"].as_str()) + .collect(); + assert!( + statuses.contains(&"interrupted") && statuses.contains(&"completed"), + "expected interrupted + completed turns, got {statuses:?}" + ); + let turn_ids: std::collections::HashSet<&str> = turns + .iter() + .filter_map(|turn| turn["id"].as_str()) + .collect(); + assert_eq!(turn_ids.len(), 2, "expected two distinct turns: {turns:?}"); + + // Synchronous race: turn already over — turn/steer degrades to the + // queue instead of failing (message preserved). + let degraded = history_request( + &runtime, + connection_id, + 5, + "turn/steer", + serde_json::json!({ + "session_id": session_id.to_string(), + "expected_turn_id": turn_id.to_string(), + "input": [{ "type": "text", "text": "too late" }], + }), + ) + .await; + assert_eq!( + degraded["result"]["disposition"], + serde_json::json!("queued"), + "late steer must degrade: {degraded}" + ); + let entries = queue_list(&runtime, connection_id, session_id).await; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].preview, "too late"); + + Ok(()) + } + + #[tokio::test] + async fn queue_survives_restart_and_btw_restores_into_turn_queue() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + + // Seed one queued and one stale-btw row directly in SQLite. + let queued_item = devo_core::PendingInputItem::new( + devo_core::PendingInputKind::UserText { + text: "queued text".into(), + }, + None, + chrono::Utc::now(), + ); + let btw_item = devo_core::PendingInputItem::new( + devo_core::PendingInputKind::UserText { + text: "stale steer".into(), + }, + None, + chrono::Utc::now(), + ); + runtime + .deps + .db + .push_pending(&session_id, crate::db::QueueType::Turn, &queued_item)?; + runtime + .deps + .db + .push_pending(&session_id, crate::db::QueueType::Btw, &btw_item)?; + drop(runtime); + + let rebuilt = build_runtime(data_root.path()); + rebuilt.load_persisted_sessions().await?; + let rebuilt_connection = initialized_connection(&rebuilt).await; + let entries = queue_list(&rebuilt, rebuilt_connection, session_id).await; + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].preview, "queued text"); + assert_eq!(entries[1].preview, "stale steer"); + // The btw row moved into the turn queue table (the original queued + // row is consumed by the resume drain and lives in memory only). + assert!( + rebuilt + .deps + .db + .list_pending(&session_id, crate::db::QueueType::Btw)? + .is_empty() + ); + let turn_rows = rebuilt + .deps + .db + .list_pending(&session_id, crate::db::QueueType::Turn)?; + assert_eq!(turn_rows.len(), 1); + assert_eq!(turn_rows[0].id, btw_item.id); + + Ok(()) + } } diff --git a/crates/server/src/runtime/handlers.rs b/crates/server/src/runtime/handlers.rs index ce293df1..cbb36e5a 100644 --- a/crates/server/src/runtime/handlers.rs +++ b/crates/server/src/runtime/handlers.rs @@ -7,6 +7,7 @@ pub mod goal; mod history; mod message_edit; mod message_edit_restore; +mod queue; mod session; pub(crate) mod subscription; mod turn; diff --git a/crates/server/src/runtime/handlers/queue.rs b/crates/server/src/runtime/handlers/queue.rs new file mode 100644 index 00000000..4fe986c4 --- /dev/null +++ b/crates/server/src/runtime/handlers/queue.rs @@ -0,0 +1,934 @@ +//! Handlers for the `session/queue/*` API (devo-api-design/01 §4.3). +//! +//! Queue entries are pre-items: editable, not yet in history, addressed by a +//! stable `queueItemId`. The in-memory `pending_turn_queue` is the source of +//! truth (mirrored in SQLite `pending_messages`); ops serialize per session +//! on the queue mutex, last-write-wins. + +use std::collections::VecDeque; + +use devo_core::{ + CollaborationMode, InputItem, PendingInputId, PendingInputItem, PendingInputKind, + TurnExecutionMode, +}; +use devo_protocol::canonical::event::ServerNotification; +use devo_protocol::canonical::ids::{ + ItemId as CanonicalItemId, QueueItemId, SessionId as CanonicalSessionId, + TurnId as CanonicalTurnId, +}; +use devo_protocol::canonical::item::UserInput; +use devo_protocol::canonical::model::ModelBinding; +use devo_protocol::canonical::queue::{QueueChange, QueueEntry}; +use devo_protocol::canonical::rpc_turn::{ + SessionQueueListResult, SessionQueuePushParams, SessionQueuePushResult, + SessionQueueRemoveResult, SessionQueueSteerParams, SessionQueueSteerResult, + SessionQueueUpdateParams, SessionQueueUpdateResult, +}; +use devo_protocol::canonical::turn::{Turn as CanonicalTurn, TurnKind as CanonicalTurnKind, + TurnStatus as CanonicalTurnStatus}; +use uuid::Uuid; + +use super::super::*; + +impl ServerRuntime { + pub(crate) async fn handle_session_queue_push( + self: &Arc, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionQueuePushParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/queue/push params: {error}"), + ); + } + }; + let input_items = match legacy_input_items(¶ms.input) { + Ok(items) => items, + Err(message) => { + return self.error_response(request_id, ProtocolErrorCode::InvalidParams, message); + } + }; + if input_items.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::EmptyInput, + "queue push input is empty", + ); + } + let Ok(legacy_session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid session id", + ); + }; + + // Idle vs busy is decided by the exact turn/start path: it starts a + // new turn when the session is idle and queues otherwise (the same + // operation turn/start uses today, so the two entry points can + // never disagree). + let response = self + .handle_turn_start_for_connection( + Some(connection_id), + request_id.clone(), + serde_json::to_value(TurnStartParams { + session_id: legacy_session_id, + input: input_items, + model: None, + model_binding_id: None, + reasoning_effort_selection: None, + sandbox: None, + approval_policy: None, + cwd: None, + collaboration_mode: CollaborationMode::default(), + execution_mode: TurnExecutionMode::default(), + }) + .expect("serialize turn/start params"), + ) + .await; + if response.get("error").is_some() { + return response; + } + match response["result"]["disposition"].as_str() { + Some("started") => { + let turn = self + .active_canonical_turn(legacy_session_id) + .await + .expect("a turn just started"); + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionQueuePushResult::Started { + turn: Box::new(turn), + }, + }) + .expect("serialize session/queue/push response") + } + Some("queued") => { + let queued_id = response["result"]["queued_input_id"] + .as_str() + .expect("queued result carries queued_input_id") + .to_owned(); + // The dedup key rides on the queued pre-item so a later + // materialization can collapse retries. + if let Some(client_user_message_id) = ¶ms.client_user_message_id { + self.attach_queue_metadata( + legacy_session_id, + &queued_id, + client_user_message_id, + ) + .await; + } + let position = self + .session_turn_reservation_snapshot(legacy_session_id) + .await + .map(|reservation| { + // turn/start enqueues into the shared queue + // synchronously (appending to the back), so the + // entry's position is the current length. + reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned") + .len() as u32 + }) + .unwrap_or(1); + // turn/start enqueues into the shared queue synchronously + // now, but the response entry is still built from the + // accepted input directly (session/queue/list reflects the + // queue truth immediately after). `enqueued_at` is + // approximate. + let entry = QueueEntry { + queue_item_id: QueueItemId::from_legacy_uuid( + Uuid::parse_str(&queued_id).expect("queued_input_id is a uuid"), + ), + position, + input: params.input.clone(), + preview: params + .input + .iter() + .find_map(|part| match part { + UserInput::Text { text } => Some( + text.lines().next().unwrap_or_default().chars().take(80).collect(), + ), + _ => None, + }) + .unwrap_or_default(), + enqueued_at: chrono::Utc::now(), + }; + self.broadcast_queue_updated( + legacy_session_id, + QueueChange::Added, + entry.queue_item_id.clone(), + None, + ) + .await; + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionQueuePushResult::Queued { + entry: Box::new(entry), + }, + }) + .expect("serialize session/queue/push response") + } + _ => self.error_response( + request_id, + ProtocolErrorCode::InternalError, + "unexpected turn/start outcome for queue push", + ), + } + } + + pub(crate) async fn handle_session_queue_list( + &self, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: devo_protocol::canonical::rpc_turn::SessionQueueListParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/queue/list params: {error}"), + ); + } + }; + let Ok(legacy_session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid session id", + ); + }; + let Some(reservation) = self + .session_turn_reservation_snapshot(legacy_session_id) + .await + else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let entries = canonical_queue_entries( + &reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"), + ); + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionQueueListResult { entries }, + }) + .expect("serialize session/queue/list response") + } + + pub(crate) async fn handle_session_queue_update( + self: &Arc, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionQueueUpdateParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/queue/update params: {error}"), + ); + } + }; + let Ok(legacy_session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid session id", + ); + }; + let Some(reservation) = self + .session_turn_reservation_snapshot(legacy_session_id) + .await + else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let queue_item_uuid = match Uuid::parse_str(params.queue_item_id.as_str()) { + Ok(uuid) => uuid, + Err(_) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid queueItemId", + ); + } + }; + let pending_id = PendingInputId::from(queue_item_uuid); + + // Resolve the replacement input up front (skill resolution can + // fail before any state changes). + let new_kind = match ¶ms.input { + Some(input) => { + let input_items = match legacy_input_items(input) { + Ok(items) => items, + Err(message) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + message, + ); + } + }; + if input_items.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::EmptyInput, + "queue update input is empty", + ); + } + let workspace_root = reservation.summary.cwd.clone(); + let resolved = match reservation + .runtime_context + .resolve_input_items(&input_items, Some(workspace_root.as_path())) + { + Ok(Some(resolved)) => resolved, + Ok(None) => { + return self.error_response( + request_id, + ProtocolErrorCode::EmptyInput, + "queue update input is empty", + ); + } + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("failed to resolve queue update input: {error}"), + ); + } + }; + let display_text = super::super::items::render_input_items(&input_items) + .unwrap_or_default(); + Some(PendingInputKind::UserInput { + input: input_items, + display_text, + prompt_text: resolved.prompt_text, + prompt_messages: resolved.prompt_messages, + }) + } + None => None, + }; + + let entry = { + let mut queue = reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + let Some(index) = queue.iter().position(|item| item.id == pending_id) else { + return self.error_response( + request_id, + ProtocolErrorCode::QueueItemNotFound, + "queue item is no longer queued", + ); + }; + if let Some(kind) = new_kind { + queue[index].kind = kind; + } + if let Some(position) = params.position { + let item = queue.remove(index).expect("index just validated"); + let target = (position.saturating_sub(1) as usize).min(queue.len()); + queue.insert(target, item); + } + canonical_queue_entries(&queue) + .into_iter() + .find(|entry| entry.queue_item_id == params.queue_item_id) + .expect("entry just updated") + }; + + if !reservation.ephemeral { + let ordered: Vec = { + reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned") + .iter() + .cloned() + .collect() + }; + let updated = ordered + .iter() + .find(|item| item.id == pending_id) + .expect("entry just updated"); + if let Err(error) = + self.deps + .db + .update_pending_content(&legacy_session_id, QueueType::Turn, updated) + { + tracing::warn!( + session_id = %legacy_session_id, + error = %error, + "failed to persist queue entry update" + ); + } + if params.position.is_some() { + let ordered_ids: Vec = + ordered.iter().map(|item| item.id).collect(); + if let Err(error) = self.deps.db.set_pending_positions( + &legacy_session_id, + QueueType::Turn, + &ordered_ids, + ) { + tracing::warn!( + session_id = %legacy_session_id, + error = %error, + "failed to persist queue reorder" + ); + } + } + } + + self.broadcast_queue_updated( + legacy_session_id, + QueueChange::Updated, + entry.queue_item_id.clone(), + None, + ) + .await; + // The legacy queue broadcast goes through the session actor's + // mailbox, which a busy actor may not service until the turn ends — + // fire and forget, exactly like turn/start's queued path. + { + let runtime = Arc::clone(self); + tokio::spawn(async move { + runtime.broadcast_updated_queue(legacy_session_id).await; + }); + } + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionQueueUpdateResult { entry }, + }) + .expect("serialize session/queue/update response") + } + + pub(crate) async fn handle_session_queue_remove( + self: &Arc, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: devo_protocol::canonical::rpc_turn::SessionQueueRemoveParams = + match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/queue/remove params: {error}"), + ); + } + }; + let Ok(legacy_session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid session id", + ); + }; + let queue_item_uuid = match Uuid::parse_str(params.queue_item_id.as_str()) { + Ok(uuid) => uuid, + Err(_) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid queueItemId", + ); + } + }; + let pending_id = PendingInputId::from(queue_item_uuid); + // Remove directly through the shared queue, not the actor mailbox: + // the actor loop is busy for the whole duration of a running turn + // (`ExecuteTurn` is inline in the actor), so a mailbox round-trip + // would block the RPC until the turn ends. The queue mutex is the + // per-session serialization point for queue ops (01 §4.3). + let Some(reservation) = self + .session_turn_reservation_snapshot(legacy_session_id) + .await + else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let removed = { + let mut queue = reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + let Some(index) = queue.iter().position(|item| item.id == pending_id) else { + return self.error_response( + request_id, + ProtocolErrorCode::QueueItemNotFound, + "queue item is no longer queued", + ); + }; + queue.remove(index).is_some() + }; + if !removed { + return self.error_response( + request_id, + ProtocolErrorCode::QueueItemNotFound, + "queue item is no longer queued", + ); + } + if !reservation.ephemeral + && let Err(error) = self.deps.db.remove_pending_by_id( + &legacy_session_id, + QueueType::Turn, + &pending_id, + ) + { + tracing::warn!( + session_id = %legacy_session_id, + error = %error, + "failed to remove queue entry from database" + ); + } + self.broadcast_queue_updated( + legacy_session_id, + QueueChange::Removed, + params.queue_item_id.clone(), + None, + ) + .await; + // The legacy queue broadcast goes through the session actor's + // mailbox, which a busy actor may not service until the turn ends — + // fire and forget, exactly like turn/start's queued path. + { + let runtime = Arc::clone(self); + tokio::spawn(async move { + runtime.broadcast_updated_queue(legacy_session_id).await; + }); + } + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionQueueRemoveResult {}, + }) + .expect("serialize session/queue/remove response") + } + + pub(crate) async fn handle_session_queue_steer( + self: &Arc, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionQueueSteerParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/queue/steer params: {error}"), + ); + } + }; + let Ok(legacy_session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid session id", + ); + }; + let queue_item_uuid = match Uuid::parse_str(params.queue_item_id.as_str()) { + Ok(uuid) => uuid, + Err(_) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid queueItemId", + ); + } + }; + let pending_id = PendingInputId::from(queue_item_uuid); + let Some(reservation) = self + .session_turn_reservation_snapshot(legacy_session_id) + .await + else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let Some(active_turn) = reservation.active_turn.as_ref() else { + // The race-safe outcome: the turn is over, the entry simply + // stays queued — the message is never lost. + return self.error_response( + request_id, + ProtocolErrorCode::ActiveTurnNotSteerable, + "turn already ended; the entry remains queued", + ); + }; + if active_turn.turn_id.to_string() != params.expected_turn_id.as_str() { + return self.error_response( + request_id, + ProtocolErrorCode::ExpectedTurnMismatch, + "active turn did not match expectedTurnId", + ); + } + if active_turn.kind != devo_core::TurnKind::Regular { + return self.error_response( + request_id, + ProtocolErrorCode::ActiveTurnNotSteerable, + "cannot steer a non-regular turn", + ); + } + let turn_id = active_turn.turn_id; + + let (display_input, item) = { + let mut queue = reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + let Some(index) = queue.iter().position(|item| item.id == pending_id) else { + return self.error_response( + request_id, + ProtocolErrorCode::QueueItemNotFound, + "queue item is no longer queued", + ); + }; + let display_input = match &queue[index].kind { + PendingInputKind::UserText { text } => text.clone(), + PendingInputKind::UserInput { display_text, .. } => display_text.clone(), + _ => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "queued input cannot be steered", + ); + } + }; + let item = queue.remove(index).expect("index just validated"); + (display_input, item) + }; + + reservation + .btw_input_queue + .lock() + .expect("btw input queue mutex should not be poisoned") + .push_back(item.clone()); + if !reservation.ephemeral { + if let Err(error) = self.deps.db.remove_pending_by_id( + &legacy_session_id, + QueueType::Turn, + &pending_id, + ) { + tracing::warn!( + session_id = %legacy_session_id, + error = %error, + "failed to remove promoted entry from database" + ); + } + if let Err(error) = + self.deps + .db + .push_pending(&legacy_session_id, QueueType::Btw, &item) + { + tracing::warn!( + session_id = %legacy_session_id, + error = %error, + "failed to persist promoted entry to database" + ); + } + } + + // Materialize the user message with entry=steer (the legacy + // SteerInput payload carries that through the projectors). + let (item_id, item_seq) = self + .start_item( + legacy_session_id, + turn_id, + ItemKind::UserMessage, + serde_json::json!({ "title": "You", "text": display_input.clone() }), + ) + .await; + self.complete_item( + legacy_session_id, + turn_id, + item_id, + item_seq, + ItemKind::UserMessage, + TurnItem::SteerInput(TextItem { + text: display_input.clone(), + }), + serde_json::json!({ "title": "You", "text": display_input }), + ) + .await; + + self.broadcast_queue_updated( + legacy_session_id, + QueueChange::Promoted, + params.queue_item_id.clone(), + None, + ) + .await; + // The legacy queue broadcast goes through the session actor's + // mailbox, which a busy actor may not service until the turn ends — + // fire and forget, exactly like turn/start's queued path. + { + let runtime = Arc::clone(self); + tokio::spawn(async move { + runtime.broadcast_updated_queue(legacy_session_id).await; + }); + } + self.emit_to_connection( + connection_id, + "serverRequest/resolved", + ServerEvent::ServerRequestResolved(ServerRequestResolvedPayload { + session_id: legacy_session_id, + request_id: "queued-steer-accepted".into(), + turn_id: Some(turn_id), + }), + ) + .await; + serde_json::to_value(SuccessResponse { + id: request_id, + result: SessionQueueSteerResult { + item_id: CanonicalItemId::from_legacy_uuid(Uuid::from(item_id)), + }, + }) + .expect("serialize session/queue/steer response") + } + + /// Broadcasts one canonical `queue/updated` notification to connections + /// subscribed to this session via the new subscription API. The legacy + /// `inputQueue/updated` broadcast stays on its own path + /// (`broadcast_updated_queue`) — TUI is unaffected. + pub(crate) async fn broadcast_queue_updated( + &self, + session_id: SessionId, + change: QueueChange, + queue_item_id: QueueItemId, + started_turn_id: Option, + ) { + let session_id_string = session_id.to_string(); + let entries = self + .session_turn_reservation_snapshot(session_id) + .await + .map(|reservation| { + canonical_queue_entries( + &reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"), + ) + }) + .unwrap_or_default(); + let notification = ServerNotification::QueueUpdated { + session_id: CanonicalSessionId::from_string(session_id_string.clone()), + change, + queue_item_id, + started_turn_id, + queue: entries, + }; + let params = serde_json::to_value(¬ification) + .expect("serialize queue/updated notification") + .get("params") + .cloned() + .unwrap_or_default(); + let mut connections = self.connections.lock().await; + for (connection_id, connection) in connections.iter_mut() { + let subscribed = connection.event_selectors.iter().any(|selector| { + matches!( + selector, + devo_protocol::canonical::event::StreamSelector::Session { session_id } + if session_id.as_str() == session_id_string + ) + }); + if !subscribed { + continue; + } + let event_seq = connection.next_seq(); + let frame = super::super::outbound::OutboundFrame::notification( + *connection_id, + "queue/updated".to_string(), + event_seq, + params.clone(), + ); + let _ = super::super::outbound::enqueue_outbound_notification( + &connection.outbound_tx, + frame, + super::super::outbound::OutboundDeliveryPolicy::Reliable, + "connection_notifications", + ) + .await; + } + } + + /// Stores the domain-level dedup key on a freshly queued entry + /// (`clientUserMessageId`, 01 §4.3). Db-only for now: the in-memory + /// pre-item is handed to the actor asynchronously, so its metadata can + /// only be merged once materialization needs it (a later phase reads + /// the key back from the index on drain/resume). + async fn attach_queue_metadata( + &self, + session_id: SessionId, + queued_id: &str, + client_user_message_id: &str, + ) { + let Ok(uuid) = Uuid::parse_str(queued_id) else { + return; + }; + let pending_id = PendingInputId::from(uuid); + if let Err(error) = self.deps.db.set_pending_metadata_field( + &session_id, + QueueType::Turn, + &pending_id, + "clientUserMessageId", + client_user_message_id, + ) { + tracing::warn!( + session_id = %session_id, + error = %error, + "failed to persist queue entry dedup key" + ); + } + } + + /// The running turn as a canonical `Turn` (for queue/push Started). + async fn active_canonical_turn(&self, session_id: SessionId) -> Option { + let reservation = self.session_turn_reservation_snapshot(session_id).await?; + let turn = reservation.active_turn.as_ref()?; + Some(canonical_turn_from_metadata(turn)) + } +} + +/// Converts one canonical `UserInput` into the legacy `InputItem` the turn +/// machinery consumes. Image/audio modalities have no legacy counterpart +/// and are rejected (the design's `UNSUPPORTED_MODALITY` case). +pub(crate) fn legacy_input_items(input: &[UserInput]) -> Result, String> { + let mut items = Vec::new(); + for part in input { + let item = match part { + UserInput::Text { text } => InputItem::Text { text: text.clone() }, + UserInput::Skill { name } => InputItem::Skill { + name: name.clone(), + path: std::path::PathBuf::from(name), + }, + UserInput::LocalImage { path, .. } => InputItem::LocalImage { path: path.clone() }, + UserInput::Mention { uri } => InputItem::Mention { + path: uri.clone(), + name: None, + }, + UserInput::Image { uri, .. } | UserInput::Audio { uri, .. } => { + return Err(format!("unsupported input modality for queue: {uri}")); + } + }; + items.push(item); + } + Ok(items) +} + +/// Maps one legacy `InputItem` back to the canonical `UserInput` part +/// (queue/list; fixes the text-only placeholder from the P4b snapshot). +pub(crate) fn canonical_user_input_from_input_item(item: &InputItem) -> UserInput { + match item { + InputItem::Text { text } => UserInput::Text { text: text.clone() }, + InputItem::Skill { name, .. } => UserInput::Skill { name: name.clone() }, + InputItem::LocalImage { path } => UserInput::LocalImage { + path: path.clone(), + detail: None, + }, + InputItem::Mention { path, .. } => UserInput::Mention { uri: path.clone() }, + } +} + +/// Builds the canonical queue view from the session's in-memory turn queue. +/// `queueItemId` is the stable pending-input id; `position` is 1-based in +/// current queue order; `preview` is the first 80 chars of the display text. +pub(crate) fn canonical_queue_entries(queue: &VecDeque) -> Vec { + queue + .iter() + .enumerate() + .map(|(index, item)| { + let input: Vec = match &item.kind { + PendingInputKind::UserText { text } => vec![UserInput::Text { text: text.clone() }], + PendingInputKind::UserInput { input, .. } => { + input.iter().map(canonical_user_input_from_input_item).collect() + } + _ => Vec::new(), + }; + let display_text = match &item.kind { + PendingInputKind::UserText { text } => text.as_str(), + PendingInputKind::UserInput { display_text, .. } => display_text.as_str(), + _ => "", + }; + QueueEntry { + queue_item_id: QueueItemId::from_legacy_uuid(Uuid::from(item.id)), + position: (index + 1) as u32, + input, + preview: display_text + .lines() + .next() + .unwrap_or_default() + .chars() + .take(80) + .collect(), + enqueued_at: item.created_at, + } + }) + .collect() +} + +/// Converts runtime turn metadata into the canonical `Turn` snapshot used +/// by `session/queue/push`'s `Started` outcome. +pub(crate) fn canonical_turn_from_metadata(turn: &crate::turn::TurnMetadata) -> CanonicalTurn { + let kind = match &turn.kind { + devo_core::TurnKind::Regular | devo_core::TurnKind::Review | devo_core::TurnKind::Other(_) => { + CanonicalTurnKind::Regular + } + devo_core::TurnKind::ManualCompaction => CanonicalTurnKind::Compaction, + }; + let status = match turn.status { + TurnStatus::Pending | TurnStatus::Running | TurnStatus::WaitingApproval => { + CanonicalTurnStatus::InProgress + } + TurnStatus::Completed => CanonicalTurnStatus::Completed, + TurnStatus::Interrupted => CanonicalTurnStatus::Interrupted, + TurnStatus::Failed => CanonicalTurnStatus::Failed, + }; + CanonicalTurn { + id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn.turn_id)), + session_id: CanonicalSessionId::from_legacy_uuid(Uuid::from(turn.session_id)), + sequence: turn.sequence, + kind, + status, + model: ModelBinding { + provider: turn + .model_binding_id + .clone() + .unwrap_or_else(|| "unknown".into()), + model: if turn.request_model.is_empty() { + turn.model.clone() + } else { + turn.request_model.clone() + }, + reasoning_effort: turn + .reasoning_effort_selection + .as_deref() + .and_then(|selection| selection.parse().ok()), + }, + started_at: turn.started_at, + completed_at: turn.completed_at, + error: None, + usage: None, + } +} diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index 1df0ad77..3c86a686 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -225,9 +225,16 @@ impl ServerRuntime { now, ); let queued_input_id = item.id; - session_handle - .enqueue_pending_turn_input(item.clone()) - .await; + // Push into the shared queue directly instead of the actor + // mailbox: a busy actor does not service its mailbox until the + // turn finishes, and callers must see their entry synchronously + // (01 §4.3 last-write-wins). The actor reads the same shared + // queue at drain time. + reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned") + .push_back(item.clone()); if !reservation.ephemeral && let Err(err) = self.deps @@ -636,28 +643,6 @@ impl ServerRuntime { "session does not exist", ); }; - let Some(active_turn) = reservation.active_turn.as_ref() else { - return self.error_response( - request_id, - ProtocolErrorCode::NoActiveTurn, - "no active turn exists", - ); - }; - let turn_id = active_turn.turn_id; - if turn_id != params.expected_turn_id { - return self.error_response( - request_id, - ProtocolErrorCode::ExpectedTurnMismatch, - "active turn did not match expectedTurnId", - ); - } - if active_turn.kind != devo_core::TurnKind::Regular { - return self.error_response( - request_id, - ProtocolErrorCode::ActiveTurnNotSteerable, - "cannot steer a non-regular turn", - ); - } let workspace_root = reservation.summary.cwd.clone(); let runtime_context = reservation.runtime_context; let resolved_input = match runtime_context @@ -692,6 +677,74 @@ impl ServerRuntime { } }; + // The turn having just ended is the ONLY race that degrades (01 + // §4.3): the input must not be lost, so it lands in the session + // queue instead of erroring. Genuine steerability violations + // (wrong turn, wrong kind) still error below. + if reservation.active_turn.is_none() { + let item = devo_core::PendingInputItem::new( + devo_core::PendingInputKind::UserInput { + input: params.input.clone(), + display_text: display_input.clone(), + prompt_text: resolved_input.prompt_text, + prompt_messages: resolved_input.prompt_messages, + }, + None, + chrono::Utc::now(), + ); + reservation + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned") + .push_back(item.clone()); + if !reservation.ephemeral + && let Err(err) = self + .deps + .db + .push_pending(¶ms.session_id, QueueType::Turn, &item) + { + tracing::warn!( + session_id = %params.session_id, + error = %err, + "failed to persist degraded steer input to database" + ); + } + self.broadcast_queue_updated( + params.session_id, + devo_protocol::canonical::queue::QueueChange::Added, + devo_protocol::canonical::ids::QueueItemId::from_legacy_uuid(uuid::Uuid::from( + item.id, + )), + None, + ) + .await; + self.broadcast_updated_queue(params.session_id).await; + return serde_json::to_value(SuccessResponse { + id: request_id, + result: TurnSteerResult { + turn_id: params.expected_turn_id, + disposition: TurnInputDisposition::Queued, + }, + }) + .expect("serialize turn/steer degraded response"); + } + let active_turn = reservation.active_turn.as_ref().expect("checked above"); + let turn_id = active_turn.turn_id; + if turn_id != params.expected_turn_id { + return self.error_response( + request_id, + ProtocolErrorCode::ExpectedTurnMismatch, + "active turn did not match expectedTurnId", + ); + } + if active_turn.kind != devo_core::TurnKind::Regular { + return self.error_response( + request_id, + ProtocolErrorCode::ActiveTurnNotSteerable, + "cannot steer a non-regular turn", + ); + } + self.emit_turn_item( params.session_id, turn_id, @@ -854,11 +907,16 @@ impl ServerRuntime { ); }; let Some(active_turn) = reservation.active_turn.as_ref() else { - return self.error_response( - request_id, - ProtocolErrorCode::NoActiveTurn, - "no active turn exists", - ); + // The turn just ended (01 §4.3): the queued entry stays queued — + // the message is never lost. + return serde_json::to_value(SuccessResponse { + id: request_id, + result: TurnQueueSteerResult { + turn_id: params.expected_turn_id, + disposition: TurnInputDisposition::Queued, + }, + }) + .expect("serialize turn/queue/steer degraded response"); }; if active_turn.turn_id != params.expected_turn_id { return self.error_response( diff --git a/crates/server/src/runtime/lifecycle.rs b/crates/server/src/runtime/lifecycle.rs index 2c788218..32081a63 100644 --- a/crates/server/src/runtime/lifecycle.rs +++ b/crates/server/src/runtime/lifecycle.rs @@ -130,16 +130,48 @@ impl ServerRuntime { } } - if let Err(err) = self + // Stale btw (steer) inputs are no longer discarded (01 §4.3): they + // degrade into the session turn queue like any other queued input. + match self .deps .db - .clear_pending(&session_id, crate::db::QueueType::Btw) + .drain_pending(&session_id, crate::db::QueueType::Btw) { - tracing::warn!( - session_id = %session_id, - error = %err, - "failed to clear stale btw inputs from database" - ); + Ok(items) => { + if !items.is_empty() { + let core_session = runtime_session.core_session.lock().await; + let mut queue = core_session + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + for item in &items { + queue.push_back(item.clone()); + if let Err(error) = + self.deps + .db + .push_pending(&session_id, crate::db::QueueType::Turn, item) + { + tracing::warn!( + session_id = %session_id, + error = %error, + "failed to restore btw input into the turn queue" + ); + } + } + tracing::debug!( + session_id = %session_id, + restored_btw_count = items.len(), + "degraded stale btw inputs into the pending turn queue" + ); + } + } + Err(err) => { + tracing::warn!( + session_id = %session_id, + error = %err, + "failed to restore stale btw inputs from database" + ); + } } match self.goal_durable_store.replay_goal_store(session_id).await { diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index 6d6eb42a..8940dbb7 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -272,16 +272,77 @@ impl ServerRuntime { ) { let is_ephemeral = state.summary.ephemeral; let btw_input_queue = Arc::clone(&state.btw_input_queue); - btw_input_queue - .lock() - .expect("btw input queue mutex should not be poisoned") - .clear(); - if !is_ephemeral && let Err(err) = self.deps.db.clear_pending(&session_id, QueueType::Btw) { - tracing::warn!( - session_id = %session_id, - error = %err, - "failed to clear btw input messages from database" - ); + let leftover: Vec = { + let mut queue = btw_input_queue + .lock() + .expect("btw input queue mutex should not be poisoned"); + queue.drain(..).collect() + }; + if !leftover.is_empty() { + // Any unconsumed steer degrades into the session turn queue + // (01 §4.3); normally `SessionState::end_turn` already moved + // them, this covers terminal paths that skip it. + let mut queue = state + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned"); + for item in &leftover { + queue.push_back(item.clone()); + } + } + if !is_ephemeral { + // The db mirrors the same degradation — but selectively: a steer + // already drained onward by the follow-up-turn machinery (its + // memory copy was popped to start a turn) must NOT leave a db row + // behind, or a restart would resurrect a duplicate. Only items + // still sitting in the in-memory turn queue become db turn rows; + // every other btw row is dropped. + let queued_ids: std::collections::HashSet = state + .pending_turn_queue + .lock() + .expect("pending turn queue mutex should not be poisoned") + .iter() + .map(|item| item.id) + .collect(); + match self.deps.db.drain_pending(&session_id, QueueType::Btw) { + Ok(rows) => { + for item in &rows { + if !queued_ids.contains(&item.id) { + continue; + } + if let Err(error) = + self.deps.db.push_pending(&session_id, QueueType::Turn, item) + { + tracing::warn!( + session_id = %session_id, + error = %error, + "failed to degrade btw input into the turn queue" + ); + } + } + } + Err(err) => { + tracing::warn!( + session_id = %session_id, + error = %err, + "failed to drain btw input messages from database" + ); + } + } + } + for item in &leftover { + self.broadcast_queue_updated( + session_id, + devo_protocol::canonical::queue::QueueChange::Added, + devo_protocol::canonical::ids::QueueItemId::from_legacy_uuid(uuid::Uuid::from( + item.id, + )), + None, + ) + .await; + } + if !leftover.is_empty() { + self.broadcast_updated_queue(session_id).await; } } diff --git a/crates/server/src/runtime/turn_exec/followup.rs b/crates/server/src/runtime/turn_exec/followup.rs index ef3013a3..4aa2774a 100644 --- a/crates/server/src/runtime/turn_exec/followup.rs +++ b/crates/server/src/runtime/turn_exec/followup.rs @@ -36,6 +36,18 @@ impl ServerRuntime { turn: turn.clone(), })) .await; + // `drained` carries queueItemId + startedTurnId, emitted in the same + // session-actor operation as the matching turn/started so handles + // bind atomically (01 §4.3). + self.broadcast_queue_updated( + session_id, + devo_protocol::canonical::queue::QueueChange::Drained, + queued.queued_item_id, + Some(devo_protocol::canonical::ids::TurnId::from_legacy_uuid(uuid::Uuid::from( + turn.turn_id, + ))), + ) + .await; let runtime = Arc::clone(self); tokio::spawn(async move { runtime @@ -81,6 +93,15 @@ impl ServerRuntime { turn: turn.clone(), })) .await; + self.broadcast_queue_updated( + session_id, + devo_protocol::canonical::queue::QueueChange::Drained, + queued.queued_item_id.clone(), + Some(devo_protocol::canonical::ids::TurnId::from_legacy_uuid(uuid::Uuid::from( + turn.turn_id, + ))), + ) + .await; Box::pin(Arc::clone(self).execute_turn(ExecuteTurnRequest { session_id, turn, @@ -136,6 +157,9 @@ impl ServerRuntime { ); } Some(QueuedTurnInput { + queued_item_id: devo_protocol::canonical::ids::QueueItemId::from_legacy_uuid( + uuid::Uuid::from(popped.queued_input_id), + ), display_input: popped.display_input, input_text: popped.input_text, input_messages: popped.input_messages, diff --git a/crates/server/src/runtime/turn_exec/types.rs b/crates/server/src/runtime/turn_exec/types.rs index 176135cb..cbd95dc3 100644 --- a/crates/server/src/runtime/turn_exec/types.rs +++ b/crates/server/src/runtime/turn_exec/types.rs @@ -79,6 +79,9 @@ pub(crate) struct TurnQueryOutcome { } pub(super) struct QueuedTurnInput { + /// Canonical queue entry id of the drained entry (`queue/updated` + /// `drained` notification, 01 §4.3). + pub(super) queued_item_id: devo_protocol::canonical::ids::QueueItemId, pub(super) display_input: String, pub(super) input_text: String, pub(super) input_messages: Vec, From a6e550bae45a6ddcfda51801d5a94cb83c4c39f3 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 20:43:48 +0800 Subject: [PATCH 12/17] feat(server): separate side questions from steer input --- .../core/src/conversation/event_projection.rs | 42 +++-- crates/core/src/conversation/history.rs | 13 +- .../core/src/conversation/legacy_projector.rs | 137 +++++++------- crates/core/src/conversation/mod.rs | 14 +- crates/core/src/conversation/rollout_v2.rs | 14 +- crates/core/src/conversation/v2_inverse.rs | 113 ++++++------ crates/core/src/session.rs | 48 ++--- crates/core/tests/legacy_projector.rs | 24 ++- crates/core/tests/v2_roundtrip.rs | 23 ++- crates/protocol/src/canonical/event.rs | 30 ++- crates/protocol/src/canonical/item.rs | 38 +++- crates/protocol/src/canonical/methods.rs | 38 +++- crates/protocol/src/canonical/patch.rs | 3 +- crates/protocol/src/canonical/rpc_turn.rs | 25 ++- crates/protocol/src/canonical/session.rs | 10 +- .../protocol/src/canonical/wire_projector.rs | 24 ++- crates/protocol/tests/canonical_golden.rs | 10 +- crates/server/AGENTS.md | 5 +- crates/server/src/db.rs | 171 +++++++++++++++--- crates/server/src/event_reconcile.rs | 13 +- crates/server/src/execution.rs | 4 +- crates/server/src/persistence.rs | 84 ++++++--- crates/server/src/runtime.rs | 3 +- crates/server/src/runtime/agents.rs | 4 +- crates/server/src/runtime/connection.rs | 136 ++++++++------ .../src/runtime/handlers/acp/session.rs | 4 +- crates/server/src/runtime/handlers/history.rs | 5 +- crates/server/src/runtime/handlers/queue.rs | 59 +++--- crates/server/src/runtime/handlers/session.rs | 8 +- .../src/runtime/handlers/subscription.rs | 79 ++++---- crates/server/src/runtime/handlers/turn.rs | 28 +-- crates/server/src/runtime/lifecycle.rs | 12 +- .../session_actor/{loop_.rs => actor_loop.rs} | 2 +- .../src/runtime/session_actor/handle.rs | 2 +- .../server/src/runtime/session_actor/mod.rs | 2 +- .../src/runtime/session_actor/registry.rs | 12 +- .../src/runtime/session_actor/snapshots.rs | 2 +- .../server/src/runtime/session_actor/state.rs | 10 +- .../server/src/runtime/session_actor/turn.rs | 6 + .../server/src/runtime/turn_exec/finalize.rs | 31 ++-- .../server/src/runtime/turn_exec/followup.rs | 12 +- .../tests/provider_failure_reporting.rs | 15 +- crates/tui/src/worker.rs | 49 ++++- 43 files changed, 850 insertions(+), 514 deletions(-) rename crates/server/src/runtime/session_actor/{loop_.rs => actor_loop.rs} (99%) diff --git a/crates/core/src/conversation/event_projection.rs b/crates/core/src/conversation/event_projection.rs index bacde80c..4be4399e 100644 --- a/crates/core/src/conversation/event_projection.rs +++ b/crates/core/src/conversation/event_projection.rs @@ -43,7 +43,10 @@ pub fn source_fact_id(rollout_path: &std::path::Path, line_index: u64, sub_index if sub_index == 0 { format!("{}#{line_index}", rollout_path.to_string_lossy()) } else { - format!("{}#{line_index}.{sub_index}", rollout_path.to_string_lossy()) + format!( + "{}#{line_index}.{sub_index}", + rollout_path.to_string_lossy() + ) } } @@ -61,7 +64,10 @@ pub fn sessions_stream_id(cwd: &str) -> String { } fn hex_prefix(bytes: &[u8], len: usize) -> String { - bytes[..len].iter().map(|byte| format!("{byte:02x}")).collect() + bytes[..len] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } fn is_terminal_item_state(state: ItemState) -> bool { @@ -81,11 +87,20 @@ pub fn events_from_v2_line(line: &RolloutLineV2) -> Vec { let envelope = Box::new(item.clone()); let (event_kind, notification) = if is_terminal_item_state(item.state) { // All terminal states share `item/completed` (08 §3). - ("item/completed", ServerNotification::ItemCompleted { item: envelope }) + ( + "item/completed", + ServerNotification::ItemCompleted { item: envelope }, + ) } else if item.revision > 1 { - ("item/updated", ServerNotification::ItemUpdated { item: envelope }) + ( + "item/updated", + ServerNotification::ItemUpdated { item: envelope }, + ) } else { - ("item/started", ServerNotification::ItemStarted { item: envelope }) + ( + "item/started", + ServerNotification::ItemStarted { item: envelope }, + ) }; vec![DerivedEvent { event_kind, @@ -148,16 +163,13 @@ pub fn events_from_v2_line(line: &RolloutLineV2) -> Vec { }] } RolloutLineV2::WorkspaceRestoreCompleted { record, .. } => { - let succeeded = record - .outcomes - .iter() - .all(|outcome| { - matches!( - outcome.status, - crate::durable_record::RestoreFileStatus::Restored - | crate::durable_record::RestoreFileStatus::Skipped - ) - }); + let succeeded = record.outcomes.iter().all(|outcome| { + matches!( + outcome.status, + crate::durable_record::RestoreFileStatus::Restored + | crate::durable_record::RestoreFileStatus::Skipped + ) + }); vec![DerivedEvent { event_kind: "workspace/restoreCompleted", stream_id: session_stream_id(&SessionId::from_string( diff --git a/crates/core/src/conversation/history.rs b/crates/core/src/conversation/history.rs index 0a55a32c..b0a24573 100644 --- a/crates/core/src/conversation/history.rs +++ b/crates/core/src/conversation/history.rs @@ -17,7 +17,9 @@ use devo_protocol::canonical::session::Session; use devo_protocol::canonical::turn::Turn; use super::legacy_projector::{LegacyProjectError, LegacyProjector}; -use super::rollout_v2::{ParsedRolloutLine, RolloutLineReadError, RolloutLineV2, parse_rollout_line}; +use super::rollout_v2::{ + ParsedRolloutLine, RolloutLineReadError, RolloutLineV2, parse_rollout_line, +}; /// A session's effective canonical history, in file order. #[derive(Debug, Clone, Default, PartialEq)] @@ -101,11 +103,10 @@ fn apply_v2_line(history: &mut CanonicalHistory, line: RolloutLineV2) { RolloutLineV2::SessionRollback { retained_turn_ids, .. } => { - let retained: HashSet<&str> = retained_turn_ids - .iter() - .map(|id| id.as_str()) - .collect(); - history.turns.retain(|turn| retained.contains(turn.id.as_str())); + let retained: HashSet<&str> = retained_turn_ids.iter().map(|id| id.as_str()).collect(); + history + .turns + .retain(|turn| retained.contains(turn.id.as_str())); history .items .retain(|item| retained.contains(item.turn_id.as_str())); diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index f022fbef..bc30448c 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -34,8 +34,8 @@ use crate::conversation::{ }; use super::rollout_v2::{ - InternalRecordV2, RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, - ROLLOUT_FORMAT_VERSION, + InternalRecordV2, ROLLOUT_FORMAT_VERSION, RolloutLineV2, SessionPersistenceExtras, + TurnPersistenceExtras, }; /// Errors from projecting a legacy rollout line. Every known legacy shape @@ -201,32 +201,26 @@ impl LegacyProjector { previous_title: line.previous_title.clone(), }]) } - RolloutLine::SessionContextUpdated(line) => { - Ok(vec![RolloutLineV2::Internal { - v: ROLLOUT_FORMAT_VERSION, - timestamp: line.timestamp, - session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), - turn_id: None, - seq: 0, - entry: InternalRecordV2::SessionContext(Box::new( - line.session_context.clone(), - )), - }]) - } - RolloutLine::CompactionSnapshot(line) => { - Ok(vec![RolloutLineV2::CompactionSnapshot { - v: ROLLOUT_FORMAT_VERSION, - timestamp: line.timestamp, - session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), - turn_id: TurnId::from_legacy_uuid(legacy_uuid(line.turn_id)?), - summary_item_id: ItemId::from_legacy_uuid(legacy_uuid(line.summary_item_id)?), - preserved_item_ids: line - .preserved_item_ids - .iter() - .map(|id| legacy_uuid(id).map(ItemId::from_legacy_uuid)) - .collect::>()?, - }]) - } + RolloutLine::SessionContextUpdated(line) => Ok(vec![RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), + turn_id: None, + seq: 0, + entry: InternalRecordV2::SessionContext(Box::new(line.session_context.clone())), + }]), + RolloutLine::CompactionSnapshot(line) => Ok(vec![RolloutLineV2::CompactionSnapshot { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: SessionId::from_legacy_uuid(legacy_uuid(line.session_id)?), + turn_id: TurnId::from_legacy_uuid(legacy_uuid(line.turn_id)?), + summary_item_id: ItemId::from_legacy_uuid(legacy_uuid(line.summary_item_id)?), + preserved_item_ids: line + .preserved_item_ids + .iter() + .map(|id| legacy_uuid(id).map(ItemId::from_legacy_uuid)) + .collect::>()?, + }]), RolloutLine::MessageEditRecorded(line) => { let record = &line.record; Ok(vec![RolloutLineV2::Internal { @@ -461,36 +455,31 @@ impl LegacyProjector { } else { ItemId::from_legacy_uuid(Uuid::now_v7()) }; - let (id, seq, revision, state, item) = match self.project_payload( - record, - &item_id, - payload, - )? { - Projected::Item { item, state } => { - (item_id, self.next_seq(), 1, state, item) - } - Projected::FoldedItem { - id, - seq, - revision, - item, - state, - } => (id, seq, revision, state, item), - Projected::Internal(entry) => { - // Internal entries consume one sequence position, shared - // with the item stream, so their order among items is - // exactly recoverable by the inverse projector. - out.push(RolloutLineV2::Internal { - v: ROLLOUT_FORMAT_VERSION, - timestamp: line.timestamp, - session_id: session_id.clone(), - turn_id: Some(turn_id.clone()), - seq: self.next_seq(), - entry: *entry, - }); - continue; - } - }; + let (id, seq, revision, state, item) = + match self.project_payload(record, &item_id, payload)? { + Projected::Item { item, state } => (item_id, self.next_seq(), 1, state, item), + Projected::FoldedItem { + id, + seq, + revision, + item, + state, + } => (id, seq, revision, state, item), + Projected::Internal(entry) => { + // Internal entries consume one sequence position, shared + // with the item stream, so their order among items is + // exactly recoverable by the inverse projector. + out.push(RolloutLineV2::Internal { + v: ROLLOUT_FORMAT_VERSION, + timestamp: line.timestamp, + session_id: session_id.clone(), + turn_id: Some(turn_id.clone()), + seq: self.next_seq(), + entry: *entry, + }); + continue; + } + }; out.push(RolloutLineV2::Item { v: ROLLOUT_FORMAT_VERSION, timestamp: line.timestamp, @@ -580,12 +569,14 @@ impl LegacyProjector { input: Some(call.input.clone()), }, }, - TurnItem::ToolProgress(progress) => Projected::Internal(Box::new(InternalRecordV2::Entry { - entry: InternalEntry::ToolProgress { - call_id: progress.tool_call_id.clone(), - message: progress.message.clone(), - }, - })), + TurnItem::ToolProgress(progress) => { + Projected::Internal(Box::new(InternalRecordV2::Entry { + entry: InternalEntry::ToolProgress { + call_id: progress.tool_call_id.clone(), + message: progress.message.clone(), + }, + })) + } TurnItem::ToolResult(result) => Projected::Item { state: ItemState::Completed, item: Item::ToolResult { @@ -761,9 +752,9 @@ pub fn canonical_turn_from_record(record: &TurnRecord) -> Result { - TurnStatus::InProgress - } + LegacyTurnStatus::Pending + | LegacyTurnStatus::Running + | LegacyTurnStatus::WaitingApproval => TurnStatus::InProgress, LegacyTurnStatus::Completed => TurnStatus::Completed, LegacyTurnStatus::Interrupted => TurnStatus::Interrupted, LegacyTurnStatus::Failed => TurnStatus::Failed, @@ -845,11 +836,13 @@ fn approval_request_from_parts( available_scopes: &[String], target: &Option, ) -> ApprovalRequestItem { - let (path, host, target) = target.as_ref().map_or((None, None, None), |target| match target { - ApprovalTarget::Path { path } => (Some(path.display().to_string()), None, None), - ApprovalTarget::Host { host } => (None, Some(host.clone()), None), - ApprovalTarget::Command { command } => (None, None, Some(command.clone())), - }); + let (path, host, target) = target + .as_ref() + .map_or((None, None, None), |target| match target { + ApprovalTarget::Path { path } => (Some(path.display().to_string()), None, None), + ApprovalTarget::Host { host } => (None, Some(host.clone()), None), + ApprovalTarget::Command { command } => (None, None, Some(command.clone())), + }); ApprovalRequestItem { approval_id: approval_id.into(), action_summary: action_summary.into(), diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index 4f47a666..2fb99ffb 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -8,16 +8,11 @@ mod records; pub use devo_protocol::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus, TurnUsage}; pub use event_projection::{ - DerivedEvent, EVENT_SCHEMA_VERSION, events_from_v2_line, session_stream_id, - sessions_stream_id, source_fact_id, + DerivedEvent, EVENT_SCHEMA_VERSION, events_from_v2_line, session_stream_id, sessions_stream_id, + source_fact_id, }; pub use history::{CanonicalHistory, HistoryReadError, read_canonical_history}; pub use legacy_projector::{LegacyProjectError, LegacyProjector, canonical_turn_from_record}; -pub use rollout_v2::{ - InternalRecordV2, ParsedRolloutLine, ROLLOUT_FORMAT_VERSION, RolloutLineReadError, - RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, parse_rollout_line, -}; -pub use v2_inverse::{V2InverseError, V2InverseProjector}; pub use records::{ ApprovalDecisionItem, ApprovalRequestItem, CommandExecutionItem, CompactionSnapshotLine, ItemLine, ItemRecord, MessageEditRecordedLine, RolloutLine, SessionContextUpdatedLine, @@ -26,3 +21,8 @@ pub use records::{ TurnSupersededLine, TurnWorkspaceChangeRecordedLine, TurnWorkspaceCheckpointRecordedLine, TurnWorkspaceRestoreCompletedLine, TurnWorkspaceRestoreStartedLine, Worklog, }; +pub use rollout_v2::{ + InternalRecordV2, ParsedRolloutLine, ROLLOUT_FORMAT_VERSION, RolloutLineReadError, + RolloutLineV2, SessionPersistenceExtras, TurnPersistenceExtras, parse_rollout_line, +}; +pub use v2_inverse::{V2InverseError, V2InverseProjector}; diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs index 26a5dd31..14ab2023 100644 --- a/crates/core/src/conversation/rollout_v2.rs +++ b/crates/core/src/conversation/rollout_v2.rs @@ -72,7 +72,11 @@ pub struct TurnPersistenceExtras { /// version, a wall-clock timestamp, and its payload in a stable flat shape, /// e.g. `{"v":2,"kind":"item","timestamp":"...","item":{...}}`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum RolloutLineV2 { /// Canonical session metadata. Session and extras are boxed to keep the /// enum small (serde-transparent). @@ -176,7 +180,11 @@ pub enum RolloutLineV2 { /// `item/*` events or the public schema; the rollout reader hands them /// straight to the recovery pipeline. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum InternalRecordV2 { /// A canonical internal replay entry. Kept as a nested payload rather than /// a flattened newtype variant: both enums use the `type` tag, so @@ -274,8 +282,8 @@ mod tests { use super::*; use crate::conversation::{SessionTitleState, SessionTitleUpdatedLine}; - use devo_protocol::canonical::item::{Item, ItemState, UserInput, UserMessageEntry}; use devo_protocol::canonical::ids::ItemId as CanonicalItemId; + use devo_protocol::canonical::item::{Item, ItemState, UserInput, UserMessageEntry}; fn fixed_ts() -> DateTime { Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap() diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs index d0a575eb..b5ae5e74 100644 --- a/crates/core/src/conversation/v2_inverse.rs +++ b/crates/core/src/conversation/v2_inverse.rs @@ -109,16 +109,18 @@ impl V2InverseProjector { title, previous_title, .. - } => Ok(vec![RolloutLine::SessionTitleUpdated(SessionTitleUpdatedLine { - timestamp: *timestamp, - session_id: legacy_session_id(session_id)?, - title: title.clone(), - // The title lifecycle is a derived cache in the canonical - // model; any Final variant is honest here because it only - // suppresses later regeneration of a recorded title. - title_state: SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate), - previous_title: previous_title.clone(), - })]), + } => Ok(vec![RolloutLine::SessionTitleUpdated( + SessionTitleUpdatedLine { + timestamp: *timestamp, + session_id: legacy_session_id(session_id)?, + title: title.clone(), + // The title lifecycle is a derived cache in the canonical + // model; any Final variant is honest here because it only + // suppresses later regeneration of a recorded title. + title_state: SessionTitleState::Final(SessionTitleFinalSource::ExplicitCreate), + previous_title: previous_title.clone(), + }, + )]), RolloutLineV2::CompactionSnapshot { timestamp, session_id, @@ -163,12 +165,12 @@ impl V2InverseProjector { ))]), RolloutLineV2::WorkspaceCheckpoint { timestamp, record, .. - } => Ok(vec![RolloutLine::TurnWorkspaceCheckpointRecorded(Box::new( - crate::conversation::TurnWorkspaceCheckpointRecordedLine { + } => Ok(vec![RolloutLine::TurnWorkspaceCheckpointRecorded( + Box::new(crate::conversation::TurnWorkspaceCheckpointRecordedLine { timestamp: *timestamp, record: record.clone(), - }, - ))]), + }), + )]), RolloutLineV2::WorkspaceChange { timestamp, record, .. } => Ok(vec![RolloutLine::TurnWorkspaceChangeRecorded(Box::new( @@ -223,10 +225,10 @@ impl V2InverseProjector { PermissionProfile::FullAccess => "full-access", }; - let (git_sha, git_branch, git_origin_url) = session.git_info.as_ref().map_or( - (None, None, None), - |git| (git.sha.clone(), git.branch.clone(), git.origin_url.clone()), - ); + let (git_sha, git_branch, git_origin_url) = + session.git_info.as_ref().map_or((None, None, None), |git| { + (git.sha.clone(), git.branch.clone(), git.origin_url.clone()) + }); let record = SessionRecord { id, @@ -238,7 +240,9 @@ impl V2InverseProjector { // for the metadata update time as well. updated_at: session.last_activity_at, last_activity_at: Some(session.last_activity_at), - source: extras.map(|extras| extras.source.clone()).unwrap_or_default(), + source: extras + .map(|extras| extras.source.clone()) + .unwrap_or_default(), // Nickname/path are not modeled on canonical `SessionParent`. agent_nickname: None, agent_role, @@ -262,11 +266,7 @@ impl V2InverseProjector { } else { SessionTitleState::Unset }, - sandbox_policy: session - .settings - .sandbox_profile - .clone() - .unwrap_or_default(), + sandbox_policy: session.settings.sandbox_profile.clone().unwrap_or_default(), approval_mode: approval_mode.into(), tokens_used: session .usage @@ -399,12 +399,10 @@ impl V2InverseProjector { UserMessageEntry::Steer => TurnItem::SteerInput(TextItem { text }), } } - Item::AssistantMessage { text, .. } => TurnItem::AgentMessage(TextItem { - text: text.clone(), - }), - Item::Reasoning { text, .. } => TurnItem::Reasoning(TextItem { - text: text.clone(), - }), + Item::AssistantMessage { text, .. } => { + TurnItem::AgentMessage(TextItem { text: text.clone() }) + } + Item::Reasoning { text, .. } => TurnItem::Reasoning(TextItem { text: text.clone() }), Item::Plan { entries } => TurnItem::Plan(TextItem { // The v2 writer produces exactly one entry (the legacy // rendered text); multiple entries join lossily with @@ -507,13 +505,14 @@ impl V2InverseProjector { )?; return Ok(Some(RolloutLine::Item(record))); } - let (path, host, target) = target.as_ref().map_or((None, None, None), |t| match t { - ApprovalTarget::Path { path } => { - (Some(path.display().to_string()), None, None) - } - ApprovalTarget::Host { host } => (None, Some(host.clone()), None), - ApprovalTarget::Command { command } => (None, None, Some(command.clone())), - }); + let (path, host, target) = + target.as_ref().map_or((None, None, None), |t| match t { + ApprovalTarget::Path { path } => { + (Some(path.display().to_string()), None, None) + } + ApprovalTarget::Host { host } => (None, Some(host.clone()), None), + ApprovalTarget::Command { command } => (None, None, Some(command.clone())), + }); TurnItem::ApprovalRequest(ApprovalRequestItem { approval_id: approval_id.clone(), action_summary: action_summary.clone(), @@ -581,18 +580,18 @@ impl V2InverseProjector { match entry { InternalRecordV2::Entry { entry } => { let payload = match entry { - InternalEntry::TurnSummary { text } => TurnItem::TurnSummary(TextItem { - text: text.clone(), - }), + InternalEntry::TurnSummary { text } => { + TurnItem::TurnSummary(TextItem { text: text.clone() }) + } InternalEntry::ToolProgress { call_id, message } => { TurnItem::ToolProgress(ToolProgressItem { tool_call_id: call_id.clone(), message: message.clone(), }) } - InternalEntry::HookPrompt { text } => TurnItem::HookPrompt(TextItem { - text: text.clone(), - }), + InternalEntry::HookPrompt { text } => { + TurnItem::HookPrompt(TextItem { text: text.clone() }) + } }; // Identity and position travel on the line (exact); only the // record id is synthesized, since internal entries have no @@ -626,22 +625,18 @@ impl V2InverseProjector { }, ))]) } - InternalRecordV2::MessageEdit(record) => { - Ok(vec![RolloutLine::MessageEditRecorded(Box::new( - crate::conversation::MessageEditRecordedLine { - timestamp, - record: record.clone(), - }, - ))]) - } - InternalRecordV2::TurnSuperseded(record) => { - Ok(vec![RolloutLine::TurnSuperseded(Box::new( - crate::conversation::TurnSupersededLine { - timestamp, - record: record.clone(), - }, - ))]) - } + InternalRecordV2::MessageEdit(record) => Ok(vec![RolloutLine::MessageEditRecorded( + Box::new(crate::conversation::MessageEditRecordedLine { + timestamp, + record: record.clone(), + }), + )]), + InternalRecordV2::TurnSuperseded(record) => Ok(vec![RolloutLine::TurnSuperseded( + Box::new(crate::conversation::TurnSupersededLine { + timestamp, + record: record.clone(), + }), + )]), } } } diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index eff37ff8..f71a5240 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -263,10 +263,10 @@ pub struct SessionState { /// - Lifecycle: preserved across turns; unconsumed items are pushed back /// when the current turn ends and consumed when the next turn starts. pub pending_turn_queue: Arc>>, - /// Thread-safe queue for /btw steer inputs. + /// Thread-safe queue for inputs steering the active turn. /// - Source: user sends `turn/steer` while a turn is active. /// - Lifecycle: scoped to current turn only; cleared when the turn ends. - pub btw_input_queue: Arc>>, + pub steer_input_queue: Arc>>, /// Turn-scoped state (Some while a turn is active). pub(crate) turn_state: Option, } @@ -294,7 +294,7 @@ impl SessionState { last_turn_tokens: 0, last_turn_interrupted: false, pending_turn_queue: Arc::new(Mutex::new(VecDeque::new())), - btw_input_queue: Arc::new(Mutex::new(VecDeque::new())), + steer_input_queue: Arc::new(Mutex::new(VecDeque::new())), turn_state: None, } } @@ -322,7 +322,7 @@ impl SessionState { last_turn_tokens: self.last_turn_tokens, last_turn_interrupted: self.last_turn_interrupted, pending_turn_queue: Arc::clone(&self.pending_turn_queue), - btw_input_queue: Arc::clone(&self.btw_input_queue), + steer_input_queue: Arc::clone(&self.steer_input_queue), turn_state: None, } } @@ -410,12 +410,12 @@ impl SessionState { pending.drain(..).collect() } - /// Drains all pending inputs from the /btw queue. - pub fn drain_btw_input_queue(&self) -> Vec { + /// Drains all pending inputs from the active-turn steer queue. + pub fn drain_steer_input_queue(&self) -> Vec { let mut guard = self - .btw_input_queue + .steer_input_queue .lock() - .expect("btw input queue mutex should not be poisoned"); + .expect("steer input queue mutex should not be poisoned"); guard.drain(..).collect() } @@ -438,17 +438,17 @@ impl SessionState { queue.push_front(item); } } - // /btw steer inputs that arrived too late to be consumed degrade - // back into the session turn queue (01 §4.3: a steer racing the - // turn end must never lose the message). They append behind - // already-queued inputs — re-queued at turn end, arrival order among - // themselves preserved. + // Steer inputs that arrived before the injection boundary but + // were not consumed before turn end degrade back into the session turn + // queue. This preserves the message; a later follow-up drain may start + // it as its own turn. They append behind already-queued inputs, while + // preserving arrival order among themselves. let late_steer: Vec = { - let mut btw = self - .btw_input_queue + let mut steer = self + .steer_input_queue .lock() - .expect("btw input queue mutex should not be poisoned"); - btw.drain(..).collect() + .expect("steer input queue mutex should not be poisoned"); + steer.drain(..).collect() }; if !late_steer.is_empty() { let mut queue = self @@ -462,9 +462,9 @@ impl SessionState { } /// Merge turn-scoped pending input with both cross-thread inboxes. - /// Order: btw inbox → turn-state pending → turn queue + /// Order: steer inbox → turn-state pending → turn queue pub fn take_turn_pending_input(&mut self) -> Vec { - let mut result = self.drain_btw_input_queue(); + let mut result = self.drain_steer_input_queue(); if let Some(turn) = self.turn_state.as_mut() { result.extend(turn.take_pending_input()); } @@ -561,7 +561,7 @@ mod tests { } #[test] - fn end_turn_degrades_unconsumed_btw_inputs_into_the_turn_queue() { + fn end_turn_degrades_unconsumed_steer_inputs_into_the_turn_queue() { let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); session.start_turn(TurnKind::Regular); let steer = PendingInputItem::new( @@ -572,9 +572,9 @@ mod tests { chrono::Utc::now(), ); session - .btw_input_queue + .steer_input_queue .lock() - .expect("btw lock") + .expect("steer lock") .push_back(steer.clone()); session.end_turn(); @@ -587,9 +587,9 @@ mod tests { ); assert!( session - .btw_input_queue + .steer_input_queue .lock() - .expect("btw lock") + .expect("steer lock") .is_empty() ); } diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs index 8451e1bc..c05f0e48 100644 --- a/crates/core/tests/legacy_projector.rs +++ b/crates/core/tests/legacy_projector.rs @@ -31,7 +31,9 @@ use devo_protocol::canonical::item::{ }; use devo_protocol::canonical::model::PermissionProfile; use devo_protocol::canonical::session::SessionParent; -use devo_protocol::canonical::turn::{TurnKind as CanonicalTurnKind, TurnStatus as CanonicalTurnStatus}; +use devo_protocol::canonical::turn::{ + TurnKind as CanonicalTurnKind, TurnStatus as CanonicalTurnStatus, +}; use pretty_assertions::assert_eq; use uuid::Uuid; @@ -187,7 +189,9 @@ fn basic_session_lines() -> Vec { text: "Fix the flaky test".into(), })]; conversation.output_items = vec![ - TurnItem::AgentMessage(TextItem { text: "On it.".into() }), + TurnItem::AgentMessage(TextItem { + text: "On it.".into(), + }), TurnItem::Plan(TextItem { text: "1. reproduce\n2. fix".into(), }), @@ -541,11 +545,9 @@ fn basic_session_projects_all_lines_in_order() { matches!(&projected[0], RolloutLineV2::SessionMeta { session, .. } if session.cwd.as_os_str() == "/tmp/legacy-project") ); - assert!( - matches!(&projected[1], RolloutLineV2::Turn { turn, .. } + assert!(matches!(&projected[1], RolloutLineV2::Turn { turn, .. } if turn.kind == CanonicalTurnKind::Regular - && turn.status == CanonicalTurnStatus::Completed) - ); + && turn.status == CanonicalTurnStatus::Completed)); let envelopes = item_envelopes(&projected); assert_eq!(envelopes.len(), 12); @@ -581,9 +583,13 @@ fn basic_session_projects_all_lines_in_order() { matches!(&envelopes[5].item, Item::CommandExecution { command, cwd, execution_mode: ExecutionMode::Foreground, origin: ExecOrigin::AgentTool, .. } if command == "cargo test" && cwd.as_os_str() == "/tmp/legacy-project") ); - assert!( - matches!(&envelopes[8].item, Item::UserMessage { entry: UserMessageEntry::Steer, .. }) - ); + assert!(matches!( + &envelopes[8].item, + Item::UserMessage { + entry: UserMessageEntry::Steer, + .. + } + )); assert!( matches!(&envelopes[9].item, Item::HostedToolCall { tool_name, .. } if tool_name == "web_search") ); diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs index d91d43bf..6d3d689d 100644 --- a/crates/core/tests/v2_roundtrip.rs +++ b/crates/core/tests/v2_roundtrip.rs @@ -38,10 +38,12 @@ fn fixture_lines(name: &str) -> Vec { std::fs::read_to_string(&path) .expect("read fixture") .lines() - .map(|line| match parse_rollout_line(line).expect("fixture line parses") { - ParsedRolloutLine::Legacy(line) => *line, - ParsedRolloutLine::V2(_) => panic!("fixture {name} must contain only legacy lines"), - }) + .map( + |line| match parse_rollout_line(line).expect("fixture line parses") { + ParsedRolloutLine::Legacy(line) => *line, + ParsedRolloutLine::V2(_) => panic!("fixture {name} must contain only legacy lines"), + }, + ) .collect() } @@ -535,7 +537,10 @@ fn mixed_v1_v2_file_dispatches_per_line() { }) .collect(); // Two legacy lines, then the two item records expand to 3 + 2 v2 lines. - assert_eq!(kinds, vec!["legacy", "legacy", "v2", "v2", "v2", "v2", "v2"]); + assert_eq!( + kinds, + vec!["legacy", "legacy", "v2", "v2", "v2", "v2", "v2"] + ); } #[test] @@ -581,7 +586,9 @@ fn inverse_rejects_prefixed_canonical_ids() { extras: None, }; let inverse = V2InverseProjector::new(); - let error = inverse.project_line(&line).expect_err("prefixed id must fail"); + let error = inverse + .project_line(&line) + .expect_err("prefixed id must fail"); assert!(matches!(error, V2InverseError::NonLegacyId(_))); } @@ -594,9 +601,7 @@ fn inverse_rejects_turn_scoped_internal_line_without_turn_id() { turn_id: None, seq: 1, entry: devo_core::InternalRecordV2::Entry { - entry: devo_protocol::canonical::item::InternalEntry::TurnSummary { - text: "1".into(), - }, + entry: devo_protocol::canonical::item::InternalEntry::TurnSummary { text: "1".into() }, }, }; let inverse = V2InverseProjector::new(); diff --git a/crates/protocol/src/canonical/event.rs b/crates/protocol/src/canonical/event.rs index 8f39d922..8fd7e0e0 100644 --- a/crates/protocol/src/canonical/event.rs +++ b/crates/protocol/src/canonical/event.rs @@ -129,7 +129,10 @@ pub enum ServerNotification { active_turn_id: Option, }, #[serde(rename = "session/archived")] - SessionArchived { session_id: SessionId, archived: bool }, + SessionArchived { + session_id: SessionId, + archived: bool, + }, #[serde(rename = "session/deleted")] SessionDeleted { session_id: SessionId }, #[serde(rename = "workspace/restoreStarted")] @@ -149,10 +152,7 @@ pub enum ServerNotification { #[serde(rename = "turn/started")] TurnStarted { turn: Box }, #[serde(rename = "turn/statusChanged")] - TurnStatusChanged { - turn_id: TurnId, - status: TurnStatus, - }, + TurnStatusChanged { turn_id: TurnId, status: TurnStatus }, #[serde(rename = "turn/completed")] TurnCompleted { turn: Box }, /// Item birth; carries the revision=1 full snapshot (delta baseline). @@ -310,7 +310,11 @@ pub struct EventCursor { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum StreamSelector { SessionsByCwd { cwd: PathBuf }, Session { session_id: SessionId }, @@ -329,16 +333,24 @@ pub struct StreamSnapshot { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum SnapshotData { - SessionsList { sessions: Vec }, + SessionsList { + sessions: Vec, + }, Session { session: Box, #[serde(default, skip_serializing_if = "Option::is_none")] active_turn: Option>, queue: Vec, }, - BackgroundTask { item: Box }, + BackgroundTask { + item: Box, + }, } /// Forced full content of an active item on resubscription, so transient diff --git a/crates/protocol/src/canonical/item.rs b/crates/protocol/src/canonical/item.rs index bcf37e00..b7344ead 100644 --- a/crates/protocol/src/canonical/item.rs +++ b/crates/protocol/src/canonical/item.rs @@ -69,7 +69,11 @@ pub enum ItemState { // --------------------------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum Item { // ── Conversation ── UserMessage { @@ -279,7 +283,11 @@ impl ItemOrUnknown { /// appear in `item/*` events or the public schema, and the rollout reader /// hands them straight to the recovery pipeline. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum InternalEntry { TurnSummary { text: String }, ToolProgress { call_id: String, message: String }, @@ -292,7 +300,11 @@ pub enum InternalEntry { /// One submission = one `UserMessage` item whose content is a list of parts. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum UserInput { Text { text: String, @@ -414,10 +426,18 @@ pub struct FileChangeEntry { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum FileChangeKind { - Add { content: String }, - Delete { content: String }, + Add { + content: String, + }, + Delete { + content: String, + }, Update { unified_diff: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -459,7 +479,11 @@ pub enum ApprovalScope { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum ApprovalTarget { Path { path: PathBuf }, Host { host: String }, diff --git a/crates/protocol/src/canonical/methods.rs b/crates/protocol/src/canonical/methods.rs index 3453c085..d03e2361 100644 --- a/crates/protocol/src/canonical/methods.rs +++ b/crates/protocol/src/canonical/methods.rs @@ -133,7 +133,11 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ name: "session/new", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::INVALID_CWD, codes::CWD_ACCESS_DENIED, codes::IDEMPOTENCY_CONFLICT], + error_codes: &[ + codes::INVALID_CWD, + codes::CWD_ACCESS_DENIED, + codes::IDEMPOTENCY_CONFLICT, + ], required_capability: None, idempotency: Idempotency::Key, }, @@ -202,7 +206,11 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ name: "session/cwd/change", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::SESSION_NOT_FOUND, codes::INVALID_CWD, codes::CWD_ACCESS_DENIED], + error_codes: &[ + codes::SESSION_NOT_FOUND, + codes::INVALID_CWD, + codes::CWD_ACCESS_DENIED, + ], required_capability: None, idempotency: Idempotency::None, }, @@ -243,7 +251,12 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ name: "turn/start", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::SESSION_NOT_FOUND, codes::TURN_ALREADY_ACTIVE, codes::IDEMPOTENCY_CONFLICT, codes::UNSUPPORTED_MODALITY], + error_codes: &[ + codes::SESSION_NOT_FOUND, + codes::TURN_ALREADY_ACTIVE, + codes::IDEMPOTENCY_CONFLICT, + codes::UNSUPPORTED_MODALITY, + ], required_capability: None, idempotency: Idempotency::Key, }, @@ -251,7 +264,12 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ name: "turn/steer", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::SESSION_NOT_FOUND, codes::TURN_NOT_STEERABLE, codes::IDEMPOTENCY_CONFLICT, codes::UNSUPPORTED_MODALITY], + error_codes: &[ + codes::SESSION_NOT_FOUND, + codes::TURN_NOT_STEERABLE, + codes::IDEMPOTENCY_CONFLICT, + codes::UNSUPPORTED_MODALITY, + ], required_capability: None, idempotency: Idempotency::Key, }, @@ -275,7 +293,11 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ name: "session/queue/push", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::SESSION_NOT_FOUND, codes::IDEMPOTENCY_CONFLICT, codes::UNSUPPORTED_MODALITY], + error_codes: &[ + codes::SESSION_NOT_FOUND, + codes::IDEMPOTENCY_CONFLICT, + codes::UNSUPPORTED_MODALITY, + ], required_capability: None, idempotency: Idempotency::Key, }, @@ -307,7 +329,11 @@ pub static NATIVE_METHODS: &[MethodSpec] = &[ name: "session/queue/steer", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::SESSION_NOT_FOUND, codes::QUEUE_ITEM_NOT_FOUND, codes::TURN_NOT_STEERABLE], + error_codes: &[ + codes::SESSION_NOT_FOUND, + codes::QUEUE_ITEM_NOT_FOUND, + codes::TURN_NOT_STEERABLE, + ], required_capability: None, idempotency: Idempotency::None, }, diff --git a/crates/protocol/src/canonical/patch.rs b/crates/protocol/src/canonical/patch.rs index 5d909984..884231c6 100644 --- a/crates/protocol/src/canonical/patch.rs +++ b/crates/protocol/src/canonical/patch.rs @@ -46,8 +46,7 @@ impl<'de, T: Deserialize<'de>> Deserialize<'de> for PatchField { fn deserialize>(deserializer: D) -> Result { // A present field is either `null` or a value; absence is handled by // `#[serde(default)]` on the containing struct field. - Ok(Option::::deserialize(deserializer)? - .map_or(Self::Null, Self::Value)) + Ok(Option::::deserialize(deserializer)?.map_or(Self::Null, Self::Value)) } } diff --git a/crates/protocol/src/canonical/rpc_turn.rs b/crates/protocol/src/canonical/rpc_turn.rs index 18fc0531..5f170f57 100644 --- a/crates/protocol/src/canonical/rpc_turn.rs +++ b/crates/protocol/src/canonical/rpc_turn.rs @@ -39,10 +39,11 @@ pub struct TurnStartResult { // ── turn/steer ── -/// Injects into the running turn: the item is persisted immediately +/// Injects input into the running main turn: the item is persisted immediately /// (`entry = steer`) and takes effect at the next injection boundary. If the /// turn ended before injection, the input degrades back into the queue -/// (message is never lost) — the result says which happened. +/// (message is never lost) — the result says which happened. This is distinct +/// from the TUI `/btw` side question, which does not modify the main turn. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct TurnSteerParams { @@ -56,11 +57,19 @@ pub struct TurnSteerParams { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "outcome", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "outcome", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum TurnSteerResult { - Injected { item_id: ItemId }, + Injected { + item_id: ItemId, + }, /// Turn ended before the injection boundary; input was queued instead. - DegradedToQueue { entry: QueueEntry }, + DegradedToQueue { + entry: QueueEntry, + }, } // ── turn/interrupt / turn/read ── @@ -107,7 +116,11 @@ pub struct SessionQueuePushParams { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "outcome", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "outcome", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum SessionQueuePushResult { Started { turn: Box }, Queued { entry: Box }, diff --git a/crates/protocol/src/canonical/session.rs b/crates/protocol/src/canonical/session.rs index caf8dca5..b9724093 100644 --- a/crates/protocol/src/canonical/session.rs +++ b/crates/protocol/src/canonical/session.rs @@ -86,7 +86,9 @@ pub enum SessionStatus { /// Blocking reasons, stackable on top of `status`. "Waiting" is a flag, not a /// status: clients can tell "working" apart from "blocked on you". -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, TS)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, TS, +)] #[serde(rename_all = "camelCase")] pub enum SessionFlag { /// A pending approval request. @@ -103,7 +105,11 @@ pub enum SessionFlag { /// subagent is a spawned executor; permissions, visibility and presentation /// all differ. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum SessionParent { Fork { session_id: SessionId, diff --git a/crates/protocol/src/canonical/wire_projector.rs b/crates/protocol/src/canonical/wire_projector.rs index 788bd6f0..d9c6ad32 100644 --- a/crates/protocol/src/canonical/wire_projector.rs +++ b/crates/protocol/src/canonical/wire_projector.rs @@ -19,12 +19,12 @@ use super::item::{ ContextUsage, ExecOrigin, ExecutionMode, FileChangeEntry, FileChangeKind, Item, ItemEnvelope, ItemState, PlanEntry, PlanStepStatus, ToolSource, UserInput, UserMessageEntry, }; +use crate::protocol::ExecCommandSource; use crate::{ ApprovalDecisionPayload, ApprovalRequestPayload, CommandExecutionPayload, EventContext, FileChangePayload, ItemKind, ServerEvent, ToolCallPayload, ToolResultPayload, TypedItemEventPayload, }; -use crate::protocol::ExecCommandSource; /// Projects one legacy wire payload into the canonical `Item` for its kind. /// @@ -108,7 +108,8 @@ pub fn project_wire_item( }) } ItemKind::CommandExecution => { - let command = serde_json::from_value::(payload.clone()).ok()?; + let command = + serde_json::from_value::(payload.clone()).ok()?; let origin = match command.source { ExecCommandSource::Agent | ExecCommandSource::UnifiedExecStartup @@ -193,7 +194,10 @@ pub fn project_wire_item( ..ContextUsage::default() }, after: None, - summary: payload.get("text").and_then(serde_json::Value::as_str).map(str::to_owned), + summary: payload + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), }), ItemKind::ApprovalRequest => { let request = serde_json::from_value::(payload.clone()).ok()?; @@ -209,8 +213,8 @@ pub fn project_wire_item( }) } ItemKind::ApprovalDecision => { - let decision = serde_json::from_value::(payload.clone()) - .ok()?; + let decision = + serde_json::from_value::(payload.clone()).ok()?; // The wire decision event carries only the id + decision/scope // strings; the request fields are not repeated, so they stay // empty here (clients fold by `approval_id`). @@ -535,9 +539,13 @@ mod tests { "source": "user_shell", }); let item = project(ItemKind::CommandExecution, payload); - assert!( - matches!(item, Some(Item::CommandExecution { origin: ExecOrigin::UserShell, .. })) - ); + assert!(matches!( + item, + Some(Item::CommandExecution { + origin: ExecOrigin::UserShell, + .. + }) + )); } #[test] diff --git a/crates/protocol/tests/canonical_golden.rs b/crates/protocol/tests/canonical_golden.rs index a85fa59f..75d00ea0 100644 --- a/crates/protocol/tests/canonical_golden.rs +++ b/crates/protocol/tests/canonical_golden.rs @@ -68,17 +68,15 @@ fn known_item_does_not_fall_into_unknown() { #[test] fn patch_field_null_is_explicit_clear() { let golden = read_golden("patch_title_null.json"); - let params: SessionMetadataUpdateParams = - serde_json::from_value(golden).expect("params parse"); + let params: SessionMetadataUpdateParams = serde_json::from_value(golden).expect("params parse"); assert_eq!(params.title, PatchField::Null); } #[test] fn legacy_bare_uuid_id_round_trips() { - let id: SessionId = serde_json::from_value(serde_json::json!( - "019b1c2d-3e4f-7890-abcd-ef1234567890" - )) - .expect("legacy id parses"); + let id: SessionId = + serde_json::from_value(serde_json::json!("019b1c2d-3e4f-7890-abcd-ef1234567890")) + .expect("legacy id parses"); assert_eq!(id.as_str(), "019b1c2d-3e4f-7890-abcd-ef1234567890"); assert_eq!( serde_json::to_value(&id).expect("serialize"), diff --git a/crates/server/AGENTS.md b/crates/server/AGENTS.md index aa563eea..a5610ffc 100644 --- a/crates/server/AGENTS.md +++ b/crates/server/AGENTS.md @@ -29,7 +29,10 @@ The server runtime uses **one session actor per session**. Durable session state ### Queues - **`pending_turn_queue`:** user-visible queued turns while a session is busy. Enqueue via `SessionHandle::enqueue_pending_turn_input`; pop/remove/steer via actor commands only. -- **`btw_input_queue`:** steer / between-turn input during an active turn. Enqueue only via `EnqueueBtwInput` (mailbox); clear at turn finalize. +- **`steer_input_queue`:** input for injection into an active turn. Active-turn + handlers mutate it through the reservation snapshot's shared mutex rather + than waiting on the actor mailbox; finalization either consumes it or + degrades unconsumed input to `pending_turn_queue`. - **After dequeuing,** broadcast queue updates and start the next turn from a spawned task (`chain_queued_followup_turn` / `spawn_next_turn_from_queue`). ### Tests diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 8e53b00f..383617dd 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -19,15 +19,15 @@ use devo_protocol::{ pub enum QueueType { /// Pending turn inputs (from turn/start while a turn is active). Turn, - /// /btw steer inputs (from turn/steer, scoped to current turn only). - Btw, + /// Inputs injected into the active turn by `turn/steer`. + Steer, } impl QueueType { fn as_str(&self) -> &'static str { match self { QueueType::Turn => "turn", - QueueType::Btw => "btw", + QueueType::Steer => "steer", } } } @@ -91,7 +91,8 @@ pub struct EventLogRow { /// Bump when a migration changes the index layout; the rollout files are /// the rebuildable source of truth on any mismatch. /// v2: adds `event_log` + `projection_watermark` (08 §5/§7). -const CURRENT_SCHEMA_VERSION: u32 = 2; +/// v3: renames the persisted active-turn steer queue from `btw` to `steer`. +const CURRENT_SCHEMA_VERSION: u32 = 3; /// SQLite database for session metadata, token stats, and pending queues. pub struct Database { @@ -145,7 +146,7 @@ impl Database { CREATE TABLE IF NOT EXISTS pending_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - queue_type TEXT NOT NULL CHECK(queue_type IN ('turn', 'btw')), + queue_type TEXT NOT NULL CHECK(queue_type IN ('turn', 'steer')), kind TEXT NOT NULL, content TEXT NOT NULL, pending_input_id TEXT, @@ -276,10 +277,59 @@ impl Database { // can reorder without rewriting row ids (P4c); existing rows keep // their insertion order (position = id). if !pending_messages_has_column(&conn, "position")? { - conn.execute("ALTER TABLE pending_messages ADD COLUMN position INTEGER", []) - .context("failed to add pending_messages position column")?; - conn.execute("UPDATE pending_messages SET position = id WHERE position IS NULL", []) - .context("failed to backfill pending_messages position")?; + conn.execute( + "ALTER TABLE pending_messages ADD COLUMN position INTEGER", + [], + ) + .context("failed to add pending_messages position column")?; + conn.execute( + "UPDATE pending_messages SET position = id WHERE position IS NULL", + [], + ) + .context("failed to backfill pending_messages position")?; + } + let pending_messages_sql: Option = conn + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'pending_messages'", + [], + |row| row.get(0), + ) + .ok(); + if pending_messages_sql + .as_deref() + .is_some_and(|sql| sql.contains("'btw'")) + { + // P4c originally used `btw` for the active-turn steer queue. The + // product `/btw` feature is an unrelated ephemeral side question, + // so migrate the storage value and CHECK constraint together. + conn.execute_batch( + " + BEGIN; + ALTER TABLE pending_messages RENAME TO pending_messages_legacy; + CREATE TABLE pending_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + queue_type TEXT NOT NULL CHECK(queue_type IN ('turn', 'steer')), + kind TEXT NOT NULL, + content TEXT NOT NULL, + pending_input_id TEXT, + metadata TEXT, + created_at INTEGER NOT NULL, + position INTEGER NOT NULL + ); + INSERT INTO pending_messages + (id, session_id, queue_type, kind, content, pending_input_id, metadata, created_at, position) + SELECT id, session_id, + CASE queue_type WHEN 'btw' THEN 'steer' ELSE queue_type END, + kind, content, pending_input_id, metadata, created_at, position + FROM pending_messages_legacy; + DROP TABLE pending_messages_legacy; + CREATE INDEX idx_pending_session + ON pending_messages(session_id, queue_type); + COMMIT; + ", + ) + .context("failed to migrate pending steer queue from btw to steer")?; } // Schema version table (05 §2.3): the new authority going forward. // The ad-hoc column probes above are the v0 baseline and keep working @@ -442,12 +492,19 @@ impl Database { } /// Advances the projection watermark for a rollout file. - pub fn set_projection_watermark(&self, rollout_path: &Path, last_line_index: u64) -> Result<()> { + pub fn set_projection_watermark( + &self, + rollout_path: &Path, + last_line_index: u64, + ) -> Result<()> { let conn = self.conn.lock().expect("database mutex poisoned"); conn.execute( "INSERT INTO projection_watermark (rollout_path, last_line_index) VALUES (?1, ?2) ON CONFLICT(rollout_path) DO UPDATE SET last_line_index = excluded.last_line_index", - params![rollout_path.to_string_lossy().as_ref(), last_line_index as i64], + params![ + rollout_path.to_string_lossy().as_ref(), + last_line_index as i64 + ], ) .context("failed to update projection watermark")?; Ok(()) @@ -1169,7 +1226,10 @@ fn pending_input_from_row( "tool_call_blocked" => { let parsed: serde_json::Value = serde_json::from_str(content).unwrap_or_default(); PendingInputKind::ToolCallBlockedByHook { - tool_use_id: parsed["tool_use_id"].as_str().unwrap_or_default().to_string(), + tool_use_id: parsed["tool_use_id"] + .as_str() + .unwrap_or_default() + .to_string(), reason: parsed["reason"].as_str().unwrap_or_default().to_string(), } } @@ -1221,6 +1281,73 @@ mod tests { ); } + #[test] + fn migration_renames_legacy_btw_queue_rows_to_steer() { + let dir = TempDir::new().expect("create temp dir"); + let db_path = dir.path().join("legacy.db"); + let session_id = SessionId::new(); + { + let conn = Connection::open(&db_path).expect("open legacy database"); + conn.execute_batch( + " + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE pending_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + queue_type TEXT NOT NULL CHECK(queue_type IN ('turn', 'btw')), + kind TEXT NOT NULL, + content TEXT NOT NULL, + pending_input_id TEXT, + metadata TEXT, + created_at INTEGER NOT NULL, + position INTEGER + ); + CREATE INDEX idx_pending_session + ON pending_messages(session_id, queue_type); + ", + ) + .expect("create legacy queue tables"); + conn.execute( + "INSERT INTO sessions (id, cwd, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + params![session_id.to_string(), ".", 0, 0], + ) + .expect("insert legacy session"); + conn.execute( + "INSERT INTO pending_messages + (session_id, queue_type, kind, content, pending_input_id, created_at, position) + VALUES (?1, 'btw', 'user_text', 'keep steering', ?2, 0, 1)", + params![session_id.to_string(), PendingInputId::new().to_string()], + ) + .expect("insert legacy steer row"); + } + + let db = Database::open(db_path).expect("migrate legacy database"); + assert_eq!( + db.count_pending(&session_id, QueueType::Steer) + .expect("count migrated steer row"), + 1 + ); + let conn = db.conn.lock().expect("database mutex poisoned"); + let queue_type: String = conn + .query_row("SELECT queue_type FROM pending_messages", [], |row| { + row.get(0) + }) + .expect("read migrated queue type"); + assert_eq!(queue_type, "steer"); + let old_value = conn.execute( + "INSERT INTO pending_messages + (session_id, queue_type, kind, content, created_at, position) + VALUES (?1, 'btw', 'user_text', 'obsolete', 0, 2)", + params![session_id.to_string()], + ); + assert!(old_value.is_err(), "legacy queue type must be rejected"); + } + #[test] fn migration_backfills_legacy_session_stats_total_tokens() { let dir = TempDir::new().expect("create temp dir"); @@ -1455,9 +1582,9 @@ mod tests { None, Utc::now(), ); - let btw_item = PendingInputItem::new( + let steer_item = PendingInputItem::new( PendingInputKind::UserText { - text: "btw msg".into(), + text: "steer msg".into(), }, None, Utc::now(), @@ -1465,24 +1592,24 @@ mod tests { db.push_pending(&meta.session_id, QueueType::Turn, &turn_item) .expect("push"); - db.push_pending(&meta.session_id, QueueType::Btw, &btw_item) + db.push_pending(&meta.session_id, QueueType::Steer, &steer_item) .expect("push"); let turn_count = db .count_pending(&meta.session_id, QueueType::Turn) .expect("count"); - let btw_count = db - .count_pending(&meta.session_id, QueueType::Btw) + let steer_count = db + .count_pending(&meta.session_id, QueueType::Steer) .expect("count"); assert_eq!(turn_count, 1); - assert_eq!(btw_count, 1); + assert_eq!(steer_count, 1); - db.clear_pending(&meta.session_id, QueueType::Btw) + db.clear_pending(&meta.session_id, QueueType::Steer) .expect("clear"); - let btw_count = db - .count_pending(&meta.session_id, QueueType::Btw) + let steer_count = db + .count_pending(&meta.session_id, QueueType::Steer) .expect("count"); - assert_eq!(btw_count, 0); + assert_eq!(steer_count, 0); let turn_count = db .count_pending(&meta.session_id, QueueType::Turn) diff --git a/crates/server/src/event_reconcile.rs b/crates/server/src/event_reconcile.rs index c54f1bf7..f3a992cd 100644 --- a/crates/server/src/event_reconcile.rs +++ b/crates/server/src/event_reconcile.rs @@ -225,15 +225,13 @@ mod tests { let db = Database::open(dir.path().join("devo.db")).expect("open db"); // Crash after projecting only the first row (session meta at index 0). - db.set_projection_watermark(&path, 0).expect("set watermark"); + db.set_projection_watermark(&path, 0) + .expect("set watermark"); let stats = reconcile_event_log(&store, &db).expect("reconcile"); // Lines 1 (turn) and 2 (item) backfill; session/created does not. assert_eq!(stats.rows_inserted, 2); assert_eq!(db.event_log_len().expect("count"), 2); - assert_eq!( - db.projection_watermark(&path).expect("watermark"), - Some(2) - ); + assert_eq!(db.projection_watermark(&path).expect("watermark"), Some(2)); } #[test] @@ -254,10 +252,7 @@ mod tests { assert_eq!(stats.files_damaged, 1); // All intact rows before the damage were still backfilled. assert_eq!(stats.rows_inserted, 4); - assert_eq!( - db.projection_watermark(&path).expect("watermark"), - Some(2) - ); + assert_eq!(db.projection_watermark(&path).expect("watermark"), Some(2)); } #[test] diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index 4eb04f09..62991731 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -245,8 +245,8 @@ pub(crate) struct RuntimeSession { pub(crate) latest_compaction_snapshot: Option, /// Shared handle to the pending-turn queue owned by `core_session`. pub(crate) pending_turn_queue: Arc>>, - /// Shared handle to the `/btw` queue owned by `core_session`. - pub(crate) btw_input_queue: Arc>>, + /// Shared handle to the active-turn steer queue owned by `core_session`. + pub(crate) steer_input_queue: Arc>>, /// Tool exposure policy for turns run in this session. pub(crate) agent_tool_policy: devo_protocol::AgentToolPolicy, /// Optional maximum number of turns allowed in this session. diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 59ef9aad..6f99085f 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -58,8 +58,8 @@ use devo_core::TurnWorkspaceRestoreStartedLine; use devo_core::TurnWorkspaceRestoreStartedRecord; use devo_core::V2InverseProjector; use devo_core::Worklog; -use devo_core::parse_rollout_line; use devo_core::legacy_projector::LegacyProjector; +use devo_core::parse_rollout_line; use devo_core::rollout_v2::RolloutLineV2; use devo_core::{EVENT_SCHEMA_VERSION, events_from_v2_line, source_fact_id}; use devo_protocol::canonical::event::{EventEnvelope, EventMeta}; @@ -587,10 +587,9 @@ impl RolloutStore { match parsed { ParsedRolloutLine::Legacy(legacy) => replay.apply_line(*legacy)?, ParsedRolloutLine::V2(v2) => { - for legacy_line in inverse - .project_line(&v2) - .with_context(|| format!("project v2 line from {}", rollout_path.display()))? - { + for legacy_line in inverse.project_line(&v2).with_context(|| { + format!("project v2 line from {}", rollout_path.display()) + })? { replay.apply_line(legacy_line)?; } } @@ -666,7 +665,9 @@ impl RolloutStore { Some(state) => state, None => { let state = hydrate_write_state(rollout_path)?; - write_states.entry(rollout_path.to_path_buf()).or_insert(state) + write_states + .entry(rollout_path.to_path_buf()) + .or_insert(state) } }; let v2_lines = state @@ -828,7 +829,8 @@ fn hydrate_write_state(rollout_path: &Path) -> Result { }); } Err(error) => { - return Err(error).with_context(|| format!("open rollout file {}", rollout_path.display())); + return Err(error) + .with_context(|| format!("open rollout file {}", rollout_path.display())); } }; let reader = BufReader::new(file); @@ -840,9 +842,9 @@ fn hydrate_write_state(rollout_path: &Path) -> Result { } match parse_rollout_line(&line) { Ok(ParsedRolloutLine::Legacy(legacy)) => { - projector - .project_line(&legacy) - .with_context(|| format!("hydrate projector from {}", rollout_path.display()))?; + projector.project_line(&legacy).with_context(|| { + format!("hydrate projector from {}", rollout_path.display()) + })?; } Ok(ParsedRolloutLine::V2(v2)) => projector.observe_v2_line(&v2), Err(RolloutLineReadError::TruncatedTail) if lines.peek().is_none() => break, @@ -1177,7 +1179,7 @@ impl ReplayState { .map(devo_protocol::TurnUsage::display_total_tokens) .unwrap_or(core_session.prompt_token_estimate); let pending_turn_queue = std::sync::Arc::clone(&core_session.pending_turn_queue); - let btw_input_queue = std::sync::Arc::clone(&core_session.btw_input_queue); + let steer_input_queue = std::sync::Arc::clone(&core_session.steer_input_queue); let summary_model_selection = self .latest_turn_metadata .as_ref() @@ -1275,7 +1277,7 @@ impl ReplayState { persisted_turn_items: replayed_persisted_turn_items, latest_compaction_snapshot: self.latest_compaction_snapshot, pending_turn_queue, - btw_input_queue, + steer_input_queue, agent_tool_policy: Default::default(), max_turns: None, deferred_assistant: None, @@ -3519,7 +3521,9 @@ mod tests { .expect("append session meta"); let metadata = test_turn_metadata(record.id, TurnId::new()); let turn = super::build_turn_record(&metadata, None, None, None); - rollout_store.append_turn(&record, turn).expect("append turn"); + rollout_store + .append_turn(&record, turn) + .expect("append turn"); let item = super::build_item_record( record.id, metadata.turn_id, @@ -3529,7 +3533,9 @@ mod tests { Some(TurnStatus::Running), None, ); - rollout_store.append_item(&record, item).expect("append item"); + rollout_store + .append_item(&record, item) + .expect("append item"); let raw_lines = raw_rollout_lines(&record.rollout_path); assert_eq!(raw_lines.len(), 3); @@ -3567,7 +3573,9 @@ mod tests { .expect("append session meta"); let metadata = test_turn_metadata(record.id, TurnId::new()); let turn = super::build_turn_record(&metadata, None, None, None); - rollout_store.append_turn(&record, turn).expect("append turn"); + rollout_store + .append_turn(&record, turn) + .expect("append turn"); let request_record_id = ItemId::new(); rollout_store .append_item( @@ -3674,7 +3682,10 @@ mod tests { rollout_store .append_session_meta(&record) .expect("append session meta"); - write_raw_lines(&record.rollout_path, &[r#"{"v":2,"kind":"nope"}"#.to_string()]); + write_raw_lines( + &record.rollout_path, + &[r#"{"v":2,"kind":"nope"}"#.to_string()], + ); let restarted_store = super::RolloutStore::new(dir.path().to_path_buf(), None); let metadata = test_turn_metadata(record.id, TurnId::new()); @@ -3745,10 +3756,15 @@ mod tests { .append_session_meta(&record) .expect("append session meta"); // Damaged middle line, then a valid line after it. - write_raw_lines(&record.rollout_path, &[r#"{"v":2,"kind":"nope"}"#.to_string()]); + write_raw_lines( + &record.rollout_path, + &[r#"{"v":2,"kind":"nope"}"#.to_string()], + ); let metadata = test_turn_metadata(record.id, TurnId::new()); let turn = super::build_turn_record(&metadata, None, None, None); - rollout_store.append_turn(&record, turn).expect("append turn"); + rollout_store + .append_turn(&record, turn) + .expect("append turn"); let error = rollout_store .load_session_from_rollout(&record.rollout_path, &deps) @@ -3871,7 +3887,10 @@ mod tests { "line {index} must be legacy" ); } else { - assert!(matches!(parsed, ParsedRolloutLine::V2(_)), "line {index} must be v2"); + assert!( + matches!(parsed, ParsedRolloutLine::V2(_)), + "line {index} must be v2" + ); } } @@ -3916,7 +3935,9 @@ mod tests { .expect("append session meta"); let metadata = test_turn_metadata(record.id, TurnId::new()); let turn = super::build_turn_record(&metadata, None, None, None); - rollout_store.append_turn(&record, turn).expect("append turn"); + rollout_store + .append_turn(&record, turn) + .expect("append turn"); let item = super::build_item_record( record.id, metadata.turn_id, @@ -3926,7 +3947,9 @@ mod tests { Some(TurnStatus::Running), None, ); - rollout_store.append_item(&record, item).expect("append item"); + rollout_store + .append_item(&record, item) + .expect("append item"); record } @@ -3946,18 +3969,23 @@ mod tests { // session/created lands on both the session stream and the per-cwd // sessions stream; turn and item facts land on the session stream. assert_eq!(db.event_log_len().expect("count"), 4); - let session_stream = - devo_core::session_stream_id(&devo_protocol::canonical::ids::SessionId::from_string( - record.id.to_string(), - )); - let rows = db.event_log_rows(&session_stream, 0).expect("session stream"); + let session_stream = devo_core::session_stream_id( + &devo_protocol::canonical::ids::SessionId::from_string(record.id.to_string()), + ); + let rows = db + .event_log_rows(&session_stream, 0) + .expect("session stream"); let kinds: Vec<&str> = rows.iter().map(|row| row.event_kind.as_str()).collect(); - assert_eq!(kinds, vec!["session/created", "turn/completed", "item/completed"]); + assert_eq!( + kinds, + vec!["session/created", "turn/completed", "item/completed"] + ); let seqs: Vec = rows.iter().map(|row| row.seq).collect(); assert_eq!(seqs, vec![1, 2, 3]); // Three physical rows written; watermark is the last line index. assert_eq!( - db.projection_watermark(&record.rollout_path).expect("watermark"), + db.projection_watermark(&record.rollout_path) + .expect("watermark"), Some(2) ); diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 4011bcce..dd2461a1 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -308,8 +308,7 @@ pub(super) fn subagent_usage_owner_pending_metadata( impl ServerRuntime { pub fn new(server_home: PathBuf, deps: ServerRuntimeDependencies) -> Arc { - let rollout_store = - RolloutStore::new(server_home.clone(), Some(Arc::clone(&deps.db))); + let rollout_store = RolloutStore::new(server_home.clone(), Some(Arc::clone(&deps.db))); let goal_durable_store = GoalDurableStore::new(server_home.clone()); let sandbox_network_proxy = std::sync::Arc::new(std::sync::Mutex::new(None)); // Proxy startup is async; ports are published via the thread-safe diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index 7adf9726..970437b6 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -132,7 +132,7 @@ impl ServerRuntime { .filter(|item| matches!(item.turn_item, TurnItem::UserMessage(_))) .count(); let pending_turn_queue = Arc::clone(&core_session.pending_turn_queue); - let btw_input_queue = Arc::clone(&core_session.btw_input_queue); + let steer_input_queue = Arc::clone(&core_session.steer_input_queue); let latest_turn = if stable_items.is_empty() { None } else { @@ -182,7 +182,7 @@ impl ServerRuntime { persisted_turn_items: stable_items, latest_compaction_snapshot: None, pending_turn_queue, - btw_input_queue, + steer_input_queue, agent_tool_policy: effective_tool_policy, max_turns: params.max_turns, deferred_assistant: None, diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index e2b8220f..916c8d13 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -462,22 +462,27 @@ impl ServerRuntime { Some(self.handle_session_items_list(id?, params).await) } // Durable event subscriptions (08 §4). - Some(ClientMethod::SubscriptionCreate) => { - Some(self.handle_subscription_create(connection_id, id?, params).await) - } - Some(ClientMethod::SubscriptionUpdate) => { - Some(self.handle_subscription_update(connection_id, id?, params).await) - } - Some(ClientMethod::SubscriptionAck) => { - Some(self.handle_subscription_ack(connection_id, id?, params).await) - } - Some(ClientMethod::SubscriptionUnsubscribe) => { - Some(self.handle_subscription_unsubscribe(connection_id, id?, params).await) - } + Some(ClientMethod::SubscriptionCreate) => Some( + self.handle_subscription_create(connection_id, id?, params) + .await, + ), + Some(ClientMethod::SubscriptionUpdate) => Some( + self.handle_subscription_update(connection_id, id?, params) + .await, + ), + Some(ClientMethod::SubscriptionAck) => Some( + self.handle_subscription_ack(connection_id, id?, params) + .await, + ), + Some(ClientMethod::SubscriptionUnsubscribe) => Some( + self.handle_subscription_unsubscribe(connection_id, id?, params) + .await, + ), // Session input queue of the new Native API (01 §4.3). - Some(ClientMethod::SessionQueuePush) => { - Some(self.handle_session_queue_push(connection_id, id?, params).await) - } + Some(ClientMethod::SessionQueuePush) => Some( + self.handle_session_queue_push(connection_id, id?, params) + .await, + ), Some(ClientMethod::SessionQueueList) => { Some(self.handle_session_queue_list(id?, params).await) } @@ -487,9 +492,10 @@ impl ServerRuntime { Some(ClientMethod::SessionQueueRemove) => { Some(self.handle_session_queue_remove(id?, params).await) } - Some(ClientMethod::SessionQueueSteer) => { - Some(self.handle_session_queue_steer(connection_id, id?, params).await) - } + Some(ClientMethod::SessionQueueSteer) => Some( + self.handle_session_queue_steer(connection_id, id?, params) + .await, + ), // TODO: add endpoint to kill background process opened by unified exec command. // TODO: add endpoint to list current background processes. None => Some(self.error_response( @@ -1297,7 +1303,11 @@ impl ConnectionRuntime { /// Routes one server event to its wire notification for this connection: /// native typed `item/*` when the connection opted in and the payload /// projects, otherwise the legacy ACP-wrapped shape (P2 fallback). - pub(super) fn notification_for(&self, method: &str, event: &ServerEvent) -> (String, serde_json::Value) { + pub(super) fn notification_for( + &self, + method: &str, + event: &ServerEvent, + ) -> (String, serde_json::Value) { if self.typed_items && let Some(typed) = typed_item_notification_from_server_event(event) { @@ -2178,7 +2188,9 @@ mod tests { failure_reason: None, }; let turn = crate::persistence::build_turn_record(&metadata, None, None, None); - rollout_store.append_turn(&record, turn).expect("append turn"); + rollout_store + .append_turn(&record, turn) + .expect("append turn"); // Turns 1 and 2 get two items each, turn 3 gets one. for text in match turn_index { 1 | 2 => vec!["first", "second"], @@ -2195,7 +2207,9 @@ mod tests { Some(TurnStatus::Running), None, ); - rollout_store.append_item(&record, item).expect("append item"); + rollout_store + .append_item(&record, item) + .expect("append item"); item_seq += 1; } } @@ -2258,7 +2272,11 @@ mod tests { serde_json::from_value(first["result"].clone()).expect("page 1 result"); assert_eq!(first.data.len(), 2); assert_eq!( - first.data.iter().map(|turn| turn.sequence).collect::>(), + first + .data + .iter() + .map(|turn| turn.sequence) + .collect::>(), vec![1, 2] ); assert_eq!(first.next_cursor.as_deref(), Some("2")); @@ -2383,12 +2401,7 @@ mod tests { devo_protocol::canonical::item::ItemEnvelope, > = serde_json::from_value(items["result"].clone()).expect("items result"); assert_eq!(items.data.len(), 2); - assert!( - items - .data - .iter() - .all(|item| item.turn_id == turn_two.id) - ); + assert!(items.data.iter().all(|item| item.turn_id == turn_two.id)); assert!( matches!(&items.data[0].item, devo_protocol::canonical::item::Item::AssistantMessage { text, .. } if text == "first-t2") ); @@ -2430,7 +2443,8 @@ mod tests { let data_root = TempDir::new()?; let runtime = build_runtime(data_root.path()); // A session with only its meta line (no turns, no items). - let rollout_store = crate::persistence::RolloutStore::new(data_root.path().to_path_buf(), None); + let rollout_store = + crate::persistence::RolloutStore::new(data_root.path().to_path_buf(), None); let record = rollout_store.create_session_record( SessionId::new(), Utc::now(), @@ -2590,12 +2604,10 @@ mod tests { .collect::>(), vec![1, 2, 3, 4] ); - assert!( - matches!( - &result.replay[0].notification, - devo_protocol::canonical::event::ServerNotification::SessionCreated { .. } - ) - ); + assert!(matches!( + &result.replay[0].notification, + devo_protocol::canonical::event::ServerNotification::SessionCreated { .. } + )); assert!(result.replay.iter().all(|event| event.meta.persisted)); // Snapshot: the session from the rollout history, no active turn. @@ -2763,9 +2775,11 @@ mod tests { let second = tokio::time::timeout(Duration::from_secs(1), receiver.recv()) .await? .expect("second live event"); - assert!(second["params"]["_meta"]["devo/originalEvent"] - .to_string() - .contains("second")); + assert!( + second["params"]["_meta"]["devo/originalEvent"] + .to_string() + .contains("second") + ); Ok(()) } @@ -2977,12 +2991,12 @@ mod tests { ) .await .expect("session/start response"); - Ok(serde_json::from_value::>( - response, - )? - .result - .session - .session_id) + Ok( + serde_json::from_value::>(response)? + .result + .session + .session_id, + ) } async fn start_turn( @@ -3053,7 +3067,9 @@ mod tests { #[tokio::test] async fn queue_push_idle_starts_turn_and_busy_queues_then_update_remove() -> Result<()> { - use devo_protocol::canonical::rpc_turn::{SessionQueuePushResult, SessionQueueUpdateResult}; + use devo_protocol::canonical::rpc_turn::{ + SessionQueuePushResult, SessionQueueUpdateResult, + }; use devo_protocol::canonical::turn::TurnStatus as CanonicalTurnStatus; let data_root = TempDir::new()?; @@ -3209,7 +3225,10 @@ mod tests { .db .list_pending(&session_id, crate::db::QueueType::Turn)?; assert_eq!(db_entries.len(), 2); - assert_eq!(db_entries[0].id.to_string(), third_entry.queue_item_id.as_str()); + assert_eq!( + db_entries[0].id.to_string(), + third_entry.queue_item_id.as_str() + ); // Remove works; removing again reports the entry is gone. let removed = history_request( @@ -3323,7 +3342,11 @@ mod tests { let steered: SessionQueueSteerResult = serde_json::from_value(steered["result"].clone()).expect("steer result"); assert!(!steered.item_id.as_str().is_empty()); - assert!(queue_list(&runtime, connection_id, session_id).await.is_empty()); + assert!( + queue_list(&runtime, connection_id, session_id) + .await + .is_empty() + ); // Interrupt the turn before the next injection boundary: the // promoted steer is never consumed; it degrades back into the @@ -3340,7 +3363,10 @@ mod tests { }), ) .await; - assert!(interrupted.get("error").is_none(), "interrupt: {interrupted}"); + assert!( + interrupted.get("error").is_none(), + "interrupt: {interrupted}" + ); // Open the gate: turn 1 settles as interrupted; the follow-up turn // started by the queue drain runs to completion immediately. open.store(true, std::sync::atomic::Ordering::SeqCst); @@ -3405,13 +3431,13 @@ mod tests { } #[tokio::test] - async fn queue_survives_restart_and_btw_restores_into_turn_queue() -> Result<()> { + async fn queue_survives_restart_and_steer_restores_into_turn_queue() -> Result<()> { let data_root = TempDir::new()?; let runtime = build_runtime(data_root.path()); let connection_id = initialized_connection(&runtime).await; let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; - // Seed one queued and one stale-btw row directly in SQLite. + // Seed one queued and one stale-steer row directly in SQLite. let queued_item = devo_core::PendingInputItem::new( devo_core::PendingInputKind::UserText { text: "queued text".into(), @@ -3419,7 +3445,7 @@ mod tests { None, chrono::Utc::now(), ); - let btw_item = devo_core::PendingInputItem::new( + let steer_item = devo_core::PendingInputItem::new( devo_core::PendingInputKind::UserText { text: "stale steer".into(), }, @@ -3433,7 +3459,7 @@ mod tests { runtime .deps .db - .push_pending(&session_id, crate::db::QueueType::Btw, &btw_item)?; + .push_pending(&session_id, crate::db::QueueType::Steer, &steer_item)?; drop(runtime); let rebuilt = build_runtime(data_root.path()); @@ -3443,13 +3469,13 @@ mod tests { assert_eq!(entries.len(), 2); assert_eq!(entries[0].preview, "queued text"); assert_eq!(entries[1].preview, "stale steer"); - // The btw row moved into the turn queue table (the original queued + // The steer row moved into the turn queue table (the original queued // row is consumed by the resume drain and lives in memory only). assert!( rebuilt .deps .db - .list_pending(&session_id, crate::db::QueueType::Btw)? + .list_pending(&session_id, crate::db::QueueType::Steer)? .is_empty() ); let turn_rows = rebuilt @@ -3457,7 +3483,7 @@ mod tests { .db .list_pending(&session_id, crate::db::QueueType::Turn)?; assert_eq!(turn_rows.len(), 1); - assert_eq!(turn_rows[0].id, btw_item.id); + assert_eq!(turn_rows[0].id, steer_item.id); Ok(()) } diff --git a/crates/server/src/runtime/handlers/acp/session.rs b/crates/server/src/runtime/handlers/acp/session.rs index 6168a969..ecf1ab76 100644 --- a/crates/server/src/runtime/handlers/acp/session.rs +++ b/crates/server/src/runtime/handlers/acp/session.rs @@ -434,8 +434,8 @@ impl ServerRuntime { .map_err(|error| format!("failed to clear pending turn queue: {error}"))?; self.deps .db - .clear_pending(&session_id, crate::db::QueueType::Btw) - .map_err(|error| format!("failed to clear pending btw queue: {error}"))?; + .clear_pending(&session_id, crate::db::QueueType::Steer) + .map_err(|error| format!("failed to clear pending steer queue: {error}"))?; self.deps .db .delete_session(&session_id) diff --git a/crates/server/src/runtime/handlers/history.rs b/crates/server/src/runtime/handlers/history.rs index cac12232..65c88fac 100644 --- a/crates/server/src/runtime/handlers/history.rs +++ b/crates/server/src/runtime/handlers/history.rs @@ -44,8 +44,9 @@ impl ServerRuntime { Ok(history) => history, Err(response) => return response, }; - let page = match paginate(&history.turns, ¶ms.page, |turn| u64::from(turn.sequence)) - { + let page = match paginate(&history.turns, ¶ms.page, |turn| { + u64::from(turn.sequence) + }) { Ok(page) => page, Err(message) => { return self.error_response(request_id, ProtocolErrorCode::InvalidParams, message); diff --git a/crates/server/src/runtime/handlers/queue.rs b/crates/server/src/runtime/handlers/queue.rs index 4fe986c4..feee815d 100644 --- a/crates/server/src/runtime/handlers/queue.rs +++ b/crates/server/src/runtime/handlers/queue.rs @@ -24,8 +24,9 @@ use devo_protocol::canonical::rpc_turn::{ SessionQueueRemoveResult, SessionQueueSteerParams, SessionQueueSteerResult, SessionQueueUpdateParams, SessionQueueUpdateResult, }; -use devo_protocol::canonical::turn::{Turn as CanonicalTurn, TurnKind as CanonicalTurnKind, - TurnStatus as CanonicalTurnStatus}; +use devo_protocol::canonical::turn::{ + Turn as CanonicalTurn, TurnKind as CanonicalTurnKind, TurnStatus as CanonicalTurnStatus, +}; use uuid::Uuid; use super::super::*; @@ -153,7 +154,12 @@ impl ServerRuntime { .iter() .find_map(|part| match part { UserInput::Text { text } => Some( - text.lines().next().unwrap_or_default().chars().take(80).collect(), + text.lines() + .next() + .unwrap_or_default() + .chars() + .take(80) + .collect(), ), _ => None, }) @@ -295,7 +301,7 @@ impl ServerRuntime { ); } let workspace_root = reservation.summary.cwd.clone(); - let resolved = match reservation + let resolved = match reservation .runtime_context .resolve_input_items(&input_items, Some(workspace_root.as_path())) { @@ -315,8 +321,8 @@ impl ServerRuntime { ); } }; - let display_text = super::super::items::render_input_items(&input_items) - .unwrap_or_default(); + let display_text = + super::super::items::render_input_items(&input_items).unwrap_or_default(); Some(PendingInputKind::UserInput { input: input_items, display_text, @@ -379,8 +385,7 @@ impl ServerRuntime { ); } if params.position.is_some() { - let ordered_ids: Vec = - ordered.iter().map(|item| item.id).collect(); + let ordered_ids: Vec = ordered.iter().map(|item| item.id).collect(); if let Err(error) = self.deps.db.set_pending_positions( &legacy_session_id, QueueType::Turn, @@ -489,11 +494,10 @@ impl ServerRuntime { ); } if !reservation.ephemeral - && let Err(error) = self.deps.db.remove_pending_by_id( - &legacy_session_id, - QueueType::Turn, - &pending_id, - ) + && let Err(error) = + self.deps + .db + .remove_pending_by_id(&legacy_session_id, QueueType::Turn, &pending_id) { tracing::warn!( session_id = %legacy_session_id, @@ -621,16 +625,16 @@ impl ServerRuntime { }; reservation - .btw_input_queue + .steer_input_queue .lock() - .expect("btw input queue mutex should not be poisoned") + .expect("steer input queue mutex should not be poisoned") .push_back(item.clone()); if !reservation.ephemeral { - if let Err(error) = self.deps.db.remove_pending_by_id( - &legacy_session_id, - QueueType::Turn, - &pending_id, - ) { + if let Err(error) = + self.deps + .db + .remove_pending_by_id(&legacy_session_id, QueueType::Turn, &pending_id) + { tracing::warn!( session_id = %legacy_session_id, error = %error, @@ -640,7 +644,7 @@ impl ServerRuntime { if let Err(error) = self.deps .db - .push_pending(&legacy_session_id, QueueType::Btw, &item) + .push_pending(&legacy_session_id, QueueType::Steer, &item) { tracing::warn!( session_id = %legacy_session_id, @@ -861,9 +865,10 @@ pub(crate) fn canonical_queue_entries(queue: &VecDeque) -> Vec .map(|(index, item)| { let input: Vec = match &item.kind { PendingInputKind::UserText { text } => vec![UserInput::Text { text: text.clone() }], - PendingInputKind::UserInput { input, .. } => { - input.iter().map(canonical_user_input_from_input_item).collect() - } + PendingInputKind::UserInput { input, .. } => input + .iter() + .map(canonical_user_input_from_input_item) + .collect(), _ => Vec::new(), }; let display_text = match &item.kind { @@ -892,9 +897,9 @@ pub(crate) fn canonical_queue_entries(queue: &VecDeque) -> Vec /// by `session/queue/push`'s `Started` outcome. pub(crate) fn canonical_turn_from_metadata(turn: &crate::turn::TurnMetadata) -> CanonicalTurn { let kind = match &turn.kind { - devo_core::TurnKind::Regular | devo_core::TurnKind::Review | devo_core::TurnKind::Other(_) => { - CanonicalTurnKind::Regular - } + devo_core::TurnKind::Regular + | devo_core::TurnKind::Review + | devo_core::TurnKind::Other(_) => CanonicalTurnKind::Regular, devo_core::TurnKind::ManualCompaction => CanonicalTurnKind::Compaction, }; let status = match turn.status { diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 39329fc1..0a0630dd 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -106,7 +106,7 @@ impl ServerRuntime { ); let config = core_session.config.clone(); let pending_turn_queue = Arc::clone(&core_session.pending_turn_queue); - let btw_input_queue = Arc::clone(&core_session.btw_input_queue); + let steer_input_queue = Arc::clone(&core_session.steer_input_queue); let rollout_path_for_db = record.as_ref().map(|entry| entry.rollout_path.clone()); let actor_state = SessionActorState { runtime_context, @@ -124,7 +124,7 @@ impl ServerRuntime { persisted_turn_items: Vec::new(), latest_compaction_snapshot: None, pending_turn_queue, - btw_input_queue, + steer_input_queue, agent_tool_policy: Default::default(), max_turns: None, next_item_seq: 1, @@ -977,7 +977,7 @@ impl ServerRuntime { let config = core_session.config.clone(); let pending_turn_queue = Arc::clone(&core_session.pending_turn_queue); - let btw_input_queue = Arc::clone(&core_session.btw_input_queue); + let steer_input_queue = Arc::clone(&core_session.steer_input_queue); Ok(RuntimeSession { runtime_context, record: None, @@ -991,7 +991,7 @@ impl ServerRuntime { persisted_turn_items: kept_items, latest_compaction_snapshot: None, pending_turn_queue, - btw_input_queue, + steer_input_queue, agent_tool_policy: Default::default(), max_turns: None, deferred_assistant: None, diff --git a/crates/server/src/runtime/handlers/subscription.rs b/crates/server/src/runtime/handlers/subscription.rs index 0ba272cb..cab4217c 100644 --- a/crates/server/src/runtime/handlers/subscription.rs +++ b/crates/server/src/runtime/handlers/subscription.rs @@ -115,12 +115,11 @@ impl ServerRuntime { "connection is not registered", ); }; - let mut result = match self - .prepare_subscription(&request_id, ¶ms.selectors, ¶ms.after) - { - Ok(result) => result, - Err(response) => return response, - }; + let mut result = + match self.prepare_subscription(&request_id, ¶ms.selectors, ¶ms.after) { + Ok(result) => result, + Err(response) => return response, + }; if params.include_snapshot { for selector in ¶ms.selectors { let barrier = result @@ -142,9 +141,7 @@ impl ServerRuntime { } } } - result.pending_control_requests = self - .pending_control_requests(¶ms.selectors) - .await; + result.pending_control_requests = self.pending_control_requests(¶ms.selectors).await; // TODO(P4-followup): recovery_snapshots for in-flight items. The // runtime tracks accumulated stream text (deferred_assistant / // deferred_reasoning) but not per-channel chunk indices, so an @@ -208,9 +205,7 @@ impl ServerRuntime { "unknown subscription id", ); } - let mut result = match self - .prepare_subscription(&request_id, ¶ms.selectors, &[]) - { + let mut result = match self.prepare_subscription(&request_id, ¶ms.selectors, &[]) { Ok(result) => result, Err(response) => return response, }; @@ -270,7 +265,10 @@ impl ServerRuntime { if !selector_streams.contains(&cursor.stream_id) { return self.cursor_expired_response( request_id, - format!("stream {} is not part of the subscription", cursor.stream_id), + format!( + "stream {} is not part of the subscription", + cursor.stream_id + ), ); } let barrier = match self.deps.db.event_log_max_seq(&cursor.stream_id) { @@ -283,7 +281,11 @@ impl ServerRuntime { ); } }; - let acked = subscription.acked.get(&cursor.stream_id).copied().unwrap_or(0); + let acked = subscription + .acked + .get(&cursor.stream_id) + .copied() + .unwrap_or(0); if cursor.seq < acked || cursor.seq > barrier { return self.cursor_expired_response( request_id, @@ -293,7 +295,9 @@ impl ServerRuntime { ), ); } - subscription.acked.insert(cursor.stream_id.clone(), cursor.seq); + subscription + .acked + .insert(cursor.stream_id.clone(), cursor.seq); } subscription.last_ack_at = Some(Utc::now()); drop(subscriptions); @@ -534,10 +538,7 @@ impl ServerRuntime { Ok(sessions) } - fn queue_entries( - &self, - session_id: &CanonicalSessionId, - ) -> anyhow::Result> { + fn queue_entries(&self, session_id: &CanonicalSessionId) -> anyhow::Result> { let legacy_id = SessionId::try_from(session_id.as_str()) .map_err(|error| anyhow::anyhow!("invalid session id: {error}"))?; let pending = self.deps.db.list_pending(&legacy_id, QueueType::Turn)?; @@ -586,9 +587,7 @@ impl ServerRuntime { let target = if let Some(path) = &approval.path { Some(ApprovalTarget::Path { path: path.clone() }) } else if let Some(host) = &approval.host { - Some(ApprovalTarget::Host { - host: host.clone(), - }) + Some(ApprovalTarget::Host { host: host.clone() }) } else { approval .command @@ -630,25 +629,23 @@ impl ServerRuntime { questions: user_input .questions .into_iter() - .map(|question| { - devo_protocol::canonical::item::UserQuestion { - id: question.id, - header: question.header, - question: question.question, - is_other: question.is_other, - is_secret: question.is_secret, - options: question.options.map(|options| { - options - .into_iter() - .map(|option| { - devo_protocol::canonical::item::UserQuestionOption { - label: option.label, - description: option.description, - } - }) - .collect() - }), - } + .map(|question| devo_protocol::canonical::item::UserQuestion { + id: question.id, + header: question.header, + question: question.question, + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.map(|options| { + options + .into_iter() + .map(|option| { + devo_protocol::canonical::item::UserQuestionOption { + label: option.label, + description: option.description, + } + }) + .collect() + }), }) .collect(), answers: None, diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index 3c86a686..f8e7dca7 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -698,10 +698,10 @@ impl ServerRuntime { .expect("pending turn queue mutex should not be poisoned") .push_back(item.clone()); if !reservation.ephemeral - && let Err(err) = self - .deps - .db - .push_pending(¶ms.session_id, QueueType::Turn, &item) + && let Err(err) = + self.deps + .db + .push_pending(¶ms.session_id, QueueType::Turn, &item) { tracing::warn!( session_id = %params.session_id, @@ -766,21 +766,21 @@ impl ServerRuntime { chrono::Utc::now(), ); reservation - .btw_input_queue + .steer_input_queue .lock() - .expect("btw input queue mutex should not be poisoned") + .expect("steer input queue mutex should not be poisoned") .push_back(item.clone()); if !reservation.ephemeral && let Err(err) = self .deps .db - .push_pending(¶ms.session_id, QueueType::Btw, &item) + .push_pending(¶ms.session_id, QueueType::Steer, &item) { tracing::warn!( session_id = %params.session_id, error = %err, - "failed to persist btw input to database" + "failed to persist steer input to database" ); } @@ -968,9 +968,9 @@ impl ServerRuntime { let (display_input, item) = queued; reservation - .btw_input_queue + .steer_input_queue .lock() - .expect("btw input queue mutex should not be poisoned") + .expect("steer input queue mutex should not be poisoned") .push_back(item.clone()); if !is_ephemeral { @@ -986,10 +986,10 @@ impl ServerRuntime { "failed to remove steered queued message from database" ); } - if let Err(error) = self - .deps - .db - .push_pending(¶ms.session_id, QueueType::Btw, &item) + if let Err(error) = + self.deps + .db + .push_pending(¶ms.session_id, QueueType::Steer, &item) { tracing::warn!( session_id = %params.session_id, diff --git a/crates/server/src/runtime/lifecycle.rs b/crates/server/src/runtime/lifecycle.rs index 32081a63..a2546b1a 100644 --- a/crates/server/src/runtime/lifecycle.rs +++ b/crates/server/src/runtime/lifecycle.rs @@ -130,12 +130,12 @@ impl ServerRuntime { } } - // Stale btw (steer) inputs are no longer discarded (01 §4.3): they + // Stale steer inputs are no longer discarded (01 §4.3): they // degrade into the session turn queue like any other queued input. match self .deps .db - .drain_pending(&session_id, crate::db::QueueType::Btw) + .drain_pending(&session_id, crate::db::QueueType::Steer) { Ok(items) => { if !items.is_empty() { @@ -154,14 +154,14 @@ impl ServerRuntime { tracing::warn!( session_id = %session_id, error = %error, - "failed to restore btw input into the turn queue" + "failed to restore steer input into the turn queue" ); } } tracing::debug!( session_id = %session_id, - restored_btw_count = items.len(), - "degraded stale btw inputs into the pending turn queue" + restored_steer_count = items.len(), + "degraded stale steer inputs into the pending turn queue" ); } } @@ -169,7 +169,7 @@ impl ServerRuntime { tracing::warn!( session_id = %session_id, error = %err, - "failed to restore stale btw inputs from database" + "failed to restore stale steer inputs from database" ); } } diff --git a/crates/server/src/runtime/session_actor/loop_.rs b/crates/server/src/runtime/session_actor/actor_loop.rs similarity index 99% rename from crates/server/src/runtime/session_actor/loop_.rs rename to crates/server/src/runtime/session_actor/actor_loop.rs index c80f0f15..1c83b41d 100644 --- a/crates/server/src/runtime/session_actor/loop_.rs +++ b/crates/server/src/runtime/session_actor/actor_loop.rs @@ -99,7 +99,7 @@ pub(super) async fn run_session_actor( summary: state.summary.clone(), runtime_context: Arc::clone(&state.runtime_context), pending_turn_queue: Arc::clone(&state.pending_turn_queue), - btw_input_queue: Arc::clone(&state.btw_input_queue), + steer_input_queue: Arc::clone(&state.steer_input_queue), }); } SessionCommand::GetHookContextSnapshot { reply } => { diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index e6689420..90ea078d 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -66,7 +66,7 @@ impl SessionHandle { tx, max_turns, }; - tokio::spawn(super::loop_::run_session_actor(state, rx, runtime)); + tokio::spawn(super::actor_loop::run_session_actor(state, rx, runtime)); handle } diff --git a/crates/server/src/runtime/session_actor/mod.rs b/crates/server/src/runtime/session_actor/mod.rs index a8042494..853dce01 100644 --- a/crates/server/src/runtime/session_actor/mod.rs +++ b/crates/server/src/runtime/session_actor/mod.rs @@ -4,10 +4,10 @@ // turn is in flight, transient execution state lives in ActiveTurnRegistry and // merges back through actor commands when the turn completes. +mod actor_loop; pub(crate) mod approval_scope; mod commands; mod handle; -mod loop_; pub(crate) mod registry; pub(crate) mod snapshots; pub(crate) mod state; diff --git a/crates/server/src/runtime/session_actor/registry.rs b/crates/server/src/runtime/session_actor/registry.rs index 597964b0..b2fe5214 100644 --- a/crates/server/src/runtime/session_actor/registry.rs +++ b/crates/server/src/runtime/session_actor/registry.rs @@ -73,7 +73,15 @@ impl ServerRuntime { } /// Reads turn reservation state, preferring runtime caches while the session - /// actor is blocked in `ExecuteTurn` (mailbox would deadlock callers). + /// actor is blocked in `ExecuteTurn`. + /// + /// `execute_turn_in_actor` runs inline, so its actor does not poll mailbox + /// commands until the turn finishes. This is the only synchronous fast path + /// for work that must remain responsive during an active turn. Callers may + /// mutate `pending_turn_queue` and `steer_input_queue` through the returned + /// shared mutexes; those mutexes are the per-session serialization point. + /// Do not replace this with a mailbox round-trip for queue, steer, or other + /// active-turn control paths. pub(crate) async fn session_turn_reservation_snapshot( &self, session_id: SessionId, @@ -93,7 +101,7 @@ impl ServerRuntime { summary: spawn.parent_summary, runtime_context: spawn.runtime_context, pending_turn_queue: spawn.pending_turn_queue, - btw_input_queue: spawn.btw_input_queue, + steer_input_queue: spawn.steer_input_queue, }); } let handle = self.session(session_id).await?; diff --git a/crates/server/src/runtime/session_actor/snapshots.rs b/crates/server/src/runtime/session_actor/snapshots.rs index c6c6055e..3ec6552c 100644 --- a/crates/server/src/runtime/session_actor/snapshots.rs +++ b/crates/server/src/runtime/session_actor/snapshots.rs @@ -26,7 +26,7 @@ pub(crate) struct TurnReservationSnapshot { pub(crate) summary: SessionMetadata, pub(crate) runtime_context: Arc, pub(crate) pending_turn_queue: Arc>>, - pub(crate) btw_input_queue: Arc>>, + pub(crate) steer_input_queue: Arc>>, } /// Hook runner inputs derived from session actor state. diff --git a/crates/server/src/runtime/session_actor/state.rs b/crates/server/src/runtime/session_actor/state.rs index 00bb022d..4bb6e147 100644 --- a/crates/server/src/runtime/session_actor/state.rs +++ b/crates/server/src/runtime/session_actor/state.rs @@ -29,7 +29,7 @@ pub(crate) struct SpawnSnapshot { pub(crate) parent_tool_registry: Option>, pub(crate) runtime_context: Arc, pub(crate) pending_turn_queue: Arc>>, - pub(crate) btw_input_queue: Arc>>, + pub(crate) steer_input_queue: Arc>>, } /// Approval caches cloned at turn start for permission checks while the actor @@ -80,7 +80,7 @@ pub(crate) struct SessionActorState { pub(crate) persisted_turn_items: Vec, pub(crate) latest_compaction_snapshot: Option, pub(crate) pending_turn_queue: Arc>>, - pub(crate) btw_input_queue: Arc>>, + pub(crate) steer_input_queue: Arc>>, pub(crate) agent_tool_policy: devo_protocol::AgentToolPolicy, pub(crate) max_turns: Option, pub(crate) next_item_seq: u64, @@ -134,7 +134,7 @@ impl SessionActorState { parent_tool_registry: self.tool_registry.clone(), runtime_context: Arc::clone(&self.runtime_context), pending_turn_queue: Arc::clone(&self.pending_turn_queue), - btw_input_queue: Arc::clone(&self.btw_input_queue), + steer_input_queue: Arc::clone(&self.steer_input_queue), } } @@ -162,7 +162,7 @@ impl SessionActorState { persisted_turn_items: session.persisted_turn_items, latest_compaction_snapshot: session.latest_compaction_snapshot, pending_turn_queue: session.pending_turn_queue, - btw_input_queue: session.btw_input_queue, + steer_input_queue: session.steer_input_queue, agent_tool_policy: session.agent_tool_policy, max_turns: session.max_turns, next_item_seq: session.next_item_seq, @@ -192,7 +192,7 @@ impl SessionActorState { persisted_turn_items: self.persisted_turn_items.clone(), latest_compaction_snapshot: self.latest_compaction_snapshot.clone(), pending_turn_queue: Arc::clone(&self.pending_turn_queue), - btw_input_queue: Arc::clone(&self.btw_input_queue), + steer_input_queue: Arc::clone(&self.steer_input_queue), agent_tool_policy: self.agent_tool_policy, max_turns: self.max_turns, deferred_assistant: stream.deferred_assistant.clone(), diff --git a/crates/server/src/runtime/session_actor/turn.rs b/crates/server/src/runtime/session_actor/turn.rs index 686d292c..402cde96 100644 --- a/crates/server/src/runtime/session_actor/turn.rs +++ b/crates/server/src/runtime/session_actor/turn.rs @@ -10,6 +10,12 @@ use crate::runtime::turn_exec::{ spawn_turn_event_stream, }; +/// Executes a turn inline on the session actor. +/// +/// The actor does not poll its mailbox until this function returns. Code that +/// must remain responsive while a turn runs (for example queue operations, +/// steering, or future rollback preview) must not wait for an actor command. +/// It must use the runtime reservation fast path and its shared queues instead. pub(super) async fn execute_turn_in_actor( state: &mut SessionActorState, runtime: Arc, diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index 8940dbb7..7a1785b1 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -159,7 +159,7 @@ impl ServerRuntime { } else { state.core.last_turn_interrupted = false; } - self.clear_btw_input_queue(state, session_id).await; + self.clear_steer_input_queue(state, session_id).await; self.append_terminal_turn_record( state, session_id, @@ -265,17 +265,17 @@ impl ServerRuntime { final_turn } - async fn clear_btw_input_queue( + async fn clear_steer_input_queue( self: &Arc, state: &SessionActorState, session_id: SessionId, ) { let is_ephemeral = state.summary.ephemeral; - let btw_input_queue = Arc::clone(&state.btw_input_queue); + let steer_input_queue = Arc::clone(&state.steer_input_queue); let leftover: Vec = { - let mut queue = btw_input_queue + let mut queue = steer_input_queue .lock() - .expect("btw input queue mutex should not be poisoned"); + .expect("steer input queue mutex should not be poisoned"); queue.drain(..).collect() }; if !leftover.is_empty() { @@ -291,12 +291,11 @@ impl ServerRuntime { } } if !is_ephemeral { - // The db mirrors the same degradation — but selectively: a steer - // already drained onward by the follow-up-turn machinery (its - // memory copy was popped to start a turn) must NOT leave a db row - // behind, or a restart would resurrect a duplicate. Only items - // still sitting in the in-memory turn queue become db turn rows; - // every other btw row is dropped. + // The database mirrors the same degradation, but only for ids that + // remain in the shared in-memory turn queue. A steer already + // drained by follow-up scheduling was consumed into its next turn; + // retaining or moving its steer row would leave an orphan and make + // a restart resurrect a duplicate. Every other steer row is dropped. let queued_ids: std::collections::HashSet = state .pending_turn_queue .lock() @@ -304,19 +303,21 @@ impl ServerRuntime { .iter() .map(|item| item.id) .collect(); - match self.deps.db.drain_pending(&session_id, QueueType::Btw) { + match self.deps.db.drain_pending(&session_id, QueueType::Steer) { Ok(rows) => { for item in &rows { if !queued_ids.contains(&item.id) { continue; } if let Err(error) = - self.deps.db.push_pending(&session_id, QueueType::Turn, item) + self.deps + .db + .push_pending(&session_id, QueueType::Turn, item) { tracing::warn!( session_id = %session_id, error = %error, - "failed to degrade btw input into the turn queue" + "failed to degrade steer input into the turn queue" ); } } @@ -325,7 +326,7 @@ impl ServerRuntime { tracing::warn!( session_id = %session_id, error = %err, - "failed to drain btw input messages from database" + "failed to drain steer input messages from database" ); } } diff --git a/crates/server/src/runtime/turn_exec/followup.rs b/crates/server/src/runtime/turn_exec/followup.rs index 4aa2774a..0f33212e 100644 --- a/crates/server/src/runtime/turn_exec/followup.rs +++ b/crates/server/src/runtime/turn_exec/followup.rs @@ -43,9 +43,9 @@ impl ServerRuntime { session_id, devo_protocol::canonical::queue::QueueChange::Drained, queued.queued_item_id, - Some(devo_protocol::canonical::ids::TurnId::from_legacy_uuid(uuid::Uuid::from( - turn.turn_id, - ))), + Some(devo_protocol::canonical::ids::TurnId::from_legacy_uuid( + uuid::Uuid::from(turn.turn_id), + )), ) .await; let runtime = Arc::clone(self); @@ -97,9 +97,9 @@ impl ServerRuntime { session_id, devo_protocol::canonical::queue::QueueChange::Drained, queued.queued_item_id.clone(), - Some(devo_protocol::canonical::ids::TurnId::from_legacy_uuid(uuid::Uuid::from( - turn.turn_id, - ))), + Some(devo_protocol::canonical::ids::TurnId::from_legacy_uuid( + uuid::Uuid::from(turn.turn_id), + )), ) .await; Box::pin(Arc::clone(self).execute_turn(ExecuteTurnRequest { diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index 579013f7..de179bc8 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -216,12 +216,15 @@ async fn exhausted_provider_retries_persist_for_history_but_do_not_enter_context wait_for_original_event(&mut notifications_rx, "turn/completed").await?; let rollout = std::fs::read_to_string(rollout_path(data_root.path(), &session))?; assert!(rollout.contains(PROVIDER_ERROR_TEXT)); - let persisted_error = support::read_rollout_lines_dual(&rollout_path(data_root.path(), &session))? - .into_iter() - .find_map(|line| match line { - devo_core::RolloutLine::Turn(line) if line.turn.id == failed_turn_id => line.turn.error, - _ => None, - }); + let persisted_error = + support::read_rollout_lines_dual(&rollout_path(data_root.path(), &session))? + .into_iter() + .find_map(|line| match line { + devo_core::RolloutLine::Turn(line) if line.turn.id == failed_turn_id => { + line.turn.error + } + _ => None, + }); assert_eq!( persisted_error, Some(devo_core::TurnError { diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index ad1cae7a..f4d18e98 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -2006,16 +2006,8 @@ async fn run_worker_inner( }); continue; }; - let prompt = btw_agent_prompt(&question); match client - .agent_spawn(SpawnAgentParams { - session_id: active_session_id, - message: prompt, - fork_turns: Some("all".to_string()), - max_turns: Some(1), - tool_policy: AgentToolPolicy::DenyAll, - ephemeral: true, - }) + .agent_spawn(btw_spawn_params(active_session_id, &question)) .await { Ok(result) => { @@ -3252,6 +3244,23 @@ fn btw_agent_prompt(question: &str) -> String { ) } +/// Builds the isolated child-session request for the TUI `/btw` command. +/// +/// `/btw` is a side question, not a `turn/steer` input: it must not alter the +/// parent's turn, history, or queues. `ephemeral`, `DenyAll`, and the one-turn +/// limit make that boundary enforceable by the runtime rather than relying +/// only on the model prompt. +fn btw_spawn_params(session_id: SessionId, question: &str) -> SpawnAgentParams { + SpawnAgentParams { + session_id, + message: btw_agent_prompt(question), + fork_turns: Some("all".to_string()), + max_turns: Some(1), + tool_policy: AgentToolPolicy::DenyAll, + ephemeral: true, + } +} + async fn handle_btw_agent_event( method: &str, event: &ServerEvent, @@ -4618,6 +4627,8 @@ mod tests { use super::ShellCommandExecStart; use super::acp_terminal_output_event; use super::acp_terminal_snapshot_delta; + use super::btw_agent_prompt; + use super::btw_spawn_params; use super::handle_completed_item; use super::is_stale_turn_interrupt_error; use super::last_query_tokens_from_resume; @@ -4644,10 +4655,12 @@ mod tests { use crate::events::WorkerEvent; use devo_core::ItemId; use devo_core::TurnId; + use devo_protocol::AgentToolPolicy; use devo_protocol::DEVO_SESSION_META; use devo_protocol::DEVO_TURN_USAGE_META; use devo_protocol::SessionHistoryMetadata; use devo_protocol::SessionPlanStepStatus; + use devo_protocol::SpawnAgentParams; use devo_protocol::ThreadGoal; use devo_protocol::ThreadGoalStatus; use devo_server::ItemEnvelope; @@ -4658,6 +4671,24 @@ mod tests { use devo_server::ToolCallPayload; use devo_server::ToolResultPayload; + #[test] + fn btw_spawns_an_ephemeral_tool_free_one_turn_side_question() { + let session_id = SessionId::new(); + let question = "what changed in the parser?"; + + assert_eq!( + btw_spawn_params(session_id, question), + SpawnAgentParams { + session_id, + message: btw_agent_prompt(question), + fork_turns: Some("all".to_string()), + max_turns: Some(1), + tool_policy: AgentToolPolicy::DenyAll, + ephemeral: true, + } + ); + } + #[tokio::test] async fn worker_shutdown_aborts_unresponsive_task() { let (command_tx, _command_rx) = tokio::sync::mpsc::unbounded_channel(); From d426b8397cd33843582f6ae177841e05d62e6348 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Wed, 29 Jul 2026 22:32:24 +0800 Subject: [PATCH 13/17] feat(server): add two-phase session rollback --- crates/client/src/client_core.rs | 3 + crates/core/src/durable_record.rs | 9 + crates/protocol/src/protocol.rs | 50 ++ crates/server/src/persistence.rs | 52 ++ crates/server/src/runtime.rs | 3 + crates/server/src/runtime/agents.rs | 5 +- crates/server/src/runtime/connection.rs | 484 +++++++++++++ .../server/src/runtime/goal_continuation.rs | 1 + crates/server/src/runtime/handlers.rs | 3 + .../runtime/handlers/acp/session_support.rs | 1 + .../runtime/handlers/acp_config_options.rs | 1 + .../server/src/runtime/handlers/compaction.rs | 9 + .../src/runtime/handlers/message_edit.rs | 23 + .../server/src/runtime/handlers/rollback.rs | 665 ++++++++++++++++++ .../src/runtime/handlers/rollback_plan.rs | 182 +++++ .../runtime/handlers/rollback_workspace.rs | 263 +++++++ crates/server/src/runtime/handlers/session.rs | 58 +- crates/server/src/runtime/handlers/turn.rs | 3 + crates/server/src/runtime/items.rs | 5 + .../src/runtime/session_actor/handle.rs | 8 + .../server/src/runtime/turn_exec/followup.rs | 10 + .../src/workspace_changes/fs_snapshot.rs | 2 + crates/server/src/workspace_changes/git.rs | 136 +++- crates/server/src/workspace_changes/mod.rs | 117 +++ 24 files changed, 2077 insertions(+), 16 deletions(-) create mode 100644 crates/server/src/runtime/handlers/rollback.rs create mode 100644 crates/server/src/runtime/handlers/rollback_plan.rs create mode 100644 crates/server/src/runtime/handlers/rollback_workspace.rs diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 563f5efe..cf30d2a9 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -965,6 +965,9 @@ fn format_protocol_error_code(code: &ProtocolErrorCode) -> &'static str { ProtocolErrorCode::InvalidContentParts => "invalid_content_parts", ProtocolErrorCode::InvalidMentions => "invalid_mentions", ProtocolErrorCode::WorkspaceRestoreFailedToStart => "workspace_restore_failed_to_start", + ProtocolErrorCode::RestorePlanNotFound => "restore_plan_not_found", + ProtocolErrorCode::RestorePlanExpired => "restore_plan_expired", + ProtocolErrorCode::WorkspaceVersionConflict => "workspace_version_conflict", ProtocolErrorCode::InternalError => "internal_error", } } diff --git a/crates/core/src/durable_record.rs b/crates/core/src/durable_record.rs index 3415a8f5..9ae2d8e3 100644 --- a/crates/core/src/durable_record.rs +++ b/crates/core/src/durable_record.rs @@ -790,6 +790,15 @@ pub struct TurnWorkspaceCheckpointRecordedRecord { pub warnings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub artifact_ref: Option, + /// Untracked paths that existed at checkpoint creation. Git rollback uses + /// this manifest to delete only paths created after the checkpoint. + /// Legacy checkpoints omit it and therefore restore tracked files only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preexisting_untracked_files: Option>, + /// Pre-existing untracked directories paired with + /// `preexisting_untracked_files`; see that field for legacy semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preexisting_untracked_dirs: Option>, pub created_at: DateTime, } diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 71bc28c7..25c61bb5 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -109,6 +109,8 @@ pub enum ClientMethod { // `_devo/*` alias surface). SessionTurnsList, SessionItemsList, + SessionRollbackPreview, + SessionRollbackCommit, SubscriptionCreate, SubscriptionUpdate, SubscriptionAck, @@ -174,6 +176,8 @@ impl ClientMethod { Self::ProviderVendorUpsert => "provider/upsert", Self::SessionTurnsList => "session/turns/list", Self::SessionItemsList => "session/items/list", + Self::SessionRollbackPreview => "session/rollback/preview", + Self::SessionRollbackCommit => "session/rollback/commit", Self::SubscriptionCreate => "subscription/create", Self::SubscriptionUpdate => "subscription/update", Self::SubscriptionAck => "subscription/ack", @@ -239,6 +243,8 @@ impl ClientMethod { "provider/upsert" => Self::ProviderVendorUpsert, "session/turns/list" => Self::SessionTurnsList, "session/items/list" => Self::SessionItemsList, + "session/rollback/preview" => Self::SessionRollbackPreview, + "session/rollback/commit" => Self::SessionRollbackCommit, "subscription/create" => Self::SubscriptionCreate, "subscription/update" => Self::SubscriptionUpdate, "subscription/ack" => Self::SubscriptionAck, @@ -321,6 +327,15 @@ pub enum ProtocolErrorCode { InvalidMentions, #[error("WorkspaceRestoreFailedToStart")] WorkspaceRestoreFailedToStart, + #[serde(rename = "RESTORE_PLAN_NOT_FOUND")] + #[error("RESTORE_PLAN_NOT_FOUND")] + RestorePlanNotFound, + #[serde(rename = "RESTORE_PLAN_EXPIRED")] + #[error("RESTORE_PLAN_EXPIRED")] + RestorePlanExpired, + #[serde(rename = "WORKSPACE_VERSION_CONFLICT")] + #[error("WORKSPACE_VERSION_CONFLICT")] + WorkspaceVersionConflict, #[error("InternalError")] InternalError, } @@ -550,6 +565,8 @@ impl fmt::Display for McpAuthStatus { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use super::*; #[test] @@ -608,6 +625,18 @@ mod tests { ProtocolErrorCode::WorkspaceRestoreFailedToStart, "WorkspaceRestoreFailedToStart", ), + ( + ProtocolErrorCode::RestorePlanNotFound, + "RESTORE_PLAN_NOT_FOUND", + ), + ( + ProtocolErrorCode::RestorePlanExpired, + "RESTORE_PLAN_EXPIRED", + ), + ( + ProtocolErrorCode::WorkspaceVersionConflict, + "WORKSPACE_VERSION_CONFLICT", + ), ]; for (code, expected_str) in &codes { @@ -667,6 +696,27 @@ mod tests { ); } + #[test] + fn client_method_recognizes_two_phase_session_rollback() { + assert_eq!( + [ + ClientMethod::parse("session/rollback/preview"), + ClientMethod::parse("session/rollback/commit"), + ], + [ + Some(ClientMethod::SessionRollbackPreview), + Some(ClientMethod::SessionRollbackCommit), + ] + ); + assert_eq!( + [ + ClientMethod::SessionRollbackPreview.as_str(), + ClientMethod::SessionRollbackCommit.as_str(), + ], + ["session/rollback/preview", "session/rollback/commit"] + ); + } + #[test] fn client_method_recognizes_model_config_set() { assert_eq!( diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 6f99085f..db015a3c 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -610,6 +610,58 @@ impl RolloutStore { Ok(recovered) } + /// Reads durable workspace checkpoints without reconstructing runtime state. + /// + /// P4d rollback plans need the pre-turn ghost commit plus its untracked + /// manifest. This follows the same dual-read and fail-closed rules as + /// `load_session_from_rollout`. + pub(crate) fn workspace_checkpoints( + &self, + record: &SessionRecord, + ) -> Result> { + let file = File::open(&record.rollout_path) + .with_context(|| format!("open rollout file {}", record.rollout_path.display()))?; + let reader = BufReader::new(file); + let inverse = V2InverseProjector::new(); + let mut checkpoints = Vec::new(); + let mut lines = reader.lines().enumerate().peekable(); + while let Some((line_index, line)) = lines.next() { + let line = + line.with_context(|| format!("read line from {}", record.rollout_path.display()))?; + if line.trim().is_empty() { + continue; + } + let parsed = match parse_rollout_line(&line) { + Ok(parsed) => parsed, + Err(RolloutLineReadError::TruncatedTail) if lines.peek().is_none() => break, + Err(error) => { + return Err(error).with_context(|| { + format!( + "rollout {} is damaged at line {}; refusing checkpoint read", + record.rollout_path.display(), + line_index + 1 + ) + }); + } + }; + let legacy_lines = match parsed { + ParsedRolloutLine::Legacy(legacy) => vec![*legacy], + ParsedRolloutLine::V2(v2) => inverse.project_line(&v2).with_context(|| { + format!( + "project v2 checkpoint line from {}", + record.rollout_path.display() + ) + })?, + }; + for legacy in legacy_lines { + if let RolloutLine::TurnWorkspaceCheckpointRecorded(line) = legacy { + checkpoints.push(line.record); + } + } + } + Ok(checkpoints) + } + pub(crate) fn rollout_paths(&self) -> Result> { let mut files = Vec::new(); let root = self.data_root.join("sessions"); diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index dd2461a1..b8850964 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -223,6 +223,8 @@ pub struct ServerRuntime { code_index_warmup: code_index_warmup::CodeIndexWarmup, /// Turn-scoped workspace baselines captured at actual execution start. active_workspace_baselines: Mutex>, + /// Short-lived, connection-bound P4d rollback plans. + restore_plans: Mutex, /// Sessions with an in-flight model title-generation task. title_generation_in_flight: Mutex>, /// Weak back-reference used when session actors need the owning runtime `Arc`. @@ -370,6 +372,7 @@ impl ServerRuntime { command_exec_manager: command_exec::CommandExecManager::new(), code_index_warmup: code_index_warmup::CodeIndexWarmup::new(), active_workspace_baselines: Mutex::new(HashMap::new()), + restore_plans: Mutex::new(HashMap::new()), title_generation_in_flight: Mutex::new(HashSet::new()), self_weak: self_weak.clone(), session_lru: Mutex::new(session_cache::ParentSessionLru::new( diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index 970437b6..aa6f9a9e 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -399,9 +399,10 @@ impl ServerRuntime { let session_handle = self.session(session_id).await.ok_or_else(|| { ToolCallError::InvalidInput(format!("session not found: {session_id}")) })?; + let _state_change_guard = session_handle.lock_state_change().await; - let reservation = session_handle - .turn_reservation_snapshot() + let reservation = self + .session_turn_reservation_snapshot(session_id) .await .ok_or_else(|| { ToolCallError::InvalidInput(format!( diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 916c8d13..1317ea96 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -174,6 +174,7 @@ impl ServerRuntime { let _ = pending.send(Err("client connection closed".to_string())); } } + self.drop_restore_plans_for_connection(connection_id).await; self.active_turns.drop_connection_id(connection_id).await; self.drop_event_subscriptions_for_connection(connection_id) .await; @@ -341,6 +342,14 @@ impl ServerRuntime { self.handle_session_rollback(connection_id, id?, params) .await, ), + Some(ClientMethod::SessionRollbackPreview) => Some( + self.handle_session_rollback_preview(connection_id, id?, params) + .await, + ), + Some(ClientMethod::SessionRollbackCommit) => Some( + self.handle_session_rollback_commit(connection_id, id?, params) + .await, + ), // compact session context history Some(ClientMethod::SessionCompact) => { Some(self.handle_session_compact(id?, params).await) @@ -3028,6 +3037,481 @@ mod tests { Ok(result.result.turn_id().expect("turn started")) } + #[tokio::test] + async fn rollback_preview_commit_restores_git_and_is_idempotent() -> Result<()> { + use devo_protocol::canonical::rpc_session::{RestorePlan, SessionRollbackCommitResult}; + + let data_root = TempDir::new()?; + let repo = TempDir::new()?; + for args in [ + vec!["init"], + vec!["config", "user.email", "rollback@example.com"], + vec!["config", "user.name", "Rollback Test"], + ] { + let status = std::process::Command::new("git") + .current_dir(repo.path()) + .args(args) + .status()?; + assert!(status.success()); + } + std::fs::write(repo.path().join("tracked.txt"), "initial\n")?; + for args in [vec!["add", "tracked.txt"], vec!["commit", "-m", "initial"]] { + let status = std::process::Command::new("git") + .current_dir(repo.path()) + .args(args) + .status()?; + assert!(status.success()); + } + + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, repo.path()).await?; + start_turn(&runtime, connection_id, session_id, "first").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + + std::fs::write(repo.path().join("tracked.txt"), "before second\n")?; + start_turn(&runtime, connection_id, session_id, "second").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + std::fs::write(repo.path().join("tracked.txt"), "after second\n")?; + std::fs::write(repo.path().join("new.txt"), "new\n")?; + let turns_before_preview = session_turns_json(&runtime, connection_id, session_id).await; + + let preview = history_request( + &runtime, + connection_id, + 200, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 1, + "mode": "beforeUserTurn", + }), + ) + .await; + let plan: RestorePlan = + serde_json::from_value(preview["result"].clone()).expect("rollback preview result"); + assert_eq!( + plan.affected_files, + vec![PathBuf::from("new.txt"), PathBuf::from("tracked.txt")] + ); + assert_eq!(plan.dropped_turn_count, 1); + assert_eq!( + session_turns_json(&runtime, connection_id, session_id).await, + turns_before_preview + ); + let turn_ids_before: HashSet = turns_before_preview + .iter() + .filter_map(|turn| turn["id"].as_str().map(str::to_string)) + .collect(); + assert_eq!(turn_ids_before.len(), 2); + let index_before = std::process::Command::new("git") + .current_dir(repo.path()) + .args(["show", ":tracked.txt"]) + .output()?; + assert!(index_before.status.success()); + + let commit_params = serde_json::json!({ + "restorePlanId": plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": plan.workspace_version, + }); + let other_connection_id = initialized_connection(&runtime).await; + let wrong_connection = history_request( + &runtime, + other_connection_id, + 201, + "session/rollback/commit", + commit_params.clone(), + ) + .await; + assert_eq!( + wrong_connection["error"]["code"], + serde_json::json!("RESTORE_PLAN_NOT_FOUND") + ); + std::fs::write(repo.path().join("drift.txt"), "drift\n")?; + let conflicted = history_request( + &runtime, + connection_id, + 202, + "session/rollback/commit", + commit_params.clone(), + ) + .await; + assert_eq!( + conflicted["error"]["code"], + serde_json::json!("WORKSPACE_VERSION_CONFLICT") + ); + std::fs::remove_file(repo.path().join("drift.txt"))?; + let title_update = history_request( + &runtime, + connection_id, + 206, + "session/title/update", + serde_json::json!({ + "session_id": session_id, + "title": "Preserved rollback title", + }), + ) + .await; + assert!(title_update.get("error").is_none(), "{title_update}"); + let queued_input = devo_protocol::PendingInputItem::new( + devo_protocol::PendingInputKind::UserText { + text: "preserve queued input".to_string(), + }, + None, + Utc::now(), + ); + let queued_input_id = queued_input.id; + runtime + .session_turn_reservation_snapshot(session_id) + .await + .expect("turn reservation") + .pending_turn_queue + .lock() + .expect("pending queue") + .push_back(queued_input); + let (committed, concurrent_retry) = tokio::join!( + history_request( + &runtime, + connection_id, + 203, + "session/rollback/commit", + commit_params.clone(), + ), + history_request( + &runtime, + connection_id, + 204, + "session/rollback/commit", + commit_params.clone(), + ) + ); + assert_eq!(concurrent_retry["result"], committed["result"]); + let result: SessionRollbackCommitResult = + serde_json::from_value(committed["result"].clone()).expect("rollback commit result"); + assert_eq!( + result, + SessionRollbackCommitResult { + restored_turn_count: 1, + restored_file_count: 2, + } + ); + assert_eq!( + std::fs::read_to_string(repo.path().join("tracked.txt"))?, + "before second\n" + ); + assert!(!repo.path().join("new.txt").exists()); + assert_eq!( + std::process::Command::new("git") + .current_dir(repo.path()) + .args(["show", ":tracked.txt"]) + .output()? + .stdout, + index_before.stdout + ); + let turn_ids_after: HashSet = + session_turns_json(&runtime, connection_id, session_id) + .await + .iter() + .filter_map(|turn| turn["id"].as_str().map(str::to_string)) + .collect(); + assert_eq!(turn_ids_after.len(), 1); + assert!(turn_ids_after.is_subset(&turn_ids_before)); + assert_eq!( + runtime + .session(session_id) + .await + .expect("session") + .summary() + .await + .expect("summary") + .title, + Some("Preserved rollback title".to_string()) + ); + let pending_after = runtime + .session_turn_reservation_snapshot(session_id) + .await + .expect("turn reservation") + .pending_turn_queue + .lock() + .expect("pending queue") + .front() + .map(|item| item.id); + assert_eq!(pending_after, Some(queued_input_id)); + + let retried = history_request( + &runtime, + connection_id, + 205, + "session/rollback/commit", + commit_params, + ) + .await; + assert_eq!(retried["result"], committed["result"]); + Ok(()) + } + + #[tokio::test] + async fn turn_start_waits_for_session_state_change_gate() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + let session_handle = runtime.session(session_id).await.expect("session"); + let state_change_guard = session_handle.lock_state_change().await; + let runtime_for_turn = Arc::clone(&runtime); + let turn_start = tokio::spawn(async move { + runtime_for_turn + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 300, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": "wait" }], + "model": null, + "sandbox": null, + "approval_policy": null, + "cwd": null + } + }), + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!turn_start.is_finished()); + drop(state_change_guard); + let response = turn_start.await?.expect("turn/start response"); + assert!(response.get("error").is_none(), "turn/start: {response}"); + Ok(()) + } + + #[tokio::test] + async fn manual_compaction_waits_for_session_state_change_gate() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, data_root.path()).await?; + let session_handle = runtime.session(session_id).await.expect("session"); + let state_change_guard = session_handle.lock_state_change().await; + let compaction = + tokio::spawn(Arc::clone(&runtime).run_session_compaction(session_id, session_handle)); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!compaction.is_finished()); + drop(state_change_guard); + tokio::time::timeout(Duration::from_secs(5), compaction).await??; + Ok(()) + } + + #[tokio::test] + async fn disconnect_wakes_concurrent_rollback_commit_waiter() -> Result<()> { + let data_root = TempDir::new()?; + let workspace = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, workspace.path()).await?; + start_turn(&runtime, connection_id, session_id, "first").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let preview = history_request( + &runtime, + connection_id, + 350, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 0, + "mode": "beforeUserTurn", + }), + ) + .await; + let plan: devo_protocol::canonical::rpc_session::RestorePlan = + serde_json::from_value(preview["result"].clone())?; + let params = serde_json::json!({ + "restorePlanId": plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": plan.workspace_version, + }); + let session_handle = runtime.session(session_id).await.expect("session"); + let state_change_guard = session_handle.lock_state_change().await; + let first_runtime = Arc::clone(&runtime); + let first_params = params.clone(); + let first = tokio::spawn(async move { + first_runtime + .handle_session_rollback_commit(connection_id, serde_json::json!(351), first_params) + .await + }); + tokio::time::sleep(Duration::from_millis(20)).await; + let second_runtime = Arc::clone(&runtime); + let second = tokio::spawn(async move { + second_runtime + .handle_session_rollback_commit(connection_id, serde_json::json!(352), params) + .await + }); + tokio::time::sleep(Duration::from_millis(20)).await; + runtime.unregister_connection(connection_id).await; + drop(state_change_guard); + + let first_response = tokio::time::timeout(Duration::from_secs(5), first).await??; + let second_response = tokio::time::timeout(Duration::from_secs(5), second).await??; + assert!(first_response.get("error").is_none(), "{first_response}"); + assert_eq!( + second_response["error"]["code"], + serde_json::json!("RESTORE_PLAN_NOT_FOUND") + ); + Ok(()) + } + + #[tokio::test] + async fn rollback_in_non_git_workspace_is_history_only() -> Result<()> { + use devo_protocol::canonical::rpc_session::{RestorePlan, SessionRollbackCommitResult}; + + let data_root = TempDir::new()?; + let workspace = TempDir::new()?; + let runtime = build_runtime(data_root.path()); + let connection_id = initialized_connection(&runtime).await; + let session_id = start_durable_session(&runtime, connection_id, workspace.path()).await?; + for text in ["first", "second"] { + start_turn(&runtime, connection_id, session_id, text).await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + } + + let preview = history_request( + &runtime, + connection_id, + 400, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 1, + "mode": "beforeUserTurn", + }), + ) + .await; + let plan: RestorePlan = + serde_json::from_value(preview["result"].clone()).expect("rollback preview result"); + assert_eq!(plan.affected_files, Vec::::new()); + assert_eq!(plan.workspace_version, "history-only"); + + start_turn(&runtime, connection_id, session_id, "third").await?; + for _ in 0..200 { + if runtime.runtime_active_turn_id(session_id).await.is_none() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(runtime.runtime_active_turn_id(session_id).await.is_none()); + let stale_commit = history_request( + &runtime, + connection_id, + 401, + "session/rollback/commit", + serde_json::json!({ + "restorePlanId": plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": plan.workspace_version, + }), + ) + .await; + assert_eq!( + stale_commit["error"]["code"], + serde_json::json!("WORKSPACE_VERSION_CONFLICT") + ); + + let preview = history_request( + &runtime, + connection_id, + 402, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 1, + "mode": "beforeUserTurn", + }), + ) + .await; + let plan: RestorePlan = + serde_json::from_value(preview["result"].clone()).expect("rollback preview result"); + let committed = history_request( + &runtime, + connection_id, + 403, + "session/rollback/commit", + serde_json::json!({ + "restorePlanId": plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": plan.workspace_version, + }), + ) + .await; + assert_eq!( + serde_json::from_value::(committed["result"].clone())?, + SessionRollbackCommitResult { + restored_turn_count: 2, + restored_file_count: 0, + } + ); + let turn_ids: HashSet = session_turns_json(&runtime, connection_id, session_id) + .await + .iter() + .filter_map(|turn| turn["id"].as_str().map(str::to_string)) + .collect(); + assert_eq!(turn_ids.len(), 1); + + let disconnecting_connection_id = initialized_connection(&runtime).await; + let disconnect_preview = history_request( + &runtime, + disconnecting_connection_id, + 404, + "session/rollback/preview", + serde_json::json!({ + "sessionId": session_id.to_string(), + "userTurnIndex": 0, + "mode": "beforeUserTurn", + }), + ) + .await; + let disconnect_plan: RestorePlan = + serde_json::from_value(disconnect_preview["result"].clone())?; + runtime + .unregister_connection(disconnecting_connection_id) + .await; + let disconnected_commit = runtime + .handle_session_rollback_commit( + disconnecting_connection_id, + serde_json::json!(405), + serde_json::json!({ + "restorePlanId": disconnect_plan.restore_plan_id.as_str(), + "expectedWorkspaceVersion": disconnect_plan.workspace_version, + }), + ) + .await; + assert_eq!( + disconnected_commit["error"]["code"], + serde_json::json!("RESTORE_PLAN_NOT_FOUND") + ); + Ok(()) + } + async fn queue_list( runtime: &Arc, connection_id: u64, diff --git a/crates/server/src/runtime/goal_continuation.rs b/crates/server/src/runtime/goal_continuation.rs index cf482d67..bcb192ee 100644 --- a/crates/server/src/runtime/goal_continuation.rs +++ b/crates/server/src/runtime/goal_continuation.rs @@ -39,6 +39,7 @@ impl ServerRuntime { return; } + let _state_change_guard = session_handle.lock_state_change().await; let Some(reservation) = session_handle.turn_reservation_snapshot().await else { return; }; diff --git a/crates/server/src/runtime/handlers.rs b/crates/server/src/runtime/handlers.rs index cbb36e5a..255ee540 100644 --- a/crates/server/src/runtime/handlers.rs +++ b/crates/server/src/runtime/handlers.rs @@ -8,6 +8,9 @@ mod history; mod message_edit; mod message_edit_restore; mod queue; +pub(super) mod rollback; +pub(super) mod rollback_plan; +mod rollback_workspace; mod session; pub(crate) mod subscription; mod turn; diff --git a/crates/server/src/runtime/handlers/acp/session_support.rs b/crates/server/src/runtime/handlers/acp/session_support.rs index 1eba3fc1..3d9340fa 100644 --- a/crates/server/src/runtime/handlers/acp/session_support.rs +++ b/crates/server/src/runtime/handlers/acp/session_support.rs @@ -77,6 +77,7 @@ impl ServerRuntime { let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() else { return Err("session does not exist".to_string()); }; + let _state_change_guard = session_arc.lock_state_change().await; let snapshot = session_arc .hook_context_snapshot() .await diff --git a/crates/server/src/runtime/handlers/acp_config_options.rs b/crates/server/src/runtime/handlers/acp_config_options.rs index b8087f77..f4aed0ca 100644 --- a/crates/server/src/runtime/handlers/acp_config_options.rs +++ b/crates/server/src/runtime/handlers/acp_config_options.rs @@ -59,6 +59,7 @@ impl ServerRuntime { "session does not exist".to_string(), )); }; + let _state_change_guard = session_arc.lock_state_change().await; let snapshot: HookContextSnapshot = session_arc.hook_context_snapshot().await.ok_or_else(|| { diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index 9da28aa4..7dea863d 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -82,6 +82,11 @@ impl ServerRuntime { ) .await; + // Compaction computes a replacement from a history snapshot. Keep the + // session mutation gate for the whole summarize-and-apply operation so + // rollback, turn admission, and metadata edits cannot make that + // replacement stale while the model call is in flight. + let state_change_guard = session_handle.lock_state_change().await; let result = { let Some(runtime_session) = session_handle.export_runtime_session().await else { tracing::warn!(session_id = %session_id, "session compaction failed: session unavailable"); @@ -316,6 +321,7 @@ impl ServerRuntime { ), ) .await; + drop(state_change_guard); self.run_session_hook( session_id, devo_core::HookEvent::PostCompact, @@ -344,6 +350,7 @@ impl ServerRuntime { ), ) .await; + drop(state_change_guard); tracing::info!(session_id = %session_id, "session compaction completed with replacement"); self.broadcast_event(ServerEvent::SessionCompactionCompleted( SessionEventPayload { session: summary }, @@ -354,6 +361,7 @@ impl ServerRuntime { let Some(summary) = session_handle.summary().await else { return; }; + drop(state_change_guard); tracing::info!(session_id = %session_id, "session compaction completed without replacement"); self.broadcast_event(ServerEvent::SessionCompactionCompleted( SessionEventPayload { session: summary }, @@ -361,6 +369,7 @@ impl ServerRuntime { .await; } Err(error) => { + drop(state_change_guard); tracing::warn!(session_id = %session_id, error = %error, "session compaction failed"); self.broadcast_event(ServerEvent::SessionCompactionFailed( SessionCompactionFailedPayload { diff --git a/crates/server/src/runtime/handlers/message_edit.rs b/crates/server/src/runtime/handlers/message_edit.rs index 4e51dbf4..a87dd2e2 100644 --- a/crates/server/src/runtime/handlers/message_edit.rs +++ b/crates/server/src/runtime/handlers/message_edit.rs @@ -51,6 +51,29 @@ impl ServerRuntime { "session does not exist", ); }; + if self + .runtime_active_turn_id(params.session_id) + .await + .is_some() + { + return self.error_response( + request_id, + ProtocolErrorCode::ActiveTurnEditRejected, + "cannot edit the previous message while a turn is active", + ); + } + let _state_change_guard = session_handle.lock_state_change().await; + if self + .runtime_active_turn_id(params.session_id) + .await + .is_some() + { + return self.error_response( + request_id, + ProtocolErrorCode::ActiveTurnEditRejected, + "cannot edit the previous message while a turn is active", + ); + } let Some(hook_context) = session_handle.hook_context_snapshot().await else { return self.error_response( request_id, diff --git a/crates/server/src/runtime/handlers/rollback.rs b/crates/server/src/runtime/handlers/rollback.rs new file mode 100644 index 00000000..dfd77822 --- /dev/null +++ b/crates/server/src/runtime/handlers/rollback.rs @@ -0,0 +1,665 @@ +//! Two-phase `session/rollback/preview|commit` implementation (P4d). +//! +//! Plans are connection-bound and short-lived. Commit revalidates both the +//! session history and Git worktree before applying any change. + +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::Utc; +use devo_core::SessionId; +use devo_protocol::canonical::ids::RestorePlanId; +use devo_protocol::canonical::rpc_session::{ + RestorePlan, SessionRollbackCommitParams, SessionRollbackCommitResult, + SessionRollbackPreviewParams, +}; +use tokio::sync::Notify; + +use super::super::*; +use super::rollback_plan::*; +use super::session::RuntimeSessionTurnCutOptions; + +impl ServerRuntime { + pub(crate) async fn drop_restore_plans_for_connection(&self, connection_id: u64) { + let mut plans = self.restore_plans.lock().await; + let owned_plan_ids = plans + .iter() + .filter(|(_, plan)| plan.connection_id == connection_id) + .map(|(plan_id, _)| plan_id.clone()) + .collect::>(); + let mut recovery_attempts = Vec::new(); + for plan_id in owned_plan_ids { + let Some(plan) = plans.get_mut(&plan_id) else { + continue; + }; + if matches!(&plan.status, RestorePlanStatus::InFlight) { + plan.owner_disconnected = true; + continue; + } + let recovery_action = recovery_action(&plan.status); + let removed = plans.remove(&plan_id).expect("restore plan still exists"); + removed.notify.notify_waiters(); + if let Some(action) = recovery_action { + recovery_attempts.push((removed, action)); + } + } + drop(plans); + for (plan, action) in recovery_attempts { + let runtime = self.runtime_arc(); + tokio::spawn(async move { + runtime + .reconcile_disconnected_restore_plan(plan, action) + .await; + }); + } + } + + pub(crate) async fn handle_session_rollback_preview( + &self, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionRollbackPreviewParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/rollback/preview params: {error}"), + ); + } + }; + let Ok(session_id) = SessionId::try_from(params.session_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "invalid session id", + ); + }; + if self.runtime_active_turn_id(session_id).await.is_some() { + return self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot preview rollback while a turn is active", + ); + } + let Some(session_handle) = self.session(session_id).await else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let _state_change_guard = session_handle.lock_state_change().await; + if self.runtime_active_turn_id(session_id).await.is_some() { + return self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot preview rollback while a turn is active", + ); + } + let Some(source) = session_handle.export_runtime_session().await else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let rollback_mode = legacy_rollback_mode(params.mode); + let rebuilt = match self + .build_runtime_session_from_user_turn_cut( + &source, + RuntimeSessionTurnCutOptions { + session_id, + user_turn_index: Some(params.user_turn_index), + rollback_mode: rollback_mode.clone(), + cwd_override: None, + title_override: source.summary.title.clone(), + created_at: source.summary.created_at, + }, + ) + .await + { + Ok(rebuilt) => rebuilt, + Err(message) => { + return self.error_response(request_id, ProtocolErrorCode::InvalidParams, message); + } + }; + let dropped_turn_ids = + dropped_turn_ids(&source.persisted_turn_items, &rebuilt.persisted_turn_items); + let checkpoint = match source.record.as_ref() { + Some(record) => { + let rollout_store = self.rollout_store.clone(); + let record = record.clone(); + let checkpoints = tokio::task::spawn_blocking(move || { + rollout_store.workspace_checkpoints(&record) + }) + .await; + match checkpoints { + Ok(Ok(checkpoints)) => dropped_turn_ids.first().and_then(|turn_id| { + checkpoints.into_iter().rev().find(|checkpoint| { + checkpoint.turn_id == *turn_id + && checkpoint.backend.as_deref() == Some("git_ghost_commit") + && checkpoint.workspace_root.is_some() + }) + }), + Ok(Err(error)) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to read workspace checkpoints: {error}"), + ); + } + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("workspace checkpoint read task failed: {error}"), + ); + } + } + } + None => None, + }; + let (workspace_version, affected_files) = match checkpoint.as_ref() { + Some(checkpoint) => { + let workspace_root = PathBuf::from( + checkpoint + .workspace_root + .as_deref() + .expect("Git checkpoint has workspace root"), + ); + match crate::workspace_changes::preview_git_rollback( + workspace_root, + checkpoint.checkpoint_id.clone(), + ) + .await + { + Ok(preview) => (preview.workspace_version, preview.affected_files), + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to preview workspace rollback: {error}"), + ); + } + } + } + None => (HISTORY_ONLY_WORKSPACE_VERSION.to_string(), Vec::new()), + }; + let restore_plan_id = RestorePlanId::new(); + let public_plan = RestorePlan { + restore_plan_id: restore_plan_id.clone(), + affected_files, + dropped_turn_count: u32::try_from(dropped_turn_ids.len()).unwrap_or(u32::MAX), + workspace_version, + }; + let mut plans = self.restore_plans.lock().await; + let now = Utc::now(); + plans.retain(|_, plan| { + matches!( + &plan.status, + RestorePlanStatus::InFlight + | RestorePlanStatus::WorkspaceRestoreRetry { .. } + | RestorePlanStatus::WorkspaceCompletionPending { .. } + | RestorePlanStatus::HistoryPending { .. } + ) || plan.expires_at + RESTORE_PLAN_TOMBSTONE_TTL > now + }); + if plans.len() >= RESTORE_PLAN_STORE_LIMIT { + return self.error_response( + request_id, + ProtocolErrorCode::InternalError, + "restore plan store is full; retry after an existing plan expires", + ); + } + plans.insert( + restore_plan_id.to_string(), + StoredRestorePlan { + connection_id, + owner_disconnected: false, + session_id, + user_turn_index: params.user_turn_index, + rollback_mode, + history_fingerprint: history_fingerprint(&source.persisted_turn_items), + checkpoint, + public_plan: public_plan.clone(), + expires_at: now + RESTORE_PLAN_TTL, + status: RestorePlanStatus::Ready, + notify: Arc::new(Notify::new()), + }, + ); + serde_json::to_value(SuccessResponse { + id: request_id, + result: public_plan, + }) + .expect("serialize session/rollback/preview response") + } + + pub(crate) async fn handle_session_rollback_commit( + self: &Arc, + connection_id: u64, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: SessionRollbackCommitParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid session/rollback/commit params: {error}"), + ); + } + }; + let (plan, action) = loop { + let mut plans = self.restore_plans.lock().await; + let Some(stored) = plans.get_mut(params.restore_plan_id.as_str()) else { + return self.error_response( + request_id, + ProtocolErrorCode::RestorePlanNotFound, + "restore plan does not exist", + ); + }; + if stored.connection_id != connection_id { + return self.error_response( + request_id, + ProtocolErrorCode::RestorePlanNotFound, + "restore plan does not exist", + ); + } + if Utc::now() > stored.expires_at + && matches!( + &stored.status, + RestorePlanStatus::Ready | RestorePlanStatus::Completed(_) + ) + { + return self.error_response( + request_id, + ProtocolErrorCode::RestorePlanExpired, + "restore plan has expired", + ); + } + if params.expected_workspace_version != stored.public_plan.workspace_version { + return self.error_response( + request_id, + ProtocolErrorCode::WorkspaceVersionConflict, + "expected workspace version does not match the restore plan", + ); + } + let action = match &stored.status { + RestorePlanStatus::Ready => CommitAction::Full, + RestorePlanStatus::WorkspaceRestoreRetry { + expected_workspace_version, + } => CommitAction::WorkspaceRestoreRetry { + expected_workspace_version: expected_workspace_version.clone(), + }, + RestorePlanStatus::WorkspaceCompletionPending { + completed, + restored_file_count, + } => CommitAction::WorkspaceCompletionPending { + completed: completed.clone(), + restored_file_count: *restored_file_count, + }, + RestorePlanStatus::HistoryPending { + restored_file_count, + } => CommitAction::HistoryPending { + restored_file_count: *restored_file_count, + }, + RestorePlanStatus::Completed(result) => { + return rollback_commit_response(request_id, result.clone()); + } + RestorePlanStatus::InFlight => { + let notified = Arc::clone(&stored.notify).notified_owned(); + drop(plans); + notified.await; + continue; + } + }; + stored.status = RestorePlanStatus::InFlight; + let plan = stored.clone(); + drop(plans); + break (plan, action); + }; + + let runtime = Arc::clone(self); + let plan_id = params.restore_plan_id.to_string(); + let failed_task_plan_id = plan_id.clone(); + let attempt_request_id = request_id.clone(); + let attempt_action = action.clone(); + let attempt = tokio::spawn(async move { + let attempt = runtime + .execute_rollback_commit_attempt(attempt_request_id, &plan, attempt_action) + .await; + let mut plans = runtime.restore_plans.lock().await; + let disconnected_recovery = if plans + .get(&plan_id) + .is_some_and(|stored| stored.owner_disconnected) + { + let recovery = attempt + .as_ref() + .err() + .and_then(|failure| recovery_action(&failure.next_status)); + if let Some(stored) = plans.remove(&plan_id) { + stored.notify.notify_waiters(); + } + recovery + } else { + if let Some(stored) = plans.get_mut(&plan_id) { + stored.status = match &attempt { + Ok(result) => RestorePlanStatus::Completed(result.clone()), + Err(failure) => failure.next_status.clone(), + }; + stored.notify.notify_waiters(); + } + None + }; + drop(plans); + if let Some(recovery_action) = disconnected_recovery { + runtime + .reconcile_disconnected_restore_plan(plan.clone(), recovery_action) + .await; + } + attempt + }) + .await; + match attempt { + Ok(Ok(result)) => rollback_commit_response(request_id, result), + Ok(Err(failure)) => failure.response, + Err(error) => { + let mut plans = self.restore_plans.lock().await; + if plans + .get(&failed_task_plan_id) + .is_some_and(|stored| stored.owner_disconnected) + { + if let Some(stored) = plans.remove(&failed_task_plan_id) { + stored.notify.notify_waiters(); + } + } else if let Some(stored) = plans.get_mut(&failed_task_plan_id) { + stored.status = status_for_retry(&action); + stored.notify.notify_waiters(); + } + self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("rollback commit task failed: {error}"), + ) + } + } + } + + async fn reconcile_disconnected_restore_plan( + &self, + plan: StoredRestorePlan, + action: CommitAction, + ) { + tracing::warn!( + restore_plan_id = %plan.public_plan.restore_plan_id, + session_id = %plan.session_id, + "restore plan owner disconnected; attempting one background recovery" + ); + if let Err(recovery_failure) = self + .execute_rollback_commit_attempt(serde_json::Value::Null, &plan, action) + .await + { + tracing::error!( + restore_plan_id = %plan.public_plan.restore_plan_id, + session_id = %plan.session_id, + response = %recovery_failure.response, + "disconnected restore plan recovery did not complete" + ); + } + } + + async fn execute_rollback_commit_attempt( + &self, + request_id: serde_json::Value, + plan: &StoredRestorePlan, + action: CommitAction, + ) -> Result { + let retry_status = status_for_retry(&action); + if self.runtime_active_turn_id(plan.session_id).await.is_some() { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot commit rollback while a turn is active", + ), + next_status: retry_status.clone(), + }); + } + let Some(session_handle) = self.session(plan.session_id).await else { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ), + next_status: retry_status, + }); + }; + let _state_change_guard = session_handle.lock_state_change().await; + if self.runtime_active_turn_id(plan.session_id).await.is_some() { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot commit rollback while a turn is active", + ), + next_status: retry_status, + }); + } + let Some(source) = session_handle.export_runtime_session().await else { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ), + next_status: retry_status, + }); + }; + if history_fingerprint(&source.persisted_turn_items) != plan.history_fingerprint { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::WorkspaceVersionConflict, + "session history changed after rollback preview", + ), + next_status: retry_status, + }); + } + if matches!(action, CommitAction::Full) + && let Some(checkpoint) = plan.checkpoint.as_ref() + { + let workspace_root = PathBuf::from( + checkpoint + .workspace_root + .as_deref() + .expect("Git checkpoint has workspace root"), + ); + let workspace_matches = crate::workspace_changes::git_workspace_matches_version( + workspace_root, + plan.public_plan.workspace_version.clone(), + ) + .await; + match workspace_matches { + Ok(true) => {} + Ok(false) => { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::WorkspaceVersionConflict, + "workspace changed after rollback preview", + ), + next_status: RestorePlanStatus::Ready, + }); + } + Err(error) => { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to validate workspace version: {error}"), + ), + next_status: RestorePlanStatus::Ready, + }); + } + } + } + if let CommitAction::WorkspaceRestoreRetry { + expected_workspace_version, + } = &action + && let Some(checkpoint) = plan.checkpoint.as_ref() + { + let workspace_root = PathBuf::from( + checkpoint + .workspace_root + .as_deref() + .expect("Git checkpoint has workspace root"), + ); + match crate::workspace_changes::git_workspace_matches_version( + workspace_root, + expected_workspace_version.clone(), + ) + .await + { + Ok(true) => {} + Ok(false) => { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::WorkspaceVersionConflict, + "workspace changed after the failed restore attempt", + ), + next_status: retry_status, + }); + } + Err(error) => { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to validate recovery workspace version: {error}"), + ), + next_status: retry_status, + }); + } + } + } + let mut rebuilt = self + .build_runtime_session_from_user_turn_cut( + &source, + RuntimeSessionTurnCutOptions { + session_id: plan.session_id, + user_turn_index: Some(plan.user_turn_index), + rollback_mode: plan.rollback_mode.clone(), + cwd_override: None, + title_override: source.summary.title.clone(), + created_at: source.summary.created_at, + }, + ) + .await + .map_err(|message| CommitAttemptFailure { + response: self.error_response( + request_id.clone(), + ProtocolErrorCode::InvalidParams, + message, + ), + next_status: retry_status.clone(), + })?; + let record = source.record.clone(); + let restored_file_count = match action { + CommitAction::HistoryPending { + restored_file_count, + } => restored_file_count, + CommitAction::WorkspaceCompletionPending { + completed, + restored_file_count, + } => { + let checkpoint = plan + .checkpoint + .as_ref() + .expect("workspace completion retry has checkpoint"); + self.persist_workspace_restore_completion( + &request_id, + plan.public_plan.restore_plan_id.as_str(), + checkpoint.turn_id, + &completed, + record.as_ref(), + ) + .await + .map_err(|response| CommitAttemptFailure { + response, + next_status: RestorePlanStatus::WorkspaceCompletionPending { + completed, + restored_file_count, + }, + })?; + restored_file_count + } + CommitAction::Full | CommitAction::WorkspaceRestoreRetry { .. } => { + if let Some(checkpoint) = plan.checkpoint.as_ref() { + self.commit_workspace_restore( + &request_id, + plan.public_plan.restore_plan_id.as_str(), + checkpoint, + &plan.public_plan.affected_files, + record.as_ref(), + ) + .await + .map_err(|failure| CommitAttemptFailure { + response: failure.response, + next_status: if let Some((completed, restored_file_count)) = + failure.completion_pending + { + RestorePlanStatus::WorkspaceCompletionPending { + completed, + restored_file_count, + } + } else if let Some(expected_workspace_version) = + failure.retry_workspace_version + { + RestorePlanStatus::WorkspaceRestoreRetry { + expected_workspace_version, + } + } else { + retry_status + }, + })? + } else { + 0 + } + } + }; + let (retained_turn_ids, retained_item_ids) = retained_ids(&rebuilt.persisted_turn_items); + let latest_turn_id = rebuilt.latest_turn.as_ref().map(|turn| turn.turn_id); + if let Some(record) = record.as_ref() + && let Err(error) = self.rollout_store.append_session_rollback( + record, + retained_turn_ids, + retained_item_ids, + latest_turn_id, + ) + { + return Err(CommitAttemptFailure { + response: self.error_response( + request_id, + ProtocolErrorCode::InternalError, + format!("failed to persist session rollback: {error}"), + ), + next_status: RestorePlanStatus::HistoryPending { + restored_file_count, + }, + }); + } + rebuilt.record = record; + session_handle + .replace_state(SessionActorState::from_runtime_session(rebuilt)) + .await; + Ok(SessionRollbackCommitResult { + restored_turn_count: plan.public_plan.dropped_turn_count, + restored_file_count, + }) + } +} diff --git a/crates/server/src/runtime/handlers/rollback_plan.rs b/crates/server/src/runtime/handlers/rollback_plan.rs new file mode 100644 index 00000000..89f55d3a --- /dev/null +++ b/crates/server/src/runtime/handlers/rollback_plan.rs @@ -0,0 +1,182 @@ +//! In-memory state and pure helpers for P4d restore plans. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use chrono::{DateTime, Duration, Utc}; +use devo_core::TurnWorkspaceRestoreCompletedRecord; +use devo_core::{ItemId, SessionId, TurnWorkspaceCheckpointRecordedRecord}; +use devo_protocol::canonical::rpc_session::{ + RestorePlan, RollbackMode, SessionRollbackCommitResult, +}; +use tokio::sync::Notify; + +use super::super::*; + +pub(super) const RESTORE_PLAN_TTL: Duration = Duration::minutes(10); +pub(super) const RESTORE_PLAN_TOMBSTONE_TTL: Duration = Duration::minutes(10); +pub(super) const RESTORE_PLAN_STORE_LIMIT: usize = 1024; +pub(super) const HISTORY_ONLY_WORKSPACE_VERSION: &str = "history-only"; + +pub(crate) type RestorePlanStore = HashMap; + +#[derive(Debug, Clone)] +pub(crate) struct StoredRestorePlan { + pub(super) connection_id: u64, + pub(super) owner_disconnected: bool, + pub(super) session_id: SessionId, + pub(super) user_turn_index: u32, + pub(super) rollback_mode: SessionRollbackMode, + pub(super) history_fingerprint: Vec<(TurnId, ItemId)>, + pub(super) checkpoint: Option, + pub(super) public_plan: RestorePlan, + pub(super) expires_at: DateTime, + pub(super) status: RestorePlanStatus, + pub(super) notify: Arc, +} + +#[derive(Debug, Clone)] +pub(super) enum RestorePlanStatus { + Ready, + InFlight, + // A restore may have changed files before an error was reported. These + // recovery phases deliberately outlive the normal plan TTL: retry must + // finish the same checkpoint restore/history cut instead of comparing + // against the now-obsolete preview worktree. + WorkspaceRestoreRetry { + expected_workspace_version: String, + }, + WorkspaceCompletionPending { + completed: TurnWorkspaceRestoreCompletedRecord, + restored_file_count: u32, + }, + HistoryPending { + restored_file_count: u32, + }, + Completed(SessionRollbackCommitResult), +} + +#[derive(Debug, Clone)] +pub(super) enum CommitAction { + Full, + WorkspaceRestoreRetry { + expected_workspace_version: String, + }, + WorkspaceCompletionPending { + completed: TurnWorkspaceRestoreCompletedRecord, + restored_file_count: u32, + }, + HistoryPending { + restored_file_count: u32, + }, +} + +pub(super) struct CommitAttemptFailure { + pub response: serde_json::Value, + pub next_status: RestorePlanStatus, +} + +pub(super) fn legacy_rollback_mode(mode: RollbackMode) -> SessionRollbackMode { + match mode { + RollbackMode::ThroughUserTurn => SessionRollbackMode::ThroughUserTurn, + RollbackMode::BeforeUserTurn => SessionRollbackMode::BeforeUserTurn, + } +} + +pub(super) fn status_for_retry(action: &CommitAction) -> RestorePlanStatus { + match action { + CommitAction::Full => RestorePlanStatus::Ready, + CommitAction::WorkspaceRestoreRetry { + expected_workspace_version, + } => RestorePlanStatus::WorkspaceRestoreRetry { + expected_workspace_version: expected_workspace_version.clone(), + }, + CommitAction::WorkspaceCompletionPending { + completed, + restored_file_count, + } => RestorePlanStatus::WorkspaceCompletionPending { + completed: completed.clone(), + restored_file_count: *restored_file_count, + }, + CommitAction::HistoryPending { + restored_file_count, + } => RestorePlanStatus::HistoryPending { + restored_file_count: *restored_file_count, + }, + } +} + +pub(super) fn recovery_action(status: &RestorePlanStatus) -> Option { + match status { + RestorePlanStatus::WorkspaceRestoreRetry { + expected_workspace_version, + } => Some(CommitAction::WorkspaceRestoreRetry { + expected_workspace_version: expected_workspace_version.clone(), + }), + RestorePlanStatus::WorkspaceCompletionPending { + completed, + restored_file_count, + } => Some(CommitAction::WorkspaceCompletionPending { + completed: completed.clone(), + restored_file_count: *restored_file_count, + }), + RestorePlanStatus::HistoryPending { + restored_file_count, + } => Some(CommitAction::HistoryPending { + restored_file_count: *restored_file_count, + }), + RestorePlanStatus::Ready + | RestorePlanStatus::InFlight + | RestorePlanStatus::Completed(_) => None, + } +} + +pub(super) fn history_fingerprint( + items: &[crate::execution::PersistedTurnItem], +) -> Vec<(TurnId, ItemId)> { + items + .iter() + .map(|item| (item.turn_id, item.item_id)) + .collect() +} + +pub(super) fn dropped_turn_ids( + source: &[crate::execution::PersistedTurnItem], + retained: &[crate::execution::PersistedTurnItem], +) -> Vec { + let retained_ids: HashSet = retained.iter().map(|item| item.turn_id).collect(); + let mut dropped = Vec::new(); + for item in source { + if !retained_ids.contains(&item.turn_id) && !dropped.contains(&item.turn_id) { + dropped.push(item.turn_id); + } + } + dropped +} + +pub(super) fn retained_ids( + items: &[crate::execution::PersistedTurnItem], +) -> (Vec, Vec) { + let mut turn_ids = Vec::new(); + let mut item_ids = Vec::new(); + for item in items { + if !turn_ids.contains(&item.turn_id) { + turn_ids.push(item.turn_id); + } + if !item_ids.contains(&item.item_id) { + item_ids.push(item.item_id); + } + } + (turn_ids, item_ids) +} + +pub(super) fn rollback_commit_response( + request_id: serde_json::Value, + result: SessionRollbackCommitResult, +) -> serde_json::Value { + serde_json::to_value(SuccessResponse { + id: request_id, + result, + }) + .expect("serialize session/rollback/commit response") +} diff --git a/crates/server/src/runtime/handlers/rollback_workspace.rs b/crates/server/src/runtime/handlers/rollback_workspace.rs new file mode 100644 index 00000000..7e0d189f --- /dev/null +++ b/crates/server/src/runtime/handlers/rollback_workspace.rs @@ -0,0 +1,263 @@ +//! Git workspace side effects for two-phase session rollback. + +use std::collections::HashSet; +use std::path::PathBuf; + +use chrono::Utc; +use devo_core::{ + FileRestoreOutcome, RestoreFileStatus, RestoreId, SessionId, + TurnWorkspaceCheckpointRecordedRecord, TurnWorkspaceRestoreCompletedRecord, + TurnWorkspaceRestoreStartedRecord, WorkspaceRestorePolicy, +}; + +use super::super::*; + +pub(super) struct WorkspaceRestoreFailure { + pub response: serde_json::Value, + pub retry_workspace_version: Option, + pub completion_pending: Option<(TurnWorkspaceRestoreCompletedRecord, u32)>, +} + +impl ServerRuntime { + pub(super) async fn commit_workspace_restore( + &self, + request_id: &serde_json::Value, + restore_plan_id: &str, + checkpoint: &TurnWorkspaceCheckpointRecordedRecord, + affected_files: &[PathBuf], + record: Option<&devo_core::SessionRecord>, + ) -> Result { + let workspace_root = PathBuf::from( + checkpoint + .workspace_root + .as_deref() + .expect("Git checkpoint has workspace root"), + ); + let restore_id = RestoreId::new(); + let started = TurnWorkspaceRestoreStartedRecord { + schema_version: 1, + session_id: checkpoint.session_id, + turn_id: checkpoint.turn_id, + restore_id, + candidate_files: affected_files + .iter() + .map(|path| path.display().to_string()) + .collect(), + policy: WorkspaceRestorePolicy::Safe, + started_at: Utc::now(), + }; + if let Some(record) = record + && let Err(error) = self + .rollout_store + .append_workspace_restore_started(record, started.clone()) + { + return Err(WorkspaceRestoreFailure { + response: self.error_response( + request_id.clone(), + ProtocolErrorCode::WorkspaceRestoreFailedToStart, + format!("failed to persist workspace restore start: {error}"), + ), + retry_workspace_version: None, + completion_pending: None, + }); + } + self.broadcast_event(ServerEvent::WorkspaceRestoreStarted( + super::message_edit_restore::restore_started_payload(&started, restore_plan_id), + )) + .await; + if let Err(error) = crate::workspace_changes::restore_git_checkpoint( + workspace_root.clone(), + checkpoint.clone(), + ) + .await + { + let completed = restore_completed_record( + checkpoint.session_id, + started.restore_id, + affected_files, + &HashSet::new(), + RestoreFileStatus::Failed, + ); + if let Some(record) = record { + let _ = self + .rollout_store + .append_workspace_restore_completed(record, completed.clone()); + } + self.broadcast_event(ServerEvent::WorkspaceRestoreCompleted( + super::message_edit_restore::restore_completed_payload( + &completed, + restore_plan_id, + checkpoint.turn_id, + ), + )) + .await; + let retry_workspace_version = + crate::workspace_changes::current_git_workspace_version(workspace_root).await.ok(); + return Err(WorkspaceRestoreFailure { + response: self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to restore workspace checkpoint: {error}"), + ), + retry_workspace_version, + completion_pending: None, + }); + } + let remaining = match crate::workspace_changes::preview_git_rollback( + workspace_root, + checkpoint.checkpoint_id.clone(), + ) + .await + { + Ok(preview) => preview.affected_files.into_iter().collect::>(), + Err(error) => { + let completed = restore_completed_record( + checkpoint.session_id, + started.restore_id, + affected_files, + &HashSet::new(), + RestoreFileStatus::Failed, + ); + if let Some(record) = record { + let _ = self + .rollout_store + .append_workspace_restore_completed(record, completed.clone()); + } + self.broadcast_event(ServerEvent::WorkspaceRestoreCompleted( + super::message_edit_restore::restore_completed_payload( + &completed, + restore_plan_id, + checkpoint.turn_id, + ), + )) + .await; + return Err(WorkspaceRestoreFailure { + response: self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to verify workspace restore: {error}"), + ), + retry_workspace_version: crate::workspace_changes::current_git_workspace_version( + PathBuf::from( + checkpoint + .workspace_root + .as_deref() + .expect("Git checkpoint has workspace root"), + ), + ) + .await + .ok(), + completion_pending: None, + }); + } + }; + let completed = restore_completed_record( + checkpoint.session_id, + started.restore_id, + affected_files, + &remaining, + RestoreFileStatus::Restored, + ); + let restored_file_count = restored_file_count(&completed); + if let Some(record) = record + && let Err(error) = self + .rollout_store + .append_workspace_restore_completed(record, completed.clone()) + { + let retry_workspace_version = + crate::workspace_changes::current_git_workspace_version(PathBuf::from( + checkpoint + .workspace_root + .as_deref() + .expect("Git checkpoint has workspace root"), + )) + .await + .ok(); + return Err(WorkspaceRestoreFailure { + response: self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to persist workspace restore completion: {error}"), + ), + retry_workspace_version, + completion_pending: Some((completed, restored_file_count)), + }); + } + self.broadcast_event(ServerEvent::WorkspaceRestoreCompleted( + super::message_edit_restore::restore_completed_payload( + &completed, + restore_plan_id, + checkpoint.turn_id, + ), + )) + .await; + Ok(restored_file_count) + } + + pub(super) async fn persist_workspace_restore_completion( + &self, + request_id: &serde_json::Value, + restore_plan_id: &str, + checkpoint_turn_id: devo_core::TurnId, + completed: &TurnWorkspaceRestoreCompletedRecord, + record: Option<&devo_core::SessionRecord>, + ) -> Result<(), serde_json::Value> { + if let Some(record) = record + && let Err(error) = self + .rollout_store + .append_workspace_restore_completed(record, completed.clone()) + { + return Err(self.error_response( + request_id.clone(), + ProtocolErrorCode::InternalError, + format!("failed to persist workspace restore completion: {error}"), + )); + } + self.broadcast_event(ServerEvent::WorkspaceRestoreCompleted( + super::message_edit_restore::restore_completed_payload( + completed, + restore_plan_id, + checkpoint_turn_id, + ), + )) + .await; + Ok(()) + } +} + +fn restored_file_count(completed: &TurnWorkspaceRestoreCompletedRecord) -> u32 { + u32::try_from( + completed + .outcomes + .iter() + .filter(|outcome| outcome.status == RestoreFileStatus::Restored) + .count(), + ) + .unwrap_or(u32::MAX) +} + +fn restore_completed_record( + session_id: SessionId, + restore_id: RestoreId, + affected_files: &[PathBuf], + remaining: &HashSet, + default_status: RestoreFileStatus, +) -> TurnWorkspaceRestoreCompletedRecord { + TurnWorkspaceRestoreCompletedRecord { + schema_version: 1, + session_id, + restore_id, + outcomes: affected_files + .iter() + .map(|path| FileRestoreOutcome { + file_path: path.display().to_string(), + status: if remaining.contains(path) { + RestoreFileStatus::Skipped + } else { + default_status + }, + }) + .collect(), + completed_at: Utc::now(), + } +} diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 0a0630dd..8434ea63 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -1,12 +1,12 @@ use super::super::*; pub(crate) struct RuntimeSessionTurnCutOptions { - session_id: SessionId, - user_turn_index: Option, - rollback_mode: SessionRollbackMode, - cwd_override: Option, - title_override: Option, - created_at: chrono::DateTime, + pub(crate) session_id: SessionId, + pub(crate) user_turn_index: Option, + pub(crate) rollback_mode: SessionRollbackMode, + pub(crate) cwd_override: Option, + pub(crate) title_override: Option, + pub(crate) created_at: chrono::DateTime, } pub(crate) enum RuntimeSessionToolRegistryUpdate { @@ -240,6 +240,7 @@ impl ServerRuntime { "session does not exist", ); }; + let _state_change_guard = session_handle.lock_state_change().await; let Some(mut updated_session) = session_handle .update_session_metadata( params.model.clone(), @@ -307,6 +308,7 @@ impl ServerRuntime { "session does not exist", ); }; + let _state_change_guard = session_handle.lock_state_change().await; let Some(summary) = session_handle.summary().await else { return self.error_response( request_id, @@ -363,6 +365,7 @@ impl ServerRuntime { "session does not exist", ); }; + let _state_change_guard = session_handle.lock_state_change().await; let applied = match session_handle .apply_sandbox_profile(params.profile.clone()) .await @@ -427,6 +430,7 @@ impl ServerRuntime { "session does not exist", ); }; + let _state_change_guard = session_handle.lock_state_change().await; let previous_title = session_handle .summary() @@ -545,6 +549,7 @@ impl ServerRuntime { ); } }; + let _state_change_guard = session_handle.lock_state_change().await; match tool_registry_update { RuntimeSessionToolRegistryUpdate::KeepCurrent => {} RuntimeSessionToolRegistryUpdate::ReplaceIfCwdMatches { cwd, tool_registry } => { @@ -751,6 +756,29 @@ impl ServerRuntime { "session does not exist", ); }; + if self + .runtime_active_turn_id(params.session_id) + .await + .is_some() + { + return self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot rollback while a turn is active", + ); + } + let _state_change_guard = session_handle.lock_state_change().await; + if self + .runtime_active_turn_id(params.session_id) + .await + .is_some() + { + return self.error_response( + request_id, + ProtocolErrorCode::TurnAlreadyRunning, + "cannot rollback while a turn is active", + ); + } let Some(source) = session_handle.export_runtime_session().await else { return self.error_response( request_id, @@ -854,7 +882,9 @@ impl ServerRuntime { cwd.clone(), additional_directories.clone(), ); + core_session.config = source_core_session.config.clone(); core_session.session_context = source_core_session.session_context.clone(); + core_session.collaboration_mode = source_core_session.collaboration_mode; core_session.latest_turn_context = None; core_session.total_input_tokens = source_core_session.total_input_tokens; core_session.total_output_tokens = source_core_session.total_output_tokens; @@ -975,9 +1005,11 @@ impl ServerRuntime { }; drop(source_core_session); + core_session.pending_turn_queue = Arc::clone(&source.pending_turn_queue); + core_session.steer_input_queue = Arc::clone(&source.steer_input_queue); let config = core_session.config.clone(); - let pending_turn_queue = Arc::clone(&core_session.pending_turn_queue); - let steer_input_queue = Arc::clone(&core_session.steer_input_queue); + let pending_turn_queue = Arc::clone(&source.pending_turn_queue); + let steer_input_queue = Arc::clone(&source.steer_input_queue); Ok(RuntimeSession { runtime_context, record: None, @@ -992,17 +1024,17 @@ impl ServerRuntime { latest_compaction_snapshot: None, pending_turn_queue, steer_input_queue, - agent_tool_policy: Default::default(), - max_turns: None, + agent_tool_policy: source.agent_tool_policy, + max_turns: source.max_turns, deferred_assistant: None, deferred_reasoning: None, next_item_seq: u64::try_from(source.persisted_turn_items.len().saturating_add(1)) .unwrap_or(u64::MAX), first_user_input: source.first_user_input.clone(), tool_registry: source.tool_registry.clone(), - file_read_ledger: std::sync::Arc::new(devo_core::tools::FileReadLedger::new()), - session_approval_cache: crate::execution::ApprovalGrantCache::default(), - turn_approval_cache: crate::execution::ApprovalGrantCache::default(), + file_read_ledger: Arc::clone(&source.file_read_ledger), + session_approval_cache: source.session_approval_cache.clone(), + turn_approval_cache: source.turn_approval_cache.clone(), session_context_recorded: source.session_context_recorded, }) } diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index f8e7dca7..f780c349 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -83,6 +83,7 @@ impl ServerRuntime { "session does not exist", ); }; + let state_change_guard = session_handle.lock_state_change().await; let Some(reservation) = self .session_turn_reservation_snapshot(params.session_id) .await @@ -320,6 +321,7 @@ impl ServerRuntime { session_handle .begin_active_turn(turn.clone(), turn_config.clone()) .await; + drop(state_change_guard); if let Some((old_cwd, new_cwd)) = cwd_change { self.run_session_hook( params.session_id, @@ -450,6 +452,7 @@ impl ServerRuntime { "session does not exist", ); }; + let _state_change_guard = session_handle.lock_state_change().await; let requested_runtime_context = match params.cwd.as_ref() { Some(cwd) => match self.deps.context_for_workspace(cwd).await { diff --git a/crates/server/src/runtime/items.rs b/crates/server/src/runtime/items.rs index 28e0c5f6..0d0e1e76 100644 --- a/crates/server/src/runtime/items.rs +++ b/crates/server/src/runtime/items.rs @@ -40,6 +40,7 @@ impl ServerRuntime { let Some(session_handle) = self.session(session_id).await else { return; }; + let _state_change_guard = session_handle.lock_state_change().await; let _ = session_handle .set_first_user_input_if_unset(user_input.to_string()) .await; @@ -131,6 +132,7 @@ impl ServerRuntime { let Some(session_handle) = self.session(session_id).await else { return; }; + let state_change_guard = session_handle.lock_state_change().await; let Some(title_context) = session_handle.title_generation_context().await else { return; }; @@ -165,6 +167,7 @@ impl ServerRuntime { self.persist_session_summary_if_persistent(session_id, &updated_summary) .await; + drop(state_change_guard); self.broadcast_event(ServerEvent::SessionTitleUpdated(SessionEventPayload { session: updated_summary, @@ -263,6 +266,7 @@ impl ServerRuntime { let Some(session_handle) = self.session(session_id).await else { return; }; + let state_change_guard = session_handle.lock_state_change().await; let Some(updated_summary) = session_handle .update_title( generated_title.clone(), @@ -286,6 +290,7 @@ impl ServerRuntime { self.persist_session_summary_if_persistent(session_id, &updated_summary) .await; + drop(state_change_guard); self.broadcast_event(ServerEvent::SessionTitleUpdated(SessionEventPayload { session: updated_summary, diff --git a/crates/server/src/runtime/session_actor/handle.rs b/crates/server/src/runtime/session_actor/handle.rs index 90ea078d..1d0234f3 100644 --- a/crates/server/src/runtime/session_actor/handle.rs +++ b/crates/server/src/runtime/session_actor/handle.rs @@ -37,6 +37,7 @@ pub(crate) struct SessionHandle { session_id: SessionId, tx: mpsc::Sender, max_turns: Option, + state_change_gate: Arc>, } impl SessionHandle { @@ -65,6 +66,7 @@ impl SessionHandle { session_id, tx, max_turns, + state_change_gate: Arc::new(tokio::sync::Mutex::new(())), }; tokio::spawn(super::actor_loop::run_session_actor(state, rx, runtime)); handle @@ -74,6 +76,12 @@ impl SessionHandle { self.tx.send(command).await.is_ok() } + /// Serializes idle-session state changes that must not overlap turn + /// admission, such as two-phase rollback commit and message edit. + pub(crate) async fn lock_state_change(&self) -> tokio::sync::OwnedMutexGuard<()> { + Arc::clone(&self.state_change_gate).lock_owned().await + } + /// Non-blocking enqueue. Used by turn event streams so they never park on a /// session actor that is itself waiting for that stream to finish. fn try_send(&self, command: SessionCommand) -> bool { diff --git a/crates/server/src/runtime/turn_exec/followup.rs b/crates/server/src/runtime/turn_exec/followup.rs index 0f33212e..fc24eef0 100644 --- a/crates/server/src/runtime/turn_exec/followup.rs +++ b/crates/server/src/runtime/turn_exec/followup.rs @@ -14,6 +14,10 @@ impl ServerRuntime { self: &Arc, session_id: SessionId, ) -> bool { + let Some(session_handle) = self.session(session_id).await else { + return false; + }; + let state_change_guard = session_handle.lock_state_change().await; let Some(queued) = self .pop_next_queued_turn_input(session_id, /*require_idle_session*/ false) .await @@ -31,6 +35,7 @@ impl ServerRuntime { } self.activate_queued_turn(session_id, &turn, &turn_config) .await; + drop(state_change_guard); self.broadcast_event(crate::ServerEvent::TurnStarted(TurnEventPayload { session_id, turn: turn.clone(), @@ -71,6 +76,10 @@ impl ServerRuntime { self: &Arc, session_id: SessionId, ) -> bool { + let Some(session_handle) = self.session(session_id).await else { + return false; + }; + let state_change_guard = session_handle.lock_state_change().await; let Some(queued) = self .pop_next_queued_turn_input(session_id, /*require_idle_session*/ false) .await @@ -88,6 +97,7 @@ impl ServerRuntime { } self.activate_queued_turn(session_id, &turn, &turn_config) .await; + drop(state_change_guard); self.broadcast_event(crate::ServerEvent::TurnStarted(TurnEventPayload { session_id, turn: turn.clone(), diff --git a/crates/server/src/workspace_changes/fs_snapshot.rs b/crates/server/src/workspace_changes/fs_snapshot.rs index 197fd373..f96b4423 100644 --- a/crates/server/src/workspace_changes/fs_snapshot.rs +++ b/crates/server/src/workspace_changes/fs_snapshot.rs @@ -99,6 +99,8 @@ pub(crate) fn capture_file_baseline( coverage, warnings: baseline.warnings.clone(), artifact_ref: Some(artifact_ref), + preexisting_untracked_files: None, + preexisting_untracked_dirs: None, }), baseline: ActiveWorkspaceBaseline::File(baseline), }) diff --git a/crates/server/src/workspace_changes/git.rs b/crates/server/src/workspace_changes/git.rs index 9b28caf8..c436cfb7 100644 --- a/crates/server/src/workspace_changes/git.rs +++ b/crates/server/src/workspace_changes/git.rs @@ -3,6 +3,7 @@ use std::process::Output; use anyhow::{Context, Result}; use devo_core::ChangeSetCoverage; +use devo_core::TurnWorkspaceCheckpointRecordedRecord; use devo_protocol::{ SessionId, TurnId, WorkspaceChangeAttribution, WorkspaceChangeBase, WorkspaceChangeCoverage, WorkspaceChangeScope, WorkspaceChangeSetStatus, WorkspaceChangeView, @@ -10,7 +11,8 @@ use devo_protocol::{ }; use devo_util_git::{ CreateGhostCommitOptions, GhostCommit, GhostSnapshotReport, create_ghost_commit_with_report, - default_branch_name, diff_ghost_commits, get_git_repo_root, merge_base_with_head, + default_branch_name, diff_ghost_commits, extract_paths_from_patch, get_git_repo_root, + merge_base_with_head, restore_ghost_commit, restore_to_commit, }; use tokio::process::Command; @@ -28,6 +30,122 @@ pub(crate) struct GitWorkspaceBaseline { warnings: Vec, } +/// Rebuilds the restore-capable ghost snapshot from a durable checkpoint. +/// +/// Checkpoints written before P4d have no untracked-path manifest. Returning +/// `None` for them makes callers use tracked-file-only restore rather than +/// guessing which user files may safely be deleted. +pub(crate) fn ghost_commit_from_checkpoint( + checkpoint: &TurnWorkspaceCheckpointRecordedRecord, +) -> Option { + let files = checkpoint.preexisting_untracked_files.as_ref()?; + let dirs = checkpoint.preexisting_untracked_dirs.as_ref()?; + Some(GhostCommit::new( + checkpoint.checkpoint_id.clone(), + /*parent*/ None, + files.iter().map(PathBuf::from).collect(), + dirs.iter().map(PathBuf::from).collect(), + )) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GitRollbackPreview { + pub workspace_version: String, + pub affected_files: Vec, +} + +pub(crate) fn preview_git_rollback( + workspace_root: &Path, + checkpoint_id: &str, +) -> Result { + let (current, _) = create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(workspace_root) + .message("devo rollback preview") + .ignore_large_untracked_files(10 * 1024 * 1024), + ) + .with_context(|| { + format!( + "capture rollback workspace version at {}", + workspace_root.display() + ) + })?; + let checkpoint = GhostCommit::new( + checkpoint_id.to_string(), + /*parent*/ None, + Vec::new(), + Vec::new(), + ); + let diff = diff_ghost_commits(workspace_root, &checkpoint, ¤t) + .with_context(|| format!("diff rollback checkpoint {checkpoint_id}"))?; + Ok(GitRollbackPreview { + workspace_version: current.id().to_string(), + affected_files: extract_paths_from_patch(&diff) + .into_iter() + .map(PathBuf::from) + .collect(), + }) +} + +pub(crate) fn git_workspace_matches_version( + workspace_root: &Path, + workspace_version: &str, +) -> Result { + let (current, _) = create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(workspace_root) + .message("devo rollback commit validation") + .ignore_large_untracked_files(10 * 1024 * 1024), + ) + .with_context(|| { + format!( + "capture rollback workspace version at {}", + workspace_root.display() + ) + })?; + let preview = GhostCommit::new( + workspace_version.to_string(), + /*parent*/ None, + Vec::new(), + Vec::new(), + ); + diff_ghost_commits(workspace_root, &preview, ¤t) + .map(|diff| diff.is_empty()) + .with_context(|| format!("compare rollback workspace version {workspace_version}")) +} + +pub(crate) fn current_git_workspace_version(workspace_root: &Path) -> Result { + create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(workspace_root) + .message("devo rollback recovery checkpoint") + .ignore_large_untracked_files(10 * 1024 * 1024), + ) + .map(|(current, _)| current.id().to_string()) + .with_context(|| { + format!( + "capture rollback recovery version at {}", + workspace_root.display() + ) + }) +} + +pub(crate) fn restore_git_checkpoint( + workspace_root: &Path, + checkpoint: &TurnWorkspaceCheckpointRecordedRecord, +) -> Result<()> { + if let Some(ghost) = ghost_commit_from_checkpoint(checkpoint) { + restore_ghost_commit(workspace_root, &ghost) + .with_context(|| format!("restore git checkpoint {}", checkpoint.checkpoint_id)) + } else { + // Checkpoints written before P4d lack the original untracked manifest. + // Restoring tracked files is safe; deleting untracked files by guess is not. + restore_to_commit(workspace_root, &checkpoint.checkpoint_id).with_context(|| { + format!( + "restore legacy git checkpoint {} (tracked files only)", + checkpoint.checkpoint_id + ) + }) + } +} + pub(crate) fn capture_git_baseline( artifact_dir: &Path, session_id: SessionId, @@ -71,6 +189,22 @@ pub(crate) fn capture_git_baseline( coverage: ChangeSetCoverage::Full, warnings: baseline.warnings.clone(), artifact_ref: Some(artifact_ref), + preexisting_untracked_files: Some( + baseline + .ghost + .preexisting_untracked_files() + .iter() + .map(|path| path.display().to_string()) + .collect(), + ), + preexisting_untracked_dirs: Some( + baseline + .ghost + .preexisting_untracked_dirs() + .iter() + .map(|path| path.display().to_string()) + .collect(), + ), }), baseline: ActiveWorkspaceBaseline::Git(baseline), }) diff --git a/crates/server/src/workspace_changes/mod.rs b/crates/server/src/workspace_changes/mod.rs index db982e69..22a26b83 100644 --- a/crates/server/src/workspace_changes/mod.rs +++ b/crates/server/src/workspace_changes/mod.rs @@ -42,6 +42,41 @@ pub(crate) struct FinalizedWorkspaceChanges { pub record: TurnWorkspaceChangeRecordedRecord, } +pub(crate) async fn preview_git_rollback( + workspace_root: PathBuf, + checkpoint_id: String, +) -> Result { + tokio::task::spawn_blocking(move || git::preview_git_rollback(&workspace_root, &checkpoint_id)) + .await + .context("preview Git rollback task failed")? +} + +pub(crate) async fn git_workspace_matches_version( + workspace_root: PathBuf, + workspace_version: String, +) -> Result { + tokio::task::spawn_blocking(move || { + git::git_workspace_matches_version(&workspace_root, &workspace_version) + }) + .await + .context("validate Git rollback workspace task failed")? +} + +pub(crate) async fn current_git_workspace_version(workspace_root: PathBuf) -> Result { + tokio::task::spawn_blocking(move || git::current_git_workspace_version(&workspace_root)) + .await + .context("capture Git rollback recovery version task failed")? +} + +pub(crate) async fn restore_git_checkpoint( + workspace_root: PathBuf, + checkpoint: TurnWorkspaceCheckpointRecordedRecord, +) -> Result<()> { + tokio::task::spawn_blocking(move || git::restore_git_checkpoint(&workspace_root, &checkpoint)) + .await + .context("restore Git checkpoint task failed")? +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct FinalizedWorkspaceChangeArtifact { schema_version: u32, @@ -208,6 +243,8 @@ pub(super) struct CheckpointRecordInput<'a> { pub coverage: ChangeSetCoverage, pub warnings: Vec, pub artifact_ref: Option, + pub preexisting_untracked_files: Option>, + pub preexisting_untracked_dirs: Option>, } fn checkpoint_record(input: CheckpointRecordInput<'_>) -> TurnWorkspaceCheckpointRecordedRecord { @@ -223,6 +260,8 @@ fn checkpoint_record(input: CheckpointRecordInput<'_>) -> TurnWorkspaceCheckpoin coverage: Some(input.coverage), warnings: input.warnings, artifact_ref: input.artifact_ref, + preexisting_untracked_files: input.preexisting_untracked_files, + preexisting_untracked_dirs: input.preexisting_untracked_dirs, created_at: Utc::now(), } } @@ -390,6 +429,84 @@ mod tests { Ok(()) } + #[tokio::test] + async fn git_rollback_deletes_only_new_untracked_paths_with_manifest() -> Result<()> { + let data_root = tempdir()?; + let repo = tempdir()?; + run_git(repo.path(), &["init"]); + run_git( + repo.path(), + &["config", "user.email", "rollback@example.com"], + ); + run_git(repo.path(), &["config", "user.name", "Rollback Test"]); + fs::write(repo.path().join("tracked.txt"), "before\n")?; + run_git(repo.path(), &["add", "tracked.txt"]); + run_git(repo.path(), &["commit", "-m", "initial"]); + fs::write(repo.path().join("preexisting.txt"), "keep\n")?; + + let captured = capture_baseline( + data_root.path().to_path_buf(), + SessionId::new(), + TurnId::new(), + repo.path().to_path_buf(), + ) + .await?; + assert_eq!( + captured.record.preexisting_untracked_files, + Some(vec!["preexisting.txt".to_string()]) + ); + assert_eq!(captured.record.preexisting_untracked_dirs, Some(Vec::new())); + + fs::write(repo.path().join("tracked.txt"), "after\n")?; + fs::write(repo.path().join("created-after.txt"), "remove\n")?; + let preview = git::preview_git_rollback(repo.path(), &captured.record.checkpoint_id)?; + assert_eq!( + preview.affected_files, + vec![ + PathBuf::from("created-after.txt"), + PathBuf::from("tracked.txt"), + ] + ); + assert!(git::git_workspace_matches_version( + repo.path(), + &preview.workspace_version + )?); + fs::write(repo.path().join("drift.txt"), "drift\n")?; + assert!(!git::git_workspace_matches_version( + repo.path(), + &preview.workspace_version + )?); + fs::remove_file(repo.path().join("drift.txt"))?; + git::restore_git_checkpoint(repo.path(), &captured.record)?; + assert_eq!( + fs::read_to_string(repo.path().join("tracked.txt"))?, + "before\n" + ); + assert!(repo.path().join("preexisting.txt").exists()); + assert!(!repo.path().join("created-after.txt").exists()); + + let mut legacy_checkpoint = captured.record; + legacy_checkpoint.preexisting_untracked_files = None; + legacy_checkpoint.preexisting_untracked_dirs = None; + fs::write(repo.path().join("tracked.txt"), "changed-again\n")?; + fs::write(repo.path().join("legacy-new.txt"), "must survive\n")?; + git::restore_git_checkpoint(repo.path(), &legacy_checkpoint)?; + assert_eq!( + fs::read_to_string(repo.path().join("tracked.txt"))?, + "before\n" + ); + assert_eq!( + fs::read_to_string(repo.path().join("legacy-new.txt"))?, + "must survive\n" + ); + assert_eq!( + git::preview_git_rollback(repo.path(), &legacy_checkpoint.checkpoint_id)? + .affected_files, + vec![PathBuf::from("legacy-new.txt")] + ); + Ok(()) + } + fn file_statuses(view: &WorkspaceChangeView) -> BTreeMap { view.files .iter() From 0d0523da0b1a7e6a7362fc763e793303798d0760 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 30 Jul 2026 00:20:57 +0800 Subject: [PATCH 14/17] feat(server): complete permissions goal and usage phases --- crates/client/src/acp_permissions.rs | 2 + .../core/src/conversation/legacy_projector.rs | 1 + crates/core/src/conversation/records.rs | 5 + crates/core/src/conversation/rollout_v2.rs | 11 + crates/core/src/conversation/v2_inverse.rs | 9 + crates/core/src/query.rs | 36 +- crates/core/tests/legacy_projector.rs | 2 + crates/core/tests/v2_roundtrip.rs | 2 + crates/protocol/src/canonical/event.rs | 5 +- crates/protocol/src/canonical/item.rs | 17 + crates/protocol/src/canonical/methods.rs | 13 +- crates/protocol/src/canonical/usage.rs | 4 - .../protocol/src/canonical/wire_projector.rs | 95 +++- crates/protocol/src/event.rs | 3 + crates/protocol/tests/canonical_golden.rs | 23 + crates/server/src/execution.rs | 9 + crates/server/src/goal_durable.rs | 177 +++++- crates/server/src/lib.rs | 1 + crates/server/src/persistence.rs | 198 ++++++- crates/server/src/runtime.rs | 14 +- crates/server/src/runtime/approval.rs | 507 ++++++++++++++---- crates/server/src/runtime/connection.rs | 62 +++ crates/server/src/runtime/control_requests.rs | 67 +++ .../server/src/runtime/handlers/compaction.rs | 8 +- .../src/runtime/handlers/subscription.rs | 19 +- .../server/src/runtime/interaction_items.rs | 342 ++++++++++++ crates/server/src/runtime/items.rs | 24 +- .../server/src/runtime/permission_decision.rs | 53 ++ .../runtime/session_actor/approval_scope.rs | 5 + .../server/src/runtime/session_interactive.rs | 5 + crates/server/src/runtime/turn_exec/query.rs | 15 +- .../src/runtime/turn_exec/tool_results.rs | 12 +- crates/server/src/runtime/user_input.rs | 20 + crates/server/src/session_context.rs | 16 +- crates/server/src/usage_ledger.rs | 361 +++++++++++++ .../server/tests/goal_lifecycle_interrupts.rs | 54 +- .../tests/provider_failure_reporting.rs | 4 +- 37 files changed, 2015 insertions(+), 186 deletions(-) create mode 100644 crates/server/src/runtime/control_requests.rs create mode 100644 crates/server/src/runtime/interaction_items.rs create mode 100644 crates/server/src/runtime/permission_decision.rs create mode 100644 crates/server/src/usage_ledger.rs diff --git a/crates/client/src/acp_permissions.rs b/crates/client/src/acp_permissions.rs index aa9e516f..3d34e600 100644 --- a/crates/client/src/acp_permissions.rs +++ b/crates/client/src/acp_permissions.rs @@ -237,6 +237,7 @@ fn acp_approval_decision_notification( approval_id: params.approval_id.clone(), decision: acp_approval_decision_label(¶ms.decision).to_string(), scope: acp_approval_scope_label(¶ms.scope).to_string(), + decision_source: Some(devo_protocol::canonical::item::ApprovalDecisionSource::User), }; acp_item_notification( "item/completed", @@ -572,6 +573,7 @@ mod tests { approval_id: request_payload.approval_id, decision: "approve".to_string(), scope: "once".to_string(), + decision_source: Some(devo_protocol::canonical::item::ApprovalDecisionSource::User,), } ); } diff --git a/crates/core/src/conversation/legacy_projector.rs b/crates/core/src/conversation/legacy_projector.rs index bc30448c..89b8368c 100644 --- a/crates/core/src/conversation/legacy_projector.rs +++ b/crates/core/src/conversation/legacy_projector.rs @@ -702,6 +702,7 @@ impl LegacyProjector { Some(ApprovalDecision { decision: decision_kind, scope, + decision_source: decision.decision_source.unwrap_or_default(), decided_at: record.timestamp, }), ), diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index 569368bb..5f78f2e8 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -263,6 +263,9 @@ pub struct ApprovalDecisionItem { pub decision: String, /// The scope attached to the decision. pub scope: String, + /// Authority that produced the decision. Absent on legacy records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_source: Option, } /// Enumerates the canonical persisted item kinds used by the conversation model. @@ -815,6 +818,7 @@ mod tests { approval_id: "a1".into(), decision: "Allow".into(), scope: "Once".into(), + decision_source: None, }), TurnItem::Plan(TextItem { text: "[]".into() }), TurnItem::ContextCompaction(TextItem { @@ -1017,6 +1021,7 @@ mod tests { approval_id: "apr-1".into(), decision: decision.into(), scope: scope.into(), + decision_source: None, }; assert_eq!(dec.decision, decision); assert_eq!(dec.scope, scope); diff --git a/crates/core/src/conversation/rollout_v2.rs b/crates/core/src/conversation/rollout_v2.rs index 14ab2023..010505b4 100644 --- a/crates/core/src/conversation/rollout_v2.rs +++ b/crates/core/src/conversation/rollout_v2.rs @@ -14,6 +14,7 @@ use devo_protocol::canonical::ids::{ItemId, SessionId, TurnId}; use devo_protocol::canonical::item::{InternalEntry, ItemEnvelope}; use devo_protocol::canonical::session::Session; use devo_protocol::canonical::turn::Turn; +use devo_protocol::canonical::usage::UsageRecord; use crate::{ MessageEditRecordedRecord, SessionContext, TurnContext, TurnSupersededRecord, @@ -197,6 +198,16 @@ pub enum InternalRecordV2 { MessageEdit(MessageEditRecordedRecord), /// A superseded-turn marker, payload unchanged from the legacy record. TurnSuperseded(TurnSupersededRecord), + /// Latest server-owned Goal snapshot for the session. `None` means the + /// current Goal was cleared. Core replay deliberately treats the payload + /// as opaque because Goal orchestration is server-owned. + GoalState { + schema_version: u32, + goal: Option, + }, + /// One append-only model-call accounting entry. Unlike turn summary usage, + /// this preserves failed attempts and non-turn overhead calls. + UsageRecord { record: UsageRecord }, } /// A rollout line parsed from disk in either supported format. diff --git a/crates/core/src/conversation/v2_inverse.rs b/crates/core/src/conversation/v2_inverse.rs index b5ae5e74..4828c02c 100644 --- a/crates/core/src/conversation/v2_inverse.rs +++ b/crates/core/src/conversation/v2_inverse.rs @@ -501,6 +501,9 @@ impl V2InverseProjector { approval_id: approval_id.clone(), decision: legacy_decision_string(decision.decision).into(), scope: legacy_scope_string(decision.scope).into(), + decision_source: (decision.decision_source + != devo_protocol::canonical::item::ApprovalDecisionSource::User) + .then_some(decision.decision_source), }), )?; return Ok(Some(RolloutLine::Item(record))); @@ -637,6 +640,12 @@ impl V2InverseProjector { record: record.clone(), }), )]), + // There is no legacy session-rollout representation for Goal + // snapshots; old builds continue to use the read-only + // goal-records compatibility store. + InternalRecordV2::GoalState { .. } | InternalRecordV2::UsageRecord { .. } => { + Ok(Vec::new()) + } } } } diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs index cde7a114..90fd34cc 100644 --- a/crates/core/src/query.rs +++ b/crates/core/src/query.rs @@ -210,9 +210,29 @@ pub enum QueryProviderRetryPhase { Resumed, } -#[derive(Debug, Clone, Default)] +#[derive(Clone, Default)] pub struct QueryOptions { pub cancel_token: Option, + /// Optional provider used only for compaction summaries. Servers use this + /// seam to attach Compaction metering without misclassifying the main + /// streaming query as compaction overhead. + pub compaction_provider: Option>, +} + +impl std::fmt::Debug for QueryOptions { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("QueryOptions") + .field("cancel_token", &self.cancel_token) + .field( + "compaction_provider", + &self + .compaction_provider + .as_ref() + .map(|provider| provider.name()), + ) + .finish() + } } async fn emit_query_event(on_event: &Option, event: QueryEvent) { @@ -904,6 +924,11 @@ pub async fn query_with_options( on_event: Option, options: QueryOptions, ) -> Result<(), AgentError> { + let compaction_provider = options + .compaction_provider + .as_ref() + .unwrap_or(&provider) + .clone(); let agents_md_manager = AgentsMdManager::new(session.config.agents_md.clone()); let current_agents_snapshot = load_workspace_instructions(&session.cwd, &agents_md_manager); let agent_scope = runtime.agent_scope(); @@ -1041,7 +1066,7 @@ pub async fn query_with_options( summarize_and_compact( session, &on_event, - &provider, + &compaction_provider, &compaction_model_slug, &compaction_request_model, turn_config.model.max_tokens.unwrap_or(4096) as usize, @@ -1180,7 +1205,7 @@ pub async fn query_with_options( summarize_and_compact( session, &on_event, - &provider, + &compaction_provider, &compaction_model_slug, &compaction_request_model, turn_config.model.max_tokens.unwrap_or(4096) as usize, @@ -1385,7 +1410,7 @@ pub async fn query_with_options( summarize_and_compact( session, &on_event, - &provider, + &compaction_provider, &compaction_model_slug, &compaction_request_model, turn_config.model.max_tokens.unwrap_or(4096) as usize, @@ -3596,6 +3621,7 @@ mod tests { None, QueryOptions { cancel_token: Some(cancel_token), + ..QueryOptions::default() }, ) .await; @@ -3626,6 +3652,7 @@ mod tests { None, QueryOptions { cancel_token: Some(cancel_token), + ..QueryOptions::default() }, ) .await; @@ -5031,6 +5058,7 @@ mod tests { None, QueryOptions { cancel_token: Some(cancel_token), + ..QueryOptions::default() }, )); tokio::select! { diff --git a/crates/core/tests/legacy_projector.rs b/crates/core/tests/legacy_projector.rs index c05f0e48..9996150d 100644 --- a/crates/core/tests/legacy_projector.rs +++ b/crates/core/tests/legacy_projector.rs @@ -242,6 +242,7 @@ fn basic_session_lines() -> Vec { // tests); it must still map to Approved. decision: "Allow".into(), scope: "Once".into(), + decision_source: None, })]; let mut steer = item_record(0xb8, session, turn, 6); @@ -427,6 +428,7 @@ fn orphan_decision_lines() -> Vec { approval_id: "appr-orphan".into(), decision: "approve".into(), scope: "session".into(), + decision_source: None, })]; let mut image = item_record(0xd4, 0xd1, 0xd2, 2); diff --git a/crates/core/tests/v2_roundtrip.rs b/crates/core/tests/v2_roundtrip.rs index 6d3d689d..505d064e 100644 --- a/crates/core/tests/v2_roundtrip.rs +++ b/crates/core/tests/v2_roundtrip.rs @@ -180,6 +180,7 @@ impl Normalizer { approval_id: decision.approval_id.clone(), decision: normalize_decision_string(&decision.decision), scope: normalize_scope_string(&decision.scope), + decision_source: decision.decision_source, }), )); } @@ -497,6 +498,7 @@ fn live_write_lines() -> Vec { approval_id: "appr-1".into(), decision: "approve".into(), scope: "once".into(), + decision_source: None, }), ), item( diff --git a/crates/protocol/src/canonical/event.rs b/crates/protocol/src/canonical/event.rs index 8fd7e0e0..c290a9ca 100644 --- a/crates/protocol/src/canonical/event.rs +++ b/crates/protocol/src/canonical/event.rs @@ -371,8 +371,9 @@ pub struct ChannelAccumulation { } /// An unanswered server->client control request (approval / structured -/// question). The first valid response wins; others get -/// `CONTROL_REQUEST_ALREADY_RESOLVED`. +/// question). The first valid response wins. A persisted waiting item left by +/// a process crash remains audit history, but is not advertised here unless +/// the runtime still owns a live response channel for it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub struct PendingControlRequest { diff --git a/crates/protocol/src/canonical/item.rs b/crates/protocol/src/canonical/item.rs index b7344ead..82fc2eec 100644 --- a/crates/protocol/src/canonical/item.rs +++ b/crates/protocol/src/canonical/item.rs @@ -454,9 +454,26 @@ pub enum FileChangeKind { pub struct ApprovalDecision { pub decision: ApprovalDecisionKind, pub scope: ApprovalScope, + /// The authority that produced this decision. Legacy records predate this + /// field and deserialize as `user`, which is the only decision source they + /// could persist. + #[serde(default)] + pub decision_source: ApprovalDecisionSource, pub decided_at: DateTime, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub enum ApprovalDecisionSource { + StaticPolicy, + ExecPolicy, + #[default] + User, + AutoReview, + Hook, + ExternalPolicy, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] #[serde(rename_all = "camelCase")] pub enum ApprovalDecisionKind { diff --git a/crates/protocol/src/canonical/methods.rs b/crates/protocol/src/canonical/methods.rs index d03e2361..62a1fa35 100644 --- a/crates/protocol/src/canonical/methods.rs +++ b/crates/protocol/src/canonical/methods.rs @@ -55,7 +55,8 @@ fn schema_of() -> RootSchema { /// Interactions needing one unique verifiable answer use JSON-RPC requests, /// not notifications. Each corresponds to a `waiting`-state item; the first -/// valid response wins, the rest get `CONTROL_REQUEST_ALREADY_RESOLVED`. +/// valid response wins. Late JSON-RPC responses are ignored because a response +/// cannot itself receive another JSON-RPC error response. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ApprovalRespondParams { @@ -559,7 +560,7 @@ pub static REVERSE_METHODS: &[MethodSpec] = &[ name: "approval/command/request", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + error_codes: &[], required_capability: None, idempotency: Idempotency::None, }, @@ -567,7 +568,7 @@ pub static REVERSE_METHODS: &[MethodSpec] = &[ name: "approval/fileChange/request", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + error_codes: &[], required_capability: None, idempotency: Idempotency::None, }, @@ -575,7 +576,7 @@ pub static REVERSE_METHODS: &[MethodSpec] = &[ name: "approval/permission/request", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + error_codes: &[], required_capability: None, idempotency: Idempotency::None, }, @@ -583,7 +584,7 @@ pub static REVERSE_METHODS: &[MethodSpec] = &[ name: "userInput/request", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + error_codes: &[], required_capability: None, idempotency: Idempotency::None, }, @@ -591,7 +592,7 @@ pub static REVERSE_METHODS: &[MethodSpec] = &[ name: "session/goal/completionApproval/request", params_schema: schema_of::, result_schema: schema_of::, - error_codes: &[codes::CONTROL_REQUEST_ALREADY_RESOLVED], + error_codes: &[], required_capability: None, idempotency: Idempotency::None, }, diff --git a/crates/protocol/src/canonical/usage.rs b/crates/protocol/src/canonical/usage.rs index cdcbadeb..48b5d374 100644 --- a/crates/protocol/src/canonical/usage.rs +++ b/crates/protocol/src/canonical/usage.rs @@ -40,10 +40,6 @@ pub enum UsagePurpose { Compaction, /// Session title generation. TitleGeneration, - /// Goal classification/orchestration (reserved). - GoalClassification, - /// Memory extraction/merge (reserved). - MemoryExtraction, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] diff --git a/crates/protocol/src/canonical/wire_projector.rs b/crates/protocol/src/canonical/wire_projector.rs index d9c6ad32..827e5805 100644 --- a/crates/protocol/src/canonical/wire_projector.rs +++ b/crates/protocol/src/canonical/wire_projector.rs @@ -215,17 +215,48 @@ pub fn project_wire_item( ItemKind::ApprovalDecision => { let decision = serde_json::from_value::(payload.clone()).ok()?; - // The wire decision event carries only the id + decision/scope - // strings; the request fields are not repeated, so they stay - // empty here (clients fold by `approval_id`). Some(Item::Approval { approval_id: decision.approval_id.to_string(), target_item_id: None, - action_summary: String::new(), - justification: String::new(), - resource: None, - available_scopes: Vec::new(), - target: None, + action_summary: payload + .get("action_summary") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(), + justification: payload + .get("justification") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(), + resource: payload + .get("resource") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + available_scopes: payload + .get("available_scopes") + .and_then(serde_json::Value::as_array) + .map(|scopes| { + scopes + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(), + target: approval_target( + payload + .get("path") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + payload + .get("host") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + payload + .get("target") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + ), decision: Some(ApprovalDecision { // Same string mapping as the rollout projector: legacy // decisions were free-form ("Allow" appears in @@ -236,19 +267,10 @@ pub fn project_wire_item( "deny" | "denied" => ApprovalDecisionKind::Denied, _ => ApprovalDecisionKind::Cancelled, }, + decision_source: decision.decision_source.unwrap_or_default(), // Unknown legacy scope strings fall back to the // narrowest scope. - scope: match decision.scope.to_ascii_lowercase().as_str() { - "once" => ApprovalScope::Once, - "turn" => ApprovalScope::Turn, - "session" => ApprovalScope::Session, - "path_prefix" => ApprovalScope::PathPrefix, - "host" => ApprovalScope::Host, - "tool" => ApprovalScope::Tool, - "command_prefix" => ApprovalScope::CommandPrefix, - "command_prefix_persist" => ApprovalScope::CommandPrefixPersist, - _ => ApprovalScope::Once, - }, + scope: approval_scope_from_str(&decision.scope), decided_at, }), }) @@ -294,13 +316,27 @@ pub fn typed_item_notification_from_server_event( event: &ServerEvent, ) -> Option<(String, serde_json::Value)> { let (payload, state) = match event { - ServerEvent::ItemStarted(payload) => (payload, ItemState::Running), + ServerEvent::ItemStarted(payload) => ( + payload, + if payload.item.item_kind == ItemKind::ApprovalRequest { + ItemState::Waiting + } else { + ItemState::Running + }, + ), ServerEvent::ItemCompleted(payload) => (payload, ItemState::Completed), _ => return None, }; // No timestamp travels with legacy item events; the envelope is stamped // with the fan-out time (see `typed_item_envelope`). - let envelope = typed_item_envelope(&payload.context, &payload.item, state, Utc::now())?; + let mut envelope = typed_item_envelope(&payload.context, &payload.item, state, Utc::now())?; + envelope.revision = payload + .item + .payload + .get("revision") + .and_then(serde_json::Value::as_u64) + .and_then(|revision| u32::try_from(revision).ok()) + .unwrap_or(1); let value = serde_json::to_value(TypedItemEventPayload { context: payload.context.clone(), item: envelope, @@ -345,6 +381,20 @@ fn approval_target( } } +fn approval_scope_from_str(scope: &str) -> ApprovalScope { + match scope.to_ascii_lowercase().as_str() { + "once" => ApprovalScope::Once, + "turn" => ApprovalScope::Turn, + "session" => ApprovalScope::Session, + "path_prefix" => ApprovalScope::PathPrefix, + "host" => ApprovalScope::Host, + "tool" => ApprovalScope::Tool, + "command_prefix" => ApprovalScope::CommandPrefix, + "command_prefix_persist" => ApprovalScope::CommandPrefixPersist, + _ => ApprovalScope::Once, + } +} + #[cfg(test)] mod tests { use chrono::TimeZone; @@ -352,6 +402,7 @@ mod tests { use smol_str::SmolStr; use super::*; + use crate::canonical::item::ApprovalDecisionSource; use crate::parse_command::ParsedCommand; use crate::{ApprovalRequestPayload, PendingServerRequestContext, ServerRequestKind}; @@ -708,6 +759,7 @@ mod tests { approval_id: SmolStr::new("appr-1"), decision: "Allow".into(), scope: "Session".into(), + decision_source: Some(ApprovalDecisionSource::User), }) .expect("serialize payload"); let item = project(ItemKind::ApprovalDecision, payload); @@ -724,6 +776,7 @@ mod tests { decision: Some(ApprovalDecision { decision: ApprovalDecisionKind::Approved, scope: ApprovalScope::Session, + decision_source: ApprovalDecisionSource::User, decided_at: decided_at(), }), }) diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 5585d9e9..e8be4eaf 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -371,6 +371,9 @@ pub struct ApprovalDecisionPayload { pub approval_id: SmolStr, pub decision: String, pub scope: String, + /// Authority that produced the decision. Missing on legacy events. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decision_source: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/protocol/tests/canonical_golden.rs b/crates/protocol/tests/canonical_golden.rs index 75d00ea0..e41f8da1 100644 --- a/crates/protocol/tests/canonical_golden.rs +++ b/crates/protocol/tests/canonical_golden.rs @@ -6,6 +6,9 @@ use devo_protocol::canonical::ids::SessionId; use devo_protocol::canonical::item::Item; use devo_protocol::canonical::item::ItemEnvelope; use devo_protocol::canonical::item::ItemOrUnknown; +use devo_protocol::canonical::item::{ + ApprovalDecision, ApprovalDecisionKind, ApprovalDecisionSource, ApprovalScope, +}; use devo_protocol::canonical::patch::PatchField; use devo_protocol::canonical::rpc_session::SessionMetadataUpdateParams; use pretty_assertions::assert_eq; @@ -45,6 +48,26 @@ fn approval_waiting_state_matches_golden_and_round_trips() { assert_eq!(serde_json::to_value(&envelope).expect("serialize"), golden); } +#[test] +fn approval_decision_serializes_decision_source() { + let decision = ApprovalDecision { + decision: ApprovalDecisionKind::Approved, + scope: ApprovalScope::Once, + decision_source: ApprovalDecisionSource::AutoReview, + decided_at: chrono::DateTime::UNIX_EPOCH, + }; + + assert_eq!( + serde_json::to_value(decision).expect("serialize approval decision"), + serde_json::json!({ + "decision": "approved", + "scope": "once", + "decisionSource": "autoReview", + "decidedAt": "1970-01-01T00:00:00Z" + }) + ); +} + #[test] fn unknown_future_variant_degrades_with_raw_preserved() { let golden = read_golden("item_unknown_future_variant.json"); diff --git a/crates/server/src/execution.rs b/crates/server/src/execution.rs index 62991731..352e2c13 100644 --- a/crates/server/src/execution.rs +++ b/crates/server/src/execution.rs @@ -77,6 +77,7 @@ pub(crate) struct PendingApproval { pub(crate) command: Option, pub(crate) cwd: PathBuf, pub(crate) sandbox_permissions: String, + pub(crate) persisted: Option, pub(crate) tx: oneshot::Sender, } @@ -85,9 +86,17 @@ pub(crate) struct PendingUserInput { /// The questions the tool asked; kept so subscription snapshots can /// rebuild the waiting `UserInputRequest` item (08 §4). pub(crate) questions: Vec, + pub(crate) persisted: Option, pub(crate) tx: oneshot::Sender, } +#[derive(Clone)] +pub(crate) struct PersistedLivingItem { + pub(crate) item_id: devo_protocol::canonical::ids::ItemId, + pub(crate) seq: u64, + pub(crate) created_at: chrono::DateTime, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct ApprovalGrantCache { pub(crate) tools: HashSet, diff --git a/crates/server/src/goal_durable.rs b/crates/server/src/goal_durable.rs index f03d5455..4301ceb1 100644 --- a/crates/server/src/goal_durable.rs +++ b/crates/server/src/goal_durable.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; +use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use chrono::Utc; use devo_core::DurableRecord; use devo_core::GoalBudgetAccountedRecord; @@ -16,27 +17,46 @@ use devo_core::StoreErrorCode; use devo_protocol::SessionId; use devo_protocol::TurnId; +use crate::db::Database; use crate::goal::Goal; use crate::goal::GoalBudget; use crate::goal::GoalId; use crate::goal::GoalStatus; use crate::goal::GoalUsage; use crate::goal::TurnRef; +use crate::persistence::RolloutStore; use crate::runtime::GoalStore; -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct GoalDurableStore { store: JsonlSessionStore, + primary: Option<(RolloutStore, Arc)>, } impl GoalDurableStore { + #[cfg(test)] pub(crate) fn new(server_home: PathBuf) -> Self { Self { store: JsonlSessionStore::new(server_home.join("goal-records")), + primary: None, + } + } + + pub(crate) fn with_primary( + server_home: PathBuf, + rollout_store: RolloutStore, + db: Arc, + ) -> Self { + Self { + store: JsonlSessionStore::new(server_home.join("goal-records")), + primary: Some((rollout_store, db)), } } pub(crate) async fn append_goal_created(&self, goal: &Goal) -> Result<()> { + if self.append_primary_goal(goal.session_id, Some(goal))? { + return Ok(()); + } let record = DurableRecord::GoalCreated(GoalCreatedRecord { schema_version: 1, goal_id: goal.durable_goal_id, @@ -61,6 +81,9 @@ impl GoalDurableStore { previous_status: GoalStatus, reason: Option, ) -> Result<()> { + if self.append_primary_goal(goal.session_id, Some(goal))? { + return Ok(()); + } let record = DurableRecord::GoalStatusChanged(GoalStatusChangedRecord { schema_version: 1, goal_id: goal.durable_goal_id, @@ -82,6 +105,9 @@ impl GoalDurableStore { turn_delta: u32, duration_delta_seconds: u64, ) -> Result<()> { + if self.append_primary_goal(goal.session_id, Some(goal))? { + return Ok(()); + } let record = DurableRecord::GoalBudgetAccounted(GoalBudgetAccountedRecord { schema_version: 1, goal_id: goal.durable_goal_id, @@ -106,6 +132,9 @@ impl GoalDurableStore { snapshot_id: String, summary: String, ) -> Result<()> { + if self.append_primary_goal(goal.session_id, Some(goal))? { + return Ok(()); + } let record = DurableRecord::GoalContextSnapshotRecorded(GoalContextSnapshotRecordedRecord { schema_version: 1, @@ -125,6 +154,9 @@ impl GoalDurableStore { goal_id: devo_core::GoalId, reason: Option, ) -> Result<()> { + if self.append_primary_goal(session_id, None)? { + return Ok(()); + } let record = DurableRecord::GoalCleared(GoalClearedRecord { schema_version: 1, goal_id, @@ -140,6 +172,11 @@ impl GoalDurableStore { &self, session_id: SessionId, ) -> Result> { + if let Some(goal) = self.replay_primary_goal(session_id)? { + return Ok(goal.map(|goal| GoalStore { + active_goal: Some(goal), + })); + } let mut replay = match self.store.replay(session_id, /*from_offset*/ 0).await { Ok(replay) => replay, Err(error) if error.code == StoreErrorCode::SessionNotFound => return Ok(None), @@ -268,6 +305,76 @@ impl GoalDurableStore { active_goal: Some(goal), })) } + + /// Returns `Some(snapshot)` when the main rollout contains any GoalState + /// record. The nested option distinguishes an explicit clear from no + /// primary record (which must fall back to legacy goal-records). + fn replay_primary_goal(&self, session_id: SessionId) -> Result>> { + let Some((_, db)) = &self.primary else { + return Ok(None); + }; + let Some(index) = db.get_session_index(&session_id)? else { + return Ok(None); + }; + let Some(rollout_path) = index.rollout_path else { + return Ok(None); + }; + let text = std::fs::read_to_string(&rollout_path) + .with_context(|| format!("read goal state from {}", rollout_path.display()))?; + let mut found = false; + let mut current = None; + let lines = text + .lines() + .filter(|line| !line.trim().is_empty()) + .collect::>(); + for (index, raw) in lines.iter().enumerate() { + let parsed = match devo_core::parse_rollout_line(raw) { + Ok(parsed) => parsed, + Err(devo_core::RolloutLineReadError::TruncatedTail) if index + 1 == lines.len() => { + break; + } + Err(error) => return Err(anyhow::anyhow!("parse goal rollout: {error}")), + }; + let devo_core::ParsedRolloutLine::V2(line) = parsed else { + continue; + }; + if let devo_core::RolloutLineV2::Internal { + entry: + devo_core::InternalRecordV2::GoalState { + schema_version, + goal, + }, + .. + } = *line + { + anyhow::ensure!( + schema_version == 1, + "unsupported Goal snapshot schema version {schema_version}" + ); + found = true; + current = goal + .map(serde_json::from_value) + .transpose() + .context("decode primary goal snapshot")?; + } + } + Ok(found.then_some(current)) + } + + fn append_primary_goal(&self, session_id: SessionId, goal: Option<&Goal>) -> Result { + let Some((rollout_store, db)) = &self.primary else { + return Ok(false); + }; + let index = db + .get_session_index(&session_id)? + .context("session index missing while persisting goal")?; + let rollout_path = index + .rollout_path + .context("session rollout path missing while persisting goal")?; + let goal = goal.map(serde_json::to_value).transpose()?; + rollout_store.append_goal_state(&rollout_path, session_id, goal)?; + Ok(true) + } } fn durable_budget_from_goal(budget: &GoalBudget) -> Option { @@ -355,6 +462,7 @@ fn apply_progress_record(goal: &mut Goal, record: GoalProgressRecordedRecord) { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::io::Write; use tempfile::TempDir; fn active_goal(session_id: SessionId) -> Goal { @@ -367,6 +475,71 @@ mod tests { .expect("goal") } + #[tokio::test] + async fn primary_goal_snapshot_round_trips_without_goal_records_writer() { + let temp = TempDir::new().expect("temp dir"); + let db = Arc::new(Database::open(temp.path().join("devo.db")).expect("open database")); + let rollout_store = RolloutStore::new(temp.path().to_path_buf(), Some(Arc::clone(&db))); + let session_id = SessionId::new(); + let record = rollout_store.create_session_record( + session_id, + Utc::now(), + temp.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&record) + .expect("append session meta"); + let metadata = crate::persistence::session_metadata_from_record(&record, record.created_at); + db.upsert_session(&metadata, Some(record.rollout_path.as_path())) + .expect("index session"); + let store = GoalDurableStore::with_primary( + temp.path().to_path_buf(), + rollout_store.clone(), + Arc::clone(&db), + ); + let mut goal = active_goal(session_id); + store + .append_goal_created(&goal) + .await + .expect("append primary goal"); + goal.status = GoalStatus::Paused; + goal.blocker_summary = Some("waiting".into()); + goal.updated_at = Utc::now(); + store + .append_status_changed(&goal, GoalStatus::Active, Some("waiting".into())) + .await + .expect("append primary status"); + std::fs::OpenOptions::new() + .append(true) + .open(&record.rollout_path) + .expect("open rollout crash tail") + .write_all(br#"{"v":2,"kind":"internal""#) + .expect("append truncated crash tail"); + + assert!( + !temp.path().join("goal-records").exists(), + "new Goal facts must not create the legacy store" + ); + let restarted = GoalDurableStore::with_primary( + temp.path().to_path_buf(), + RolloutStore::new(temp.path().to_path_buf(), Some(Arc::clone(&db))), + db, + ); + let replayed = restarted + .replay_goal_store(session_id) + .await + .expect("replay primary") + .expect("goal"); + assert_eq!(replayed.get(), Some(&goal)); + } + #[tokio::test] async fn goal_durable_store_replays_current_goal_projection() { // Trace: L2-DES-GOAL-001 diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 05833a51..c3f7e823 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -24,6 +24,7 @@ mod titles; mod tool_actions; mod transport; mod turn; +mod usage_ledger; mod workspace_changes; pub use approval::*; diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index db015a3c..f316dcef 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -14,6 +14,7 @@ use chrono::Datelike; use chrono::SecondsFormat; use chrono::Utc; use tokio::sync::Mutex; +use uuid::Uuid; use devo_core::CommandExecutionItem; use devo_core::CompactionSnapshotLine; @@ -690,6 +691,64 @@ impl RolloutStore { /// single-write v2 (05 §2.2): the line is projected through the per-file /// [`LegacyProjector`] and every resulting v2 line becomes its own JSONL /// row. Legacy callers (the typed wrappers above) are unchanged. + pub(crate) fn append_canonical_item( + &self, + record: &SessionRecord, + item: devo_protocol::canonical::item::ItemEnvelope, + ) -> Result<()> { + let line = RolloutLineV2::Item { + v: 2, + timestamp: Utc::now(), + item, + }; + self.append_v2_lines(&record.rollout_path, vec![line]) + } + + pub(crate) fn append_goal_state( + &self, + rollout_path: &Path, + session_id: SessionId, + goal: Option, + ) -> Result<()> { + self.append_v2_lines( + rollout_path, + vec![RolloutLineV2::Internal { + v: 2, + timestamp: Utc::now(), + session_id: devo_protocol::canonical::ids::SessionId::from_legacy_uuid(Uuid::from( + session_id, + )), + turn_id: None, + seq: 0, + entry: devo_core::InternalRecordV2::GoalState { + schema_version: 1, + goal, + }, + }], + ) + } + + pub(crate) fn append_usage_record( + &self, + rollout_path: &Path, + session_id: SessionId, + record: devo_protocol::canonical::usage::UsageRecord, + ) -> Result<()> { + self.append_v2_lines( + rollout_path, + vec![RolloutLineV2::Internal { + v: 2, + timestamp: record.recorded_at, + session_id: devo_protocol::canonical::ids::SessionId::from_legacy_uuid(Uuid::from( + session_id, + )), + turn_id: record.turn_id.clone(), + seq: 0, + entry: devo_core::InternalRecordV2::UsageRecord { record }, + }], + ) + } + fn append_line(&self, rollout_path: &Path, line: &RolloutLine) -> Result<()> { if let Some(parent) = rollout_path.parent() { std::fs::create_dir_all(parent) @@ -726,14 +785,57 @@ impl RolloutStore { .projector .project_line(line) .with_context(|| format!("project rollout line for {}", rollout_path.display()))?; + self.write_v2_lines(rollout_path, state, &v2_lines) + } + + fn append_v2_lines(&self, rollout_path: &Path, v2_lines: Vec) -> Result<()> { + if let Some(parent) = rollout_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create rollout directory {}", parent.display()))?; + } + let file_lock = { + let mut locks = self + .file_locks + .lock() + .expect("rollout file-locks table poisoned"); + locks + .entry(rollout_path.to_path_buf()) + .or_insert_with(|| Arc::new(StdMutex::new(()))) + .clone() + }; + let _guard = file_lock.lock().expect("rollout per-file lock poisoned"); + let mut write_states = self + .write_states + .lock() + .expect("rollout write-state table poisoned"); + let state = match write_states.get_mut(rollout_path) { + Some(state) => state, + None => { + let state = hydrate_write_state(rollout_path)?; + write_states + .entry(rollout_path.to_path_buf()) + .or_insert(state) + } + }; + for line in &v2_lines { + state.projector.observe_v2_line(line); + } + self.write_v2_lines(rollout_path, state, &v2_lines) + } + fn write_v2_lines( + &self, + rollout_path: &Path, + state: &mut WritePathState, + v2_lines: &[RolloutLineV2], + ) -> Result<()> { let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(rollout_path) .with_context(|| format!("open rollout file {}", rollout_path.display()))?; let first_line_index = state.next_line_index; - for v2_line in &v2_lines { + for v2_line in v2_lines { serde_json::to_writer(&mut file, v2_line) .with_context(|| format!("serialize rollout line {}", rollout_path.display()))?; file.write_all(b"\n") @@ -755,7 +857,7 @@ impl RolloutStore { // or duplicate it. if let Some(db) = &self.event_log && let Err(error) = - project_events_into_log(db, rollout_path, first_line_index, &v2_lines) + project_events_into_log(db, rollout_path, first_line_index, v2_lines) { tracing::warn!( rollout = %rollout_path.display(), @@ -2056,7 +2158,7 @@ fn read_rollout_index_fields(path: &Path) -> Result<(SessionRecord, chrono::Date Ok((session, last_activity_at)) } -fn session_metadata_from_record( +pub(crate) fn session_metadata_from_record( record: &SessionRecord, last_activity_at: chrono::DateTime, ) -> SessionMetadata { @@ -3668,6 +3770,7 @@ mod tests { approval_id: "appr-1".into(), decision: "approve".into(), scope: "once".into(), + decision_source: None, }), Some(TurnStatus::Running), None, @@ -3713,6 +3816,95 @@ mod tests { ); } + #[test] + fn canonical_only_interaction_and_file_change_items_round_trip() { + use devo_protocol::canonical::ids::{ + ItemId as CanonicalItemId, SessionId as CanonicalSessionId, TurnId as CanonicalTurnId, + }; + use devo_protocol::canonical::item::{ + FileChangeEntry, FileChangeKind, Item, ItemEnvelope, ItemState, + }; + use tempfile::TempDir; + use uuid::Uuid; + + let dir = TempDir::new().expect("temp dir"); + let store = super::RolloutStore::new(dir.path().to_path_buf(), None); + let record = store.create_session_record( + SessionId::new(), + Utc::now(), + dir.path().to_path_buf(), + Vec::new(), + None, + Some("test-model".into()), + None, + None, + "test-provider".into(), + None, + ); + store + .append_session_meta(&record) + .expect("append session meta"); + let turn_id = TurnId::new(); + let metadata = test_turn_metadata(record.id, turn_id); + store + .append_turn( + &record, + super::build_turn_record(&metadata, None, None, None), + ) + .expect("append turn"); + let now = Utc::now(); + let session_id = CanonicalSessionId::from_legacy_uuid(Uuid::from(record.id)); + let turn_id = CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)); + let question_item_id = CanonicalItemId::from_legacy_uuid(Uuid::now_v7()); + let waiting = ItemEnvelope { + id: question_item_id.clone(), + session_id: session_id.clone(), + turn_id: turn_id.clone(), + seq: 1, + revision: 1, + created_at: now, + updated_at: now, + state: ItemState::Waiting, + item: Item::UserInputRequest { + request_id: "question-1".into(), + target_item_id: None, + questions: Vec::new(), + answers: None, + }, + }; + let file_change = ItemEnvelope { + id: CanonicalItemId::from_legacy_uuid(Uuid::now_v7()), + session_id, + turn_id, + seq: 2, + revision: 1, + created_at: now, + updated_at: now, + state: ItemState::Completed, + item: Item::FileChange { + call_id: "edit-1".into(), + changes: vec![FileChangeEntry { + path: PathBuf::from("src/lib.rs"), + change: FileChangeKind::Update { + unified_diff: "@@ -1 +1 @@".into(), + move_path: None, + }, + }], + sandbox: None, + }, + }; + store + .append_canonical_item(&record, waiting.clone()) + .expect("append waiting item"); + store + .append_canonical_item(&record, file_change.clone()) + .expect("append file change"); + + let history = + devo_core::read_canonical_history(&record.rollout_path).expect("read history"); + assert_eq!(history.items, vec![waiting, file_change]); + } + #[test] fn hydration_fails_closed_on_damaged_history() { use tempfile::TempDir; diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index b8850964..cf75a808 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -14,7 +14,6 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; -use devo_core::ApprovalDecisionItem; use devo_core::CommandExecutionItem; use devo_core::ItemId; use devo_core::Message; @@ -129,6 +128,7 @@ use crate::subagent::SubagentMailbox; use crate::subagent::SubagentMetadata; use crate::subagent::SubagentOutputBuffer; use crate::subagent::SubagentStatus; +use crate::usage_ledger::UsageLedger; use crate::workspace_changes::ActiveWorkspaceBaseline; mod acp_fs; @@ -139,15 +139,18 @@ mod approval; mod code_index_warmup; mod command_exec; mod connection; +mod control_requests; mod goal_accounting; mod goal_continuation; mod goal_handlers; mod handlers; mod hooks; +mod interaction_items; mod items; mod lifecycle; mod model_api; mod outbound; +mod permission_decision; mod proposed_plan; mod provider_vendor_api; mod reference_search; @@ -185,6 +188,7 @@ pub struct ServerRuntime { deps: ServerRuntimeDependencies, rollout_store: RolloutStore, goal_durable_store: GoalDurableStore, + usage_ledger: UsageLedger, /// Per-session actor handles; map lock must not be held across await. sessions: Mutex>, /// Interactive approval and user-input waits outside session actors. @@ -311,7 +315,12 @@ pub(super) fn subagent_usage_owner_pending_metadata( impl ServerRuntime { pub fn new(server_home: PathBuf, deps: ServerRuntimeDependencies) -> Arc { let rollout_store = RolloutStore::new(server_home.clone(), Some(Arc::clone(&deps.db))); - let goal_durable_store = GoalDurableStore::new(server_home.clone()); + let goal_durable_store = GoalDurableStore::with_primary( + server_home.clone(), + rollout_store.clone(), + Arc::clone(&deps.db), + ); + let usage_ledger = UsageLedger::new(rollout_store.clone(), Arc::clone(&deps.db)); let sandbox_network_proxy = std::sync::Arc::new(std::sync::Mutex::new(None)); // Proxy startup is async; ports are published via the thread-safe // `set_sandbox_proxy_ports` store (not process-wide `env::set_var`). @@ -351,6 +360,7 @@ impl ServerRuntime { deps, rollout_store, goal_durable_store, + usage_ledger, sessions: Mutex::new(HashMap::new()), session_interactive: SessionInteractiveLanes::default(), event_subscriptions: Mutex::new(HashMap::new()), diff --git a/crates/server/src/runtime/approval.rs b/crates/server/src/runtime/approval.rs index 8ce87749..b4a43622 100644 --- a/crates/server/src/runtime/approval.rs +++ b/crates/server/src/runtime/approval.rs @@ -1,20 +1,19 @@ use super::*; use crate::execution::PendingApproval; +use crate::runtime::permission_decision::AuthorizationDecision; use crate::runtime::session_actor::approval_scope::{ apply_approval_scope_to_state, apply_path_scope_to_permission_profile, }; use crate::runtime::session_interactive::complete_approval_wait; +use devo_protocol::ApprovalDecisionPayload; +use devo_protocol::ApprovalRequestPayload; +use devo_protocol::PendingServerRequestContext; +use devo_protocol::ServerRequestKind; use std::path::Component; use std::path::Path; -enum PolicyAuthorization { - Allow, - Ask, - Deny(String), -} - enum AutoReviewOutcome { Approve, Deny(String), @@ -55,9 +54,11 @@ impl ServerRuntime { permission_profile: devo_safety::RuntimePermissionProfile, request: ToolPermissionRequest, ) -> Result { - if let Some(result) = permission_mode_authorization(permission_mode, &request) { - return match result { - Ok(grant) => { + if let Some(decision) = permission_mode_authorization(permission_mode) { + return match decision { + AuthorizationDecision::Allow { source } => { + trace_permission_decision(session_id, &request, source, "allow", None); + let grant = escalation_permission_grant(&request); if grant.bypass_sandbox && let Err(reason) = self .check_escalation_unsandboxed_forbidden(session_id, &request.cwd) @@ -69,11 +70,21 @@ impl ServerRuntime { } Ok(grant) } - Err(reason) => { + AuthorizationDecision::Deny { source, reason } => { + trace_permission_decision( + session_id, + &request, + source, + "deny", + Some(reason.as_str()), + ); self.run_permission_denied_hook(session_id, &request, &reason) .await; Err(reason) } + AuthorizationDecision::Ask { .. } => { + unreachable!("permission mode override never returns ask") + } }; } if request.requests_escalation @@ -86,6 +97,13 @@ impl ServerRuntime { return Err(reason); } if let Some(grant) = self.approval_cache_grant(session_id, &request).await { + trace_permission_decision( + session_id, + &request, + devo_protocol::canonical::item::ApprovalDecisionSource::User, + "allow", + None, + ); return Ok(grant); } let permission_profile = self @@ -101,18 +119,42 @@ impl ServerRuntime { .as_ref(), ); match policy { - PolicyAuthorization::Allow => Ok(escalation_permission_grant(&request)), - PolicyAuthorization::Deny(reason) => { + AuthorizationDecision::Allow { source } => { + trace_permission_decision(session_id, &request, source, "allow", None); + Ok(escalation_permission_grant(&request)) + } + AuthorizationDecision::Deny { source, reason } => { + trace_permission_decision( + session_id, + &request, + source, + "deny", + Some(reason.as_str()), + ); self.run_permission_denied_hook(session_id, &request, &reason) .await; Err(reason) } - PolicyAuthorization::Ask => { + AuthorizationDecision::Ask { source } => { + tracing::debug!( + session_id = %session_id, + tool = %request.tool_name, + approval_id = %request.tool_call_id, + decision_source = ?source, + "permission policy requires an interactive decision" + ); if let Some(reason) = self .permission_request_hook_block_reason(session_id, &request) .await { let message = format!("blocked by PermissionRequest hook: {reason}"); + trace_permission_decision( + session_id, + &request, + devo_protocol::canonical::item::ApprovalDecisionSource::Hook, + "deny", + Some(message.as_str()), + ); self.run_permission_denied_hook(session_id, &request, &message) .await; return Err(message); @@ -133,9 +175,23 @@ impl ServerRuntime { .await { AutoReviewOutcome::Approve => { + trace_permission_decision( + session_id, + &request, + devo_protocol::canonical::item::ApprovalDecisionSource::AutoReview, + "allow", + None, + ); return Ok(approved_permission_grant(&request)); } AutoReviewOutcome::Deny(reason) => { + trace_permission_decision( + session_id, + &request, + devo_protocol::canonical::item::ApprovalDecisionSource::AutoReview, + "deny", + Some(reason.as_str()), + ); self.run_permission_denied_hook(session_id, &request, &reason) .await; return Err(format!("rejected by auto-reviewer: {reason}")); @@ -217,11 +273,14 @@ impl ServerRuntime { &runtime_context, ) .await; - let response = match runtime_context - .provider - .completion(build_approval_review_request(model, request, &context)) - .await - { + let model_request = build_approval_review_request(model, request, &context); + let provider = self.usage_ledger.instrumented_provider( + Arc::clone(&runtime_context.provider), + session_id, + Some(turn_id), + devo_protocol::canonical::usage::UsagePurpose::AutoReview, + ); + let response = match provider.completion(model_request).await { Ok(response) => response, Err(error) => { tracing::warn!( @@ -364,22 +423,43 @@ impl ServerRuntime { rationale: &str, ) { let approval_id = format!("auto-review-{}", request.tool_call_id); - self.emit_turn_item( + let item_id = ItemId::new(); + let item_seq = self.allocate_item_sequence(session_id).await; + let canonical_decision = if decision.eq_ignore_ascii_case("approve") { + devo_protocol::canonical::item::ApprovalDecisionKind::Approved + } else { + devo_protocol::canonical::item::ApprovalDecisionKind::Denied + }; + self.persist_completed_approval_item( session_id, turn_id, + item_id, + item_seq, + &approval_id, + request, + canonical_decision, + devo_protocol::canonical::item::ApprovalDecisionSource::AutoReview, + ) + .await; + self.emit_item_completed( + session_id, + turn_id, + item_id, + Some(item_seq), ItemKind::ApprovalDecision, - TurnItem::ApprovalDecision(ApprovalDecisionItem { - approval_id: approval_id.clone(), - decision: decision.to_string(), - scope: "auto_review".to_string(), - }), serde_json::json!({ "approval_id": approval_id, "decision": decision, "scope": "auto_review", + "decision_source": "autoReview", + "revision": 1, "rationale": rationale, + "action_summary": request.action_summary, + "justification": request.justification, "tool_name": request.tool_name, "resource": format!("{:?}", request.resource), + "path": request.path, + "host": request.host, "target": request.target, }), ) @@ -565,14 +645,11 @@ impl ServerRuntime { ) -> Result { let host_session_id = self.permission_host_session_id(session_id).await; let available_scopes = approval_scopes_for_request(&request); - let connection_id = self + let owner_connection_id = self .active_turns .active_connection_id(host_session_id) .await .or(self.active_turns.active_connection_id(session_id).await); - let Some(connection_id) = connection_id else { - return Err("no ACP client connection is available for permission request".to_string()); - }; if host_session_id != session_id { tracing::debug!( @@ -584,6 +661,58 @@ impl ServerRuntime { } let approval_id = request.tool_call_id.clone(); + let approval_item_id = ItemId::new(); + let approval_item_seq = self.allocate_item_sequence(session_id).await; + let request_payload = ApprovalRequestPayload { + request: PendingServerRequestContext { + request_id: approval_id.clone().into(), + request_kind: match request.resource { + devo_safety::ResourceKind::ShellExec => { + ServerRequestKind::ItemCommandExecutionRequestApproval + } + devo_safety::ResourceKind::FileWrite => { + ServerRequestKind::ItemFileChangeRequestApproval + } + devo_safety::ResourceKind::FileRead + | devo_safety::ResourceKind::Network + | devo_safety::ResourceKind::Custom(_) => { + ServerRequestKind::ItemPermissionsRequestApproval + } + }, + session_id, + turn_id: Some(turn_id), + item_id: Some(approval_item_id), + }, + approval_id: approval_id.clone().into(), + action_summary: request.action_summary.clone(), + justification: request.justification.clone().unwrap_or_default(), + resource: Some(format!("{:?}", request.resource)), + available_scopes: available_scopes.clone(), + path: request.path.as_ref().map(|path| path.display().to_string()), + host: request.host.clone(), + target: request.target.clone(), + command_pattern: request.command_pattern.clone(), + command_prefix: request.command_prefix.clone(), + }; + self.emit_item_started( + session_id, + turn_id, + approval_item_id, + Some(approval_item_seq), + ItemKind::ApprovalRequest, + serde_json::to_value(&request_payload).expect("serialize approval request payload"), + ) + .await; + let persisted_approval = self + .persist_waiting_approval_item( + session_id, + turn_id, + approval_item_id, + approval_item_seq, + &request, + &available_scopes, + ) + .await; let (tx, rx) = oneshot::channel(); let pending = PendingApproval { owner_session_id: session_id, @@ -598,6 +727,7 @@ impl ServerRuntime { command: devo_core::tools::command_str_for_permission_request(&request), cwd: request.cwd.clone(), sandbox_permissions: devo_core::tools::sandbox_permissions_from_input(&request.input), + persisted: persisted_approval.clone(), tx, }; self.session_interactive @@ -610,17 +740,16 @@ impl ServerRuntime { .active_turns .cancel_token_for_host_or_session(host_session_id, session_id) .await; - let response = match self - .send_request_to_connection_cancellable( - connection_id, - devo_protocol::ACP_SESSION_REQUEST_PERMISSION_METHOD, - serde_json::to_value(request_params) - .expect("serialize ACP permission request params"), + let (decision, scope) = match self + .request_permission_from_controllers( + host_session_id, + owner_connection_id, + request_params, cancel_token, ) .await { - Ok(response) => response, + Ok(decision) => decision, Err(error) => { self.session_interactive .remove_pending_approval(host_session_id, &approval_id) @@ -628,27 +757,86 @@ impl ServerRuntime { return Err(format!("permission request failed: {error}")); } }; - let response: devo_protocol::AcpRequestPermissionResponse = - match serde_json::from_value(response) { - Ok(response) => response, - Err(error) => { - self.session_interactive - .remove_pending_approval(host_session_id, &approval_id) - .await; - return Err(format!( - "invalid session/request_permission response: {error}" - )); - } - }; - let (decision, scope) = match approval_decision_from_acp_outcome(response.outcome) { - Ok(decision) => decision, - Err(error) => { - self.session_interactive - .remove_pending_approval(host_session_id, &approval_id) - .await; - return Err(error); + let (outcome, reason) = match &decision { + ApprovalDecisionValue::Approve => ("allow", None), + ApprovalDecisionValue::Deny => ("deny", Some("rejected by user")), + ApprovalDecisionValue::Cancel => ("deny", Some("cancelled by user")), + }; + trace_permission_decision( + session_id, + &request, + devo_protocol::canonical::item::ApprovalDecisionSource::User, + outcome, + reason, + ); + let decision_label = match &decision { + ApprovalDecisionValue::Approve => "approve", + ApprovalDecisionValue::Deny => "deny", + ApprovalDecisionValue::Cancel => "cancel", + }; + let scope_label = approval_scope_label(&scope); + let canonical_decision = match &decision { + ApprovalDecisionValue::Approve => { + devo_protocol::canonical::item::ApprovalDecisionKind::Approved + } + ApprovalDecisionValue::Deny => { + devo_protocol::canonical::item::ApprovalDecisionKind::Denied + } + ApprovalDecisionValue::Cancel => { + devo_protocol::canonical::item::ApprovalDecisionKind::Cancelled } }; + if let Some(persisted) = &persisted_approval { + self.persist_resolved_approval_item( + session_id, + turn_id, + &request, + &available_scopes, + canonical_decision, + canonical_approval_scope(&scope), + devo_protocol::canonical::item::ApprovalDecisionSource::User, + persisted, + ) + .await; + } + let mut decision_payload = serde_json::to_value(ApprovalDecisionPayload { + approval_id: approval_id.clone().into(), + decision: decision_label.to_string(), + scope: scope_label.to_string(), + decision_source: Some(devo_protocol::canonical::item::ApprovalDecisionSource::User), + }) + .expect("serialize approval decision payload"); + if let Some(payload) = decision_payload.as_object_mut() { + payload.insert("revision".into(), serde_json::json!(2)); + payload.insert( + "action_summary".into(), + serde_json::json!(request.action_summary.clone()), + ); + payload.insert( + "justification".into(), + serde_json::json!(request.justification.clone().unwrap_or_default()), + ); + payload.insert( + "resource".into(), + serde_json::json!(format!("{:?}", request.resource)), + ); + payload.insert( + "available_scopes".into(), + serde_json::json!(available_scopes), + ); + payload.insert("path".into(), serde_json::json!(request.path.clone())); + payload.insert("host".into(), serde_json::json!(request.host.clone())); + payload.insert("target".into(), serde_json::json!(request.target.clone())); + } + self.emit_item_completed( + session_id, + turn_id, + approval_item_id, + Some(approval_item_seq), + ItemKind::ApprovalDecision, + decision_payload, + ) + .await; if let Some(pending) = self .session_interactive @@ -671,6 +859,7 @@ impl ServerRuntime { command: pending.command, cwd: pending.cwd, sandbox_permissions: pending.sandbox_permissions, + persisted: pending.persisted, tx: scope_tx, }; // ExecuteTurn owns the session mailbox, so ApplyApprovalScope cannot @@ -716,19 +905,29 @@ fn policy_decision( profile: &devo_safety::RuntimePermissionProfile, request: &ToolPermissionRequest, exec_policy: Option<&devo_execpolicy::Policy>, -) -> PolicyAuthorization { +) -> AuthorizationDecision { + use devo_protocol::canonical::item::ApprovalDecisionSource; + if profile.auto_approve { - return PolicyAuthorization::Allow; + return AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + }; } if request_forces_approval(request) { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; } match request.resource { devo_safety::ResourceKind::Network => { if profile.allow_network { - PolicyAuthorization::Allow + AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + } } else { - PolicyAuthorization::Ask + AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + } } } devo_safety::ResourceKind::ShellExec => { @@ -736,27 +935,41 @@ fn policy_decision( } devo_safety::ResourceKind::FileRead => { let Some(path) = request.path.as_ref() else { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; }; if path_matches_any_prefix(path, &profile.readable_roots) || path_matches_any_prefix(path, &profile.writable_roots) { - PolicyAuthorization::Allow + AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + } } else { - PolicyAuthorization::Ask + AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + } } } devo_safety::ResourceKind::FileWrite => { let Some(path) = request.path.as_ref() else { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; }; if path_matches_any_prefix(path, &profile.writable_roots) { - PolicyAuthorization::Allow + AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + } } else { - PolicyAuthorization::Ask + AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + } } } - devo_safety::ResourceKind::Custom(_) => PolicyAuthorization::Allow, + devo_safety::ResourceKind::Custom(_) => AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + }, } } @@ -764,16 +977,21 @@ fn shell_exec_policy_decision( profile: &devo_safety::RuntimePermissionProfile, request: &ToolPermissionRequest, exec_policy: Option<&devo_execpolicy::Policy>, -) -> PolicyAuthorization { +) -> AuthorizationDecision { use devo_execpolicy::Decision; + use devo_protocol::canonical::item::ApprovalDecisionSource; use devo_util_shell_command::is_dangerous_command::command_might_be_dangerous; if !profile.allow_shell_commands { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; } let command = shell_command_for_policy(request); if command.is_empty() { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; } // Fail closed on multi-line / control-separated commands even when a // pre-parsed argv is present (shlex would otherwise collapse newlines). @@ -782,12 +1000,16 @@ fn shell_exec_policy_decision( .iter() .any(|b| matches!(b, b'\n' | b'\r' | 0x0b | 0x0c)) { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; } // Background `&` (not `&&`) splits jobs; argv-based checks miss the // trailing command, so fail closed like newlines. if command_contains_standalone_ampersand(&command) { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; } let argv = shell_argv_for_policy(request, &command); @@ -796,11 +1018,16 @@ fn shell_exec_policy_decision( crate::exec_policy_store::exec_policy_decision_for_argv(policy, argv) { return match decision { - Decision::Allow => PolicyAuthorization::Allow, - Decision::Forbidden => { - PolicyAuthorization::Deny("command blocked by user exec policy rules".to_string()) - } - Decision::Prompt => PolicyAuthorization::Ask, + Decision::Allow => AuthorizationDecision::Allow { + source: ApprovalDecisionSource::ExecPolicy, + }, + Decision::Forbidden => AuthorizationDecision::Deny { + source: ApprovalDecisionSource::ExecPolicy, + reason: "command blocked by user exec policy rules".to_string(), + }, + Decision::Prompt => AuthorizationDecision::Ask { + source: ApprovalDecisionSource::ExecPolicy, + }, }; } @@ -808,7 +1035,9 @@ fn shell_exec_policy_decision( .as_ref() .is_some_and(|argv| command_might_be_dangerous(argv)) { - return PolicyAuthorization::Ask; + return AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }; } match devo_safety::evaluate_shell_command_for_profile(profile, &command, &request.cwd) { @@ -816,12 +1045,34 @@ fn shell_exec_policy_decision( // command was ambiguous without a definite file touch). Allow and run // under the session sandbox. Ask/Deny still require user approval. devo_safety::permission::PolicyDecision::NoMatch - | devo_safety::permission::PolicyDecision::Allow => PolicyAuthorization::Allow, + | devo_safety::permission::PolicyDecision::Allow => AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + }, devo_safety::permission::PolicyDecision::Ask - | devo_safety::permission::PolicyDecision::Deny { .. } => PolicyAuthorization::Ask, + | devo_safety::permission::PolicyDecision::Deny { .. } => AuthorizationDecision::Ask { + source: ApprovalDecisionSource::StaticPolicy, + }, } } +fn trace_permission_decision( + session_id: SessionId, + request: &ToolPermissionRequest, + source: devo_protocol::canonical::item::ApprovalDecisionSource, + outcome: &'static str, + reason: Option<&str>, +) { + tracing::info!( + session_id = %session_id, + tool = %request.tool_name, + approval_id = %request.tool_call_id, + decision_source = ?source, + outcome, + reason, + "permission decision resolved" + ); +} + fn shell_command_for_policy(request: &ToolPermissionRequest) -> String { request .target @@ -921,6 +1172,38 @@ fn approval_scopes_for_request(request: &ToolPermissionRequest) -> Vec { scopes } +fn approval_scope_label(scope: &ApprovalScopeValue) -> &'static str { + match scope { + ApprovalScopeValue::Once => "once", + ApprovalScopeValue::Turn => "turn", + ApprovalScopeValue::Session => "session", + ApprovalScopeValue::PathPrefix => "path_prefix", + ApprovalScopeValue::Host => "host", + ApprovalScopeValue::Tool => "tool", + ApprovalScopeValue::CommandPrefix => "command_prefix", + ApprovalScopeValue::CommandPrefixPersist => "command_prefix_persist", + } +} + +fn canonical_approval_scope( + scope: &ApprovalScopeValue, +) -> devo_protocol::canonical::item::ApprovalScope { + match scope { + ApprovalScopeValue::Once => devo_protocol::canonical::item::ApprovalScope::Once, + ApprovalScopeValue::Turn => devo_protocol::canonical::item::ApprovalScope::Turn, + ApprovalScopeValue::Session => devo_protocol::canonical::item::ApprovalScope::Session, + ApprovalScopeValue::PathPrefix => devo_protocol::canonical::item::ApprovalScope::PathPrefix, + ApprovalScopeValue::Host => devo_protocol::canonical::item::ApprovalScope::Host, + ApprovalScopeValue::Tool => devo_protocol::canonical::item::ApprovalScope::Tool, + ApprovalScopeValue::CommandPrefix => { + devo_protocol::canonical::item::ApprovalScope::CommandPrefix + } + ApprovalScopeValue::CommandPrefixPersist => { + devo_protocol::canonical::item::ApprovalScope::CommandPrefixPersist + } + } +} + fn acp_request_permission_params( session_id: SessionId, request: &ToolPermissionRequest, @@ -1091,7 +1374,7 @@ fn acp_tool_kind_for_permission_request( } } -fn approval_decision_from_acp_outcome( +pub(super) fn approval_decision_from_acp_outcome( outcome: devo_protocol::AcpPermissionOutcome, ) -> Result<(ApprovalDecisionValue, ApprovalScopeValue), String> { match outcome { @@ -1239,13 +1522,17 @@ fn permission_tool_extra( ]) } -fn permission_mode_authorization( - mode: PermissionMode, - request: &ToolPermissionRequest, -) -> Option> { +fn permission_mode_authorization(mode: PermissionMode) -> Option { + use devo_protocol::canonical::item::ApprovalDecisionSource; + match mode { - PermissionMode::AutoApprove => Some(Ok(escalation_permission_grant(request))), - PermissionMode::Deny => Some(Err("approval policy is deny".to_string())), + PermissionMode::AutoApprove => Some(AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + }), + PermissionMode::Deny => Some(AuthorizationDecision::Deny { + source: ApprovalDecisionSource::StaticPolicy, + reason: "approval policy is deny".to_string(), + }), PermissionMode::Interactive => None, } } @@ -1499,17 +1786,21 @@ mod tests { #[test] fn permission_mode_overrides_authorization_policy() { - let request = test_permission_request("shell_command"); assert_eq!( - permission_mode_authorization(PermissionMode::AutoApprove, &request), - Some(Ok(PermissionGrant::default())) + permission_mode_authorization(PermissionMode::AutoApprove), + Some(AuthorizationDecision::Allow { + source: devo_protocol::canonical::item::ApprovalDecisionSource::StaticPolicy, + }) ); assert_eq!( - permission_mode_authorization(PermissionMode::Deny, &request), - Some(Err("approval policy is deny".to_string())) + permission_mode_authorization(PermissionMode::Deny), + Some(AuthorizationDecision::Deny { + source: devo_protocol::canonical::item::ApprovalDecisionSource::StaticPolicy, + reason: "approval policy is deny".to_string(), + }) ); assert_eq!( - permission_mode_authorization(PermissionMode::Interactive, &request), + permission_mode_authorization(PermissionMode::Interactive), None ); } @@ -1524,11 +1815,17 @@ mod tests { }); assert_eq!( - permission_mode_authorization(PermissionMode::AutoApprove, &request), - Some(Ok(PermissionGrant { + permission_mode_authorization(PermissionMode::AutoApprove), + Some(AuthorizationDecision::Allow { + source: devo_protocol::canonical::item::ApprovalDecisionSource::StaticPolicy, + }) + ); + assert_eq!( + escalation_permission_grant(&request), + PermissionGrant { bypass_sandbox: true, already_approved: false, - })) + } ); } @@ -1551,7 +1848,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Allow + AuthorizationDecision::Allow { .. } )); assert_eq!( escalation_permission_grant(&request), @@ -1713,7 +2010,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Allow + AuthorizationDecision::Allow { .. } )); } @@ -1730,7 +2027,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Ask + AuthorizationDecision::Ask { .. } )); } @@ -1750,7 +2047,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Ask + AuthorizationDecision::Ask { .. } )); } @@ -1768,7 +2065,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Allow + AuthorizationDecision::Allow { .. } )); } @@ -1786,7 +2083,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Allow + AuthorizationDecision::Allow { .. } )); } @@ -1804,7 +2101,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Ask + AuthorizationDecision::Ask { .. } )); } @@ -1825,7 +2122,7 @@ mod tests { assert!( matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Ask + AuthorizationDecision::Ask { .. } ), "expected Ask for {command}" ); @@ -1847,7 +2144,7 @@ mod tests { assert!(matches!( test_policy_decision(&profile, &request), - PolicyAuthorization::Allow + AuthorizationDecision::Allow { .. } )); } @@ -1893,7 +2190,7 @@ mod tests { fn test_policy_decision( profile: &devo_safety::RuntimePermissionProfile, request: &ToolPermissionRequest, - ) -> PolicyAuthorization { + ) -> AuthorizationDecision { policy_decision(profile, request, /*exec_policy*/ None) } diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index 1317ea96..49f67a56 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -6,6 +6,7 @@ use std::sync::OnceLock; use std::time::Duration; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::ACP_AUTHENTICATE_METHOD; use crate::ACP_INITIALIZE_METHOD; @@ -1092,6 +1093,33 @@ impl ServerRuntime { } impl ServerRuntime { + pub(super) async fn controlling_connection_ids( + &self, + session_id: SessionId, + owner_connection_id: Option, + ) -> Vec { + let canonical_session_id = + devo_protocol::canonical::ids::SessionId::from_legacy_uuid(Uuid::from(session_id)); + let connections = self.connections.lock().await; + let mut connection_ids = connections + .iter() + .filter_map(|(connection_id, connection)| { + let subscribed = connection.event_selectors.iter().any(|selector| { + matches!( + selector, + devo_protocol::canonical::event::StreamSelector::Session { + session_id + } if session_id == &canonical_session_id + ) + }); + (subscribed || Some(*connection_id) == owner_connection_id) + .then_some(*connection_id) + }) + .collect::>(); + connection_ids.sort_unstable(); + connection_ids + } + async fn child_parent_by_session(&self) -> HashMap { self.agent_registries .lock() @@ -2243,6 +2271,40 @@ mod tests { connection_id } + #[tokio::test] + async fn controlling_connections_union_owner_and_session_subscribers() { + let temp = TempDir::new().expect("temp dir"); + let runtime = build_runtime(temp.path()); + let owner_id = initialized_connection(&runtime).await; + let subscriber_id = initialized_connection(&runtime).await; + let unrelated_id = initialized_connection(&runtime).await; + let session_id = SessionId::new(); + let canonical_session_id = + devo_protocol::canonical::ids::SessionId::from_legacy_uuid(Uuid::from(session_id)); + { + let mut connections = runtime.connections.lock().await; + connections + .get_mut(&subscriber_id) + .expect("subscriber connection") + .event_selectors = vec![devo_protocol::canonical::event::StreamSelector::Session { + session_id: canonical_session_id, + }]; + } + + assert_eq!( + runtime + .controlling_connection_ids(session_id, Some(owner_id)) + .await, + vec![owner_id, subscriber_id] + ); + assert!( + !runtime + .controlling_connection_ids(session_id, Some(owner_id)) + .await + .contains(&unrelated_id) + ); + } + async fn history_request( runtime: &Arc, connection_id: u64, diff --git a/crates/server/src/runtime/control_requests.rs b/crates/server/src/runtime/control_requests.rs new file mode 100644 index 00000000..929c8063 --- /dev/null +++ b/crates/server/src/runtime/control_requests.rs @@ -0,0 +1,67 @@ +use futures::StreamExt; +use futures::stream::FuturesUnordered; +use tokio_util::sync::CancellationToken; + +use super::*; + +impl ServerRuntime { + /// Sends the same logical approval request to every connection controlling + /// the session. The first syntactically valid decision wins; transport + /// failures and malformed responses do not prevent another controller from + /// answering. Dropping the remaining futures ignores late ACP JSON-RPC + /// responses: a response cannot itself be answered with an + /// `CONTROL_REQUEST_ALREADY_RESOLVED` error. + pub(super) async fn request_permission_from_controllers( + &self, + host_session_id: SessionId, + owner_connection_id: Option, + request_params: devo_protocol::AcpRequestPermissionParams, + cancel_token: CancellationToken, + ) -> Result<(ApprovalDecisionValue, ApprovalScopeValue), String> { + let connection_ids = self + .controlling_connection_ids(host_session_id, owner_connection_id) + .await; + if connection_ids.is_empty() { + return Err("no ACP client connection is available for permission request".to_string()); + } + + let params = + serde_json::to_value(request_params).expect("serialize ACP permission request params"); + let mut pending = FuturesUnordered::new(); + for connection_id in connection_ids { + pending.push(self.send_request_to_connection_cancellable( + connection_id, + devo_protocol::ACP_SESSION_REQUEST_PERMISSION_METHOD, + params.clone(), + cancel_token.clone(), + )); + } + + let mut last_error = None; + while let Some(response) = pending.next().await { + let response = match response { + Ok(response) => response, + Err(error) => { + last_error = Some(error); + continue; + } + }; + let response: devo_protocol::AcpRequestPermissionResponse = + match serde_json::from_value(response) { + Ok(response) => response, + Err(error) => { + last_error = Some(format!( + "invalid session/request_permission response: {error}" + )); + continue; + } + }; + match super::approval::approval_decision_from_acp_outcome(response.outcome) { + Ok(decision) => return Ok(decision), + Err(error) => last_error = Some(error), + } + } + + Err(last_error.unwrap_or_else(|| "all permission controllers disconnected".to_string())) + } +} diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index 7dea863d..a136c86c 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -139,10 +139,16 @@ impl ServerRuntime { output_tokens = token_info.output_tokens, "starting compaction summarization" ); - let summarizer = DefaultHistorySummarizer::with_models( + let provider = self.usage_ledger.instrumented_provider( runtime_session .runtime_context .provider_for_route(turn_config.provider_route.clone()), + session_id, + None, + devo_protocol::canonical::usage::UsagePurpose::Compaction, + ); + let summarizer = DefaultHistorySummarizer::with_models( + provider, model_slug, request_model, max_tokens, diff --git a/crates/server/src/runtime/handlers/subscription.rs b/crates/server/src/runtime/handlers/subscription.rs index cab4217c..d71bf3e8 100644 --- a/crates/server/src/runtime/handlers/subscription.rs +++ b/crates/server/src/runtime/handlers/subscription.rs @@ -600,6 +600,7 @@ impl ServerRuntime { item: waiting_item_envelope( session_id, approval.turn_id, + approval.persisted.as_ref(), Item::Approval { approval_id: approval.approval_id.clone(), target_item_id: None, @@ -623,6 +624,7 @@ impl ServerRuntime { item: waiting_item_envelope( session_id, user_input.turn_id, + user_input.persisted.as_ref(), Item::UserInputRequest { request_id: user_input.request_id.clone(), target_item_id: None, @@ -653,6 +655,11 @@ impl ServerRuntime { ), }); } + + // Only live lanes are actionable. Canonical waiting revisions stay + // in history for audit after a crash, but are not advertised as + // pending because the interrupted tool continuation and its reply + // channel cannot be reconstructed honestly yet. } out } @@ -693,22 +700,22 @@ impl ServerRuntime { } /// Builds the waiting-state envelope for a pending control request. The -/// approval/question is not a persisted item, so the envelope id is a fresh -/// bare UUID (the fold id is only needed once it materializes as an item); -/// seq 0 marks it as not part of the item stream. fn waiting_item_envelope( session_id: &CanonicalSessionId, turn_id: devo_core::TurnId, + persisted: Option<&crate::execution::PersistedLivingItem>, item: Item, ) -> ItemEnvelope { let now = Utc::now(); ItemEnvelope { - id: CanonicalItemId::from_legacy_uuid(Uuid::now_v7()), + id: persisted + .map(|persisted| persisted.item_id.clone()) + .unwrap_or_else(|| CanonicalItemId::from_legacy_uuid(Uuid::now_v7())), session_id: session_id.clone(), turn_id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)), - seq: 0, + seq: persisted.map_or(0, |persisted| persisted.seq), revision: 1, - created_at: now, + created_at: persisted.map_or(now, |persisted| persisted.created_at), updated_at: now, state: ItemState::Waiting, item, diff --git a/crates/server/src/runtime/interaction_items.rs b/crates/server/src/runtime/interaction_items.rs new file mode 100644 index 00000000..f72f8a1e --- /dev/null +++ b/crates/server/src/runtime/interaction_items.rs @@ -0,0 +1,342 @@ +use chrono::Utc; +use devo_protocol::canonical::ids::{ + ItemId as CanonicalItemId, SessionId as CanonicalSessionId, TurnId as CanonicalTurnId, +}; +use devo_protocol::canonical::item::{ + ApprovalDecision, ApprovalDecisionKind, ApprovalDecisionSource, ApprovalScope, ApprovalTarget, + FileChangeEntry, FileChangeKind, Item, ItemEnvelope, ItemState, UserQuestion, + UserQuestionOption, +}; +use uuid::Uuid; + +use super::*; + +impl ServerRuntime { + pub(super) async fn persist_waiting_approval_item( + &self, + session_id: SessionId, + turn_id: TurnId, + item_id: devo_core::ItemId, + seq: u64, + request: &devo_core::tools::ToolPermissionRequest, + available_scopes: &[String], + ) -> Option { + let item_id = CanonicalItemId::from_legacy_uuid(Uuid::from(item_id)); + let now = Utc::now(); + let item = approval_envelope( + item_id.clone(), + session_id, + turn_id, + seq, + 1, + now, + now, + ItemState::Waiting, + &request.tool_call_id, + request, + available_scopes, + None, + ); + self.persist_canonical_active_turn_item(session_id, item) + .await + .then_some(crate::execution::PersistedLivingItem { + item_id, + seq, + created_at: now, + }) + } + + #[allow(clippy::too_many_arguments)] + pub(super) async fn persist_resolved_approval_item( + &self, + session_id: SessionId, + turn_id: TurnId, + request: &devo_core::tools::ToolPermissionRequest, + available_scopes: &[String], + decision: ApprovalDecisionKind, + scope: ApprovalScope, + source: ApprovalDecisionSource, + persisted: &crate::execution::PersistedLivingItem, + ) { + let now = Utc::now(); + let item = approval_envelope( + persisted.item_id.clone(), + session_id, + turn_id, + persisted.seq, + 2, + persisted.created_at, + now, + ItemState::Completed, + &request.tool_call_id, + request, + available_scopes, + Some(ApprovalDecision { + decision, + scope, + decision_source: source, + decided_at: now, + }), + ); + self.persist_canonical_active_turn_item(session_id, item) + .await; + } + + #[allow(clippy::too_many_arguments)] + pub(super) async fn persist_completed_approval_item( + &self, + session_id: SessionId, + turn_id: TurnId, + item_id: devo_core::ItemId, + seq: u64, + approval_id: &str, + request: &devo_core::tools::ToolPermissionRequest, + decision: ApprovalDecisionKind, + source: ApprovalDecisionSource, + ) { + let now = Utc::now(); + let item = approval_envelope( + CanonicalItemId::from_legacy_uuid(Uuid::from(item_id)), + session_id, + turn_id, + seq, + 1, + now, + now, + ItemState::Completed, + approval_id, + request, + &[], + Some(ApprovalDecision { + decision, + scope: ApprovalScope::Once, + decision_source: source, + decided_at: now, + }), + ); + self.persist_canonical_active_turn_item(session_id, item) + .await; + } + + pub(super) async fn persist_file_change_item( + &self, + session_id: SessionId, + turn_id: TurnId, + item_id: devo_core::ItemId, + seq: u64, + call_id: String, + changes: &[(std::path::PathBuf, devo_protocol::FileChange)], + ) { + let now = Utc::now(); + let item = ItemEnvelope { + id: CanonicalItemId::from_legacy_uuid(Uuid::from(item_id)), + session_id: CanonicalSessionId::from_legacy_uuid(Uuid::from(session_id)), + turn_id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)), + seq, + revision: 2, + created_at: now, + updated_at: now, + state: ItemState::Completed, + item: Item::FileChange { + call_id, + changes: changes + .iter() + .map(|(path, change)| FileChangeEntry { + path: path.clone(), + change: match change { + devo_protocol::FileChange::Add { content } => FileChangeKind::Add { + content: content.clone(), + }, + devo_protocol::FileChange::Delete { content } => { + FileChangeKind::Delete { + content: content.clone(), + } + } + devo_protocol::FileChange::Update { + unified_diff, + move_path, + .. + } => FileChangeKind::Update { + unified_diff: unified_diff.clone(), + move_path: move_path.clone(), + }, + }, + }) + .collect(), + sandbox: None, + }, + }; + self.persist_canonical_active_turn_item(session_id, item) + .await; + } + + pub(super) async fn persist_waiting_user_input_item( + &self, + session_id: SessionId, + turn_id: TurnId, + request_id: String, + questions: &[devo_protocol::RequestUserInputQuestion], + ) -> Option { + let item_id = CanonicalItemId::from_legacy_uuid(Uuid::now_v7()); + let seq = self.allocate_item_sequence(session_id).await; + let now = Utc::now(); + let item = ItemEnvelope { + id: item_id.clone(), + session_id: CanonicalSessionId::from_legacy_uuid(Uuid::from(session_id)), + turn_id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)), + seq, + revision: 1, + created_at: now, + updated_at: now, + state: ItemState::Waiting, + item: Item::UserInputRequest { + request_id, + target_item_id: None, + questions: canonical_questions(questions), + answers: None, + }, + }; + self.persist_canonical_active_turn_item(session_id, item) + .await + .then_some(crate::execution::PersistedLivingItem { + item_id, + seq, + created_at: now, + }) + } + + pub(super) async fn persist_answered_user_input_item( + &self, + session_id: SessionId, + turn_id: TurnId, + request_id: String, + questions: &[devo_protocol::RequestUserInputQuestion], + response: &devo_protocol::RequestUserInputResponse, + persisted: &crate::execution::PersistedLivingItem, + ) { + let now = Utc::now(); + let item = ItemEnvelope { + id: persisted.item_id.clone(), + session_id: CanonicalSessionId::from_legacy_uuid(Uuid::from(session_id)), + turn_id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)), + seq: persisted.seq, + revision: 2, + created_at: persisted.created_at, + updated_at: now, + state: ItemState::Completed, + item: Item::UserInputRequest { + request_id, + target_item_id: None, + questions: canonical_questions(questions), + answers: serde_json::to_value(response).ok(), + }, + }; + self.persist_canonical_active_turn_item(session_id, item) + .await; + } + + async fn persist_canonical_active_turn_item( + &self, + session_id: SessionId, + item: ItemEnvelope, + ) -> bool { + let Some(stream) = self.active_stream_state(session_id).await else { + return false; + }; + let record = { + let stream = stream.lock().await; + stream + .turn_inline + .as_ref() + .and_then(|inline| inline.record.clone()) + }; + let Some(record) = record else { + return false; + }; + match self.rollout_store.append_canonical_item(&record, item) { + Ok(()) => true, + Err(error) => { + tracing::warn!( + session_id = %session_id, + error = %error, + "failed to persist canonical interaction item" + ); + false + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn approval_envelope( + item_id: CanonicalItemId, + session_id: SessionId, + turn_id: TurnId, + seq: u64, + revision: u32, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + state: ItemState, + approval_id: &str, + request: &devo_core::tools::ToolPermissionRequest, + available_scopes: &[String], + decision: Option, +) -> ItemEnvelope { + ItemEnvelope { + id: item_id, + session_id: CanonicalSessionId::from_legacy_uuid(Uuid::from(session_id)), + turn_id: CanonicalTurnId::from_legacy_uuid(Uuid::from(turn_id)), + seq, + revision, + created_at, + updated_at, + state, + item: Item::Approval { + approval_id: approval_id.to_owned(), + target_item_id: None, + action_summary: request.action_summary.clone(), + justification: request.justification.clone().unwrap_or_default(), + resource: Some(format!("{:?}", request.resource)), + available_scopes: available_scopes.to_vec(), + target: request + .path + .as_ref() + .map(|path| ApprovalTarget::Path { path: path.clone() }) + .or_else(|| { + request + .host + .clone() + .map(|host| ApprovalTarget::Host { host }) + }) + .or_else(|| { + request + .target + .clone() + .map(|command| ApprovalTarget::Command { command }) + }), + decision, + }, + } +} + +fn canonical_questions(questions: &[devo_protocol::RequestUserInputQuestion]) -> Vec { + questions + .iter() + .map(|question| UserQuestion { + id: question.id.clone(), + header: question.header.clone(), + question: question.question.clone(), + is_other: question.is_other, + is_secret: question.is_secret, + options: question.options.as_ref().map(|options| { + options + .iter() + .map(|option| UserQuestionOption { + label: option.label.clone(), + description: option.description.clone(), + }) + .collect() + }), + }) + .collect() +} diff --git a/crates/server/src/runtime/items.rs b/crates/server/src/runtime/items.rs index 0d0e1e76..62be78fa 100644 --- a/crates/server/src/runtime/items.rs +++ b/crates/server/src/runtime/items.rs @@ -212,18 +212,18 @@ impl ServerRuntime { let catalog_request_model = resolved_request.request_model.clone(); let request_model = turn_config.provider_request_model(&catalog_request_model); - let response = match runtime_context - .provider_router - .complete( - turn_config.provider_route.clone(), - build_title_generation_request( - catalog_request_model, - request_model.clone(), - &first_user_input, - ), - ) - .await - { + let provider = self.usage_ledger.instrumented_provider( + runtime_context.provider_for_route(turn_config.provider_route.clone()), + session_id, + None, + devo_protocol::canonical::usage::UsagePurpose::TitleGeneration, + ); + let model_request = build_title_generation_request( + catalog_request_model, + request_model.clone(), + &first_user_input, + ); + let response = match provider.completion(model_request).await { Ok(response) => response, Err(error) => { tracing::warn!( diff --git a/crates/server/src/runtime/permission_decision.rs b/crates/server/src/runtime/permission_decision.rs new file mode 100644 index 00000000..4948be77 --- /dev/null +++ b/crates/server/src/runtime/permission_decision.rs @@ -0,0 +1,53 @@ +use devo_protocol::canonical::item::ApprovalDecisionSource; + +/// One authorization-layer decision before it is converted into an execution +/// grant or an interactive control request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AuthorizationDecision { + Allow { + source: ApprovalDecisionSource, + }, + Ask { + source: ApprovalDecisionSource, + }, + Deny { + source: ApprovalDecisionSource, + reason: String, + }, +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn every_authorization_outcome_has_an_explicit_source() { + let decisions = [ + AuthorizationDecision::Allow { + source: ApprovalDecisionSource::StaticPolicy, + }, + AuthorizationDecision::Ask { + source: ApprovalDecisionSource::ExecPolicy, + }, + AuthorizationDecision::Deny { + source: ApprovalDecisionSource::Hook, + reason: "blocked".to_string(), + }, + ]; + + assert_eq!( + decisions.map(|decision| match decision { + AuthorizationDecision::Allow { source } + | AuthorizationDecision::Ask { source } + | AuthorizationDecision::Deny { source, .. } => source, + }), + [ + ApprovalDecisionSource::StaticPolicy, + ApprovalDecisionSource::ExecPolicy, + ApprovalDecisionSource::Hook, + ] + ); + } +} diff --git a/crates/server/src/runtime/session_actor/approval_scope.rs b/crates/server/src/runtime/session_actor/approval_scope.rs index cf58720d..8565188c 100644 --- a/crates/server/src/runtime/session_actor/approval_scope.rs +++ b/crates/server/src/runtime/session_actor/approval_scope.rs @@ -177,6 +177,7 @@ mod tests { command: None, cwd: PathBuf::from("/workspace"), sandbox_permissions: String::new(), + persisted: None, tx, }; @@ -222,6 +223,7 @@ mod tests { } else { String::new() }, + persisted: None, tx, } } @@ -245,6 +247,7 @@ mod tests { command: None, cwd: PathBuf::from("/workspace"), sandbox_permissions: String::new(), + persisted: None, tx, }; @@ -282,6 +285,7 @@ mod tests { command: None, cwd: PathBuf::from("/workspace"), sandbox_permissions: String::new(), + persisted: None, tx, }; @@ -431,6 +435,7 @@ mod tests { command: None, cwd: temp.path().to_path_buf(), sandbox_permissions: String::new(), + persisted: None, tx, }; diff --git a/crates/server/src/runtime/session_interactive.rs b/crates/server/src/runtime/session_interactive.rs index 8f5fe199..d71ad874 100644 --- a/crates/server/src/runtime/session_interactive.rs +++ b/crates/server/src/runtime/session_interactive.rs @@ -165,6 +165,7 @@ impl SessionInteractiveLanes { path: pending.path.clone(), host: pending.host.clone(), command: pending.command.clone(), + persisted: pending.persisted.clone(), }) .collect(), user_inputs: state @@ -174,6 +175,7 @@ impl SessionInteractiveLanes { request_id: request_id.clone(), turn_id: pending.turn_id, questions: pending.questions.clone(), + persisted: pending.persisted.clone(), }) .collect(), } @@ -194,12 +196,14 @@ pub(crate) struct PendingApprovalSnapshot { pub(crate) path: Option, pub(crate) host: Option, pub(crate) command: Option, + pub(crate) persisted: Option, } pub(crate) struct PendingUserInputSnapshot { pub(crate) request_id: String, pub(crate) turn_id: devo_core::TurnId, pub(crate) questions: Vec, + pub(crate) persisted: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -248,6 +252,7 @@ mod tests { command: None, cwd: std::path::PathBuf::new(), sandbox_permissions: String::new(), + persisted: None, tx, }, ) diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index f8bdc106..2fb85067 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -161,15 +161,28 @@ impl ServerRuntime { // turn cleanly. The tool-execution cancel guard already handles the // "cancel during tools" case (line ~1827). let result = { + let provider = self.usage_ledger.instrumented_provider( + runtime_context.provider_for_route(turn_config.provider_route.clone()), + session_id, + Some(turn_id), + devo_protocol::canonical::usage::UsagePurpose::TurnQuery, + ); + let compaction_provider = self.usage_ledger.instrumented_provider( + runtime_context.provider_for_route(turn_config.provider_route.clone()), + session_id, + Some(turn_id), + devo_protocol::canonical::usage::UsagePurpose::Compaction, + ); let mut query_future = std::pin::pin!(query_with_options( &mut state.core, turn_config, - runtime_context.provider_for_route(turn_config.provider_route.clone()), + provider, registry, &runtime, Some(callback), QueryOptions { cancel_token: Some(query_cancel_token.clone()), + compaction_provider: Some(compaction_provider), }, )); tokio::select! { diff --git a/crates/server/src/runtime/turn_exec/tool_results.rs b/crates/server/src/runtime/turn_exec/tool_results.rs index bc05c9ef..ce70e939 100644 --- a/crates/server/src/runtime/turn_exec/tool_results.rs +++ b/crates/server/src/runtime/turn_exec/tool_results.rs @@ -196,12 +196,22 @@ async fn complete_file_change_tool_call( tool_call_id: tool_use_id.to_string(), tool_name: Some(tool_name.to_string()), input: Some(pending.input.clone()), - changes, + changes: changes.clone(), is_error, }) .expect("serialize file change payload"), ) .await; + runtime + .persist_file_change_item( + session_id, + turn_id, + pending_item_id, + pending_item_seq, + tool_use_id.to_string(), + &changes, + ) + .await; } fn file_changes_from_output( diff --git a/crates/server/src/runtime/user_input.rs b/crates/server/src/runtime/user_input.rs index fdaba2b4..4a32407c 100644 --- a/crates/server/src/runtime/user_input.rs +++ b/crates/server/src/runtime/user_input.rs @@ -51,6 +51,17 @@ impl ServerRuntime { } }; + if let Some(persisted) = &pending.persisted { + self.persist_answered_user_input_item( + params.session_id, + params.turn_id, + request_key, + &pending.questions, + ¶ms.response, + persisted, + ) + .await; + } let _ = pending.tx.send(params.response); self.broadcast_event(ServerEvent::ServerRequestResolved( ServerRequestResolvedPayload { @@ -84,6 +95,14 @@ impl ServerRuntime { )); } + let persisted = self + .persist_waiting_user_input_item( + session_id, + turn_id, + request_id.clone(), + &args.questions, + ) + .await; self.session_interactive .register_pending_user_input( session_id, @@ -91,6 +110,7 @@ impl ServerRuntime { PendingUserInput { turn_id, questions: args.questions.clone(), + persisted, tx, }, ) diff --git a/crates/server/src/session_context.rs b/crates/server/src/session_context.rs index e9137955..aa75ce9f 100644 --- a/crates/server/src/session_context.rs +++ b/crates/server/src/session_context.rs @@ -68,11 +68,16 @@ pub(crate) struct SessionRuntimeContext { struct RoutedModelProvider { router: Arc, route: ProviderRoute, + provider_name: String, } impl RoutedModelProvider { - fn new(router: Arc, route: ProviderRoute) -> Self { - Self { router, route } + fn new(router: Arc, route: ProviderRoute, provider_name: String) -> Self { + Self { + router, + route, + provider_name, + } } } @@ -96,7 +101,7 @@ impl ModelProviderSDK for RoutedModelProvider { } fn name(&self) -> &str { - self.router.name() + &self.provider_name } } @@ -214,9 +219,14 @@ impl SessionRuntimeContext { } pub(crate) fn provider_for_route(&self, route: ProviderRoute) -> Arc { + let provider_name = match &route { + ProviderRoute::Default => self.provider.name().to_owned(), + ProviderRoute::Binding { provider_id, .. } => provider_id.clone(), + }; Arc::new(RoutedModelProvider::new( Arc::clone(&self.provider_router), route, + provider_name, )) } diff --git a/crates/server/src/usage_ledger.rs b/crates/server/src/usage_ledger.rs new file mode 100644 index 00000000..ce4d7466 --- /dev/null +++ b/crates/server/src/usage_ledger.rs @@ -0,0 +1,361 @@ +use chrono::Utc; +use devo_core::SessionId; +use devo_core::TurnId; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::StreamEvent; +use devo_protocol::Usage; +use devo_protocol::canonical::ids; +use devo_protocol::canonical::model::ModelBinding; +use devo_protocol::canonical::usage::{ + CallContext, TokenUsage, UsageCallOutcome, UsagePurpose, UsageRecord, +}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use tracing::warn; +use uuid::Uuid; + +use anyhow::Result; +use async_trait::async_trait; +use devo_provider::ModelProviderSDK; +use futures::Stream; + +use crate::db::Database; +use crate::persistence::RolloutStore; + +#[derive(Clone)] +pub(crate) struct UsageLedger { + rollout_store: RolloutStore, + db: Arc, +} + +impl UsageLedger { + pub(crate) fn new(rollout_store: RolloutStore, db: Arc) -> Self { + Self { rollout_store, db } + } + + pub(crate) fn instrumented_provider( + &self, + provider: Arc, + session_id: SessionId, + turn_id: Option, + purpose: UsagePurpose, + ) -> Arc { + Arc::new(InstrumentedProvider { + provider, + ledger: self.clone(), + context: RuntimeCallContext::new(session_id, turn_id, purpose), + }) + } + + fn record( + &self, + context: &RuntimeCallContext, + provider: &str, + request: &ModelRequest, + outcome: UsageCallOutcome, + usage: Option<&Usage>, + ) { + let Some(rollout_path) = self + .db + .get_session_index(&context.session_id) + .ok() + .flatten() + .and_then(|record| record.rollout_path) + else { + warn!( + session_id = %context.session_id, + purpose = ?context.call.purpose, + "cannot persist model-call usage because the session index is unavailable" + ); + return; + }; + let record = UsageRecord { + call_id: Uuid::now_v7().to_string(), + session_id: context.call.session_id.clone(), + turn_id: context.call.turn_id.clone(), + purpose: context.call.purpose, + model: ModelBinding { + provider: provider.to_owned(), + model: request.model.clone(), + reasoning_effort: request.reasoning_effort, + }, + outcome, + usage: usage.map(token_usage), + estimated_cost: None, + recorded_at: Utc::now(), + }; + if let Err(error) = + self.rollout_store + .append_usage_record(&rollout_path, context.session_id, record) + { + warn!( + session_id = %context.session_id, + purpose = ?context.call.purpose, + %error, + "failed to persist model-call usage" + ); + } + } +} + +#[derive(Clone)] +struct RuntimeCallContext { + session_id: SessionId, + call: CallContext, +} + +impl RuntimeCallContext { + fn new(session_id: SessionId, turn_id: Option, purpose: UsagePurpose) -> Self { + Self { + session_id, + call: CallContext { + session_id: ids::SessionId::from_legacy_uuid(Uuid::from(session_id)), + turn_id: turn_id.map(Uuid::from).map(ids::TurnId::from_legacy_uuid), + purpose, + }, + } + } +} + +fn token_usage(usage: &Usage) -> TokenUsage { + TokenUsage { + input_tokens: usage.input_tokens as u64, + output_tokens: usage.output_tokens as u64, + reasoning_tokens: usage.reasoning_output_tokens.unwrap_or(0) as u64, + cache_read_input_tokens: usage.cache_read_input_tokens.unwrap_or(0) as u64, + cache_creation_input_tokens: usage.cache_creation_input_tokens.unwrap_or(0) as u64, + } +} + +struct InstrumentedProvider { + provider: Arc, + ledger: UsageLedger, + context: RuntimeCallContext, +} + +#[async_trait] +impl ModelProviderSDK for InstrumentedProvider { + async fn completion(&self, request: ModelRequest) -> Result { + let mut attempt = CallAttemptGuard::new( + self.ledger.clone(), + self.context.clone(), + self.provider.name().to_owned(), + request.clone(), + ); + match self.provider.completion(request.clone()).await { + Ok(response) => { + attempt.finish(UsageCallOutcome::Succeeded, Some(&response.usage)); + Ok(response) + } + Err(error) => { + attempt.finish(UsageCallOutcome::Failed, None); + Err(error) + } + } + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + let mut attempt = CallAttemptGuard::new( + self.ledger.clone(), + self.context.clone(), + self.provider.name().to_owned(), + request.clone(), + ); + match self.provider.completion_stream(request.clone()).await { + Ok(inner) => Ok(Box::pin(MeteredStream { inner, attempt })), + Err(error) => { + attempt.finish(UsageCallOutcome::Failed, None); + Err(error) + } + } + } + + fn name(&self) -> &str { + self.provider.name() + } +} + +struct CallAttemptGuard { + ledger: UsageLedger, + context: RuntimeCallContext, + provider_name: String, + request: ModelRequest, + terminal: bool, +} + +impl CallAttemptGuard { + fn new( + ledger: UsageLedger, + context: RuntimeCallContext, + provider_name: String, + request: ModelRequest, + ) -> Self { + Self { + ledger, + context, + provider_name, + request, + terminal: false, + } + } + + fn finish(&mut self, outcome: UsageCallOutcome, usage: Option<&Usage>) { + if self.terminal { + return; + } + self.ledger.record( + &self.context, + &self.provider_name, + &self.request, + outcome, + usage, + ); + self.terminal = true; + } +} + +impl Drop for CallAttemptGuard { + fn drop(&mut self) { + self.finish(UsageCallOutcome::Cancelled, None); + } +} + +struct MeteredStream { + inner: Pin> + Send>>, + attempt: CallAttemptGuard, +} + +impl Stream for MeteredStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + let item = self.inner.as_mut().poll_next(context); + match &item { + Poll::Ready(Some(Ok(StreamEvent::MessageDone { response }))) => { + self.attempt + .finish(UsageCallOutcome::Succeeded, Some(&response.usage)); + } + Poll::Ready(Some(Err(_))) | Poll::Ready(None) => { + self.attempt.finish(UsageCallOutcome::Failed, None); + } + Poll::Pending | Poll::Ready(Some(Ok(_))) => {} + } + item + } +} + +#[cfg(test)] +mod tests { + use devo_core::ParsedRolloutLine; + use devo_core::RolloutLineV2; + use devo_core::parse_rollout_line; + use devo_protocol::ModelProfileKey; + use devo_protocol::SamplingControls; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + use super::*; + + #[test] + fn usage_record_is_appended_to_the_session_rollout() { + let temp = TempDir::new().expect("temp dir"); + let db = Arc::new(Database::open(temp.path().join("devo.db")).expect("open database")); + let rollout_store = RolloutStore::new(temp.path().to_path_buf(), Some(Arc::clone(&db))); + let session_id = SessionId::new(); + let session_record = rollout_store.create_session_record( + session_id, + Utc::now(), + temp.path().to_path_buf(), + Vec::new(), + None, + Some("catalog-model".into()), + None, + None, + "test-provider".into(), + None, + ); + rollout_store + .append_session_meta(&session_record) + .expect("append session metadata"); + let metadata = crate::persistence::session_metadata_from_record( + &session_record, + session_record.created_at, + ); + db.upsert_session(&metadata, Some(session_record.rollout_path.as_path())) + .expect("index session"); + let ledger = UsageLedger::new(rollout_store, db); + let request = ModelRequest { + model_slug: ModelProfileKey::CatalogSlug("catalog-model".into()), + model: "wire-model".into(), + system: None, + messages: Vec::new(), + max_tokens: 128, + tools: None, + hosted_tools: Vec::new(), + sampling: SamplingControls::default(), + request_thinking: None, + reasoning_effort: None, + extra_body: None, + }; + let usage = Usage { + input_tokens: 10, + output_tokens: 4, + cache_creation_input_tokens: Some(2), + cache_read_input_tokens: Some(3), + reasoning_output_tokens: Some(1), + total_tokens: Some(14), + }; + + ledger.record( + &RuntimeCallContext::new(session_id, None, UsagePurpose::TitleGeneration), + "test-provider", + &request, + UsageCallOutcome::Succeeded, + Some(&usage), + ); + + let raw = std::fs::read_to_string(&session_record.rollout_path).expect("read rollout"); + let last = raw.lines().last().expect("usage line"); + let ParsedRolloutLine::V2(line) = parse_rollout_line(last).expect("parse usage line") + else { + panic!("usage must use v2"); + }; + let RolloutLineV2::Internal { + entry: devo_core::InternalRecordV2::UsageRecord { record }, + .. + } = *line + else { + panic!("usage must use the internal usage record"); + }; + assert_eq!( + record, + UsageRecord { + call_id: record.call_id.clone(), + session_id: ids::SessionId::from_legacy_uuid(Uuid::from(session_id)), + turn_id: None, + purpose: UsagePurpose::TitleGeneration, + model: ModelBinding { + provider: "test-provider".into(), + model: "wire-model".into(), + reasoning_effort: None, + }, + outcome: UsageCallOutcome::Succeeded, + usage: Some(TokenUsage { + input_tokens: 10, + output_tokens: 4, + reasoning_tokens: 1, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 2, + }), + estimated_cost: None, + recorded_at: record.recorded_at, + } + ); + } +} diff --git a/crates/server/tests/goal_lifecycle_interrupts.rs b/crates/server/tests/goal_lifecycle_interrupts.rs index 24c6f9ad..281a4507 100644 --- a/crates/server/tests/goal_lifecycle_interrupts.rs +++ b/crates/server/tests/goal_lifecycle_interrupts.rs @@ -5,7 +5,10 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; -use devo_core::DurableRecord; +use devo_core::InternalRecordV2; +use devo_core::ParsedRolloutLine; +use devo_core::RolloutLineV2; +use devo_core::parse_rollout_line; use devo_protocol::Usage; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -337,16 +340,45 @@ fn persisted_goal_id( data_root: &std::path::Path, session_id: devo_protocol::SessionId, ) -> Result { - let path = data_root - .join("goal-records") - .join("sessions") - .join(format!("{session_id}.jsonl")); - let contents = std::fs::read_to_string(path).context("read durable goal records")?; - for line in contents.lines().rev() { - let record: DurableRecord = serde_json::from_str(line).context("parse durable record")?; - if let DurableRecord::GoalCreated(record) = record { - return Ok(format!("goal-{}", record.goal_id.0)); + let mut directories = vec![data_root.join("sessions")]; + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(&directory) + .with_context(|| format!("read rollout directory {}", directory.display()))? + { + let path = entry?.path(); + if path.is_dir() { + directories.push(path); + continue; + } + if !path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&format!("-{session_id}.jsonl"))) + { + continue; + } + let contents = std::fs::read_to_string(&path).context("read session rollout")?; + for line in contents.lines().rev() { + let ParsedRolloutLine::V2(line) = + parse_rollout_line(line).context("parse rollout line")? + else { + continue; + }; + let RolloutLineV2::Internal { + entry: + InternalRecordV2::GoalState { + goal: Some(goal), .. + }, + .. + } = *line + else { + continue; + }; + let goal: devo_server::goal::Goal = + serde_json::from_value(goal).context("parse goal snapshot")?; + return Ok(goal.goal_id.to_string()); + } } } - anyhow::bail!("goal id not found in durable records") + anyhow::bail!("goal id not found in session rollout") } diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index de179bc8..d29a2d4a 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -315,7 +315,7 @@ fn expected_retry_statuses( turn_id, attempt, backoff_ms, - provider: "exhausting-router".to_string(), + provider: "openai".to_string(), model: "default-model".to_string(), phase: ProviderRetryPhase::Scheduled, message: format!( @@ -328,7 +328,7 @@ fn expected_retry_statuses( turn_id, attempt, backoff_ms: 0, - provider: "exhausting-router".to_string(), + provider: "openai".to_string(), model: "default-model".to_string(), phase: ProviderRetryPhase::Resumed, message: "Retrying provider request now".to_string(), From 0ca540b274e37333545f38465635e9ef9893cde8 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 30 Jul 2026 10:42:04 +0800 Subject: [PATCH 15/17] refactor(core): split query into deep modules and unify token estimates --- crates/cli/src/prompt_command.rs | 1 + crates/core/prompts/subagent_mode_reminder.md | 3 + crates/core/src/context/mod.rs | 5 +- crates/core/src/history/compaction.rs | 11 +- crates/core/src/history/mod.rs | 26 +- crates/core/src/query.rs | 6429 ----------------- crates/core/src/query/event.rs | 137 + crates/core/src/query/mod.rs | 935 +++ crates/core/src/query/prompt_estimate.rs | 27 + crates/core/src/query/provider_retry.rs | 323 + crates/core/src/query/stream_consumer.rs | 660 ++ crates/core/src/query/tests.rs | 4423 ++++++++++++ crates/core/src/query/turn_continuation.rs | 191 + crates/core/src/response_item.rs | 48 + .../core/src/tools/client_terminal_shell.rs | 3 +- crates/core/src/tools/shell_exec.rs | 3 +- crates/core/src/tools/unified_exec/process.rs | 12 +- crates/core/tests/context_limit_compaction.rs | 2 + crates/core/tests/real_llm_e2e.rs | 2 + crates/protocol/src/conversation.rs | 8 + .../server/src/runtime/handlers/compaction.rs | 10 +- .../runtime/handlers/rollback_workspace.rs | 15 +- crates/server/src/runtime/turn_exec/query.rs | 4 +- 23 files changed, 6815 insertions(+), 6463 deletions(-) create mode 100644 crates/core/prompts/subagent_mode_reminder.md delete mode 100644 crates/core/src/query.rs create mode 100644 crates/core/src/query/event.rs create mode 100644 crates/core/src/query/mod.rs create mode 100644 crates/core/src/query/prompt_estimate.rs create mode 100644 crates/core/src/query/provider_retry.rs create mode 100644 crates/core/src/query/stream_consumer.rs create mode 100644 crates/core/src/query/tests.rs create mode 100644 crates/core/src/query/turn_continuation.rs diff --git a/crates/cli/src/prompt_command.rs b/crates/cli/src/prompt_command.rs index dad6ebb0..40078d31 100644 --- a/crates/cli/src/prompt_command.rs +++ b/crates/cli/src/prompt_command.rs @@ -152,6 +152,7 @@ pub(crate) async fn run_prompt( registry, &runtime, jsonl_event_callback(output_format, session_id_for_events), + devo_core::QueryOptions::default(), ) .await; diff --git a/crates/core/prompts/subagent_mode_reminder.md b/crates/core/prompts/subagent_mode_reminder.md new file mode 100644 index 00000000..56a4fc1a --- /dev/null +++ b/crates/core/prompts/subagent_mode_reminder.md @@ -0,0 +1,3 @@ + +You are running as a sub-agent. Complete the delegated task using the available non-agent tools. Do not call agent coordination tools such as spawn_agent, send_message, await_task, list_tasks, or cancel_task; report progress and final results through assistant output. + diff --git a/crates/core/src/context/mod.rs b/crates/core/src/context/mod.rs index 1d135daa..1a6de0d4 100644 --- a/crates/core/src/context/mod.rs +++ b/crates/core/src/context/mod.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::{ItemId, ResponseItem, SessionId, SummaryModelSelection, TurnId}; +use devo_protocol::approx_tokens_from_byte_count; use devo_protocol::{ContentBlock, Message, Model, Role}; // --------------------------------------------------------------------------- @@ -258,7 +259,9 @@ impl TokenEstimator for ByteTokenEstimator { } fn bytes_to_tokens(bytes: usize) -> u32 { - bytes.div_ceil(4).try_into().unwrap_or(u32::MAX) + approx_tokens_from_byte_count(bytes) + .try_into() + .unwrap_or(u32::MAX) } /// Stores the summary payload created during compaction. diff --git a/crates/core/src/history/compaction.rs b/crates/core/src/history/compaction.rs index 7df49240..690aa7d6 100644 --- a/crates/core/src/history/compaction.rs +++ b/crates/core/src/history/compaction.rs @@ -26,6 +26,7 @@ //! 6. If the summarizer LLM call fails with a context‑length error, move the //! newest to‑compact item back into the preserve set and retry. +use devo_protocol::approx_tokens_from_byte_count; use std::time::Duration; use async_trait::async_trait; @@ -423,8 +424,9 @@ fn estimate_item_tokens(item: &ResponseItem) -> usize { } ResponseItem::ToolCallOutput { content, .. } => content.len(), }; - // Rough estimate: ~4 bytes per token. - bytes.div_ceil(4) + approx_tokens_from_byte_count(bytes) + .try_into() + .unwrap_or(usize::MAX) } #[cfg(test)] @@ -745,7 +747,10 @@ mod tests { }; let expected_bytes = "shell_command".len() + 2 + input.to_string().len(); - assert_eq!(estimate_item_tokens(&item), expected_bytes.div_ceil(4)); + assert_eq!( + estimate_item_tokens(&item), + approx_tokens_from_byte_count(expected_bytes) as usize + ); } #[test] diff --git a/crates/core/src/history/mod.rs b/crates/core/src/history/mod.rs index 79feb49e..ef702a0a 100644 --- a/crates/core/src/history/mod.rs +++ b/crates/core/src/history/mod.rs @@ -411,19 +411,23 @@ impl History { } } -/// Converts locked prefix `UserInput`s into request messages and prepends them -/// ahead of the existing prompt-visible history. /// Merges consecutive assistant `RequestMessage`s into a single message by /// concatenating their content arrays. /// -/// This is necessary because `message_to_response_items` can split a single -/// assistant `Message` (containing both text and `ToolUse` blocks) into -/// separate `ResponseItem`s (one `Message` item for the text, plus one -/// `ToolCall` per tool use). When these are converted to `RequestMessage`s, -/// the result would be multiple consecutive assistant messages, which violates -/// provider protocol requirements (e.g. OpenAI requires that an assistant -/// message with `tool_calls` be immediately followed by tool-result messages, -/// not by another assistant message). +/// This is the inverse of the split performed by +/// [`crate::response_item::message_to_response_items`]. For example, a mixed +/// assistant turn that became: +/// +/// ```text +/// ResponseItem::Reason { "hmm" } +/// ResponseItem::Message { Assistant, [Text("hello")] } +/// ResponseItem::ToolCall { "tu-1", "bash", ... } +/// ``` +/// +/// converts to three consecutive assistant `RequestMessage`s. Providers such as +/// OpenAI require an assistant message that carries `tool_calls` to be followed +/// immediately by tool-result messages, not by another assistant message. Merging +/// restores a single assistant request message before the tool results are sent. fn merge_consecutive_assistant_messages(messages: &mut Vec) { let assistant_role = Role::Assistant.as_str(); let capacity = messages.len(); @@ -438,6 +442,8 @@ fn merge_consecutive_assistant_messages(messages: &mut Vec) { } } +/// Converts locked prefix `UserInput`s into request messages and prepends them +/// ahead of the existing prompt-visible history. pub fn prepend_user_inputs(messages: &mut Vec, user_inputs: &[UserInput]) { messages.splice( 0..0, diff --git a/crates/core/src/query.rs b/crates/core/src/query.rs deleted file mode 100644 index 90fd34cc..00000000 --- a/crates/core/src/query.rs +++ /dev/null @@ -1,6429 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::io::ErrorKind; -use std::sync::Arc; -use std::time::Duration; - -use devo_protocol::HostedToolDefinition; -use devo_protocol::HostedWebFetchTool; -use devo_protocol::HostedWebSearchTool; -use devo_protocol::ModelRequest; -use devo_protocol::RequestContent; -use devo_protocol::RequestMessage; -use devo_protocol::ResolvedReasoningRequest; -use devo_protocol::ResponseContent; -use devo_protocol::ResponseExtra; -use devo_protocol::SamplingControls; -use devo_protocol::StopReason; -use devo_protocol::StreamEvent; -use devo_protocol::ToolDefinition; -use devo_protocol::TruncationPolicy; -use futures::StreamExt; -use futures::future::BoxFuture; -use tokio::time::sleep; -use tokio_util::sync::CancellationToken; -use tracing::debug; -use tracing::info; -use tracing::info_span; -use tracing::warn; - -use crate::tools::ToolAgentScope; -use crate::tools::ToolCall; -use crate::tools::ToolContent; -use crate::tools::ToolRegistry; -use crate::tools::ToolRuntime; -use crate::tools::deferred_loading::is_subagent_agent_coordination_tool; -use devo_provider::ModelProviderSDK; -use devo_provider::error::ProviderError; - -use crate::AgentError; -use crate::ContentBlock; -use crate::Message; -use crate::Model; -use crate::Role; -use crate::SessionState; -use crate::TurnConfig; -use crate::context::AgentsMdDiffFragment; -use crate::context::AgentsMdManager; -use crate::context::ContextualUserFragment; -use crate::context::SessionContext; -use crate::context::TurnContext; -use crate::context::load_workspace_instructions; -use crate::context::turn_aborted::TurnAborted; -use crate::history::ContextView; -use crate::history::History; -use crate::history::TokenInfo; -use crate::history::compaction::CompactAction; -use crate::history::compaction::CompactionConfig; -use crate::history::compaction::CompactionKind; -use crate::history::compaction::compact_history; -use crate::history::summarizer::DefaultHistorySummarizer; -use crate::response_item::ResponseItem; -use crate::response_item::message_to_response_items; - -const SUBAGENT_MODE_REMINDER: &str = "\nYou are running as a sub-agent. Complete the delegated task using the available non-agent tools. Do not call agent coordination tools such as spawn_agent, send_message, await_task, list_tasks, or cancel_task; report progress and final results through assistant output.\n"; -const DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT: &str = "Your previous response contained only hidden reasoning and no user-visible answer. Provide the final answer to the user's original request now. Do not reveal or summarize hidden reasoning; return only user-visible content."; -const MAX_DSML_TEXT_TOOL_CALL_CONTINUATIONS: usize = 3; -const DSML_TEXT_TOOL_CALL_CONTINUATION_REMINDER: &str = "Your previous assistant message contained DSML tagged tool-call text. Those tags were emitted as ordinary text and no tool was executed. Do not repeat the DSML block. Continue now by using the provider's native hosted tool interface when you need a hosted tool, by invoking one of the available local tools when appropriate, or by producing normal prose if no tool is needed."; -const DSML_TOOL_CALL_MARKERS: [&str; 4] = [ - "<|DSML|tool_calls>", - "<||DSML||tool_calls>", - "<|DSML|tool_calls>", - "<||DSML||tool_calls>", -]; - -fn hosted_tools_for_web_capabilities( - web_search: &devo_config::ResolvedWebSearchConfig, - web_fetch: devo_config::ResolvedWebFetchConfig, -) -> Vec { - let mut hosted_tools = Vec::new(); - if matches!(web_search, devo_config::ResolvedWebSearchConfig::Provider) { - hosted_tools.push(HostedToolDefinition::WebSearch(HostedWebSearchTool::new())); - } - if web_fetch.is_provider() { - hosted_tools.push(HostedToolDefinition::WebFetch(HostedWebFetchTool::new())); - } - hosted_tools -} - -fn hosted_tool_name_for_reminder(tool: &HostedToolDefinition) -> &'static str { - match tool { - HostedToolDefinition::WebSearch(_) => "web_search", - HostedToolDefinition::WebFetch(_) => "web_fetch", - } -} - -#[cfg(test)] -fn hosted_tools_for_web_search( - web_search: &devo_config::ResolvedWebSearchConfig, -) -> Vec { - hosted_tools_for_web_capabilities(web_search, devo_config::ResolvedWebFetchConfig::Disabled) -} - -fn estimate_request_prompt_tokens(request: &ModelRequest) -> usize { - let system_bytes = request.system.as_ref().map_or(0, String::len); - let message_bytes = request - .messages - .iter() - .map(|message| serde_json::to_string(message).map_or(0, |json| json.len())) - .sum::(); - let tool_bytes = request - .tools - .as_ref() - .map(|tools| serde_json::to_string(tools).map_or(0, |json| json.len())) - .unwrap_or(0); - let hosted_tool_bytes = - serde_json::to_string(&request.hosted_tools).map_or(0, |json| json.len()); - (system_bytes + message_bytes + tool_bytes + hosted_tool_bytes).div_ceil(4) -} - -/// Events emitted during a query for the caller (CLI/UI) to observe. -#[derive(Debug, Clone)] -pub enum QueryEvent { - /// Provider request retry status. - ProviderRetryStatus(ProviderRetryStatus), - /// Context compaction is about to begin. - ContextCompactionStarted, - /// Context compaction replaced the current prompt history. - ContextCompactionCompleted, - /// Context compaction did not replace the current prompt history. - ContextCompactionFailed { - /// Human-readable reason the compaction did not complete. - message: String, - }, - /// Incremental text from the assistant. - TextDelta(String), - /// Incremental reasoning text from the assistant. - ReasoningDelta(String), - /// Current reasoning block completed. - ReasoningCompleted, - /// Incremental token usage update from the provider stream. - /// TODO: Review the mechanism from the OpenAI API / Anthropic API documentation. - UsageDelta { usage: devo_protocol::Usage }, - /// The assistant started a tool call. - ToolUseStart { - /// Stable provider-issued tool use identifier. - id: String, - /// Tool name selected by the model. - name: String, - /// Fully decoded tool input payload, when available. - input: serde_json::Value, - }, - /// A locally executed tool has passed permission checks and started running. - ToolExecutionStart { - /// Stable provider-issued tool use identifier. - id: String, - }, - /// Incremental output delta from a running tool. - ToolProgress { - tool_use_id: String, - progress: crate::tools::ToolProgress, - }, - /// A tool call completed. - ToolResult { - tool_use_id: String, - tool_name: String, - input: serde_json::Value, - content: ToolContent, - display_content: Option, - is_error: bool, - /// Human-readable summary for client-side rendering (e.g. "bash: npm run dev"). - summary: String, - }, - /// A turn is complete (model stopped generating). - TurnComplete { stop_reason: StopReason }, - /// Token usage update. - Usage { usage: devo_protocol::Usage }, -} - -/// Async sink for streaming `QueryEvent`s out of the core query loop. -/// -/// The type is intentionally erased so `query()` can accept callbacks from tests, the server -/// runtime, and tool-progress plumbing without knowing their concrete future types: -/// -/// - `Arc`: shared, cheap-to-clone ownership. The same callback is cloned into model-stream and -/// tool-progress paths that may outlive the immediate stack frame. -/// - `dyn Fn(QueryEvent)`: dynamic callback interface. Callers provide any closure that accepts one -/// event and can be invoked repeatedly. -/// - `BoxFuture<'static, ()>`: boxed async work returned by the callback. Boxing hides the -/// closure's concrete future type behind one trait-object shape; `'static` prevents borrowed -/// stack data from escaping into spawned or delayed event paths. -/// - `Send + Sync`: the callback can be shared and awaited across Tokio tasks and worker threads. -/// -/// Awaiting this future is what lets callers use bounded async channels for backpressure instead of -/// the old synchronous callback bridge. -pub type EventCallback = Arc BoxFuture<'static, ()> + Send + Sync>; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProviderRetryStatus { - pub provider: String, - pub model: String, - pub attempt: usize, - pub backoff_ms: u64, - pub phase: QueryProviderRetryPhase, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum QueryProviderRetryPhase { - Scheduled, - Resumed, -} - -#[derive(Clone, Default)] -pub struct QueryOptions { - pub cancel_token: Option, - /// Optional provider used only for compaction summaries. Servers use this - /// seam to attach Compaction metering without misclassifying the main - /// streaming query as compaction overhead. - pub compaction_provider: Option>, -} - -impl std::fmt::Debug for QueryOptions { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("QueryOptions") - .field("cancel_token", &self.cancel_token) - .field( - "compaction_provider", - &self - .compaction_provider - .as_ref() - .map(|provider| provider.name()), - ) - .finish() - } -} - -async fn emit_query_event(on_event: &Option, event: QueryEvent) { - if let Some(callback) = on_event { - callback(event).await; - } -} - -// --------------------------------------------------------------------------- -// Error classification -// --------------------------------------------------------------------------- - -#[derive(Debug, PartialEq, Eq)] -enum ErrorClass { - ContextTooLong, - ParameterError, - FileContentAnomaly, - AuthenticationFailure, - FeatureUnavailable, - TaskNotFound, - RateLimit, - NoApiPermission, - FileTooLarge, - ServerError, - NetworkError, - Unretryable, -} - -enum ProviderRetryDecision { - RetryAfter(Duration), - CompactAndRetry, - Fail, -} - -fn classify_error(e: &anyhow::Error) -> ErrorClass { - for cause in e.chain() { - let Some(provider_error) = cause.downcast_ref::() else { - continue; - }; - match provider_error { - ProviderError::AuthenticationError { .. } => return ErrorClass::AuthenticationFailure, - ProviderError::RateLimitError { .. } => return ErrorClass::RateLimit, - ProviderError::ProviderServerError { - status_code: Some(429), - .. - } => return ErrorClass::RateLimit, - ProviderError::ProviderServerError { - status_code: Some(408), - .. - } - | ProviderError::ProviderTimeoutError { .. } - | ProviderError::StreamError { .. } => return ErrorClass::NetworkError, - ProviderError::ProviderServerError { .. } => return ErrorClass::ServerError, - ProviderError::ContextLimitError { .. } => return ErrorClass::ContextTooLong, - ProviderError::ModelNotFoundError { .. } => return ErrorClass::TaskNotFound, - ProviderError::InvalidRequestError { .. } => return ErrorClass::ParameterError, - ProviderError::QuotaExceededError { .. } - | ProviderError::ContentFilteredError { .. } => { - return ErrorClass::Unretryable; - } - ProviderError::UnknownError { - status_code: Some(429), - .. - } => return ErrorClass::RateLimit, - ProviderError::UnknownError { - status_code: Some(408), - .. - } => return ErrorClass::NetworkError, - ProviderError::UnknownError { - status_code: Some(500..=599), - .. - } => return ErrorClass::ServerError, - ProviderError::UnknownError { .. } => {} - } - } - - if e.chain().any(|cause| { - cause - .downcast_ref::() - .is_some() - }) { - return ErrorClass::NetworkError; - } - - if e.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|error| { - error.is_timeout() - || error.is_connect() - || error.status() == Some(reqwest::StatusCode::REQUEST_TIMEOUT) - }) - }) { - return ErrorClass::NetworkError; - } - - if e.chain().any(|cause| { - cause.downcast_ref::().is_some_and(|error| { - matches!( - error.kind(), - ErrorKind::TimedOut - | ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::BrokenPipe - | ErrorKind::UnexpectedEof - ) - }) - }) { - return ErrorClass::NetworkError; - } - - let msg = e.to_string().to_lowercase(); - // TODO: Expand the error of ContextTooLong - if msg.contains("context_too_long") { - ErrorClass::ContextTooLong - } else if msg.contains("401") - || msg.contains("authentication failure") - || msg.contains("token timeout") - || msg.contains("unauthorized") - || msg.contains("api key") - { - ErrorClass::AuthenticationFailure - } else if msg.contains("404") - && (msg.contains("feature not available") - || msg.contains("fine-tuning feature not available")) - { - ErrorClass::FeatureUnavailable - } else if msg.contains("404") - && (msg.contains("task does not exist") - || msg.contains("does not exist") - || msg.contains("not found")) - { - ErrorClass::TaskNotFound - } else if msg.contains("429") || msg.contains("rate limit") { - ErrorClass::RateLimit - } else if msg.contains("434") || msg.contains("no api permission") || msg.contains("beta phase") - { - ErrorClass::NoApiPermission - } else if msg.contains("435") - || msg.contains("file size exceeds 100mb") - || msg.contains("smaller than 100mb") - { - ErrorClass::FileTooLarge - } else if msg.contains("400") - && (msg.contains("file content anomaly") - || msg.contains("jsonl file content") - || msg.contains("jsonl")) - { - ErrorClass::FileContentAnomaly - } else if msg.contains("408") - || msg.contains("request timeout") - || msg.contains("request timed out") - || msg.contains("operation timed out") - || msg.contains("timed out") - || msg.contains("deadline has elapsed") - || msg.contains("deadline exceeded") - || msg.contains("provider timeout") - || msg.contains("stream idle timeout") - || msg.contains("network error") - || msg.contains("network is unreachable") - || msg.contains("network unreachable") - || msg.contains("host unreachable") - || msg.contains("destination unreachable") - || msg.contains("unreachable host") - || msg.contains("no route to host") - || msg.contains("connection refused") - || msg.contains("connection reset") - || msg.contains("connection closed") - || msg.contains("connection aborted") - || msg.contains("connection timed out") - || msg.contains("connection failure") - || msg.contains("connection failed") - || msg.contains("failed to connect") - || msg.contains("connect error") - || msg.contains("error trying to connect") - || msg.contains("error sending request") - || msg.contains("dns error") - || msg.contains("failed to lookup address information") - || msg.contains("temporary failure in name resolution") - || msg.contains("name or service not known") - || msg.contains("nodename nor servname") - || msg.contains("could not resolve host") - || msg.contains("unexpected eof") - || msg.contains("invalidcontenttype") - || msg.contains("invalid content-type") - || msg.contains("invalid header value") - || msg.contains("text/event-stream") - // Stream-level decode/decrypt errors (e.g. TLS decrypt failure, chunk - // deserialization). These are typically transient — the proxy or - // TLS-terminator that sits between us and the provider may have had a - // hiccup; retrying usually succeeds. - || msg.contains("error decoding") - || msg.contains("decoding response") - || msg.contains("cannot decrypt") - || msg.contains("decrypt error") - || msg.contains("decrypterror") - || msg.contains("stream error") - || msg.contains("failed to decode") - { - ErrorClass::NetworkError - } else if msg.contains("400") - || msg.contains("parameter error") - || msg.contains("invalid parameter") - || msg.contains("bad request") - { - ErrorClass::ParameterError - } else if msg.starts_with('5') - || msg.contains("500") - || msg.contains("502") - || msg.contains("503") - || msg.contains("504") - || msg.contains("internal server error") - || msg.contains("server error occurred while processing the request") - { - ErrorClass::ServerError - } else { - ErrorClass::Unretryable - } -} - -fn provider_retry_decision( - error: &anyhow::Error, - retry_count: &mut usize, - context_compacted: &mut bool, -) -> ProviderRetryDecision { - match classify_error(error) { - ErrorClass::ContextTooLong => { - if *context_compacted { - ProviderRetryDecision::Fail - } else { - *context_compacted = true; - ProviderRetryDecision::CompactAndRetry - } - } - ErrorClass::RateLimit => { - if *retry_count >= MAX_RETRIES { - ProviderRetryDecision::Fail - } else { - *retry_count += 1; - ProviderRetryDecision::RetryAfter(RATE_LIMIT_RETRY_DELAY) - } - } - ErrorClass::ServerError | ErrorClass::NetworkError => { - if *retry_count >= MAX_RETRIES { - ProviderRetryDecision::Fail - } else { - *retry_count += 1; - ProviderRetryDecision::RetryAfter(retry_backoff_duration(*retry_count)) - } - } - ErrorClass::ParameterError - | ErrorClass::FileContentAnomaly - | ErrorClass::AuthenticationFailure - | ErrorClass::FeatureUnavailable - | ErrorClass::TaskNotFound - | ErrorClass::NoApiPermission - | ErrorClass::FileTooLarge - | ErrorClass::Unretryable => ProviderRetryDecision::Fail, - } -} - -// --------------------------------------------------------------------------- -// Session compaction -// --------------------------------------------------------------------------- - -/// Compact session messages using LLM-backed summarization. -/// -/// `kind` selects the preserve strategy inside [`compact_history`]: -/// - [`CompactionKind::Auto`]: preventive compaction when the session token -/// budget is high; keeps a tail token window. -/// - [`CompactionKind::Proactive`]: forced compaction after provider -/// `context_too_long`; keeps from the latest user message onward. -async fn summarize_and_compact( - session: &mut SessionState, - on_event: &Option, - provider: &Arc, - model_slug: &str, - request_model: &str, - max_tokens: usize, - kind: CompactionKind, -) { - let items: Vec = session - .prompt_source_messages() - .iter() - .cloned() - .flat_map(message_to_response_items) - .collect(); - - let token_info = TokenInfo { - input_tokens: session.total_input_tokens, - cached_input_tokens: session.total_cache_read_tokens, - output_tokens: session.total_output_tokens, - }; - - let config = CompactionConfig { - budget: session.config.token_budget.clone(), - kind, - }; - - let summarizer = DefaultHistorySummarizer::with_models( - Arc::clone(provider), - model_slug, - request_model, - max_tokens, - ); - - emit_query_event(on_event, QueryEvent::ContextCompactionStarted).await; - match compact_history(&items, &token_info, &summarizer, &config).await { - Ok(CompactAction::Replaced(compacted_items)) => { - let new_messages: Vec = compacted_items - .into_iter() - .filter_map(|item| match item { - ResponseItem::Message(msg) => Some(msg), - _ => None, - }) - .collect(); - let removed = session - .prompt_source_messages() - .len() - .saturating_sub(new_messages.len()); - info!("LLM compaction removed {removed} messages"); - session.set_prompt_messages(new_messages); - emit_query_event(on_event, QueryEvent::ContextCompactionCompleted).await; - } - Ok(CompactAction::Skipped) => { - debug!("LLM compaction skipped, nothing to compact"); - emit_query_event( - on_event, - QueryEvent::ContextCompactionFailed { - message: "Context compaction skipped: nothing to compact".to_string(), - }, - ) - .await; - } - Err(e) => { - warn!("LLM compaction failed: {e}"); - emit_query_event( - on_event, - QueryEvent::ContextCompactionFailed { - message: e.to_string(), - }, - ) - .await; - } - } -} - -// --------------------------------------------------------------------------- -// Model-visible tool result truncation -// --------------------------------------------------------------------------- - -const TOOL_RESULT_TRUNCATION_MARKER: &str = "\n...[truncated]"; - -fn truncate_tool_result_for_model( - content: String, - tool_name: Option<&str>, - truncation_policy: TruncationPolicy, -) -> String { - if preserve_full_tool_result(tool_name) { - return content; - } - - let byte_budget = truncation_policy.byte_budget(); - if content.len() <= byte_budget { - return content; - } - - let marker = if byte_budget > TOOL_RESULT_TRUNCATION_MARKER.len() { - TOOL_RESULT_TRUNCATION_MARKER - } else { - TOOL_RESULT_TRUNCATION_MARKER.trim_start() - }; - - if byte_budget <= marker.len() { - return marker.to_string(); - } - - let content_budget = byte_budget - marker.len(); - let mut truncate_at = content_budget; - while truncate_at > 0 && !content.is_char_boundary(truncate_at) { - truncate_at -= 1; - } - - let mut truncated = content[..truncate_at].to_string(); - truncated.push_str(marker); - truncated -} - -fn preserve_full_tool_result(tool_name: Option<&str>) -> bool { - matches!( - tool_name, - Some("await_task" | "wait_agent" | "subagent_result") - ) -} - -fn insert_subagent_request_reminders(messages: &mut Vec) { - let insert_at = messages - .iter() - .rposition(is_user_text_message) - .unwrap_or(messages.len()); - messages.splice( - insert_at..insert_at, - [request_text_message(SUBAGENT_MODE_REMINDER.to_string())], - ); -} - -fn insert_goal_context_message(messages: &mut Vec, goal_context: &str) { - let insert_at = if messages.last().is_some_and(is_visible_user_text_message) { - messages.len().saturating_sub(1) - } else { - messages.len() - }; - messages.splice( - insert_at..insert_at, - [request_text_message(goal_context.to_string())], - ); -} - -fn request_text_message(text: String) -> RequestMessage { - RequestMessage { - role: Role::User.as_str().to_string(), - content: vec![RequestContent::Text { text }], - } -} - -fn is_user_text_message(message: &RequestMessage) -> bool { - message.role == Role::User.as_str() - && message - .content - .iter() - .any(|content| matches!(content, RequestContent::Text { .. })) -} - -fn is_visible_user_text_message(message: &RequestMessage) -> bool { - is_user_text_message(message) && !is_injected_context_message(message) -} - -fn is_injected_context_message(message: &RequestMessage) -> bool { - message.role == Role::User.as_str() - && message.content.iter().any(|content| match content { - RequestContent::Text { text } => { - let trimmed = text.trim_start(); - trimmed.starts_with("") - || trimmed.starts_with("") - || trimmed.starts_with("") - || trimmed.starts_with("") - || trimmed.starts_with("") - || trimmed.starts_with("") - } - RequestContent::Reasoning { .. } - | RequestContent::ProviderReasoning { .. } - | RequestContent::HostedToolUse { .. } - | RequestContent::ToolUse { .. } - | RequestContent::ToolResult { .. } => false, - }) -} - -fn tool_content_model_bytes(content: &ToolContent) -> usize { - match content { - ToolContent::Text(text) => text.len(), - ToolContent::Json(json) => json.to_string().len(), - ToolContent::Mixed { text, json } => { - text.as_ref().map_or(0, String::len) - + json.as_ref().map_or(0, |json| json.to_string().len()) - } - } -} - -fn normalize_hosted_tool_id(index: usize, id: String, name: &str) -> String { - if id.is_empty() { - format!("hosted_{}_{index}", name.replace('-', "_")) - } else { - id - } -} - -fn normalize_hosted_tool_name(name: String) -> String { - if name.is_empty() { - "web_search".to_string() - } else { - name - } -} - -fn hosted_tool_input_or_previous( - input: serde_json::Value, - previous: Option<&serde_json::Value>, -) -> serde_json::Value { - if matches!(&input, serde_json::Value::Object(map) if map.is_empty()) { - previous.cloned().unwrap_or(input) - } else { - input - } -} - -async fn emit_hosted_tool_start( - on_event: &Option, - emitted_tool_use_starts: &mut HashSet, - id: &str, - name: &str, - input: &serde_json::Value, -) { - if emitted_tool_use_starts.insert(id.to_string()) { - emit_query_event( - on_event, - QueryEvent::ToolUseStart { - id: id.to_string(), - name: name.to_string(), - input: input.clone(), - }, - ) - .await; - } -} - -struct HostedToolResultEvent<'a> { - id: &'a str, - name: &'a str, - input: &'a serde_json::Value, - output: Option, - status: Option, -} - -async fn emit_hosted_tool_result( - on_event: &Option, - emitted_tool_results: &mut HashSet, - session_cwd: &std::path::Path, - event: HostedToolResultEvent<'_>, -) { - let HostedToolResultEvent { - id, - name, - input, - output, - status, - } = event; - if !emitted_tool_results.insert(id.to_string()) { - return; - } - - let text = hosted_tool_result_text(name, input, output.as_ref(), status.as_deref()); - let content = if output.is_some() { - ToolContent::Mixed { - text: Some(text.clone()), - json: output.clone(), - } - } else { - ToolContent::Text(text.clone()) - }; - let summary = crate::tools::tool_summary::tool_summary(name, input, session_cwd); - emit_query_event( - on_event, - QueryEvent::ToolResult { - tool_use_id: id.to_string(), - tool_name: name.to_string(), - input: input.clone(), - content, - display_content: Some(text), - is_error: hosted_tool_status_is_error(status.as_deref()), - summary, - }, - ) - .await; -} - -fn hosted_tool_result_text( - _name: &str, - _input: &serde_json::Value, - _output: Option<&serde_json::Value>, - status: Option<&str>, -) -> String { - let status = status - .filter(|status| !status.is_empty()) - .unwrap_or("completed"); - format!("status: {status}") -} -fn hosted_tool_status_is_error(status: Option<&str>) -> bool { - status - .map(str::to_ascii_lowercase) - .is_some_and(|status| matches!(status.as_str(), "error" | "errored" | "failed")) -} - -fn assistant_content_contains_dsml_tool_call_text(content: &[ContentBlock]) -> bool { - content.iter().any(|block| match block { - ContentBlock::Text { text } => DSML_TOOL_CALL_MARKERS - .iter() - .any(|marker| text.contains(marker)), - ContentBlock::Reasoning { .. } - | ContentBlock::ProviderReasoning { .. } - | ContentBlock::ToolUse { .. } - | ContentBlock::HostedToolUse { .. } - | ContentBlock::ToolResult { .. } => false, - }) -} - -fn assistant_content_has_visible_content(content: &[ContentBlock]) -> bool { - content.iter().any(|block| match block { - ContentBlock::Text { text } - | ContentBlock::Reasoning { text } - | ContentBlock::ToolResult { content: text, .. } => !text.trim().is_empty(), - ContentBlock::ProviderReasoning { .. } - | ContentBlock::ToolUse { .. } - | ContentBlock::HostedToolUse { .. } => true, - }) -} - -fn dsml_text_tool_call_continuation_message( - request_tools: &[ToolDefinition], - hosted_tools: &[HostedToolDefinition], -) -> Message { - let mut reminder = String::from("\n"); - reminder.push_str(DSML_TEXT_TOOL_CALL_CONTINUATION_REMINDER); - let local_tool_names = request_tools - .iter() - .map(|tool| tool.name.as_str()) - .collect::>(); - if !local_tool_names.is_empty() { - reminder.push_str("\n\nAvailable local tools: "); - reminder.push_str(&local_tool_names.join(", ")); - reminder.push('.'); - } - let hosted_tool_names = hosted_tools - .iter() - .map(hosted_tool_name_for_reminder) - .collect::>(); - if !hosted_tool_names.is_empty() { - reminder.push_str("\nAvailable hosted tools: "); - reminder.push_str(&hosted_tool_names.join(", ")); - reminder.push_str(". Hosted tools must be invoked through provider-native server tool calls, not by writing DSML tags in text."); - } - if local_tool_names.contains(&"spawn_agent") && local_tool_names.contains(&"await_task") { - reminder.push_str("\nFor research work with separable subtasks, prefer spawning independent agents first and then waiting for their results."); - } - reminder.push_str("\n"); - Message::user(reminder) -} - -// --------------------------------------------------------------------------- -// Main query loop -// --------------------------------------------------------------------------- - -const MAX_RETRIES: usize = 5; -const INITIAL_RETRY_BACKOFF_MS: u64 = 250; -const RATE_LIMIT_RETRY_DELAY: Duration = Duration::from_secs(60); - -/// TODO: The body of `query` is too lengthy, we should move out `stream lop` out, I am -/// not sure whether we should do this. -/// The recursive agent loop the beating heart of the runtime. -/// -/// The implementation refers to Claude Code's `query.ts`. It drives -/// multi-turn conversations by: -/// -/// 1. Building the model request from session state -/// 2. Streaming the model response -/// 3. Collecting assistant text and tool_use blocks -/// 4. Executing tool calls via the orchestrator -/// 5. Appending tool_result messages -/// 6. Recursing if the model wants to continue -/// -/// The loop terminates when: -/// - The model emits `end_turn` with no tool calls -/// - An unrecoverable error occurs -pub async fn query( - session: &mut SessionState, - turn_config: &TurnConfig, - provider: Arc, - registry: Arc, - runtime: &ToolRuntime, - on_event: Option, -) -> Result<(), AgentError> { - query_with_options( - session, - turn_config, - provider, - registry, - runtime, - on_event, - QueryOptions::default(), - ) - .await -} - -pub async fn query_with_options( - session: &mut SessionState, - turn_config: &TurnConfig, - provider: Arc, - registry: Arc, - runtime: &ToolRuntime, - on_event: Option, - options: QueryOptions, -) -> Result<(), AgentError> { - let compaction_provider = options - .compaction_provider - .as_ref() - .unwrap_or(&provider) - .clone(); - let agents_md_manager = AgentsMdManager::new(session.config.agents_md.clone()); - let current_agents_snapshot = load_workspace_instructions(&session.cwd, &agents_md_manager); - let agent_scope = runtime.agent_scope(); - let mut request_tools = registry.tool_definitions(); - if agent_scope == ToolAgentScope::Subagent { - request_tools.retain(|tool| !is_subagent_agent_coordination_tool(&tool.name)); - } - if !turn_config.web_search.is_local() { - request_tools.retain(|tool| tool.name != "web_search"); - } - if !turn_config.web_fetch.is_local() { - request_tools.retain(|tool| tool.name != "webfetch"); - } - - if session.session_context.is_none() { - session.session_context = Some(SessionContext::capture( - &turn_config.model, - turn_config.reasoning_effort_selection.as_deref(), - &session.cwd, - current_agents_snapshot.clone(), - session.config.available_skills_instructions.clone(), - )); - } - let current_turn_context = - TurnContext::capture(session, turn_config, current_agents_snapshot.clone()); - if let Some(context_changes) = - current_turn_context.context_changes_since(session.latest_turn_context.as_ref()) - { - session.insert_context_message(context_changes.to_message()); - } - if let Some(previous_turn_context) = session.latest_turn_context.as_ref() - && let Some(diff) = AgentsMdManager::diff( - previous_turn_context.observed_agents_snapshot.as_ref(), - current_agents_snapshot.as_ref(), - ) - { - session.insert_context_message(AgentsMdDiffFragment::new(diff).to_message()); - } - session.latest_turn_context = Some(current_turn_context.clone()); - let session_context = session - .session_context - .clone() - .expect("session context should be initialized"); - let prefetched_user_inputs = session_context.prefix_user_inputs(); - - let mut retry_count: usize = 0; - let mut context_compacted = false; - let mut budget_steer_injected = false; - let deepseek_v4_thinking_only_continuation_enabled = - turn_config.model.slug.starts_with("deepseek-v4-") - || turn_config.request_model.starts_with("deepseek-v4-"); - let mut deepseek_v4_thinking_only_continuation_used = false; - let mut dsml_text_tool_call_continuations = 0usize; - - if session.turn_state.is_none() { - session.start_turn(devo_protocol::TurnKind::Regular); - } - - let compaction_model_slug = turn_config - .model - .resolve_reasoning_effort_selection(turn_config.reasoning_effort_selection.as_deref()) - .request_model; - let compaction_request_model = turn_config.provider_request_model(&compaction_model_slug); - - // Explicit interrupted-turn notice for the next user message after a user - // interrupt. Placed before pending-input processing so it sits just above the - // latest user text in prompt construction (after any context diff). - let previous_turn_interrupted = session.take_last_turn_interrupted(); - if previous_turn_interrupted { - let fragment = TurnAborted::new(TurnAborted::INTERRUPTED_GUIDANCE); - if let ResponseItem::Message(msg) = fragment.to_response_item() { - session.insert_context_message(msg); - } - } - - 'query_loop: loop { - let pending = session.take_turn_pending_input(); - - for item in &pending { - match &item.kind { - devo_protocol::PendingInputKind::UserText { text } => { - session.push_message(Message::user(text.clone())); - } - devo_protocol::PendingInputKind::UserInput { - prompt_text, - prompt_messages, - .. - } => { - if prompt_messages.is_empty() { - session.push_message(Message::user(prompt_text.clone())); - } else { - for prompt_message in prompt_messages { - session.push_message(Message::user(prompt_message.clone())); - } - } - } - devo_protocol::PendingInputKind::ToolCallBlockedByHook { - tool_use_id, - reason, - } => { - session.push_message(Message::user(format!( - "[Tool call {} was blocked: {}]", - tool_use_id, reason - ))); - } - devo_protocol::PendingInputKind::BudgetLimitSteering => { - session.push_message(Message::system( - "Note: The conversation is approaching the token budget limit. \ - Please be concise and consider wrapping up the current task.", - )); - } - } - } - - // 1.3 + 1.7: Check token budget and compact before building the request - if session.last_turn_tokens > 0 - && session - .config - .token_budget - .should_compact(session.last_turn_tokens) - { - if !budget_steer_injected { - if let Some(turn) = session.turn_state.as_mut() { - turn.push_pending_input(devo_protocol::PendingInputItem::new( - devo_protocol::PendingInputKind::BudgetLimitSteering, - None, - chrono::Utc::now(), - )); - } - budget_steer_injected = true; - } - info!("token budget threshold exceeded, running LLM compaction"); - // Auto: preserve tail items up to COMPACT_USER_MESSAGE_MAX_TOKENS. - // Example: [user1, asst1, user2, asst2, user3] -> [summary, asst2, user3]. - summarize_and_compact( - session, - &on_event, - &compaction_provider, - &compaction_model_slug, - &compaction_request_model, - turn_config.model.max_tokens.unwrap_or(4096) as usize, - CompactionKind::Auto, - ) - .await; - } - - session.turn_count += 1; - let turn_span = info_span!( - "turn", - turn = session.turn_count, - session_id = %session.id, - model = %turn_config.model.slug, - cwd = %session.cwd.display() - ); - let _turn_guard = turn_span.enter(); - info!("starting turn"); - - // Build model request from the session-locked prefix. - let request_system = { - let mut system = session_context.build_system_prompt(); - if !matches!( - &turn_config.web_search, - devo_config::ResolvedWebSearchConfig::Disabled - ) { - if !system.trim().is_empty() { - system.push_str("\n\n"); - } - system.push_str(&crate::tools::websearch_prompt::web_search_prompt()); - } - Some(system).filter(|system| !system.trim().is_empty()) - }; - - // Resolve provider-bound reasoning request parameters. - let ResolvedReasoningRequest { - request_model, - request_thinking, - request_reasoning_effort, - extra_body, - effective_reasoning_effort: _, - } = turn_config - .model - .resolve_reasoning_effort_selection(turn_config.reasoning_effort_selection.as_deref()); - let catalog_request_model = request_model.clone(); - let provider_request_model = turn_config.provider_request_model(&catalog_request_model); - - let prompt_source_message_count = session.prompt_source_messages().len(); - let history_items = session - .prompt_source_messages() - .iter() - .cloned() - .flat_map(message_to_response_items) - .collect::>(); - let prompt_source_item_count = history_items.len(); - let history = History { - items: history_items, - token_info: TokenInfo::default(), - context: ContextView::new( - std::env::consts::OS, - session_context.environment.shell.clone(), - session_context.environment.timezone.clone(), - session_context.model.slug.clone(), - session_context - .reasoning_effort - .map(|effort| effort.label().to_lowercase()), - Some(session_context.persona.as_str().to_string()), - session_context.environment.current_date.clone(), - session_context.environment.cwd.display().to_string(), - ), - }; - let mut messages = history - .for_prompt_with_prefix(&prefetched_user_inputs, &turn_config.model.input_modalities); - if let Some(goal_context) = session.goal_context_prompt() { - insert_goal_context_message(&mut messages, &goal_context); - } - if agent_scope == ToolAgentScope::Subagent { - insert_subagent_request_reminders(&mut messages); - } - - let hosted_tools = - hosted_tools_for_web_capabilities(&turn_config.web_search, turn_config.web_fetch); - let request = ModelRequest { - model_slug: devo_protocol::ModelProfileKey::CatalogSlug(catalog_request_model), - model: provider_request_model, - system: request_system, - messages, - max_tokens: turn_config - .model - .max_tokens - .map_or(session.config.token_budget.max_output_tokens, |value| { - value as usize - }), - tools: Some(request_tools.clone()), - hosted_tools: hosted_tools.clone(), - sampling: SamplingControls { - temperature: turn_config.model.temperature, - top_p: turn_config.model.top_p, - top_k: turn_config.model.top_k.map(|value| value as u32), - }, - request_thinking, - reasoning_effort: request_reasoning_effort, - extra_body, - }; - session.prompt_token_estimate = estimate_request_prompt_tokens(&request); - debug!( - prompt_source_messages = prompt_source_message_count, - prompt_source_items = prompt_source_item_count, - prefix_user_inputs = prefetched_user_inputs.len(), - request_messages = request.messages.len(), - exposed_tools = request.tools.as_ref().map_or(0, Vec::len), - prompt_token_estimate = session.prompt_token_estimate, - max_tokens = request.max_tokens, - has_system = request.system.is_some(), - "built model request" - ); - - // Stream with error classification - let stream_result = provider.completion_stream(request).await; - - let mut stream = match stream_result { - Ok(s) => s, - Err(e) => { - warn!( - provider = provider.name(), - model = %turn_config.model.slug, - turn = session.turn_count, - error = ?e, - "failed to create provider stream" - ); - match provider_retry_decision(&e, &mut retry_count, &mut context_compacted) { - ProviderRetryDecision::CompactAndRetry => { - warn!("context_too_long - compacting and retrying"); - // Proactive: must compact even if token estimates disagree - // with the provider; preserve from latest user only. - summarize_and_compact( - session, - &on_event, - &compaction_provider, - &compaction_model_slug, - &compaction_request_model, - turn_config.model.max_tokens.unwrap_or(4096) as usize, - CompactionKind::Proactive, - ) - .await; - session.turn_count -= 1; - continue; - } - ProviderRetryDecision::RetryAfter(backoff) => { - warn!( - attempt = retry_count, - backoff_ms = backoff.as_millis(), - "transient provider error - retrying with exponential backoff" - ); - wait_for_provider_retry( - &on_event, - options.cancel_token.as_ref(), - provider.name(), - &turn_config.model.slug, - retry_count, - backoff, - ) - .await?; - session.turn_count -= 1; - continue; - } - ProviderRetryDecision::Fail => { - return Err(AgentError::Provider(e)); - } - } - } - }; - - // HTTP return ok, then processing Server Sent Event - - let mut assistant_text = String::new(); - let mut reasoning_text = String::new(); - let mut tool_uses: Vec<(usize, String, String, serde_json::Value, String, bool)> = - Vec::new(); - let mut hosted_tool_inputs: HashMap = - HashMap::new(); - let mut emitted_tool_use_starts: HashSet = HashSet::new(); - let mut emitted_hosted_tool_starts: HashSet = HashSet::new(); - let mut emitted_hosted_tool_results: HashSet = HashSet::new(); - let mut final_response = None; - let mut stop_reason = None; - - loop { - tokio::select! { - biased; - _ = async { - if let Some(ct) = options.cancel_token.as_ref() { - ct.cancelled().await - } else { - std::future::pending::<()>().await - } - } => - { - break; - } - event = stream.next() => { - let Some(event) = event else { break; }; - match event { - Ok(StreamEvent::TextStart { .. }) => {} - Ok(StreamEvent::TextDelta { text, .. }) => { - assistant_text.push_str(&text); - emit_query_event(&on_event, QueryEvent::TextDelta(text)).await; - } - Ok(StreamEvent::ReasoningStart { .. }) => {} - Ok(StreamEvent::ReasoningDelta { text, .. }) => { - reasoning_text.push_str(&text); - emit_query_event(&on_event, QueryEvent::ReasoningDelta(text)).await; - } - Ok(StreamEvent::ReasoningDone { .. }) => { - emit_query_event(&on_event, QueryEvent::ReasoningCompleted).await; - } - Ok(StreamEvent::ToolCallStart { - index, - id, - name, - input, - }) => { - tool_uses.push((index, id, name, input, String::new(), false)); - } - Ok(StreamEvent::HostedToolCallStart { - index, - id, - name, - input, - }) => { - let id = normalize_hosted_tool_id(index, id, &name); - let name = normalize_hosted_tool_name(name); - hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); - emit_hosted_tool_start( - &on_event, - &mut emitted_hosted_tool_starts, - &id, - &name, - &input, - ) - .await; - } - Ok(StreamEvent::HostedToolCallDone { - index, - id, - name, - input, - output, - status, - }) => { - let id = normalize_hosted_tool_id(index, id, &name); - let name = normalize_hosted_tool_name(name); - let previous_input = hosted_tool_inputs - .get(&id) - .map(|(_, _, previous_input)| previous_input); - let input = hosted_tool_input_or_previous(input, previous_input); - hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); - emit_hosted_tool_start( - &on_event, - &mut emitted_hosted_tool_starts, - &id, - &name, - &input, - ) - .await; - emit_hosted_tool_result( - &on_event, - &mut emitted_hosted_tool_results, - &session.cwd, - HostedToolResultEvent { - id: &id, - name: &name, - input: &input, - output, - status, - }, - ) - .await; - } - Ok(StreamEvent::ToolCallInputDelta { - index, - partial_json, - }) => { - if let Some(tool_use) = tool_uses - .iter_mut() - .rev() - .find(|(tool_index, ..)| *tool_index == index) - { - tool_use.4.push_str(&partial_json); - tool_use.5 = true; - } - } - Ok(StreamEvent::MessageDone { response }) => { - stop_reason = response.stop_reason.clone(); - final_response = Some(response.clone()); - - // Accumulate all usage counters at completion time. - session.total_input_tokens += response.usage.input_tokens; - session.total_output_tokens += response.usage.output_tokens; - session.total_tokens += response.usage.display_total_tokens(); - session.total_cache_creation_tokens += - response.usage.cache_creation_input_tokens.unwrap_or(0); - session.total_cache_read_tokens += - response.usage.cache_read_input_tokens.unwrap_or(0); - session.last_input_tokens = response.usage.input_tokens; - session.last_turn_tokens = response.usage.display_total_tokens(); - - emit_query_event( - &on_event, - QueryEvent::Usage { - usage: response.usage.clone(), - }, - ) - .await; - } - Ok(StreamEvent::UsageDelta(usage)) => { - emit_query_event(&on_event, QueryEvent::UsageDelta { usage }).await; - } - Err(e) => { - warn!( - provider = provider.name(), - model = %turn_config.model.slug, - turn = session.turn_count, - error = ?e, - "stream error" - ); - if !assistant_text.is_empty() - || !reasoning_text.is_empty() - || !tool_uses.is_empty() - || !hosted_tool_inputs.is_empty() - || final_response.is_some() - { - return Err(AgentError::Provider(e)); - } - - match provider_retry_decision(&e, &mut retry_count, &mut context_compacted) { - ProviderRetryDecision::CompactAndRetry => { - warn!("context_too_long - compacting and retrying"); - // Proactive: must compact even if token estimates disagree - // with the provider; preserve from latest user only. - summarize_and_compact( - session, - &on_event, - &compaction_provider, - &compaction_model_slug, - &compaction_request_model, - turn_config.model.max_tokens.unwrap_or(4096) as usize, - CompactionKind::Proactive, - ) - .await; - session.turn_count -= 1; - continue 'query_loop; - } - ProviderRetryDecision::RetryAfter(backoff) => { - warn!( - attempt = retry_count, - backoff_ms = backoff.as_millis(), - "transient provider stream error - retrying with exponential backoff" - ); - wait_for_provider_retry( - &on_event, - options.cancel_token.as_ref(), - provider.name(), - &turn_config.model.slug, - retry_count, - backoff, - ) - .await?; - session.turn_count -= 1; - continue 'query_loop; - } - ProviderRetryDecision::Fail => { - return Err(AgentError::Provider(e)); - } - } - } - } - } - } - } - - retry_count = 0; - context_compacted = false; - - let mut response_assistant_content = Vec::new(); - let mut final_response_tool_use_ids = HashSet::new(); - let mut has_provider_reasoning_content = false; - let mut has_hosted_tool_uses = false; - if let Some(response) = &final_response { - let has_provider_reasoning = response - .content - .iter() - .any(|block| matches!(block, ResponseContent::ProviderReasoning { .. })); - if assistant_text.is_empty() { - assistant_text = response - .content - .iter() - .filter_map(|block| match block { - ResponseContent::Text(text) => Some(text.as_str()), - ResponseContent::ToolUse { .. } - | ResponseContent::HostedToolUse { .. } - | ResponseContent::ProviderReasoning { .. } => None, - }) - .collect(); - } - if tool_uses.is_empty() { - tool_uses = response - .content - .iter() - .enumerate() - .filter_map(|(index, block)| match block { - ResponseContent::ToolUse { id, name, input } => Some(( - index, - id.clone(), - name.clone(), - input.clone(), - String::new(), - false, - )), - ResponseContent::Text(_) - | ResponseContent::HostedToolUse { .. } - | ResponseContent::ProviderReasoning { .. } => None, - }) - .collect(); - } - for (index, block) in response.content.iter().enumerate() { - match block { - ResponseContent::Text(text) => { - if !text.is_empty() { - response_assistant_content - .push(ContentBlock::Text { text: text.clone() }); - } - } - ResponseContent::ToolUse { id, name, input } => { - final_response_tool_use_ids.insert(id.clone()); - response_assistant_content.push(ContentBlock::ToolUse { - id: id.clone(), - name: name.clone(), - input: input.clone(), - }); - } - ResponseContent::HostedToolUse { - id, - name, - input, - output, - status, - } => { - let id = normalize_hosted_tool_id(index, id.clone(), name); - let name = normalize_hosted_tool_name(name.clone()); - let previous_input = hosted_tool_inputs - .get(&id) - .map(|(_, _, previous_input)| previous_input); - let input = hosted_tool_input_or_previous(input.clone(), previous_input); - has_hosted_tool_uses = true; - response_assistant_content.push(ContentBlock::HostedToolUse { - id: id.clone(), - name: name.clone(), - input: input.clone(), - output: output.clone(), - status: status.clone(), - }); - hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); - emit_hosted_tool_start( - &on_event, - &mut emitted_hosted_tool_starts, - &id, - &name, - &input, - ) - .await; - if output.is_some() || status.is_some() { - emit_hosted_tool_result( - &on_event, - &mut emitted_hosted_tool_results, - &session.cwd, - HostedToolResultEvent { - id: &id, - name: &name, - input: &input, - output: output.clone(), - status: status.clone(), - }, - ) - .await; - } - } - ResponseContent::ProviderReasoning { provider, payload } => { - has_provider_reasoning_content = true; - response_assistant_content.push(ContentBlock::ProviderReasoning { - provider: provider.clone(), - payload: payload.clone(), - }); - } - } - } - if reasoning_text.is_empty() && has_provider_reasoning { - let final_reasoning = response_assistant_content - .iter() - .filter_map(|block| match block { - ContentBlock::ProviderReasoning { payload, .. } => { - payload.get("thinking").and_then(serde_json::Value::as_str) - } - ContentBlock::Text { .. } - | ContentBlock::Reasoning { .. } - | ContentBlock::ToolUse { .. } - | ContentBlock::HostedToolUse { .. } - | ContentBlock::ToolResult { .. } => None, - }) - .collect::(); - if !final_reasoning.is_empty() { - emit_query_event( - &on_event, - QueryEvent::ReasoningDelta(final_reasoning.clone()), - ) - .await; - emit_query_event(&on_event, QueryEvent::ReasoningCompleted).await; - reasoning_text = final_reasoning; - } - } - if reasoning_text.is_empty() && !has_provider_reasoning { - let final_reasoning = response - .metadata - .extras - .iter() - .filter_map(|extra| match extra { - ResponseExtra::ReasoningText { text } => Some(text.as_str()), - ResponseExtra::ProviderSpecific { .. } => None, - }) - .collect::(); - if !final_reasoning.is_empty() { - emit_query_event( - &on_event, - QueryEvent::ReasoningDelta(final_reasoning.clone()), - ) - .await; - emit_query_event(&on_event, QueryEvent::ReasoningCompleted).await; - reasoning_text = final_reasoning; - } - } - } - - let pending_hosted_tools = hosted_tool_inputs - .iter() - .map(|(id, (_index, name, input))| (id.clone(), name.clone(), input.clone())) - .collect::>(); - for (id, name, input) in pending_hosted_tools { - emit_hosted_tool_start( - &on_event, - &mut emitted_hosted_tool_starts, - &id, - &name, - &input, - ) - .await; - emit_hosted_tool_result( - &on_event, - &mut emitted_hosted_tool_results, - &session.cwd, - HostedToolResultEvent { - id: &id, - name: &name, - input: &input, - output: None, - status: Some("completed".to_string()), - }, - ) - .await; - } - - // Build assistant message - let mut assistant_content: Vec = response_assistant_content; - - if !reasoning_text.trim().is_empty() && !has_provider_reasoning_content { - assistant_content.insert( - 0, - ContentBlock::Reasoning { - text: reasoning_text, - }, - ); - } - - let assistant_text_has_visible_content = !assistant_text.trim().is_empty(); - - if assistant_content.is_empty() && !assistant_text.is_empty() { - assistant_content.push(ContentBlock::Text { - text: assistant_text, - }); - } - - let deepseek_v4_thinking_only_end_turn = deepseek_v4_thinking_only_continuation_enabled - && stop_reason == Some(StopReason::EndTurn) - && !assistant_text_has_visible_content - && tool_uses.is_empty() - && !has_hosted_tool_uses - && has_provider_reasoning_content; - - let final_tool_inputs: HashMap = final_response - .as_ref() - .map(|response| { - response - .content - .iter() - .filter_map(|block| match block { - ResponseContent::ToolUse { id, input, .. } => { - Some((id.clone(), input.clone())) - } - ResponseContent::Text(_) - | ResponseContent::HostedToolUse { .. } - | ResponseContent::ProviderReasoning { .. } => None, - }) - .collect() - }) - .unwrap_or_default(); - - let mut tool_calls = Vec::with_capacity(tool_uses.len()); - for (_index, id, name, initial_input, json_str, saw_delta) in tool_uses { - let input = if saw_delta { - serde_json::from_str(&json_str).unwrap_or_else(|_| { - final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) - }) - } else { - final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) - }; - if emitted_tool_use_starts.insert(id.clone()) { - emit_query_event( - &on_event, - QueryEvent::ToolUseStart { - id: id.clone(), - name: name.clone(), - input: input.clone(), - }, - ) - .await; - } - if !final_response_tool_use_ids.contains(&id) { - assistant_content.push(ContentBlock::ToolUse { - id: id.clone(), - name: name.clone(), - input: input.clone(), - }); - } - tool_calls.push(ToolCall { id, name, input }); - } - - let assistant_content_contains_dsml_tool_call = - assistant_content_contains_dsml_tool_call_text(&assistant_content); - if assistant_content_has_visible_content(&assistant_content) { - session.push_message(Message { - role: Role::Assistant, - content: assistant_content, - }); - } - - if deepseek_v4_thinking_only_end_turn { - if deepseek_v4_thinking_only_continuation_used { - return Err(AgentError::Provider(anyhow::anyhow!( - "deepseek-v4 returned thinking-only end_turn after continuation; no user-visible text was produced" - ))); - } - debug!("deepseek-v4 returned thinking-only end_turn; injecting continuation prompt"); - deepseek_v4_thinking_only_continuation_used = true; - session.push_message(Message::user(DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT)); - continue; - } - - // If no tool calls, check stop reason - if tool_calls.is_empty() { - if has_hosted_tool_uses && stop_reason == Some(StopReason::ToolUse) { - debug!("hosted tool use returned without local calls, continuing query loop"); - continue; - } - - if assistant_content_contains_dsml_tool_call { - if dsml_text_tool_call_continuations >= MAX_DSML_TEXT_TOOL_CALL_CONTINUATIONS { - return Err(AgentError::Provider(anyhow::anyhow!( - "provider returned DSML text tool calls {MAX_DSML_TEXT_TOOL_CALL_CONTINUATIONS} times without structured or hosted tool results" - ))); - } - dsml_text_tool_call_continuations += 1; - debug!( - "DSML text tool call returned without structured tool result; continuing query loop" - ); - session.push_message(dsml_text_tool_call_continuation_message( - &request_tools, - &hosted_tools, - )); - continue; - } - - // MaxOutputTokens auto-continue - if stop_reason == Some(StopReason::MaxTokens) { - debug!("max_tokens reached injecting continuation prompt"); - session.push_message(Message::user("Please continue from where you left off.")); - continue; - } - - if let Some(sr) = stop_reason { - emit_query_event(&on_event, QueryEvent::TurnComplete { stop_reason: sr }).await; - } - debug!("no tool calls, ending query loop"); - session.end_turn(); - if options - .cancel_token - .as_ref() - .is_some_and(|ct| ct.is_cancelled()) - { - return Err(AgentError::Aborted); - } - return Ok(()); - } - - // If the turn was cancelled (e.g. mid-stream interrupt with partial - // tool calls), save whatever partial assistant content was already - // committed above, skip tool execution, and end the turn. - if options - .cancel_token - .as_ref() - .is_some_and(|ct| ct.is_cancelled()) - { - session.end_turn(); - return Ok(()); - } - - let tool_result_metadata: HashMap = tool_calls - .iter() - .map(|call| { - ( - call.id.clone(), - ( - call.name.clone(), - call.input.clone(), - crate::tools::tool_summary::tool_summary( - &call.name, - &call.input, - &session.cwd, - ), - ), - ) - }) - .collect(); - - // Execute tool calls. When a caller is observing query events, wire - // tool progress and per-call completion into the same event stream so - // long-running and parallel tools can render before the whole batch ends. - let results = if let Some(progress_events) = on_event.clone() { - let completion_events = Arc::clone(&progress_events); - let metadata = Arc::new(tool_result_metadata.clone()); - runtime - .execute_batch_streaming_with_completion( - &tool_calls, - move |tool_use_id, progress| { - let progress_events = Arc::clone(&progress_events); - Box::pin(async move { - progress_events(QueryEvent::ToolProgress { - tool_use_id, - progress, - }) - .await; - }) - }, - move |result| { - let completion_events = Arc::clone(&completion_events); - let metadata = Arc::clone(&metadata); - Box::pin(async move { - let (tool_name, input, summary) = metadata - .get(result.tool_use_id.as_str()) - .cloned() - .unwrap_or_else(|| { - (String::new(), serde_json::Value::Null, String::new()) - }); - completion_events(QueryEvent::ToolResult { - tool_use_id: result.tool_use_id, - tool_name, - input, - content: result.content, - display_content: result.display_content, - is_error: result.is_error, - summary, - }) - .await; - }) - }, - ) - .await - } else { - runtime.execute_batch(&tool_calls).await - }; - let tool_result_count = results.len(); - let tool_error_count = results.iter().filter(|result| result.is_error).count(); - let tool_output_bytes = results - .iter() - .map(|result| tool_content_model_bytes(&result.content)) - .sum::(); - debug!( - tool_calls = tool_calls.len(), - tool_results = tool_result_count, - tool_errors = tool_error_count, - tool_output_bytes, - "tool batch completed" - ); - - // Build tool result message (user role, per Anthropic API convention) - let truncation_policy = TruncationPolicy::from(turn_config.model.truncation_policy); - let result_content: Vec = results - .into_iter() - .map(|r| { - let tool_name = tool_result_metadata - .get(r.tool_use_id.as_str()) - .map(|(tool_name, _, _)| tool_name.as_str()); - let content_str = r.content.into_string(); - let content = - truncate_tool_result_for_model(content_str, tool_name, truncation_policy); - ContentBlock::ToolResult { - tool_use_id: r.tool_use_id, - content, - is_error: r.is_error, - } - }) - .collect(); - - session.push_message(Message { - role: Role::User, - content: result_content, - }); - - // If the turn was cancelled while tools were running, keep the - // interrupted tool results above and stop without another model call. - if options - .cancel_token - .as_ref() - .is_some_and(CancellationToken::is_cancelled) - { - session.end_turn(); - return Err(AgentError::Aborted); - } - } -} - -/// Sends a minimal provider probe request used by onboarding and configuration checks. -pub async fn test_model_connection( - provider: &dyn ModelProviderSDK, - model: &Model, - model_profile: devo_protocol::ModelProfileKey, - request_model: &str, - prompt: &str, -) -> Result { - let ResolvedReasoningRequest { - request_model: _, - request_thinking, - request_reasoning_effort, - extra_body, - effective_reasoning_effort: _, - } = model.resolve_reasoning_effort_selection(None); - let request = ModelRequest { - model_slug: model_profile, - model: request_model.to_string(), - system: None, - messages: vec![devo_protocol::RequestMessage { - role: "user".to_string(), - content: vec![devo_protocol::RequestContent::Text { - text: prompt.to_string(), - }], - }], - max_tokens: model.max_tokens.map_or(64, |value| value as usize), - tools: None, - hosted_tools: Vec::new(), - sampling: SamplingControls { - temperature: model.temperature, - top_p: model.top_p, - top_k: model.top_k.map(|value| value as u32), - }, - request_thinking, - reasoning_effort: request_reasoning_effort, - extra_body, - }; - let mut stream = provider.completion_stream(request).await?; - let mut reply_preview = String::new(); - while let Some(event) = stream.next().await { - match event? { - StreamEvent::TextDelta { text, .. } => reply_preview.push_str(&text), - StreamEvent::MessageDone { response } => { - if reply_preview.trim().is_empty() { - reply_preview = response - .content - .into_iter() - .find_map(|content| match content { - ResponseContent::Text(text) => Some(text), - _ => None, - }) - .unwrap_or_default(); - } - break; - } - _ => {} - } - } - let preview = reply_preview.trim(); - if preview.is_empty() { - return Err(AgentError::Provider(anyhow::anyhow!( - "provider validation completed without a model reply" - ))); - } - Ok(preview.to_string()) -} - -async fn wait_for_provider_retry( - on_event: &Option, - cancel_token: Option<&CancellationToken>, - provider: &str, - model: &str, - attempt: usize, - backoff: Duration, -) -> Result<(), AgentError> { - let backoff_ms = backoff.as_millis().min(u128::from(u64::MAX)) as u64; - emit_query_event( - on_event, - QueryEvent::ProviderRetryStatus(ProviderRetryStatus { - provider: provider.to_string(), - model: model.to_string(), - attempt, - backoff_ms, - phase: QueryProviderRetryPhase::Scheduled, - message: format!("Retrying provider request in {:.1}s", backoff.as_secs_f64()), - }), - ) - .await; - - if let Some(cancel_token) = cancel_token { - tokio::select! { - biased; - () = cancel_token.cancelled() => return Err(AgentError::Aborted), - () = sleep(backoff) => {} - } - } else { - sleep(backoff).await; - } - - emit_query_event( - on_event, - QueryEvent::ProviderRetryStatus(ProviderRetryStatus { - provider: provider.to_string(), - model: model.to_string(), - attempt, - backoff_ms: 0, - phase: QueryProviderRetryPhase::Resumed, - message: "Retrying provider request now".to_string(), - }), - ) - .await; - - Ok(()) -} - -fn retry_backoff_duration(attempt: usize) -> Duration { - let exponent = attempt.saturating_sub(1).min(10) as u32; - let multiplier = 2u64.pow(exponent); - Duration::from_millis(INITIAL_RETRY_BACKOFF_MS.saturating_mul(multiplier)) -} - -#[cfg(test)] -mod tests { - use devo_protocol::Usage; - use std::collections::HashMap; - use std::pin::Pin; - use std::sync::Arc; - use std::sync::Mutex; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - - use crate::EventCallback; - use crate::ProviderRetryStatus; - use crate::QueryProviderRetryPhase; - use crate::tools::ToolAgentScope; - use crate::tools::ToolContent; - use crate::tools::ToolPreparationFeedback; - use crate::tools::ToolRegistry; - use crate::tools::ToolRuntime; - use crate::tools::ToolRuntimeContext; - use crate::tools::json_schema::JsonSchema; - use crate::tools::registry::ToolExposure; - use crate::tools::registry::ToolRegistryBuilder; - use crate::tools::router::PermissionChecker; - use crate::tools::router::ToolExecutionOptions; - use crate::tools::tool_handler::ToolHandler; - use crate::tools::tool_spec::ToolExecutionMode; - use crate::tools::tool_spec::ToolOutputMode; - use crate::tools::tool_spec::ToolSpec; - use anyhow::Result; - use async_trait::async_trait; - use devo_protocol::CollaborationMode; - use devo_protocol::ModelRequest; - use devo_protocol::ModelResponse; - use devo_protocol::RequestContent; - use devo_protocol::RequestMessage; - use devo_protocol::ResponseContent; - use devo_protocol::ResponseExtra; - use devo_protocol::ResponseMetadata; - use devo_protocol::StopReason; - use devo_protocol::StreamEvent; - use devo_protocol::ThreadGoal; - use devo_protocol::ThreadGoalStatus; - use devo_provider::ModelProviderSDK; - use devo_safety::PermissionMode; - use futures::Stream; - use pretty_assertions::assert_eq; - use serde_json::json; - use tokio_util::sync::CancellationToken; - - use super::QueryEvent; - use super::QueryOptions; - use super::hosted_tools_for_web_search; - use super::insert_subagent_request_reminders; - use super::query; - use super::query_with_options; - use super::test_model_connection; - use super::truncate_tool_result_for_model; - use crate::AgentError; - use crate::ContentBlock; - use crate::Message; - use crate::Model; - use crate::ReasoningEffort; - use crate::Role; - use crate::context::ContextualUserFragment; - use crate::context::compaction_summary::CompactionSummary; - use crate::history::compaction::CompactionKind; - use crate::response_item::ResponseItem; - - #[test] - fn assistant_content_visibility_requires_visible_content() { - assert!(!super::assistant_content_has_visible_content(&[])); - assert!(!super::assistant_content_has_visible_content(&[ - ContentBlock::Text { - text: " \n\t".to_string(), - }, - ])); - assert!(!super::assistant_content_has_visible_content(&[ - ContentBlock::ToolResult { - tool_use_id: "call-1".to_string(), - content: String::new(), - is_error: false, - }, - ])); - - for content in [ - vec![ContentBlock::Text { - text: "visible".to_string(), - }], - vec![ContentBlock::Reasoning { - text: "reasoning".to_string(), - }], - vec![ContentBlock::ProviderReasoning { - provider: "test".to_string(), - payload: serde_json::json!({"thinking":"hidden"}), - }], - vec![ContentBlock::ToolUse { - id: "call-1".to_string(), - name: "read".to_string(), - input: serde_json::json!({"filePath":"README.md"}), - }], - vec![ContentBlock::HostedToolUse { - id: "hosted-1".to_string(), - name: "web_search".to_string(), - input: serde_json::json!({"query":"docs"}), - output: None, - status: None, - }], - vec![ContentBlock::ToolResult { - tool_use_id: "call-1".to_string(), - content: "result".to_string(), - is_error: false, - }], - ] { - assert!(super::assistant_content_has_visible_content(&content)); - } - } - - #[test] - fn hosted_tools_follow_resolved_web_search_mode() { - let hosted = hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Provider); - assert_eq!(hosted.len(), 1); - assert!(matches!( - hosted.as_slice(), - [devo_protocol::HostedToolDefinition::WebSearch(_)] - )); - - assert_eq!( - hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Disabled), - Vec::new() - ); - assert_eq!( - hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Local( - devo_config::ResolvedLocalWebSearchConfig { - provider_id: "test".to_string(), - kind: devo_config::LocalWebSearchProviderKind::Exa, - api_key: "secret".to_string(), - base_url: None, - max_results: None, - }, - )), - Vec::new() - ); - } - use crate::ReasoningCapability; - use crate::ReasoningImplementation; - - #[test] - fn network_errors_are_retryable() { - let cases = [ - anyhow::anyhow!("request timed out while connecting"), - anyhow::anyhow!( - "error sending request for url (https://api.example.test): connection refused" - ), - anyhow::anyhow!("dns error: failed to lookup address information"), - anyhow::anyhow!("network is unreachable"), - anyhow::anyhow!( - "anthropic stream error for model deepseek-v4-flash: invalid header value: \"text/html; charset=utf-8\"; debug=InvalidContentType(\"text/html; charset=utf-8\")" - ), - anyhow::anyhow!("Invalid status code: 408 Request Timeout"), - anyhow::Error::new(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "socket timed out", - )), - anyhow::Error::new(devo_provider::error::ProviderError::ProviderTimeoutError { - message: "provider request timed out".into(), - provider_name: Some("test-provider".into()), - }), - anyhow::Error::new(devo_provider::timeout::stream_idle_timeout_provider_error( - "openai", - "gpt-test", - devo_provider::timeout::StreamIdleTimeoutError { - idle_timeout: std::time::Duration::from_secs(60), - }, - )), - anyhow::Error::new(devo_provider::timeout::StreamIdleTimeoutError { - idle_timeout: std::time::Duration::from_secs(60), - }), - anyhow::anyhow!( - "openai stream idle timeout for model gpt-test: provider stream idle timeout after 60s without receiving data" - ), - ]; - - for error in cases { - assert_eq!( - super::classify_error(&error), - super::ErrorClass::NetworkError - ); - - let mut retry_count = 0; - let mut context_compacted = false; - assert!(matches!( - super::provider_retry_decision(&error, &mut retry_count, &mut context_compacted), - super::ProviderRetryDecision::RetryAfter(_) - )); - assert_eq!(retry_count, 1); - assert!(!context_compacted); - } - } - - #[test] - fn token_timeout_remains_authentication_failure() { - let error = anyhow::anyhow!("token timeout"); - - assert_eq!( - super::classify_error(&error), - super::ErrorClass::AuthenticationFailure - ); - - let mut retry_count = 0; - let mut context_compacted = false; - assert!(matches!( - super::provider_retry_decision(&error, &mut retry_count, &mut context_compacted), - super::ProviderRetryDecision::Fail - )); - assert_eq!(retry_count, 0); - assert!(!context_compacted); - } - use crate::ReasoningVariant; - use crate::ReasoningVariantConfig; - use crate::SessionConfig; - use crate::SessionState; - use crate::TruncationMode; - use crate::TruncationPolicyConfig; - use crate::TurnConfig; - - #[test] - fn model_tool_result_truncation_preserves_content_within_budget() { - assert_eq!( - truncate_tool_result_for_model( - "short".to_string(), - Some("read"), - TruncationPolicyConfig::bytes(100).into(), - ), - "short" - ); - } - - #[test] - fn model_tool_result_truncation_uses_byte_policy() { - assert_eq!( - truncate_tool_result_for_model( - "abcdefghijklmnopqrstuvwxyz".to_string(), - Some("read"), - TruncationPolicyConfig::bytes(20).into(), - ), - "abcde\n...[truncated]" - ); - } - - #[test] - fn model_tool_result_truncation_uses_token_policy_byte_budget() { - assert_eq!( - truncate_tool_result_for_model( - "abcdefghijklmnopqrstuvwxyz".to_string(), - Some("read"), - TruncationPolicyConfig::tokens(5).into(), - ), - "abcde\n...[truncated]" - ); - } - - #[test] - fn model_tool_result_truncation_preserves_utf8_boundaries() { - let truncated = truncate_tool_result_for_model( - "éééééabcdefghij".to_string(), - Some("read"), - TruncationPolicyConfig::bytes(18).into(), - ); - - assert_eq!(truncated, "é\n...[truncated]"); - assert!(truncated.len() <= 18); - } - - #[test] - fn model_tool_result_truncation_preserves_agent_coordination_results() { - let content = "abcdefghijklmnopqrstuvwxyz".to_string(); - - for tool_name in [ - Some("await_task"), - Some("wait_agent"), - Some("subagent_result"), - ] { - assert_eq!( - truncate_tool_result_for_model( - content.clone(), - tool_name, - TruncationPolicyConfig::bytes(20).into(), - ), - content - ); - } - } - - const HOSTED_DSML_TEXT: &str = "<||DSML||tool_calls>\n<||DSML||invoke name=\"web_search\">\n<||DSML||parameter name=\"query\" string=\"true\">current Rust docs\n\n"; - - struct SingleToolUseProvider { - requests: AtomicUsize, - } - - struct CapturingToolUseProvider { - requests: Arc>>, - calls: AtomicUsize, - } - - struct InterleavedToolUseProvider { - requests: AtomicUsize, - } - - struct ParallelToolUseProvider { - requests: AtomicUsize, - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for SingleToolUseProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - let request_number = self.requests.fetch_add(1, Ordering::SeqCst); - - let events = if request_number == 0 { - vec![ - Ok(StreamEvent::ToolCallStart { - index: 0, - id: "tool-1".into(), - name: "mutating_tool".into(), - input: json!({}), - }), - Ok(StreamEvent::ToolCallInputDelta { - index: 0, - partial_json: r#"{"value":1}"#.into(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-1".into(), - content: vec![ResponseContent::ToolUse { - id: "tool-1".into(), - name: "mutating_tool".into(), - input: json!({ "value": 1 }), - }], - stop_reason: Some(StopReason::ToolUse), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ] - } else { - vec![ - Ok(StreamEvent::TextDelta { - index: 0, - text: "done".into(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-2".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ] - }; - - Ok(Box::pin(futures::stream::iter(events))) - } - - fn name(&self) -> &str { - "test-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for CapturingToolUseProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - let request_number = self.calls.fetch_add(1, Ordering::SeqCst); - - let events = if request_number == 0 { - vec![ - Ok(StreamEvent::ToolCallStart { - index: 0, - id: "tool-1".into(), - name: "mutating_tool".into(), - input: json!({}), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-1".into(), - content: vec![ResponseContent::ToolUse { - id: "tool-1".into(), - name: "mutating_tool".into(), - input: json!({}), - }], - stop_reason: Some(StopReason::ToolUse), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ] - } else { - vec![Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-2".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - })] - }; - - Ok(Box::pin(futures::stream::iter(events))) - } - - fn name(&self) -> &str { - "capturing-tool-use-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for InterleavedToolUseProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - let request_number = self.requests.fetch_add(1, Ordering::SeqCst); - - let events = if request_number == 0 { - vec![ - Ok(StreamEvent::ToolCallStart { - index: 0, - id: "tool-1".into(), - name: "mutating_tool".into(), - input: json!({}), - }), - Ok(StreamEvent::ToolCallStart { - index: 1, - id: "tool-2".into(), - name: "mutating_tool".into(), - input: json!({}), - }), - Ok(StreamEvent::ToolCallInputDelta { - index: 0, - partial_json: r#"{"value":1}"#.into(), - }), - Ok(StreamEvent::ToolCallInputDelta { - index: 1, - partial_json: r#"{"value":2}"#.into(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-1".into(), - content: vec![ - ResponseContent::ToolUse { - id: "tool-1".into(), - name: "mutating_tool".into(), - input: json!({}), - }, - ResponseContent::ToolUse { - id: "tool-2".into(), - name: "mutating_tool".into(), - input: json!({}), - }, - ], - stop_reason: Some(StopReason::ToolUse), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ] - } else { - vec![ - Ok(StreamEvent::TextDelta { - index: 0, - text: "done".into(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-2".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ] - }; - - Ok(Box::pin(futures::stream::iter(events))) - } - - fn name(&self) -> &str { - "interleaved-test-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for ParallelToolUseProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - let request_number = self.requests.fetch_add(1, Ordering::SeqCst); - - let events = if request_number == 0 { - vec![ - Ok(StreamEvent::ToolCallStart { - index: 0, - id: "slow".into(), - name: "parallel_tool".into(), - input: json!({ - "delay_ms": 50, - "output": "slow complete", - }), - }), - Ok(StreamEvent::ToolCallStart { - index: 1, - id: "fast".into(), - name: "parallel_tool".into(), - input: json!({ - "delay_ms": 5, - "output": "fast complete", - }), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-1".into(), - content: vec![ - ResponseContent::ToolUse { - id: "slow".into(), - name: "parallel_tool".into(), - input: json!({ - "delay_ms": 50, - "output": "slow complete", - }), - }, - ResponseContent::ToolUse { - id: "fast".into(), - name: "parallel_tool".into(), - input: json!({ - "delay_ms": 5, - "output": "fast complete", - }), - }, - ], - stop_reason: Some(StopReason::ToolUse), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ] - } else { - vec![Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-2".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - })] - }; - - Ok(Box::pin(futures::stream::iter(events))) - } - - fn name(&self) -> &str { - "parallel-tool-provider" - } - } - - struct MutatingTool; - - struct CapturingProvider { - requests: Arc>>, - } - - struct OpenAiCapturingProvider { - requests: Arc>>, - } - - struct HostedWebSearchProvider { - requests: Arc>>, - } - - struct HostedDsmlTextProvider { - requests: Arc>>, - } - - struct HostedWebFetchProvider { - requests: Arc>>, - } - - fn final_text_stream(text: &str) -> Pin> + Send>> { - Box::pin(futures::stream::iter(vec![Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-final".into(), - content: vec![ResponseContent::Text(text.to_string())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - })])) - } - - struct TransientStreamCreateProvider { - attempts: AtomicUsize, - } - - struct TransientStreamEventProvider { - attempts: AtomicUsize, - } - - struct RateLimitedStreamCreateProvider { - attempts: AtomicUsize, - } - - enum CompactionProviderOutcome { - Summary, - Error, - } - - struct CompactionProvider { - completion_calls: AtomicUsize, - outcome: CompactionProviderOutcome, - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for CapturingProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - Ok(Box::pin(futures::stream::iter(vec![Ok( - StreamEvent::MessageDone { - response: ModelResponse { - id: "resp".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }, - )]))) - } - - fn name(&self) -> &str { - "capturing-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for OpenAiCapturingProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - Ok(Box::pin(futures::stream::iter(vec![Ok( - StreamEvent::MessageDone { - response: ModelResponse { - id: "resp".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }, - )]))) - } - - fn name(&self) -> &str { - "openai" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for HostedWebSearchProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - let request_count = { - let mut requests = self.requests.lock().expect("lock requests"); - requests.push(request); - requests.len() - }; - if request_count > 1 { - return Ok(final_text_stream("done")); - } - let input = json!({ "query": "current Rust docs" }); - let output = Some(json!({ - "results": [ - { - "title": "Rust documentation", - "url": "https://example.test/rust" - } - ] - })); - Ok(Box::pin(futures::stream::iter(vec![ - Ok(StreamEvent::HostedToolCallStart { - index: 0, - id: "hosted_ws_1".into(), - name: "web_search".into(), - input: input.clone(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp".into(), - content: vec![ - ResponseContent::HostedToolUse { - id: "hosted_ws_1".into(), - name: "web_search".into(), - input: input.clone(), - output: None, - status: None, - }, - ResponseContent::HostedToolUse { - id: "hosted_ws_1".into(), - name: "web_search".into(), - input, - output, - status: Some("completed".into()), - }, - ], - stop_reason: Some(StopReason::ToolUse), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ]))) - } - - fn name(&self) -> &str { - "hosted-web-search-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for HostedDsmlTextProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - let request_count = { - let mut requests = self.requests.lock().expect("lock requests"); - requests.push(request); - requests.len() - }; - if request_count > 1 { - return Ok(final_text_stream("done")); - } - Ok(Box::pin(futures::stream::iter(vec![ - Ok(StreamEvent::TextDelta { - index: 0, - text: HOSTED_DSML_TEXT.to_string(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-dsml".into(), - content: vec![ResponseContent::Text(HOSTED_DSML_TEXT.to_string())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ]))) - } - - fn name(&self) -> &str { - "hosted-dsml-text-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for HostedWebFetchProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - let request_count = { - let mut requests = self.requests.lock().expect("lock requests"); - requests.push(request); - requests.len() - }; - if request_count > 1 { - return Ok(final_text_stream("done")); - } - let input = json!({ "url": "https://example.test/docs" }); - let output = Some(json!({ - "title": "Docs", - "url": "https://example.test/docs" - })); - Ok(Box::pin(futures::stream::iter(vec![ - Ok(StreamEvent::HostedToolCallStart { - index: 0, - id: "hosted_wf_1".into(), - name: "web_fetch".into(), - input: input.clone(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp".into(), - content: vec![ - ResponseContent::HostedToolUse { - id: "hosted_wf_1".into(), - name: "web_fetch".into(), - input: input.clone(), - output: None, - status: None, - }, - ResponseContent::HostedToolUse { - id: "hosted_wf_1".into(), - name: "web_fetch".into(), - input, - output, - status: Some("completed".into()), - }, - ], - stop_reason: Some(StopReason::ToolUse), - usage: Usage::default(), - metadata: Default::default(), - }, - }), - ]))) - } - - fn name(&self) -> &str { - "hosted-web-fetch-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for TransientStreamCreateProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if attempt == 0 { - return Err(anyhow::anyhow!("503 service unavailable")); - } - - Ok(Box::pin(futures::stream::iter(vec![Ok( - StreamEvent::MessageDone { - response: ModelResponse { - id: "resp".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }, - )]))) - } - - fn name(&self) -> &str { - "transient-stream-create-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for TransientStreamEventProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if attempt == 0 { - return Ok(Box::pin(futures::stream::iter(vec![Err(anyhow::anyhow!( - "500 internal server error" - ))]))); - } - - Ok(Box::pin(futures::stream::iter(vec![Ok( - StreamEvent::MessageDone { - response: ModelResponse { - id: "resp".into(), - content: vec![ResponseContent::Text("done".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }, - }, - )]))) - } - - fn name(&self) -> &str { - "transient-stream-event-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for RateLimitedStreamCreateProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if attempt < 2 { - return Err(anyhow::anyhow!("429 rate limit exceeded")); - } - - Ok(final_text_stream("done")) - } - - fn name(&self) -> &str { - "rate-limited-stream-create-provider" - } - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for CompactionProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - self.completion_calls.fetch_add(1, Ordering::SeqCst); - match &self.outcome { - CompactionProviderOutcome::Summary => Ok(ModelResponse { - id: "compaction-response".to_string(), - content: vec![ResponseContent::Text("summary".to_string())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: Default::default(), - }), - CompactionProviderOutcome::Error => { - Err(anyhow::anyhow!("compaction provider failed")) - } - } - } - - async fn completion_stream( - &self, - _request: ModelRequest, - ) -> Result> + Send>>> { - unreachable!("tests call the non-streaming compaction path only") - } - - fn name(&self) -> &str { - "compaction-provider" - } - } - - #[async_trait] - impl ToolHandler for MutatingTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - // Leak a static spec for test purposes - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "write", - "write tool", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - Ok(crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text("ok".into()), - "ok", - )) - } - } - - struct DisplayContentTool; - - struct LargeToolResultTool { - content: String, - display_content: Option, - } - - struct CountingWebSearchTool { - executions: Arc, - } - - struct CountingWebFetchTool { - executions: Arc, - } - - #[async_trait] - impl ToolHandler for CountingWebSearchTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "web_search", - "Search the web.", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - self.executions.fetch_add(1, Ordering::SeqCst); - Ok(crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text("local search".into()), - "local search", - )) - } - } - - #[async_trait] - impl ToolHandler for CountingWebFetchTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "webfetch", - "Fetch a URL.", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - self.executions.fetch_add(1, Ordering::SeqCst); - Ok(crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text("local fetch".into()), - "local fetch", - )) - } - } - - #[async_trait] - impl ToolHandler for DisplayContentTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "read", - "read tool", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - let mut result = crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text("canonical".into()), - "done", - ); - result.display_content = Some("display".to_string()); - Ok(result) - } - } - - #[async_trait] - impl ToolHandler for LargeToolResultTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "read", - "read tool", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - let mut result = crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text(self.content.clone()), - "done", - ); - result.display_content = self.display_content.clone(); - Ok(result) - } - } - - struct StreamingMutatingTool; - - struct ParallelDelayTool; - - #[async_trait] - impl ToolHandler for StreamingMutatingTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "write", - "write tool", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - Ok(crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text("stream complete".into()), - "done", - )) - } - } - - #[async_trait] - impl ToolHandler for ParallelDelayTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( - "read", - "read tool", - crate::tools::JsonSchema::object(Default::default(), None, None), - ))) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - input: serde_json::Value, - _progress: Option, - ) -> Result - { - let delay_ms = input - .get("delay_ms") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; - let output = input - .get("output") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - Ok(crate::tools::contracts::ToolResult::success( - crate::tools::contracts::ToolResultContent::Text(output.to_string()), - "done", - )) - } - } - - #[derive(Debug, PartialEq, Eq)] - enum RecordedCompactionEvent { - Started, - Completed, - Failed { message: String }, - } - - fn recorded_compaction_events(events: &[QueryEvent]) -> Vec { - events - .iter() - .filter_map(|event| match event { - QueryEvent::ContextCompactionStarted => Some(RecordedCompactionEvent::Started), - QueryEvent::ContextCompactionCompleted => Some(RecordedCompactionEvent::Completed), - QueryEvent::ContextCompactionFailed { message } => { - Some(RecordedCompactionEvent::Failed { - message: message.clone(), - }) - } - QueryEvent::ProviderRetryStatus(_) - | QueryEvent::TextDelta(_) - | QueryEvent::ReasoningDelta(_) - | QueryEvent::ReasoningCompleted - | QueryEvent::UsageDelta { .. } - | QueryEvent::ToolUseStart { .. } - | QueryEvent::ToolExecutionStart { .. } - | QueryEvent::ToolProgress { .. } - | QueryEvent::ToolResult { .. } - | QueryEvent::TurnComplete { .. } - | QueryEvent::Usage { .. } => None, - }) - .collect() - } - - fn recording_callback(events: &Arc>>) -> EventCallback { - let captured_events = Arc::clone(events); - Arc::new(move |event| { - let captured_events = Arc::clone(&captured_events); - Box::pin(async move { - captured_events.lock().expect("lock events").push(event); - }) - }) - } - - fn compaction_test_session(total_input_tokens: usize) -> SessionState { - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("x".repeat(80_004))); - session.push_message(Message::user("latest")); - session.total_input_tokens = total_input_tokens; - session - } - - #[tokio::test] - async fn automatic_compaction_emits_started_then_completed_when_history_is_replaced() { - let provider = Arc::new(CompactionProvider { - completion_calls: AtomicUsize::new(0), - outcome: CompactionProviderOutcome::Summary, - }); - let provider_sdk: Arc = provider.clone(); - let events = Arc::new(Mutex::new(Vec::new())); - let on_event = Some(recording_callback(&events)); - let mut session = compaction_test_session(/*total_input_tokens*/ 200_000); - - super::summarize_and_compact( - &mut session, - &on_event, - &provider_sdk, - "compaction-model", - "compaction-request-model", - /*max_tokens*/ 4096, - CompactionKind::Auto, - ) - .await; - - assert_eq!( - recorded_compaction_events(&events.lock().expect("lock events")), - vec![ - RecordedCompactionEvent::Started, - RecordedCompactionEvent::Completed, - ] - ); - assert_eq!(provider.completion_calls.load(Ordering::SeqCst), 1); - let ResponseItem::Message(expected_summary) = - CompactionSummary::new("summary").to_response_item() - else { - unreachable!("compaction summaries are messages"); - }; - assert_eq!( - session.prompt_source_messages(), - &[expected_summary, Message::user("latest")] - ); - } - - #[tokio::test] - async fn automatic_compaction_emits_failed_when_compaction_is_skipped() { - let provider = Arc::new(CompactionProvider { - completion_calls: AtomicUsize::new(0), - outcome: CompactionProviderOutcome::Summary, - }); - let provider_sdk: Arc = provider.clone(); - let events = Arc::new(Mutex::new(Vec::new())); - let on_event = Some(recording_callback(&events)); - let mut session = compaction_test_session(/*total_input_tokens*/ 0); - let original_messages = session.prompt_source_messages().to_vec(); - - super::summarize_and_compact( - &mut session, - &on_event, - &provider_sdk, - "compaction-model", - "compaction-request-model", - /*max_tokens*/ 4096, - CompactionKind::Auto, - ) - .await; - - assert_eq!( - recorded_compaction_events(&events.lock().expect("lock events")), - vec![ - RecordedCompactionEvent::Started, - RecordedCompactionEvent::Failed { - message: "Context compaction skipped: nothing to compact".to_string(), - }, - ] - ); - assert_eq!(provider.completion_calls.load(Ordering::SeqCst), 0); - assert_eq!(session.prompt_source_messages(), original_messages); - } - - #[tokio::test(start_paused = true)] - async fn proactive_compaction_emits_failed_when_compaction_errors() { - let provider = Arc::new(CompactionProvider { - completion_calls: AtomicUsize::new(0), - outcome: CompactionProviderOutcome::Error, - }); - let provider_sdk: Arc = provider.clone(); - let events = Arc::new(Mutex::new(Vec::new())); - let on_event = Some(recording_callback(&events)); - let mut session = compaction_test_session(/*total_input_tokens*/ 0); - let original_messages = session.prompt_source_messages().to_vec(); - - super::summarize_and_compact( - &mut session, - &on_event, - &provider_sdk, - "compaction-model", - "compaction-request-model", - /*max_tokens*/ 4096, - CompactionKind::Proactive, - ) - .await; - - assert_eq!( - recorded_compaction_events(&events.lock().expect("lock events")), - vec![ - RecordedCompactionEvent::Started, - RecordedCompactionEvent::Failed { - message: "summarization failed: compaction provider failed".to_string(), - }, - ] - ); - assert_eq!(provider.completion_calls.load(Ordering::SeqCst), 5); - assert_eq!(session.prompt_source_messages(), original_messages); - } - - #[tokio::test] - async fn query_retries_transient_stream_creation_errors() { - let provider = Arc::new(TransientStreamCreateProvider { - attempts: AtomicUsize::new(0), - }); - let provider_sdk: Arc = provider.clone(); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider_sdk, - registry, - &runtime, - None, - ) - .await - .expect("query should retry and succeed"); - - assert_eq!(provider.attempts.load(Ordering::SeqCst), 2); - assert_eq!( - session.messages.last(), - Some(&Message::assistant_text("done")) - ); - } - - #[tokio::test(start_paused = true)] - async fn query_retries_transient_stream_event_errors_before_content() { - let provider = Arc::new(TransientStreamEventProvider { - attempts: AtomicUsize::new(0), - }); - let provider_sdk: Arc = provider.clone(); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let turn_config = TurnConfig::new(Model::default(), None); - let model = turn_config.model.slug.clone(); - let events = Arc::new(Mutex::new(Vec::new())); - let captured_events = Arc::clone(&events); - let callback: EventCallback = Arc::new(move |event| { - let captured_events = Arc::clone(&captured_events); - Box::pin(async move { - captured_events.lock().expect("lock events").push(event); - }) - }); - - query( - &mut session, - &turn_config, - provider_sdk, - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should retry and succeed"); - - let retry_statuses = events - .lock() - .expect("lock events") - .iter() - .filter_map(|event| match event { - QueryEvent::ProviderRetryStatus(status) => Some(status.clone()), - QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted - | QueryEvent::ContextCompactionFailed { .. } - | QueryEvent::TextDelta(_) - | QueryEvent::ReasoningDelta(_) - | QueryEvent::ReasoningCompleted - | QueryEvent::UsageDelta { .. } - | QueryEvent::ToolUseStart { .. } - | QueryEvent::ToolExecutionStart { .. } - | QueryEvent::ToolProgress { .. } - | QueryEvent::ToolResult { .. } - | QueryEvent::TurnComplete { .. } - | QueryEvent::Usage { .. } => None, - }) - .collect::>(); - assert_eq!( - retry_statuses, - vec![ - ProviderRetryStatus { - provider: "transient-stream-event-provider".to_string(), - model: model.clone(), - attempt: 1, - backoff_ms: 250, - phase: QueryProviderRetryPhase::Scheduled, - message: "Retrying provider request in 0.2s".to_string(), - }, - ProviderRetryStatus { - provider: "transient-stream-event-provider".to_string(), - model, - attempt: 1, - backoff_ms: 0, - phase: QueryProviderRetryPhase::Resumed, - message: "Retrying provider request now".to_string(), - }, - ] - ); - assert_eq!(provider.attempts.load(Ordering::SeqCst), 2); - let assistant_messages = session - .messages - .iter() - .filter(|message| message.role == Role::Assistant) - .cloned() - .collect::>(); - assert_eq!(assistant_messages, vec![Message::assistant_text("done")]); - } - - #[tokio::test(start_paused = true)] - async fn query_waits_sixty_seconds_for_each_rate_limit_retry() { - let provider = Arc::new(RateLimitedStreamCreateProvider { - attempts: AtomicUsize::new(0), - }); - let provider_sdk: Arc = provider.clone(); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let turn_config = TurnConfig::new(Model::default(), None); - let model = turn_config.model.slug.clone(); - let events = Arc::new(Mutex::new(Vec::new())); - let captured_events = Arc::clone(&events); - let callback: EventCallback = Arc::new(move |event| { - let captured_events = Arc::clone(&captured_events); - Box::pin(async move { - captured_events.lock().expect("lock events").push(event); - }) - }); - let started_at = tokio::time::Instant::now(); - - query( - &mut session, - &turn_config, - provider_sdk, - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should retry and succeed"); - - assert_eq!( - tokio::time::Instant::now().duration_since(started_at), - std::time::Duration::from_secs(120) - ); - let retry_statuses = events - .lock() - .expect("lock events") - .iter() - .filter_map(|event| match event { - QueryEvent::ProviderRetryStatus(status) => Some(status.clone()), - QueryEvent::ContextCompactionStarted - | QueryEvent::ContextCompactionCompleted - | QueryEvent::ContextCompactionFailed { .. } - | QueryEvent::TextDelta(_) - | QueryEvent::ReasoningDelta(_) - | QueryEvent::ReasoningCompleted - | QueryEvent::UsageDelta { .. } - | QueryEvent::ToolUseStart { .. } - | QueryEvent::ToolExecutionStart { .. } - | QueryEvent::ToolProgress { .. } - | QueryEvent::ToolResult { .. } - | QueryEvent::TurnComplete { .. } - | QueryEvent::Usage { .. } => None, - }) - .collect::>(); - assert_eq!( - retry_statuses, - vec![ - ProviderRetryStatus { - provider: "rate-limited-stream-create-provider".to_string(), - model: model.clone(), - attempt: 1, - backoff_ms: 60_000, - phase: QueryProviderRetryPhase::Scheduled, - message: "Retrying provider request in 60.0s".to_string(), - }, - ProviderRetryStatus { - provider: "rate-limited-stream-create-provider".to_string(), - model: model.clone(), - attempt: 1, - backoff_ms: 0, - phase: QueryProviderRetryPhase::Resumed, - message: "Retrying provider request now".to_string(), - }, - ProviderRetryStatus { - provider: "rate-limited-stream-create-provider".to_string(), - model: model.clone(), - attempt: 2, - backoff_ms: 60_000, - phase: QueryProviderRetryPhase::Scheduled, - message: "Retrying provider request in 60.0s".to_string(), - }, - ProviderRetryStatus { - provider: "rate-limited-stream-create-provider".to_string(), - model, - attempt: 2, - backoff_ms: 0, - phase: QueryProviderRetryPhase::Resumed, - message: "Retrying provider request now".to_string(), - }, - ] - ); - assert_eq!(provider.attempts.load(Ordering::SeqCst), 3); - } - - #[tokio::test(start_paused = true)] - async fn query_cancels_stream_creation_retry_backoff() { - let provider = Arc::new(TransientStreamCreateProvider { - attempts: AtomicUsize::new(0), - }); - let provider_sdk: Arc = provider.clone(); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let cancel_token = CancellationToken::new(); - cancel_token.cancel(); - - let result = query_with_options( - &mut session, - &TurnConfig::new(Model::default(), None), - provider_sdk, - registry, - &runtime, - None, - QueryOptions { - cancel_token: Some(cancel_token), - ..QueryOptions::default() - }, - ) - .await; - - assert!(matches!(result, Err(AgentError::Aborted))); - assert_eq!(provider.attempts.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn query_cancels_stream_event_retry_backoff() { - let provider = Arc::new(TransientStreamEventProvider { - attempts: AtomicUsize::new(0), - }); - let provider_sdk: Arc = provider.clone(); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let cancel_token = CancellationToken::new(); - cancel_token.cancel(); - - let result = query_with_options( - &mut session, - &TurnConfig::new(Model::default(), None), - provider_sdk, - registry, - &runtime, - None, - QueryOptions { - cancel_token: Some(cancel_token), - ..QueryOptions::default() - }, - ) - .await; - - assert!(matches!(result, Err(AgentError::Aborted))); - assert_eq!(provider.attempts.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn query_exposes_stable_tools_and_appends_subagent_warning() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let mut builder = ToolRegistryBuilder::new(); - builder.push_spec_with_exposure( - ToolSpec::new( - "ToolSearch", - "Search available tools.", - JsonSchema::object(Default::default(), None, None), - ), - ToolExposure::Direct, - ); - builder.push_spec_with_exposure( - ToolSpec::new( - "web_search", - "Search the web.", - JsonSchema::object(Default::default(), None, None), - ), - ToolExposure::Direct, - ); - for (name, description) in [ - ("spawn_agent", "Create a child agent."), - ("send_message", "Send input to a child agent."), - ("await_task", "Wait for task completion."), - ("list_tasks", "List child tasks."), - ("cancel_task", "Cancel a child task."), - ] { - builder.push_spec_with_exposure( - ToolSpec::new( - name, - description, - JsonSchema::object(Default::default(), None, None), - ), - ToolExposure::Direct, - ); - } - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_with_context( - Arc::clone(®istry), - PermissionChecker::always_allow(), - ToolRuntimeContext { - agent_scope: ToolAgentScope::Subagent, - ..ToolRuntimeContext::default() - }, - ); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("work on the delegated task")); - let mut turn_config = TurnConfig::new( - Model { - base_instructions: "base system".to_string(), - ..Model::default() - }, - None, - ); - turn_config.web_search = devo_config::ResolvedWebSearchConfig::Local( - devo_config::ResolvedLocalWebSearchConfig { - provider_id: "test".to_string(), - kind: devo_config::LocalWebSearchProviderKind::Exa, - api_key: "secret".to_string(), - base_url: None, - max_results: None, - }, - ); - - query( - &mut session, - &turn_config, - provider, - registry, - &runtime, - None, - ) - .await - .expect("query should complete"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - let request = &captured[0]; - let tool_names = request - .tools - .as_ref() - .expect("tools should be present") - .iter() - .map(|tool| tool.name.as_str()) - .collect::>(); - assert_eq!(tool_names, vec!["ToolSearch", "web_search"]); - let system = request.system.as_deref().expect("system prompt"); - let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); - assert!(system.contains("base system")); - assert!(system.contains(&mode_prompt)); - assert!(system.contains("Sources:")); - assert!( - !request - .system - .as_deref() - .unwrap_or_default() - .contains("web_search") - ); - assert!( - !request - .system - .as_deref() - .unwrap_or_default() - .contains("spawn_agent") - ); - - assert!( - request - .messages - .iter() - .all(|message| !message_contains(message, "web_search: Search the web.")) - ); - let subagent_reminder_index = - request_message_index_containing(request, "You are running as a sub-agent"); - let task_index = request_message_index_containing(request, "work on the delegated task"); - assert!(subagent_reminder_index < task_index); - assert!( - request - .messages - .iter() - .any(|message| message_contains(message, "")) - ); - } - - #[tokio::test] - async fn query_adds_web_search_prompt_for_provider_hosted_search() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("search current docs")); - let mut turn_config = TurnConfig::new( - Model { - base_instructions: "base system".to_string(), - ..Model::default() - }, - None, - ); - turn_config.web_search = devo_config::ResolvedWebSearchConfig::Provider; - - query( - &mut session, - &turn_config, - provider, - registry, - &runtime, - None, - ) - .await - .expect("query should complete"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - let request = &captured[0]; - let system = request.system.as_deref().expect("system prompt"); - - assert!(system.contains("base system")); - assert!(system.contains("Sources:")); - assert!(system.contains("The current month is ")); - assert!(matches!( - request.hosted_tools.as_slice(), - [devo_protocol::HostedToolDefinition::WebSearch(_)] - )); - assert!( - request - .tools - .as_ref() - .is_none_or(|tools| tools.iter().all(|tool| tool.name != "web_search")) - ); - } - - /// Trace: L2-DES-RESEARCH-001 - /// Verifies: provider-hosted web_search emits normal tool events with hosted output. - #[tokio::test] - async fn provider_hosted_web_search_emits_tool_events_without_local_execution() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(HostedWebSearchProvider { - requests: Arc::clone(&requests), - }); - let executions = Arc::new(AtomicUsize::new(0)); - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler( - "web_search", - Arc::new(CountingWebSearchTool { - executions: Arc::clone(&executions), - }), - ); - builder.push_spec(ToolSpec { - name: "web_search".into(), - description: "Search the web.".into(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("search current docs")); - let mut turn_config = TurnConfig::new(Model::default(), None); - turn_config.web_search = devo_config::ResolvedWebSearchConfig::Provider; - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - seen_clone.lock().unwrap().push(event); - }) - }); - - query( - &mut session, - &turn_config, - provider, - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!(executions.load(Ordering::SeqCst), 0); - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let request = &captured[0]; - assert!(matches!( - request.hosted_tools.as_slice(), - [devo_protocol::HostedToolDefinition::WebSearch(_)] - )); - assert!( - request - .tools - .as_ref() - .is_none_or(|tools| tools.iter().all(|tool| tool.name != "web_search")) - ); - let continuation = &captured[1]; - assert!(continuation.messages.iter().any(|message| { - message.content.iter().any(|content| { - matches!( - content, - RequestContent::HostedToolUse { - id, - name, - input, - output: Some(_), - status, - } if id == "hosted_ws_1" - && name == "web_search" - && input == &json!({ "query": "current Rust docs" }) - && status.as_deref() == Some("completed") - ) - }) - })); - - let events = seen.lock().unwrap(); - let starts = events - .iter() - .filter_map(|event| match event { - QueryEvent::ToolUseStart { id, name, input } => { - Some((id.as_str(), name.as_str(), input.clone())) - } - _ => None, - }) - .collect::>(); - assert_eq!( - starts, - vec![( - "hosted_ws_1", - "web_search", - json!({ "query": "current Rust docs" }) - )] - ); - let results = events - .iter() - .filter_map(|event| match event { - QueryEvent::ToolResult { - tool_use_id, - tool_name, - input, - content, - is_error, - .. - } => Some(( - tool_use_id.as_str(), - tool_name.as_str(), - input.clone(), - content, - *is_error, - )), - _ => None, - }) - .collect::>(); - assert_eq!(results.len(), 1); - let (tool_use_id, tool_name, input, content, is_error) = &results[0]; - assert_eq!(*tool_use_id, "hosted_ws_1"); - assert_eq!(*tool_name, "web_search"); - assert_eq!(input, &json!({ "query": "current Rust docs" })); - assert!(!*is_error); - assert!(matches!( - *content, - ToolContent::Mixed { - text: Some(text), - json: Some(json), - } if text == "status: completed" - && json == &json!({ - "results": [ - { - "title": "Rust documentation", - "url": "https://example.test/rust" - } - ] - }) - )); - assert!(events.iter().any(|event| matches!( - event, - QueryEvent::TurnComplete { - stop_reason: StopReason::EndTurn - } - ))); - assert!(session.messages.iter().all(|message| { - message.content.iter().all(|block| { - !matches!( - block, - ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } - ) - }) - })); - } - - /// Trace: L2-DES-RESEARCH-001 - /// Verifies: DSML text that represents a provider-hosted web_search does not end the query loop. - #[tokio::test] - async fn provider_hosted_dsml_text_tool_call_continues_query_loop() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(HostedDsmlTextProvider { - requests: Arc::clone(&requests), - }); - let mut builder = ToolRegistryBuilder::new(); - for (name, description) in [ - ("spawn_agent", "Create a child agent."), - ("await_task", "Wait for task completion."), - ] { - builder.push_spec_with_exposure( - ToolSpec::new( - name, - description, - JsonSchema::object(Default::default(), None, None), - ), - ToolExposure::Direct, - ); - } - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("search current docs")); - let mut turn_config = TurnConfig::new(Model::default(), None); - turn_config.web_search = devo_config::ResolvedWebSearchConfig::Provider; - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - seen_clone.lock().unwrap().push(event); - }) - }); - - query( - &mut session, - &turn_config, - provider, - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should continue after DSML text and complete"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let request = &captured[0]; - assert!(matches!( - request.hosted_tools.as_slice(), - [devo_protocol::HostedToolDefinition::WebSearch(_)] - )); - let continuation = &captured[1]; - assert!(continuation.messages.iter().any(|message| { - message_contains(message, "DSML tagged tool-call text") - && message_contains(message, "spawn_agent") - && message_contains(message, "await_task") - && message_contains(message, "web_search") - })); - - let assistant_messages = session - .messages - .iter() - .filter(|message| message.role == Role::Assistant) - .cloned() - .collect::>(); - assert_eq!( - assistant_messages, - vec![ - Message { - role: Role::Assistant, - content: vec![ContentBlock::Text { - text: HOSTED_DSML_TEXT.to_string(), - }], - }, - Message { - role: Role::Assistant, - content: vec![ContentBlock::Text { - text: "done".to_string(), - }], - }, - ] - ); - - let turn_completes = seen - .lock() - .unwrap() - .iter() - .filter_map(|event| match event { - QueryEvent::TurnComplete { stop_reason } => Some(stop_reason.clone()), - _ => None, - }) - .collect::>(); - assert_eq!(turn_completes, vec![StopReason::EndTurn]); - } - - /// Trace: L2-DES-RESEARCH-001 - /// Verifies: provider-hosted web_fetch emits normal tool events with hosted output. - #[tokio::test] - async fn provider_hosted_web_fetch_emits_tool_events_without_local_execution() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(HostedWebFetchProvider { - requests: Arc::clone(&requests), - }); - let executions = Arc::new(AtomicUsize::new(0)); - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler( - "webfetch", - Arc::new(CountingWebFetchTool { - executions: Arc::clone(&executions), - }), - ); - builder.push_spec(ToolSpec { - name: "webfetch".into(), - description: "Fetch a URL.".into(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Mixed, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("fetch docs")); - let mut turn_config = TurnConfig::new(Model::default(), None); - turn_config.web_fetch = devo_config::ResolvedWebFetchConfig::Provider; - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - seen_clone.lock().unwrap().push(event); - }) - }); - - query( - &mut session, - &turn_config, - provider, - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!(executions.load(Ordering::SeqCst), 0); - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let request = &captured[0]; - assert!(matches!( - request.hosted_tools.as_slice(), - [devo_protocol::HostedToolDefinition::WebFetch(_)] - )); - assert!( - request - .tools - .as_ref() - .is_none_or(|tools| tools.iter().all(|tool| tool.name != "webfetch")) - ); - let continuation = &captured[1]; - assert!(continuation.messages.iter().any(|message| { - message.content.iter().any(|content| { - matches!( - content, - RequestContent::HostedToolUse { - id, - name, - input, - output: Some(_), - status, - } if id == "hosted_wf_1" - && name == "web_fetch" - && input == &json!({ "url": "https://example.test/docs" }) - && status.as_deref() == Some("completed") - ) - }) - })); - - let events = seen.lock().unwrap(); - let starts = events - .iter() - .filter_map(|event| match event { - QueryEvent::ToolUseStart { id, name, input } => { - Some((id.as_str(), name.as_str(), input.clone())) - } - _ => None, - }) - .collect::>(); - assert_eq!( - starts, - vec![( - "hosted_wf_1", - "web_fetch", - json!({ "url": "https://example.test/docs" }) - )] - ); - let results = events - .iter() - .filter_map(|event| match event { - QueryEvent::ToolResult { - tool_use_id, - tool_name, - input, - content, - is_error, - .. - } => Some(( - tool_use_id.as_str(), - tool_name.as_str(), - input.clone(), - content, - *is_error, - )), - _ => None, - }) - .collect::>(); - assert_eq!(results.len(), 1); - let (tool_use_id, tool_name, input, content, is_error) = &results[0]; - assert_eq!(*tool_use_id, "hosted_wf_1"); - assert_eq!(*tool_name, "web_fetch"); - assert_eq!(input, &json!({ "url": "https://example.test/docs" })); - assert!(!*is_error); - assert!(matches!( - *content, - ToolContent::Mixed { - text: Some(text), - json: Some(json), - } if text == "status: completed" - && json == &json!({ - "title": "Docs", - "url": "https://example.test/docs" - }) - )); - } - - #[test] - fn subagent_reminder_insertion_preserves_tool_result_adjacency() { - let mut messages = vec![ - RequestMessage { - role: Role::User.as_str().to_string(), - content: vec![RequestContent::Text { - text: "child task input".to_string(), - }], - }, - RequestMessage { - role: Role::Assistant.as_str().to_string(), - content: vec![RequestContent::ToolUse { - id: "tool-1".to_string(), - name: "read".to_string(), - input: json!({}), - }], - }, - RequestMessage { - role: Role::User.as_str().to_string(), - content: vec![RequestContent::ToolResult { - tool_use_id: "tool-1".to_string(), - content: "tool output".to_string(), - is_error: None, - }], - }, - ]; - - insert_subagent_request_reminders(&mut messages); - - assert!(message_contains( - &messages[0], - "You are running as a sub-agent" - )); - assert!(message_contains(&messages[1], "child task input")); - assert!( - matches!(messages[2].content.as_slice(), [RequestContent::ToolUse { id, .. }] if id == "tool-1") - ); - assert!( - matches!(messages[3].content.as_slice(), [RequestContent::ToolResult { tool_use_id, .. }] if tool_use_id == "tool-1") - ); - } - - fn request_message_index_containing(request: &ModelRequest, needle: &str) -> usize { - request - .messages - .iter() - .position(|message| message_contains(message, needle)) - .unwrap_or_else(|| { - panic!("expected request message containing {needle:?}: {request:?}") - }) - } - - fn message_contains(message: &RequestMessage, needle: &str) -> bool { - message.content.iter().any( - |content| matches!(content, RequestContent::Text { text } if text.contains(needle)), - ) - } - - fn active_goal(objective: &str) -> ThreadGoal { - ThreadGoal { - thread_id: devo_protocol::SessionId::new(), - objective: objective.to_string(), - status: ThreadGoalStatus::Active, - token_budget: Some(10_000), - tokens_used: 250, - time_used_seconds: 0, - created_at: 1, - updated_at: 1, - } - } - - #[tokio::test] - async fn query_uses_session_permission_mode_for_mutating_tools() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(MutatingTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: "A test-only mutating tool.".into(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let deny_checker = PermissionChecker::new(|request| { - let n = request.tool_name; - Box::pin(async move { Err(format!("{n} denied")) }) - }); - let runtime = ToolRuntime::new(Arc::clone(®istry), deny_checker); - - let mut session = SessionState::new( - SessionConfig { - permission_mode: PermissionMode::Deny, - ..Default::default() - }, - std::env::temp_dir(), - ); - session.push_message(Message::user("run the tool")); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(SingleToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - None, - ) - .await - .expect("query should complete and append a tool_result"); - - let tool_result_message = session - .messages - .iter() - .find(|message| { - message - .content - .iter() - .any(|block| matches!(block, ContentBlock::ToolResult { .. })) - }) - .expect("tool_result message should be appended"); - let ContentBlock::ToolResult { - tool_use_id, - content, - is_error, - } = &tool_result_message.content[0] - else { - panic!("expected tool_result content block"); - }; - - assert_eq!(tool_use_id, "tool-1"); - assert!( - *is_error, - "denied permission should surface as a tool error" - ); - assert!( - content.contains("permission denied"), - "expected tool_result to mention permission denial, got: {content}" - ); - } - - #[tokio::test] - async fn query_resolves_reasoning_model_variant_before_building_request() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "kimi-k2.5".into(), - display_name: "Kimi K2.5".into(), - provider: devo_protocol::ProviderWireApi::OpenAIChatCompletions, - description: None, - reasoning_capability: ReasoningCapability::Toggle, - default_reasoning_effort: Some(ReasoningEffort::Medium), - reasoning_implementation: Some(ReasoningImplementation::ModelVariant( - ReasoningVariantConfig { - variants: vec![ - ReasoningVariant { - selection_value: "disabled".into(), - model_slug: "kimi-k2.5".into(), - reasoning_effort: None, - label: "Off".into(), - description: "Use the standard model".into(), - extra_body: None, - }, - ReasoningVariant { - selection_value: "enabled".into(), - model_slug: "kimi-k2.5-thinking".into(), - reasoning_effort: Some(ReasoningEffort::Medium), - label: "On".into(), - description: "Use the reasoning model".into(), - extra_body: None, - }, - ], - }, - )), - base_instructions: String::new(), - context_window: 200_000, - effective_context_window_percent: None, - truncation_policy: TruncationPolicyConfig { - mode: TruncationMode::Tokens, - limit: 10_000, - }, - input_modalities: vec![], - supports_image_detail_original: false, - channel: None, - temperature: None, - top_p: None, - top_k: None, - max_tokens: None, - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - - query( - &mut session, - &TurnConfig::with_request_model( - model, - "vendor/kimi-k2.5".into(), - HashMap::from([( - "kimi-k2.5-thinking".into(), - "vendor/kimi-k2.5-thinking".into(), - )]) - .into(), - Some("enabled".into()), - ), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - assert_eq!(captured[0].model, "vendor/kimi-k2.5-thinking"); - assert_eq!(captured[0].request_thinking, None); - } - - #[tokio::test] - async fn query_sends_turn_config_request_model_to_provider() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "catalog-slug".into(), - display_name: "Catalog Model".into(), - base_instructions: "catalog instructions".into(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - - query( - &mut session, - &TurnConfig::with_request_model( - model, - "vendor/model-name".into(), - HashMap::new().into(), - /*reasoning_effort_selection*/ None, - ), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - assert_eq!(captured[0].model, "vendor/model-name"); - assert_eq!( - session - .session_context - .as_ref() - .expect("session context") - .model - .slug, - "catalog-slug" - ); - } - - /// Trace: L2-DES-CONTEXT-001 - /// Verifies: Plan turns append the active Plan collaboration prompt to the provider system prompt. - #[tokio::test] - async fn query_appends_plan_mode_reminder_to_system_prompt() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "model-a".into(), - base_instructions: "base instructions".into(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.collaboration_mode = CollaborationMode::Plan; - session.push_message(Message::user("plan this")); - - query( - &mut session, - &TurnConfig::new(model, None), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - let system = captured[0].system.as_deref().expect("system prompt"); - let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); - assert!(system.contains("base instructions")); - assert!(system.contains(&mode_prompt)); - let mode_index = request_message_index_containing(&captured[0], ""); - assert!(message_contains( - &captured[0].messages[mode_index], - "plan" - )); - } - - /// Trace: L2-DES-CONTEXT-001 - /// Verifies: Returning from Plan to Build uses Build system prompt and a lightweight mode diff. - #[tokio::test] - async fn query_inserts_mode_change_prompt_when_returning_to_build_mode() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "model-a".into(), - base_instructions: "base instructions".into(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.collaboration_mode = CollaborationMode::Plan; - session.push_message(Message::user("plan this")); - - query( - &mut session, - &TurnConfig::new(model.clone(), None), - Arc::clone(&provider), - Arc::clone(®istry), - &runtime, - None, - ) - .await - .expect("plan query should succeed"); - - session.collaboration_mode = CollaborationMode::Build; - session.push_message(Message::user("implement this")); - query( - &mut session, - &TurnConfig::new(model, None), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("build query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - assert_eq!(captured[0].system, captured[1].system); - let system = captured[1].system.as_deref().expect("system prompt"); - let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); - assert!(system.contains("base instructions")); - assert!(system.contains(&mode_prompt)); - - let mode_change_index = request_message_index_containing( - &captured[1], - "plan -> build", - ); - let request_index = request_message_index_containing(&captured[1], "implement this"); - assert!(mode_change_index < request_index); - assert!(message_contains( - &captured[1].messages[mode_change_index], - "plan" - )); - assert!(message_contains( - &captured[1].messages[mode_change_index], - "build" - )); - assert!(message_contains( - &captured[1].messages[mode_change_index], - "any previous instructions for other modes (e.g. Plan mode) are no longer active." - )); - assert!(!message_contains( - &captured[1].messages[mode_change_index], - "" - )); - assert!(!message_contains( - &captured[1].messages[mode_change_index], - "" - )); - } - - #[tokio::test] - async fn query_inserts_goal_context_before_latest_user_request() { - // Trace: L2-DES-GOAL-001 - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "model-a".into(), - base_instructions: "base instructions".into(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.set_active_goal(active_goal("ship /goal")); - session.push_message(Message::user("finish implementation")); - - query( - &mut session, - &TurnConfig::new(model, None), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - assert!( - !captured[0] - .system - .as_deref() - .unwrap_or_default() - .contains("ship /goal") - ); - let messages = &captured[0].messages; - let goal_index = messages - .iter() - .position(|message| message_contains(message, "ship /goal")) - .expect("goal context message"); - let request_index = messages - .iter() - .position(|message| message_contains(message, "finish implementation")) - .expect("latest user request message"); - assert!(goal_index < request_index); - } - - #[tokio::test] - async fn autonomous_goal_context_is_latest_request_after_completed_turn() { - // Trace: L2-DES-GOAL-001 - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "model-a".into(), - base_instructions: "base instructions".into(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.set_active_goal(active_goal("continue the active goal")); - session.push_message(Message::user("older user prompt")); - session.push_message(Message::assistant_text("older assistant reply")); - - query( - &mut session, - &TurnConfig::new(model, None), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - let messages = &captured[0].messages; - let goal_index = messages - .iter() - .position(|message| message_contains(message, "continue the active goal")) - .expect("goal context message"); - let assistant_index = messages - .iter() - .position(|message| message_contains(message, "older assistant reply")) - .expect("assistant history message"); - assert!(goal_index > assistant_index); - assert_eq!(goal_index, messages.len() - 1); - } - - #[tokio::test] - async fn query_locks_system_prompt_and_environment_prefix_per_session() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let temp_root = - std::env::temp_dir().join(format!("devo-query-lock-{}", uuid::Uuid::new_v4())); - let second_cwd = temp_root.join("nested"); - let first_model = Model { - slug: "model-a".into(), - base_instructions: "base-a".into(), - ..Model::default() - }; - let second_model = Model { - slug: "model-b".into(), - base_instructions: "base-b".into(), - ..Model::default() - }; - - let mut session = SessionState::new(SessionConfig::default(), temp_root.clone()); - session.push_message(Message::user("hello")); - - query( - &mut session, - &TurnConfig::new(first_model, None), - Arc::clone(&provider), - Arc::clone(®istry), - &runtime, - None, - ) - .await - .expect("first query should succeed"); - - session.cwd = second_cwd; - session.push_message(Message::user("follow up")); - - query( - &mut session, - &TurnConfig::new(second_model, Some("enabled".into())), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("second query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); - let expected_system = format!("base-a\n\n{mode_prompt}"); - assert_eq!( - captured[0].system.as_deref(), - Some(expected_system.as_str()) - ); - assert_eq!( - captured[1].system.as_deref(), - Some(expected_system.as_str()) - ); - - let first_prefix = &captured[0].messages[0]; - let second_prefix = &captured[1].messages[0]; - assert_eq!(first_prefix.role, second_prefix.role); - let devo_protocol::RequestContent::Text { text: first_text } = &first_prefix.content[0] - else { - panic!("expected text prefix"); - }; - let devo_protocol::RequestContent::Text { text: second_text } = &second_prefix.content[0] - else { - panic!("expected text prefix"); - }; - assert_eq!(first_text, second_text); - } - - #[tokio::test] - async fn query_inserts_context_diff_before_changed_turn_input() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - let first_model = Model { - slug: "model-a".into(), - ..Model::default() - }; - let second_model = Model { - slug: "model-b".into(), - ..Model::default() - }; - - session.push_message(Message::user("hello")); - query( - &mut session, - &TurnConfig::new(first_model, None), - Arc::clone(&provider), - Arc::clone(®istry), - &runtime, - None, - ) - .await - .expect("first query should succeed"); - - session.push_message(Message::user("follow up")); - query( - &mut session, - &TurnConfig::new(second_model, Some("enabled".into())), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("second query should succeed"); - - let diff_message = &session.messages[session.messages.len() - 3]; - let user_message = &session.messages[session.messages.len() - 2]; - assert_eq!(user_message, &Message::user("follow up")); - let ContentBlock::Text { text } = &diff_message.content[0] else { - panic!("expected text diff message"); - }; - assert!(text.contains("")); - assert!(text.contains("")); - assert!(text.contains("model")); - assert!(text.contains("model-a")); - assert!(text.contains("model-b")); - } - - #[tokio::test] - async fn query_skips_context_diff_when_turn_metadata_unchanged() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "model-a".into(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - - session.push_message(Message::user("hello")); - query( - &mut session, - &TurnConfig::new(model.clone(), None), - Arc::clone(&provider), - Arc::clone(®istry), - &runtime, - None, - ) - .await - .expect("first query should succeed"); - - session.push_message(Message::user("follow up")); - query( - &mut session, - &TurnConfig::new(model, None), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("second query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let follow_up_index = request_message_index_containing(&captured[1], "follow up"); - assert!( - follow_up_index > 0, - "follow-up user message should not be the first prompt message" - ); - assert!( - !message_contains( - &captured[1].messages[follow_up_index - 1], - "" - ), - "unchanged metadata after a completed turn should not insert a new context_changes before the next user message" - ); - } - - #[tokio::test] - async fn query_inserts_interrupted_notice_before_next_user_message() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - session.push_message(Message::assistant_text("partial")); - session.mark_last_turn_interrupted(); - session.push_message(Message::user("continue please")); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider, - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let abort_index = session - .messages - .iter() - .position(|message| { - message.content.iter().any(|block| { - matches!( - block, - ContentBlock::Text { text } if text.contains("") - ) - }) - }) - .expect("interrupted notice should be inserted"); - let continue_index = session - .messages - .iter() - .position(|message| { - message.content.iter().any(|block| { - matches!( - block, - ContentBlock::Text { text } if text.contains("continue please") - ) - }) - }) - .expect("user message should remain"); - assert!(abort_index < continue_index); - assert!(!session.last_turn_interrupted); - } - - #[tokio::test] - async fn query_pairs_interrupted_tool_result_when_cancel_fires_during_tool() { - #[derive(Debug)] - struct HangingMutatingTool; - - #[async_trait] - impl ToolHandler for HangingMutatingTool { - fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { - Box::leak(Box::new(crate::tools::tool_spec::ToolSpec { - name: "mutating_tool".into(), - description: "hangs until cancelled".into(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - })) - } - - async fn handle( - &self, - _ctx: crate::tools::contracts::ToolContext, - _input: serde_json::Value, - _progress: Option, - ) -> Result - { - std::future::pending::<()>().await; - unreachable!("tool should be cancelled") - } - } - - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(HangingMutatingTool)); - builder.push_spec(crate::tools::tool_spec::ToolSpec { - name: "mutating_tool".into(), - description: "hangs until cancelled".into(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let cancel_token = CancellationToken::new(); - let cancel_for_task = cancel_token.clone(); - let runtime = ToolRuntime::new_with_context_and_options( - Arc::clone(®istry), - PermissionChecker::always_allow(), - ToolRuntimeContext::default(), - ToolExecutionOptions { - cancel_token: cancel_token.clone(), - ..ToolExecutionOptions::default() - }, - ); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tool")); - let turn_config = TurnConfig::new(Model::default(), None); - let provider: Arc = Arc::new(SingleToolUseProvider { - requests: AtomicUsize::new(0), - }); - - let result = { - let mut query_future = std::pin::pin!(query_with_options( - &mut session, - &turn_config, - provider, - registry, - &runtime, - None, - QueryOptions { - cancel_token: Some(cancel_token), - ..QueryOptions::default() - }, - )); - tokio::select! { - result = &mut query_future => { - panic!("query completed before cancel: {result:?}"); - } - () = tokio::time::sleep(std::time::Duration::from_millis(50)) => { - cancel_for_task.cancel(); - } - } - query_future.await - }; - assert!(matches!(result, Err(AgentError::Aborted))); - - let tool_use = session.messages.iter().find(|message| { - message - .content - .iter() - .any(|block| matches!(block, ContentBlock::ToolUse { id, .. } if id == "tool-1")) - }); - assert!(tool_use.is_some(), "tool call should be retained"); - - let tool_result = session.messages.iter().find_map(|message| { - message.content.iter().find_map(|block| match block { - ContentBlock::ToolResult { - tool_use_id, - content, - is_error, - } if tool_use_id == "tool-1" => Some((content.clone(), *is_error)), - _ => None, - }) - }); - let (content, is_error) = tool_result.expect("interrupted tool result should exist"); - assert!(is_error); - assert_eq!(content, crate::tools::INTERRUPTED_TOOL_RESULT_MESSAGE); - } - - #[tokio::test] - async fn query_drops_orphaned_tool_calls_from_prompt_history() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(CapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - - session.push_message(Message::user("first")); - session.push_message(Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Text { - text: "Calling tool".into(), - }, - ContentBlock::ToolUse { - id: "call-1".into(), - name: "bash".into(), - input: json!({ "cmd": "pwd" }), - }, - ], - }); - session.push_message(Message::user("follow up")); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider, - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - assert!( - captured[0] - .messages - .iter() - .flat_map(|message| message.content.iter()) - .all(|content| !matches!(content, devo_protocol::RequestContent::ToolUse { .. })), - "expected orphaned tool calls to be removed from prompt history" - ); - } - - #[tokio::test] - async fn test_model_connection_sends_minimal_request() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider = CapturingProvider { - requests: Arc::clone(&requests), - }; - let model = Model { - slug: "glm-4.5".into(), - reasoning_capability: devo_protocol::ReasoningCapability::Toggle, - top_p: Some(0.95), - ..Model::default() - }; - let preview = test_model_connection( - &provider, - &model, - devo_protocol::ModelProfileKey::CatalogSlug(model.slug.clone()), - "renamed-provider-model", - "Reply with OK only.", - ) - .await - .expect("probe request should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(preview, "done"); - assert_eq!(captured.len(), 1); - assert_eq!( - captured[0].model_slug, - devo_protocol::ModelProfileKey::CatalogSlug("glm-4.5".to_string()) - ); - assert_eq!(captured[0].model, "renamed-provider-model"); - assert_eq!(captured[0].request_thinking.as_deref(), Some("enabled")); - assert_eq!(captured[0].system, None); - assert!(captured[0].tools.is_none()); - assert_eq!(captured[0].messages.len(), 1); - assert_eq!(captured[0].sampling.top_p, Some(0.95)); - } - - #[tokio::test] - async fn query_persists_streamed_reasoning_for_follow_up_request() { - struct ReasoningProvider { - requests: Arc>>, - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for ReasoningProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - Ok(Box::pin(futures::stream::iter(vec![ - Ok(StreamEvent::ReasoningStart { index: 0 }), - Ok(StreamEvent::ReasoningDelta { - index: 0, - text: "plan".into(), - }), - Ok(StreamEvent::TextStart { index: 1 }), - Ok(StreamEvent::TextDelta { - index: 1, - text: "final".into(), - }), - Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: "resp-3".into(), - content: vec![ResponseContent::Text("final".into())], - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: ResponseMetadata { - extras: vec![ResponseExtra::ReasoningText { - text: "plan".into(), - }], - }, - }, - }), - ]))) - } - - fn name(&self) -> &str { - "reasoning-provider" - } - } - - let requests = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(ReasoningProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let seen_events = Arc::new(Mutex::new(Vec::new())); - let callback_events = Arc::clone(&seen_events); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let callback_events = Arc::clone(&callback_events); - Box::pin(async move { - callback_events.lock().expect("lock callback").push(event); - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider.clone(), - Arc::clone(®istry), - &runtime, - Some(callback), - ) - .await - .expect("first query should succeed"); - - { - let events = seen_events.lock().expect("lock events"); - assert!(events.iter().any(|event| matches!( - event, - QueryEvent::ReasoningDelta(text) if text == "plan" - ))); - } - - let assistant_message = session - .messages - .iter() - .find(|message| matches!(message.role, Role::Assistant)) - .expect("assistant message"); - assert_eq!( - assistant_message, - &Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Reasoning { - text: "plan".into(), - }, - ContentBlock::Text { - text: "final".into(), - }, - ], - } - ); - - session.push_message(Message::user("follow up")); - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider, - registry, - &runtime, - None, - ) - .await - .expect("second query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let replayed_assistant = captured[1] - .messages - .iter() - .find(|message| message.role == "assistant") - .expect("assistant replay"); - assert_eq!( - serde_json::to_value(replayed_assistant).expect("serialize assistant replay"), - json!({ - "role": "assistant", - "content": [ - { "type": "reasoning", "text": "plan" }, - { "type": "text", "text": "final" } - ] - }) - ); - } - - #[tokio::test] - async fn query_round_trips_provider_reasoning_without_plain_reasoning() { - struct SignedReasoningProvider { - requests: Arc>>, - calls: AtomicUsize, - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for SignedReasoningProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - let call = self.calls.fetch_add(1, Ordering::SeqCst); - let content = if call == 0 { - vec![ - ResponseContent::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "signed plan", - "signature": "sig_123" - }), - }, - ResponseContent::Text("first".into()), - ] - } else { - vec![ResponseContent::Text("second".into())] - }; - Ok(Box::pin(futures::stream::iter(vec![Ok( - StreamEvent::MessageDone { - response: ModelResponse { - id: format!("resp-{call}"), - content, - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: ResponseMetadata::default(), - }, - }, - )]))) - } - - fn name(&self) -> &str { - "signed-reasoning-provider" - } - } - - let requests = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(SignedReasoningProvider { - requests: Arc::clone(&requests), - calls: AtomicUsize::new(0), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let seen_events = Arc::new(Mutex::new(Vec::new())); - let callback_events = Arc::clone(&seen_events); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let callback_events = Arc::clone(&callback_events); - Box::pin(async move { - callback_events.lock().expect("lock callback").push(event); - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider.clone(), - Arc::clone(®istry), - &runtime, - Some(callback), - ) - .await - .expect("first query should succeed"); - - { - let events = seen_events.lock().expect("lock events"); - assert!(events.iter().any(|event| matches!( - event, - QueryEvent::ReasoningDelta(text) if text == "signed plan" - ))); - assert!( - events - .iter() - .any(|event| matches!(event, QueryEvent::ReasoningCompleted)) - ); - } - - let assistant_message = session - .messages - .iter() - .find(|message| matches!(message.role, Role::Assistant)) - .expect("assistant message"); - assert_eq!( - assistant_message, - &Message { - role: Role::Assistant, - content: vec![ - ContentBlock::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "signed plan", - "signature": "sig_123" - }), - }, - ContentBlock::Text { - text: "first".into(), - }, - ], - } - ); - - session.push_message(Message::user("follow up")); - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider, - registry, - &runtime, - None, - ) - .await - .expect("second query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let second_request_content = captured[1] - .messages - .iter() - .flat_map(|message| message.content.iter()) - .collect::>(); - assert!(second_request_content.iter().any(|content| matches!( - content, - RequestContent::ProviderReasoning { provider, payload } - if provider == "anthropic" - && payload["thinking"] == json!("signed plan") - && payload["signature"] == json!("sig_123") - ))); - assert!( - second_request_content - .iter() - .all(|content| !matches!(content, RequestContent::Reasoning { .. })) - ); - } - - #[tokio::test] - async fn query_continues_deepseek_v4_thinking_only_end_turn_once() { - struct ThinkingOnlyThenTextProvider { - requests: Arc>>, - calls: AtomicUsize, - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for ThinkingOnlyThenTextProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - let call = self.calls.fetch_add(1, Ordering::SeqCst); - let content = if call == 0 { - vec![ResponseContent::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "internal plan", - "signature": "sig_plan" - }), - }] - } else { - vec![ResponseContent::Text("visible answer".into())] - }; - let message_done = Ok(StreamEvent::MessageDone { - response: ModelResponse { - id: format!("resp-{call}"), - content, - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: ResponseMetadata::default(), - }, - }); - let events = if call == 0 { - vec![message_done] - } else { - vec![ - Ok(StreamEvent::TextDelta { - index: 0, - text: "visible answer".into(), - }), - message_done, - ] - }; - Ok(Box::pin(futures::stream::iter(events))) - } - - fn name(&self) -> &str { - "thinking-only-then-text-provider" - } - } - - let requests = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(ThinkingOnlyThenTextProvider { - requests: Arc::clone(&requests), - calls: AtomicUsize::new(0), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "deepseek-v4-pro".into(), - provider: devo_protocol::ProviderWireApi::AnthropicMessages, - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - let seen_events = Arc::new(Mutex::new(Vec::new())); - let callback_events = Arc::clone(&seen_events); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let callback_events = Arc::clone(&callback_events); - Box::pin(async move { - callback_events.lock().expect("lock callback").push(event); - }) - }); - - query( - &mut session, - &TurnConfig::new(model, None), - provider, - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should continue once and finish with text"); - - let session_message_tail = session.messages[session.messages.len() - 4..].to_vec(); - assert_eq!( - session_message_tail, - vec![ - Message::user("hello"), - Message { - role: Role::Assistant, - content: vec![ContentBlock::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "internal plan", - "signature": "sig_plan" - }), - }], - }, - Message::user(super::DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT), - Message::assistant_text("visible answer"), - ] - ); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let second_request_messages = &captured[1].messages; - let second_request_tail = &second_request_messages[second_request_messages.len() - 3..]; - assert_eq!( - serde_json::to_value(second_request_tail).expect("serialize second request messages"), - json!([ - { - "role": "user", - "content": [{ - "type": "text", - "text": "hello" - }] - }, - { - "role": "assistant", - "content": [{ - "type": "provider_reasoning", - "provider": "anthropic", - "payload": { - "type": "thinking", - "thinking": "internal plan", - "signature": "sig_plan" - } - }] - }, - { - "role": "user", - "content": [{ - "type": "text", - "text": super::DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT - }] - } - ]) - ); - - let events = seen_events.lock().expect("lock events"); - let turn_complete_count = events - .iter() - .filter(|event| matches!(event, QueryEvent::TurnComplete { .. })) - .count(); - assert_eq!(turn_complete_count, 1); - assert!(events.iter().any(|event| matches!( - event, - QueryEvent::TextDelta(text) if text == "visible answer" - ))); - } - - #[tokio::test] - async fn query_preserves_provider_reasoning_and_hosted_tool_order() { - struct OrderedHostedProvider { - requests: Arc>>, - calls: AtomicUsize, - } - - #[async_trait] - impl devo_provider::ModelProviderSDK for OrderedHostedProvider { - async fn completion(&self, _request: ModelRequest) -> Result { - unreachable!("tests stream responses only") - } - - async fn completion_stream( - &self, - request: ModelRequest, - ) -> Result> + Send>>> { - self.requests.lock().expect("lock requests").push(request); - let call = self.calls.fetch_add(1, Ordering::SeqCst); - let content = if call == 0 { - vec![ - ResponseContent::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "before tool", - "signature": "sig_before" - }), - }, - ResponseContent::HostedToolUse { - id: "srvtool_1".into(), - name: "web_search".into(), - input: json!({"query": "desktop gui 2026"}), - output: None, - status: None, - }, - ResponseContent::HostedToolUse { - id: "srvtool_1".into(), - name: "web_search".into(), - input: json!({}), - output: Some(json!([{"title": "result"}])), - status: Some("completed".into()), - }, - ResponseContent::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "after tool", - "signature": "sig_after" - }), - }, - ResponseContent::Text("final".into()), - ] - } else { - vec![ResponseContent::Text("second".into())] - }; - Ok(Box::pin(futures::stream::iter(vec![Ok( - StreamEvent::MessageDone { - response: ModelResponse { - id: format!("resp-{call}"), - content, - stop_reason: Some(StopReason::EndTurn), - usage: Usage::default(), - metadata: ResponseMetadata::default(), - }, - }, - )]))) - } - - fn name(&self) -> &str { - "ordered-hosted-provider" - } - } - - let requests = Arc::new(Mutex::new(Vec::new())); - let provider = Arc::new(OrderedHostedProvider { - requests: Arc::clone(&requests), - calls: AtomicUsize::new(0), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("hello")); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider.clone(), - Arc::clone(®istry), - &runtime, - None, - ) - .await - .expect("first query should succeed"); - - let assistant_message = session - .messages - .iter() - .find(|message| matches!(message.role, Role::Assistant)) - .expect("assistant message"); - assert_eq!( - assistant_message.content, - vec![ - ContentBlock::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "before tool", - "signature": "sig_before" - }), - }, - ContentBlock::HostedToolUse { - id: "srvtool_1".into(), - name: "web_search".into(), - input: json!({"query": "desktop gui 2026"}), - output: None, - status: None, - }, - ContentBlock::HostedToolUse { - id: "srvtool_1".into(), - name: "web_search".into(), - input: json!({"query": "desktop gui 2026"}), - output: Some(json!([{"title": "result"}])), - status: Some("completed".into()), - }, - ContentBlock::ProviderReasoning { - provider: "anthropic".into(), - payload: json!({ - "type": "thinking", - "thinking": "after tool", - "signature": "sig_after" - }), - }, - ContentBlock::Text { - text: "final".into(), - }, - ] - ); - - session.push_message(Message::user("follow up")); - query( - &mut session, - &TurnConfig::new(Model::default(), None), - provider, - registry, - &runtime, - None, - ) - .await - .expect("second query should succeed"); - - let captured = requests.lock().expect("lock requests"); - let replayed_content = captured[1] - .messages - .iter() - .find(|message| message.role == "assistant") - .expect("assistant replay") - .content - .clone(); - assert_eq!( - serde_json::to_value(&replayed_content).expect("serialize replayed content"), - json!([ - { - "type": "provider_reasoning", - "provider": "anthropic", - "payload": { - "type": "thinking", - "thinking": "before tool", - "signature": "sig_before" - } - }, - { - "type": "hosted_tool_use", - "id": "srvtool_1", - "name": "web_search", - "input": { "query": "desktop gui 2026" } - }, - { - "type": "hosted_tool_use", - "id": "srvtool_1", - "name": "web_search", - "input": { "query": "desktop gui 2026" }, - "output": [{ "title": "result" }], - "status": "completed" - }, - { - "type": "provider_reasoning", - "provider": "anthropic", - "payload": { - "type": "thinking", - "thinking": "after tool", - "signature": "sig_after" - } - }, - { - "type": "text", - "text": "final" - } - ]) - ); - } - - #[tokio::test] - async fn query_disables_openai_thinking_when_reasoning_context_is_missing() { - let requests = Arc::new(Mutex::new(Vec::new())); - let provider: Arc = Arc::new(OpenAiCapturingProvider { - requests: Arc::clone(&requests), - }); - let registry = Arc::new(ToolRegistry::new()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let model = Model { - slug: "deepseek-v4-flash".into(), - provider: devo_protocol::ProviderWireApi::OpenAIChatCompletions, - reasoning_capability: ReasoningCapability::Toggle, - base_instructions: String::new(), - ..Model::default() - }; - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::assistant_text("legacy assistant reply")); - session.push_message(Message::user("follow up")); - - query( - &mut session, - &TurnConfig::new(model, Some("enabled".into())), - Arc::clone(&provider), - registry, - &runtime, - None, - ) - .await - .expect("query should succeed"); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 1); - assert_eq!( - captured[0].model_slug, - devo_protocol::ModelProfileKey::CatalogSlug("deepseek-v4-flash".to_string()) - ); - assert_eq!(captured[0].request_thinking.as_deref(), Some("enabled")); - // Toggle capability does not set reasoning_effort on the request. - assert_eq!(captured[0].reasoning_effort, None); - } - - #[tokio::test] - async fn query_tool_result_summary_is_set() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(MutatingTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tool")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - if let QueryEvent::ToolResult { summary, .. } = event { - seen_clone.lock().unwrap().push(summary); - } - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(SingleToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - let summaries = seen.lock().unwrap(); - assert!( - !summaries.is_empty(), - "should have at least one ToolResult summary" - ); - for summary in summaries.iter() { - assert!(!summary.is_empty(), "summary should not be empty"); - } - } - - #[tokio::test] - async fn query_tool_result_event_includes_final_tool_input() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tool")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - if let QueryEvent::ToolResult { - tool_name, input, .. - } = event - { - seen_clone.lock().unwrap().push((tool_name, input)); - } - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(SingleToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!( - seen.lock().unwrap().as_slice(), - &[(String::from("mutating_tool"), json!({ "value": 1 }))] - ); - } - - #[tokio::test] - async fn query_tool_result_event_matches_input_delta_by_tool_index() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tools")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - if let QueryEvent::ToolResult { - tool_use_id, input, .. - } = event - { - seen_clone.lock().unwrap().push((tool_use_id, input)); - } - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(InterleavedToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!( - seen.lock().unwrap().as_slice(), - &[ - (String::from("tool-1"), json!({ "value": 1 })), - (String::from("tool-2"), json!({ "value": 2 })), - ] - ); - } - - #[tokio::test] - async fn query_truncates_model_visible_tool_results_but_emits_raw_tool_result_events() { - let full_content = "abcdefghijklmnopqrstuvwxyz".to_string(); - let display_content = "raw display abcdefghijklmnopqrstuvwxyz".to_string(); - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler( - "mutating_tool", - Arc::new(LargeToolResultTool { - content: full_content.clone(), - display_content: Some(display_content.clone()), - }), - ); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - let requests = Arc::new(Mutex::new(Vec::new())); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tool")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - if let QueryEvent::ToolResult { - content, - display_content, - .. - } = event - { - seen_clone - .lock() - .expect("lock seen events") - .push((content.into_string(), display_content)); - } - }) - }); - - query( - &mut session, - &TurnConfig::new( - Model { - truncation_policy: TruncationPolicyConfig::bytes(20), - ..Model::default() - }, - None, - ), - Arc::new(CapturingToolUseProvider { - requests: Arc::clone(&requests), - calls: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!( - seen.lock().expect("lock seen events").as_slice(), - &[(full_content.clone(), Some(display_content))] - ); - - let captured = requests.lock().expect("lock requests"); - assert_eq!(captured.len(), 2); - let model_visible_tool_result = captured[1] - .messages - .iter() - .flat_map(|message| &message.content) - .find_map(|content| match content { - RequestContent::ToolResult { content, .. } => Some(content.as_str()), - RequestContent::Text { .. } - | RequestContent::Reasoning { .. } - | RequestContent::ProviderReasoning { .. } - | RequestContent::HostedToolUse { .. } - | RequestContent::ToolUse { .. } => None, - }) - .expect("continuation request should include tool result"); - assert_eq!(model_visible_tool_result, "abcde\n...[truncated]"); - } - - #[tokio::test] - async fn query_tool_start_event_includes_final_tool_input() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tools")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - if let QueryEvent::ToolUseStart { id, input, .. } = event { - seen_clone.lock().unwrap().push((id, input)); - } - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(InterleavedToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!( - seen.lock().unwrap().as_slice(), - &[ - (String::from("tool-1"), json!({ "value": 1 })), - (String::from("tool-2"), json!({ "value": 2 })), - ] - ); - } - - #[tokio::test] - #[ignore = "legacy progress mechanism replaced by L3 contracts"] - async fn query_emits_tool_result_display_content() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tool")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - if let QueryEvent::ToolResult { - content, - display_content, - .. - } = event - { - seen_clone.lock().unwrap().push((content, display_content)); - } - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(SingleToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - let seen = seen.lock().unwrap(); - assert_eq!(seen.len(), 1); - assert!(matches!( - &seen[0], - (crate::tools::ToolContent::Text(text), Some(display)) - if text == "canonical" && display == "display" - )); - } - - #[tokio::test] - #[ignore = "legacy progress mechanism replaced by L3 contracts"] - async fn query_emits_tool_progress_before_tool_result() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("mutating_tool", Arc::new(StreamingMutatingTool)); - builder.push_spec(ToolSpec { - name: "mutating_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::Mutating, - capability_tags: vec![], - supports_parallel: false, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tool")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - seen_clone.lock().unwrap().push(event); - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(SingleToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - let events = seen.lock().unwrap(); - let progress_index = events - .iter() - .position(|event| { - matches!( - event, - QueryEvent::ToolProgress { - tool_use_id, - progress: crate::tools::ToolProgress::OutputDelta { delta }, - } if tool_use_id == "tool-1" && delta == "stream chunk\n" - ) - }) - .expect("tool progress event should be emitted"); - let result_index = events - .iter() - .position(|event| { - matches!( - event, - QueryEvent::ToolResult { - tool_use_id, - content, - is_error, - .. - } if tool_use_id == "tool-1" - && matches!(content, crate::tools::ToolContent::Text(text) if text == "stream complete") - && !is_error - ) - }) - .expect("tool result event should be emitted"); - - assert!( - progress_index < result_index, - "tool progress should arrive before final result" - ); - } - - #[tokio::test] - async fn query_emits_parallel_tool_results_as_each_tool_finishes() { - let mut builder = ToolRegistryBuilder::new(); - builder.register_handler("parallel_tool", Arc::new(ParallelDelayTool)); - builder.push_spec(ToolSpec { - name: "parallel_tool".into(), - description: String::new(), - input_schema: JsonSchema::object(Default::default(), None, None), - output_mode: ToolOutputMode::Text, - execution_mode: ToolExecutionMode::ReadOnly, - capability_tags: vec![], - supports_parallel: true, - preparation_feedback: ToolPreparationFeedback::None, - display_name: None, - supports_cancellation: None, - supports_streaming: None, - }); - let registry = Arc::new(builder.build()); - let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); - - let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); - session.push_message(Message::user("run the tools")); - - let seen = Arc::new(Mutex::new(Vec::new())); - let seen_clone = Arc::clone(&seen); - let callback: EventCallback = Arc::new(move |event: QueryEvent| { - let seen_clone = Arc::clone(&seen_clone); - Box::pin(async move { - match event { - QueryEvent::ToolUseStart { id, .. } => { - seen_clone - .lock() - .expect("lock events") - .push(format!("start:{id}")); - } - QueryEvent::ToolResult { - tool_use_id, - content, - .. - } => { - let content = content.into_string(); - seen_clone - .lock() - .expect("lock events") - .push(format!("result:{tool_use_id}:{content}")); - } - _ => {} - } - }) - }); - - query( - &mut session, - &TurnConfig::new(Model::default(), None), - Arc::new(ParallelToolUseProvider { - requests: AtomicUsize::new(0), - }), - registry, - &runtime, - Some(callback), - ) - .await - .expect("query should complete"); - - assert_eq!( - seen.lock().expect("lock events").as_slice(), - &[ - "start:slow".to_string(), - "start:fast".to_string(), - "result:fast:fast complete".to_string(), - "result:slow:slow complete".to_string(), - ] - ); - - let tool_result_ids = session - .messages - .iter() - .flat_map(|message| &message.content) - .filter_map(|block| match block { - ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()), - _ => None, - }) - .collect::>(); - assert_eq!(tool_result_ids, vec!["slow", "fast"]); - } -} diff --git a/crates/core/src/query/event.rs b/crates/core/src/query/event.rs new file mode 100644 index 00000000..0d03820d --- /dev/null +++ b/crates/core/src/query/event.rs @@ -0,0 +1,137 @@ +//! Public observation surface for the query loop. +//! +//! Callers (CLI/UI/server) subscribe to a single `QueryEvent` stream covering +//! model deltas, tool progress, compaction, and provider retries. + +use std::sync::Arc; + +use futures::future::BoxFuture; +use tokio_util::sync::CancellationToken; + +use crate::tools::ToolContent; +use devo_protocol::StopReason; +use devo_provider::ModelProviderSDK; + +/// Events emitted during a query for the caller (CLI/UI) to observe. +#[derive(Debug, Clone)] +pub enum QueryEvent { + /// Provider request retry status. + ProviderRetryStatus(ProviderRetryStatus), + /// Context compaction is about to begin. + ContextCompactionStarted, + /// Context compaction replaced the current prompt history. + ContextCompactionCompleted, + /// Context compaction did not replace the current prompt history. + ContextCompactionFailed { + /// Human-readable reason the compaction did not complete. + message: String, + }, + /// Incremental text from the assistant. + TextDelta(String), + /// Incremental reasoning text from the assistant. + ReasoningDelta(String), + /// Current reasoning block completed. + ReasoningCompleted, + /// Incremental token usage update from the provider stream. + /// TODO: Review the mechanism from the OpenAI API / Anthropic API documentation. + UsageDelta { usage: devo_protocol::Usage }, + /// The assistant started a tool call. + ToolUseStart { + /// Stable provider-issued tool use identifier. + id: String, + /// Tool name selected by the model. + name: String, + /// Fully decoded tool input payload, when available. + input: serde_json::Value, + }, + /// A locally executed tool has passed permission checks and started running. + ToolExecutionStart { + /// Stable provider-issued tool use identifier. + id: String, + }, + /// Incremental output delta from a running tool. + ToolProgress { + tool_use_id: String, + progress: crate::tools::ToolProgress, + }, + /// A tool call completed. + ToolResult { + tool_use_id: String, + tool_name: String, + input: serde_json::Value, + content: ToolContent, + display_content: Option, + is_error: bool, + /// Human-readable summary for client-side rendering (e.g. "bash: npm run dev"). + summary: String, + }, + /// A turn is complete (model stopped generating). + TurnComplete { stop_reason: StopReason }, + /// Token usage update. + Usage { usage: devo_protocol::Usage }, +} + +/// Async sink for streaming `QueryEvent`s out of the core query loop. +/// +/// The type is intentionally erased so `query()` can accept callbacks from tests, the server +/// runtime, and tool-progress plumbing without knowing their concrete future types: +/// +/// - `Arc`: shared, cheap-to-clone ownership. The same callback is cloned into model-stream and +/// tool-progress paths that may outlive the immediate stack frame. +/// - `dyn Fn(QueryEvent)`: dynamic callback interface. Callers provide any closure that accepts one +/// event and can be invoked repeatedly. +/// - `BoxFuture<'static, ()>`: boxed async work returned by the callback. Boxing hides the +/// closure's concrete future type behind one trait-object shape; `'static` prevents borrowed +/// stack data from escaping into spawned or delayed event paths. +/// - `Send + Sync`: the callback can be shared and awaited across Tokio tasks and worker threads. +/// +/// Awaiting this future is what lets callers use bounded async channels for backpressure instead of +/// the old synchronous callback bridge. +pub type EventCallback = Arc BoxFuture<'static, ()> + Send + Sync>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderRetryStatus { + pub provider: String, + pub model: String, + pub attempt: usize, + pub backoff_ms: u64, + pub phase: QueryProviderRetryPhase, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QueryProviderRetryPhase { + Scheduled, + Resumed, +} + +#[derive(Clone, Default)] +pub struct QueryOptions { + pub cancel_token: Option, + /// Optional provider used only for compaction summaries. Servers use this + /// seam to attach Compaction metering without misclassifying the main + /// streaming query as compaction overhead. + pub compaction_provider: Option>, +} + +impl std::fmt::Debug for QueryOptions { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("QueryOptions") + .field("cancel_token", &self.cancel_token) + .field( + "compaction_provider", + &self + .compaction_provider + .as_ref() + .map(|provider| provider.name()), + ) + .finish() + } +} + +pub(crate) async fn emit_query_event(on_event: &Option, event: QueryEvent) { + if let Some(callback) = on_event { + callback(event).await; + } +} diff --git a/crates/core/src/query/mod.rs b/crates/core/src/query/mod.rs new file mode 100644 index 00000000..1d09c995 --- /dev/null +++ b/crates/core/src/query/mod.rs @@ -0,0 +1,935 @@ +//! Agent query loop: stream a model turn, run tools, and continue. +//! +//! Submodules isolate the observation surface, provider retry policy, prompt +//! token estimates, stream consumption, and model-turn continuation quirks +//! from the orchestration loop in this file. + +mod event; +mod prompt_estimate; +mod provider_retry; +mod stream_consumer; +mod turn_continuation; + +pub use event::EventCallback; +pub use event::ProviderRetryStatus; +pub use event::QueryEvent; +pub use event::QueryOptions; +pub use event::QueryProviderRetryPhase; + +pub(crate) use event::emit_query_event; +pub(crate) use prompt_estimate::estimate_request_prompt_tokens; +pub(crate) use provider_retry::ProviderRetryDecision; +pub(crate) use provider_retry::provider_retry_decision; +pub(crate) use provider_retry::wait_for_provider_retry; +pub(crate) use stream_consumer::AssembledModelTurn; +pub(crate) use stream_consumer::ProviderAttemptError; +pub(crate) use stream_consumer::run_provider_attempt; +pub(crate) use turn_continuation::ModelTurnSnapshot; +pub(crate) use turn_continuation::TurnContinuation; +pub(crate) use turn_continuation::TurnContinuationPolicy; +pub(crate) use turn_continuation::assistant_content_has_visible_content; + +#[cfg(test)] +pub(crate) use provider_retry::ErrorClass; +#[cfg(test)] +pub(crate) use provider_retry::classify_error; +#[cfg(test)] +pub(crate) use turn_continuation::DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT; + +use std::collections::HashMap; +use std::sync::Arc; + +use devo_protocol::HostedToolDefinition; +use devo_protocol::HostedWebFetchTool; +use devo_protocol::HostedWebSearchTool; +use devo_protocol::ModelRequest; +use devo_protocol::RequestContent; +use devo_protocol::RequestMessage; +use devo_protocol::ResolvedReasoningRequest; +use devo_protocol::ResponseContent; +use devo_protocol::SamplingControls; +use devo_protocol::StreamEvent; +use devo_protocol::TruncationPolicy; +use futures::StreamExt; +use tokio_util::sync::CancellationToken; +use tracing::debug; +use tracing::info; +use tracing::info_span; +use tracing::warn; + +use crate::tools::ToolAgentScope; +use crate::tools::ToolContent; +use crate::tools::ToolRegistry; +use crate::tools::ToolRuntime; +use crate::tools::deferred_loading::is_subagent_agent_coordination_tool; +use devo_provider::ModelProviderSDK; + +use crate::AgentError; +use crate::ContentBlock; +use crate::Message; +use crate::Model; +use crate::Role; +use crate::SessionState; +use crate::TurnConfig; +use crate::context::AgentsMdDiffFragment; +use crate::context::AgentsMdManager; +use crate::context::ContextualUserFragment; +use crate::context::SessionContext; +use crate::context::TurnContext; +use crate::context::load_workspace_instructions; +use crate::context::turn_aborted::TurnAborted; +use crate::history::ContextView; +use crate::history::History; +use crate::history::TokenInfo; +use crate::history::compaction::CompactAction; +use crate::history::compaction::CompactionConfig; +use crate::history::compaction::CompactionKind; +use crate::history::compaction::compact_history; +use crate::history::summarizer::DefaultHistorySummarizer; +use crate::response_item::ResponseItem; +use crate::response_item::message_to_response_items; + +const SUBAGENT_MODE_REMINDER: &str = include_str!("../../prompts/subagent_mode_reminder.md"); + +fn hosted_tools_for_web_capabilities( + web_search: &devo_config::ResolvedWebSearchConfig, + web_fetch: devo_config::ResolvedWebFetchConfig, +) -> Vec { + let mut hosted_tools = Vec::new(); + if matches!(web_search, devo_config::ResolvedWebSearchConfig::Provider) { + hosted_tools.push(HostedToolDefinition::WebSearch(HostedWebSearchTool::new())); + } + if web_fetch.is_provider() { + hosted_tools.push(HostedToolDefinition::WebFetch(HostedWebFetchTool::new())); + } + hosted_tools +} + +#[cfg(test)] +fn hosted_tools_for_web_search( + web_search: &devo_config::ResolvedWebSearchConfig, +) -> Vec { + hosted_tools_for_web_capabilities(web_search, devo_config::ResolvedWebFetchConfig::Disabled) +} + +/// Compact session messages using LLM-backed summarization. +/// +/// `kind` selects the preserve strategy inside [`compact_history`]: +/// - [`CompactionKind::Auto`]: preventive compaction when the session token +/// budget is high; keeps a tail token window. +/// - [`CompactionKind::Proactive`]: forced compaction after provider +/// `context_too_long`; keeps from the latest user message onward. +async fn summarize_and_compact( + session: &mut SessionState, + on_event: &Option, + provider: &Arc, + model_slug: &str, + request_model: &str, + max_tokens: usize, + kind: CompactionKind, +) { + let items: Vec = session + .prompt_source_messages() + .iter() + .cloned() + .flat_map(message_to_response_items) + .collect(); + + let token_info = TokenInfo { + input_tokens: session.total_input_tokens, + cached_input_tokens: session.total_cache_read_tokens, + output_tokens: session.total_output_tokens, + }; + + let config = CompactionConfig { + budget: session.config.token_budget.clone(), + kind, + }; + + let summarizer = DefaultHistorySummarizer::with_models( + Arc::clone(provider), + model_slug, + request_model, + max_tokens, + ); + + emit_query_event(on_event, QueryEvent::ContextCompactionStarted).await; + match compact_history(&items, &token_info, &summarizer, &config).await { + Ok(CompactAction::Replaced(compacted_items)) => { + let new_messages: Vec = compacted_items + .into_iter() + .filter_map(|item| match item { + ResponseItem::Message(msg) => Some(msg), + _ => None, + }) + .collect(); + let removed = session + .prompt_source_messages() + .len() + .saturating_sub(new_messages.len()); + info!("LLM compaction removed {removed} messages"); + session.set_prompt_messages(new_messages); + emit_query_event(on_event, QueryEvent::ContextCompactionCompleted).await; + } + Ok(CompactAction::Skipped) => { + debug!("LLM compaction skipped, nothing to compact"); + emit_query_event( + on_event, + QueryEvent::ContextCompactionFailed { + message: "Context compaction skipped: nothing to compact".to_string(), + }, + ) + .await; + } + Err(e) => { + warn!("LLM compaction failed: {e}"); + emit_query_event( + on_event, + QueryEvent::ContextCompactionFailed { + message: e.to_string(), + }, + ) + .await; + } + } +} + +// --------------------------------------------------------------------------- +// Model-visible tool result truncation +// --------------------------------------------------------------------------- + +const TOOL_RESULT_TRUNCATION_MARKER: &str = "\n...[truncated]"; + +fn truncate_tool_result_for_model( + content: String, + tool_name: Option<&str>, + truncation_policy: TruncationPolicy, +) -> String { + if preserve_full_tool_result(tool_name) { + return content; + } + + let byte_budget = truncation_policy.byte_budget(); + if content.len() <= byte_budget { + return content; + } + + let marker = if byte_budget > TOOL_RESULT_TRUNCATION_MARKER.len() { + TOOL_RESULT_TRUNCATION_MARKER + } else { + TOOL_RESULT_TRUNCATION_MARKER.trim_start() + }; + + if byte_budget <= marker.len() { + return marker.to_string(); + } + + let content_budget = byte_budget - marker.len(); + let mut truncate_at = content_budget; + while truncate_at > 0 && !content.is_char_boundary(truncate_at) { + truncate_at -= 1; + } + + let mut truncated = content[..truncate_at].to_string(); + truncated.push_str(marker); + truncated +} + +fn preserve_full_tool_result(tool_name: Option<&str>) -> bool { + matches!( + tool_name, + Some("await_task" | "wait_agent" | "subagent_result") + ) +} + +fn insert_subagent_request_reminders(messages: &mut Vec) { + let insert_at = messages + .iter() + .rposition(is_user_text_message) + .unwrap_or(messages.len()); + messages.splice( + insert_at..insert_at, + [request_text_message( + SUBAGENT_MODE_REMINDER.trim_end().to_string(), + )], + ); +} + +fn insert_goal_context_message(messages: &mut Vec, goal_context: &str) { + let insert_at = if messages.last().is_some_and(is_visible_user_text_message) { + messages.len().saturating_sub(1) + } else { + messages.len() + }; + messages.splice( + insert_at..insert_at, + [request_text_message(goal_context.to_string())], + ); +} + +fn request_text_message(text: String) -> RequestMessage { + RequestMessage { + role: Role::User.as_str().to_string(), + content: vec![RequestContent::Text { text }], + } +} + +fn is_user_text_message(message: &RequestMessage) -> bool { + message.role == Role::User.as_str() + && message + .content + .iter() + .any(|content| matches!(content, RequestContent::Text { .. })) +} + +fn is_visible_user_text_message(message: &RequestMessage) -> bool { + is_user_text_message(message) && !is_injected_context_message(message) +} + +fn is_injected_context_message(message: &RequestMessage) -> bool { + message.role == Role::User.as_str() + && message.content.iter().any(|content| match content { + RequestContent::Text { text } => { + let trimmed = text.trim_start(); + trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + || trimmed.starts_with("") + } + RequestContent::Reasoning { .. } + | RequestContent::ProviderReasoning { .. } + | RequestContent::HostedToolUse { .. } + | RequestContent::ToolUse { .. } + | RequestContent::ToolResult { .. } => false, + }) +} + +fn tool_content_model_bytes(content: &ToolContent) -> usize { + match content { + ToolContent::Text(text) => text.len(), + ToolContent::Json(json) => json.to_string().len(), + ToolContent::Mixed { text, json } => { + text.as_ref().map_or(0, String::len) + + json.as_ref().map_or(0, |json| json.to_string().len()) + } + } +} + +/// Agent loop orchestration: build request, stream, continue or run tools. +/// +/// Observation (`event`), provider retry (`provider_retry`), prompt estimates +/// (`prompt_estimate`), stream consumption (`stream_consumer`), and model-turn +/// continuation quirks (`turn_continuation`) live in submodules. +/// +/// The recursive agent loop is the beating heart of the runtime. +/// +/// The implementation refers to Claude Code's `query.ts`. It drives +/// multi-turn conversations by: +/// +/// 1. Building the model request from session state +/// 2. Streaming the model response +/// 3. Collecting assistant text and tool_use blocks +/// 4. Executing tool calls via the orchestrator +/// 5. Appending tool_result messages +/// 6. Recursing if the model wants to continue +/// +/// The loop terminates when: +/// - The model emits `end_turn` with no tool calls +/// - An unrecoverable error occurs +pub async fn query( + session: &mut SessionState, + turn_config: &TurnConfig, + provider: Arc, + registry: Arc, + runtime: &ToolRuntime, + on_event: Option, + options: QueryOptions, +) -> Result<(), AgentError> { + let compaction_provider = options + .compaction_provider + .as_ref() + .unwrap_or(&provider) + .clone(); + let agents_md_manager = AgentsMdManager::new(session.config.agents_md.clone()); + let current_agents_snapshot = load_workspace_instructions(&session.cwd, &agents_md_manager); + let agent_scope = runtime.agent_scope(); + let mut request_tools = registry.tool_definitions(); + if agent_scope == ToolAgentScope::Subagent { + request_tools.retain(|tool| !is_subagent_agent_coordination_tool(&tool.name)); + } + if !turn_config.web_search.is_local() { + request_tools.retain(|tool| tool.name != "web_search"); + } + if !turn_config.web_fetch.is_local() { + request_tools.retain(|tool| tool.name != "webfetch"); + } + + if session.session_context.is_none() { + session.session_context = Some(SessionContext::capture( + &turn_config.model, + turn_config.reasoning_effort_selection.as_deref(), + &session.cwd, + current_agents_snapshot.clone(), + session.config.available_skills_instructions.clone(), + )); + } + let current_turn_context = + TurnContext::capture(session, turn_config, current_agents_snapshot.clone()); + if let Some(context_changes) = + current_turn_context.context_changes_since(session.latest_turn_context.as_ref()) + { + session.insert_context_message(context_changes.to_message()); + } + if let Some(previous_turn_context) = session.latest_turn_context.as_ref() + && let Some(diff) = AgentsMdManager::diff( + previous_turn_context.observed_agents_snapshot.as_ref(), + current_agents_snapshot.as_ref(), + ) + { + session.insert_context_message(AgentsMdDiffFragment::new(diff).to_message()); + } + session.latest_turn_context = Some(current_turn_context.clone()); + let session_context = session + .session_context + .clone() + .expect("session context should be initialized"); + let prefetched_user_inputs = session_context.prefix_user_inputs(); + + let mut retry_count: usize = 0; + let mut context_compacted = false; + let mut budget_steer_injected = false; + let mut continuation_policy = + TurnContinuationPolicy::for_models(&turn_config.model.slug, &turn_config.request_model); + + if session.turn_state.is_none() { + session.start_turn(devo_protocol::TurnKind::Regular); + } + + let compaction_model_slug = turn_config + .model + .resolve_reasoning_effort_selection(turn_config.reasoning_effort_selection.as_deref()) + .request_model; + let compaction_request_model = turn_config.provider_request_model(&compaction_model_slug); + + // Explicit interrupted-turn notice for the next user message after a user + // interrupt. Placed before pending-input processing so it sits just above the + // latest user text in prompt construction (after any context diff). + let previous_turn_interrupted = session.take_last_turn_interrupted(); + if previous_turn_interrupted { + let fragment = TurnAborted::new(TurnAborted::INTERRUPTED_GUIDANCE); + if let ResponseItem::Message(msg) = fragment.to_response_item() { + session.insert_context_message(msg); + } + } + + loop { + let pending = session.take_turn_pending_input(); + + for item in &pending { + match &item.kind { + devo_protocol::PendingInputKind::UserText { text } => { + session.push_message(Message::user(text.clone())); + } + devo_protocol::PendingInputKind::UserInput { + prompt_text, + prompt_messages, + .. + } => { + if prompt_messages.is_empty() { + session.push_message(Message::user(prompt_text.clone())); + } else { + for prompt_message in prompt_messages { + session.push_message(Message::user(prompt_message.clone())); + } + } + } + devo_protocol::PendingInputKind::ToolCallBlockedByHook { + tool_use_id, + reason, + } => { + session.push_message(Message::user(format!( + "[Tool call {} was blocked: {}]", + tool_use_id, reason + ))); + } + devo_protocol::PendingInputKind::BudgetLimitSteering => { + session.push_message(Message::system( + "Note: The conversation is approaching the token budget limit. \ + Please be concise and consider wrapping up the current task.", + )); + } + } + } + + // Check token budget and compact before building the request + if session.last_turn_tokens > 0 + && session + .config + .token_budget + .should_compact(session.last_turn_tokens) + { + if !budget_steer_injected { + if let Some(turn) = session.turn_state.as_mut() { + turn.push_pending_input(devo_protocol::PendingInputItem::new( + devo_protocol::PendingInputKind::BudgetLimitSteering, + None, + chrono::Utc::now(), + )); + } + budget_steer_injected = true; + } + info!("token budget threshold exceeded, running LLM compaction"); + // Auto: preserve tail items up to COMPACT_USER_MESSAGE_MAX_TOKENS. + // Example: [user1, asst1, user2, asst2, user3] -> [summary, asst2, user3]. + summarize_and_compact( + session, + &on_event, + &compaction_provider, + &compaction_model_slug, + &compaction_request_model, + turn_config.model.max_tokens.unwrap_or(4096) as usize, + CompactionKind::Auto, + ) + .await; + } + + session.turn_count += 1; + let turn_span = info_span!( + "turn", + turn = session.turn_count, + session_id = %session.id, + model = %turn_config.model.slug, + cwd = %session.cwd.display() + ); + let _turn_guard = turn_span.enter(); + info!("starting turn"); + + // Build model request from the session-locked prefix. + let request_system = { + let mut system = session_context.build_system_prompt(); + if !matches!( + &turn_config.web_search, + devo_config::ResolvedWebSearchConfig::Disabled + ) { + if !system.trim().is_empty() { + system.push_str("\n\n"); + } + system.push_str(&crate::tools::websearch_prompt::web_search_prompt()); + } + Some(system).filter(|system| !system.trim().is_empty()) + }; + + // Resolve provider-bound reasoning request parameters. + let ResolvedReasoningRequest { + request_model, + request_thinking, + request_reasoning_effort, + extra_body, + effective_reasoning_effort: _, + } = turn_config + .model + .resolve_reasoning_effort_selection(turn_config.reasoning_effort_selection.as_deref()); + let catalog_request_model = request_model.clone(); + let provider_request_model = turn_config.provider_request_model(&catalog_request_model); + + let prompt_source_message_count = session.prompt_source_messages().len(); + let history_items = session + .prompt_source_messages() + .iter() + .cloned() + .flat_map(message_to_response_items) + .collect::>(); + let prompt_source_item_count = history_items.len(); + let history = History { + items: history_items, + token_info: TokenInfo::default(), + context: ContextView::new( + std::env::consts::OS, + session_context.environment.shell.clone(), + session_context.environment.timezone.clone(), + session_context.model.slug.clone(), + session_context + .reasoning_effort + .map(|effort| effort.label().to_lowercase()), + Some(session_context.persona.as_str().to_string()), + session_context.environment.current_date.clone(), + session_context.environment.cwd.display().to_string(), + ), + }; + let mut messages = history + .for_prompt_with_prefix(&prefetched_user_inputs, &turn_config.model.input_modalities); + if let Some(goal_context) = session.goal_context_prompt() { + insert_goal_context_message(&mut messages, &goal_context); + } + if agent_scope == ToolAgentScope::Subagent { + insert_subagent_request_reminders(&mut messages); + } + + let hosted_tools = + hosted_tools_for_web_capabilities(&turn_config.web_search, turn_config.web_fetch); + let request = ModelRequest { + model_slug: devo_protocol::ModelProfileKey::CatalogSlug(catalog_request_model), + model: provider_request_model, + system: request_system, + messages, + max_tokens: turn_config + .model + .max_tokens + .map_or(session.config.token_budget.max_output_tokens, |value| { + value as usize + }), + tools: Some(request_tools.clone()), + hosted_tools: hosted_tools.clone(), + sampling: SamplingControls { + temperature: turn_config.model.temperature, + top_p: turn_config.model.top_p, + top_k: turn_config.model.top_k.map(|value| value as u32), + }, + request_thinking, + reasoning_effort: request_reasoning_effort, + extra_body, + }; + session.prompt_token_estimate = estimate_request_prompt_tokens(&request); + debug!( + prompt_source_messages = prompt_source_message_count, + prompt_source_items = prompt_source_item_count, + prefix_user_inputs = prefetched_user_inputs.len(), + request_messages = request.messages.len(), + exposed_tools = request.tools.as_ref().map_or(0, Vec::len), + prompt_token_estimate = session.prompt_token_estimate, + max_tokens = request.max_tokens, + has_system = request.system.is_some(), + "built model request" + ); + + let assembled = match run_provider_attempt( + provider.as_ref(), + request, + session, + &on_event, + options.cancel_token.as_ref(), + &turn_config.model.slug, + ) + .await + { + Ok(assembled) => { + retry_count = 0; + context_compacted = false; + assembled + } + Err(ProviderAttemptError::Fatal(error)) => { + return Err(AgentError::Provider(error)); + } + Err(error) => { + let (is_create, retry_error) = match error { + ProviderAttemptError::Create(error) => (true, error), + ProviderAttemptError::Retryable(error) => (false, error), + ProviderAttemptError::Fatal(_) => { + unreachable!("fatal errors handled above") + } + }; + match provider_retry_decision( + &retry_error, + &mut retry_count, + &mut context_compacted, + ) { + ProviderRetryDecision::CompactAndRetry => { + warn!("context_too_long - compacting and retrying"); + // Proactive: must compact even if token estimates disagree + // with the provider; preserve from latest user only. + summarize_and_compact( + session, + &on_event, + &compaction_provider, + &compaction_model_slug, + &compaction_request_model, + turn_config.model.max_tokens.unwrap_or(4096) as usize, + CompactionKind::Proactive, + ) + .await; + session.turn_count -= 1; + continue; + } + ProviderRetryDecision::RetryAfter(backoff) => { + if is_create { + warn!( + attempt = retry_count, + backoff_ms = backoff.as_millis(), + "transient provider error - retrying with exponential backoff" + ); + } else { + warn!( + attempt = retry_count, + backoff_ms = backoff.as_millis(), + "transient provider stream error - retrying with exponential backoff" + ); + } + wait_for_provider_retry( + &on_event, + options.cancel_token.as_ref(), + provider.name(), + &turn_config.model.slug, + retry_count, + backoff, + ) + .await?; + session.turn_count -= 1; + continue; + } + ProviderRetryDecision::Fail => { + return Err(AgentError::Provider(retry_error)); + } + } + } + }; + + let AssembledModelTurn { + assistant_content, + tool_calls, + stop_reason, + has_hosted_tool_uses, + has_provider_reasoning, + has_visible_assistant_text, + } = assembled; + + let continuation = continuation_policy.decide(ModelTurnSnapshot { + stop_reason, + assistant_content: &assistant_content, + has_visible_assistant_text, + has_local_tool_calls: !tool_calls.is_empty(), + has_hosted_tool_uses, + has_provider_reasoning, + request_tools: &request_tools, + hosted_tools: &hosted_tools, + }); + + if assistant_content_has_visible_content(&assistant_content) { + session.push_message(Message { + role: Role::Assistant, + content: assistant_content, + }); + } + + match continuation { + TurnContinuation::RunTools => {} + TurnContinuation::Continue => continue, + TurnContinuation::ContinueWithMessage(message) => { + session.push_message(message); + continue; + } + TurnContinuation::Complete { stop_reason } => { + if let Some(sr) = stop_reason { + emit_query_event(&on_event, QueryEvent::TurnComplete { stop_reason: sr }).await; + } + debug!("no tool calls, ending query loop"); + session.end_turn(); + if options + .cancel_token + .as_ref() + .is_some_and(|ct| ct.is_cancelled()) + { + return Err(AgentError::Aborted); + } + return Ok(()); + } + TurnContinuation::Fail(error) => return Err(error), + } + + // If the turn was cancelled (e.g. mid-stream interrupt with partial + // tool calls), save whatever partial assistant content was already + // committed above, skip tool execution, and end the turn. + if options + .cancel_token + .as_ref() + .is_some_and(|ct| ct.is_cancelled()) + { + session.end_turn(); + return Ok(()); + } + + let tool_result_metadata: HashMap = tool_calls + .iter() + .map(|call| { + ( + call.id.clone(), + ( + call.name.clone(), + call.input.clone(), + crate::tools::tool_summary::tool_summary( + &call.name, + &call.input, + &session.cwd, + ), + ), + ) + }) + .collect(); + + // Execute tool calls. When a caller is observing query events, wire + // tool progress and per-call completion into the same event stream so + // long-running and parallel tools can render before the whole batch ends. + let results = if let Some(progress_events) = on_event.clone() { + let completion_events = Arc::clone(&progress_events); + let metadata = Arc::new(tool_result_metadata.clone()); + runtime + .execute_batch_streaming_with_completion( + &tool_calls, + move |tool_use_id, progress| { + let progress_events = Arc::clone(&progress_events); + Box::pin(async move { + progress_events(QueryEvent::ToolProgress { + tool_use_id, + progress, + }) + .await; + }) + }, + move |result| { + let completion_events = Arc::clone(&completion_events); + let metadata = Arc::clone(&metadata); + Box::pin(async move { + let (tool_name, input, summary) = metadata + .get(result.tool_use_id.as_str()) + .cloned() + .unwrap_or_else(|| { + (String::new(), serde_json::Value::Null, String::new()) + }); + completion_events(QueryEvent::ToolResult { + tool_use_id: result.tool_use_id, + tool_name, + input, + content: result.content, + display_content: result.display_content, + is_error: result.is_error, + summary, + }) + .await; + }) + }, + ) + .await + } else { + runtime.execute_batch(&tool_calls).await + }; + let tool_result_count = results.len(); + let tool_error_count = results.iter().filter(|result| result.is_error).count(); + let tool_output_bytes = results + .iter() + .map(|result| tool_content_model_bytes(&result.content)) + .sum::(); + debug!( + tool_calls = tool_calls.len(), + tool_results = tool_result_count, + tool_errors = tool_error_count, + tool_output_bytes, + "tool batch completed" + ); + + // Build tool result message (user role, per Anthropic API convention) + let truncation_policy = TruncationPolicy::from(turn_config.model.truncation_policy); + let result_content: Vec = results + .into_iter() + .map(|r| { + let tool_name = tool_result_metadata + .get(r.tool_use_id.as_str()) + .map(|(tool_name, _, _)| tool_name.as_str()); + let content_str = r.content.into_string(); + let content = + truncate_tool_result_for_model(content_str, tool_name, truncation_policy); + ContentBlock::ToolResult { + tool_use_id: r.tool_use_id, + content, + is_error: r.is_error, + } + }) + .collect(); + + session.push_message(Message { + role: Role::User, + content: result_content, + }); + + // If the turn was cancelled while tools were running, keep the + // interrupted tool results above and stop without another model call. + if options + .cancel_token + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + session.end_turn(); + return Err(AgentError::Aborted); + } + } +} + +/// Sends a minimal provider probe request used by onboarding and configuration checks. +pub async fn test_model_connection( + provider: &dyn ModelProviderSDK, + model: &Model, + model_profile: devo_protocol::ModelProfileKey, + request_model: &str, + prompt: &str, +) -> Result { + let ResolvedReasoningRequest { + request_model: _, + request_thinking, + request_reasoning_effort, + extra_body, + effective_reasoning_effort: _, + } = model.resolve_reasoning_effort_selection(None); + let request = ModelRequest { + model_slug: model_profile, + model: request_model.to_string(), + system: None, + messages: vec![devo_protocol::RequestMessage { + role: "user".to_string(), + content: vec![devo_protocol::RequestContent::Text { + text: prompt.to_string(), + }], + }], + max_tokens: model.max_tokens.map_or(64, |value| value as usize), + tools: None, + hosted_tools: Vec::new(), + sampling: SamplingControls { + temperature: model.temperature, + top_p: model.top_p, + top_k: model.top_k.map(|value| value as u32), + }, + request_thinking, + reasoning_effort: request_reasoning_effort, + extra_body, + }; + let mut stream = provider.completion_stream(request).await?; + let mut reply_preview = String::new(); + while let Some(event) = stream.next().await { + match event? { + StreamEvent::TextDelta { text, .. } => reply_preview.push_str(&text), + StreamEvent::MessageDone { response } => { + if reply_preview.trim().is_empty() { + reply_preview = response + .content + .into_iter() + .find_map(|content| match content { + ResponseContent::Text(text) => Some(text), + _ => None, + }) + .unwrap_or_default(); + } + break; + } + _ => {} + } + } + let preview = reply_preview.trim(); + if preview.is_empty() { + return Err(AgentError::Provider(anyhow::anyhow!( + "provider validation completed without a model reply" + ))); + } + Ok(preview.to_string()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/query/prompt_estimate.rs b/crates/core/src/query/prompt_estimate.rs new file mode 100644 index 00000000..0343402d --- /dev/null +++ b/crates/core/src/query/prompt_estimate.rs @@ -0,0 +1,27 @@ +//! Prompt token estimation for assembled model requests. + +use devo_protocol::ModelRequest; +use devo_protocol::approx_tokens_from_byte_count; + +/// Rough prompt-token estimate for a fully built [`ModelRequest`]. +/// +/// Converts serialized request bytes with the shared protocol heuristic +/// (~4 bytes/token) so query and persistence stay aligned. +pub(crate) fn estimate_request_prompt_tokens(request: &ModelRequest) -> usize { + let system_bytes = request.system.as_ref().map_or(0, String::len); + let message_bytes = request + .messages + .iter() + .map(|message| serde_json::to_string(message).map_or(0, |json| json.len())) + .sum::(); + let tool_bytes = request + .tools + .as_ref() + .map(|tools| serde_json::to_string(tools).map_or(0, |json| json.len())) + .unwrap_or(0); + let hosted_tool_bytes = + serde_json::to_string(&request.hosted_tools).map_or(0, |json| json.len()); + approx_tokens_from_byte_count(system_bytes + message_bytes + tool_bytes + hosted_tool_bytes) + .try_into() + .unwrap_or(usize::MAX) +} diff --git a/crates/core/src/query/provider_retry.rs b/crates/core/src/query/provider_retry.rs new file mode 100644 index 00000000..210801df --- /dev/null +++ b/crates/core/src/query/provider_retry.rs @@ -0,0 +1,323 @@ +//! Provider error classification and retry policy for the query loop. + +use std::io::ErrorKind; +use std::time::Duration; + +use tokio::time::sleep; +use tokio_util::sync::CancellationToken; + +use crate::AgentError; +use devo_provider::error::ProviderError; + +use super::event::EventCallback; +use super::event::ProviderRetryStatus; +use super::event::QueryEvent; +use super::event::QueryProviderRetryPhase; +use super::event::emit_query_event; + +const MAX_RETRIES: usize = 5; +const INITIAL_RETRY_BACKOFF_MS: u64 = 250; +const RATE_LIMIT_RETRY_DELAY: Duration = Duration::from_secs(60); + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ErrorClass { + ContextTooLong, + ParameterError, + FileContentAnomaly, + AuthenticationFailure, + FeatureUnavailable, + TaskNotFound, + RateLimit, + NoApiPermission, + FileTooLarge, + ServerError, + NetworkError, + Unretryable, +} + +pub(crate) enum ProviderRetryDecision { + RetryAfter(Duration), + CompactAndRetry, + Fail, +} + +pub(crate) fn classify_error(e: &anyhow::Error) -> ErrorClass { + for cause in e.chain() { + let Some(provider_error) = cause.downcast_ref::() else { + continue; + }; + match provider_error { + ProviderError::AuthenticationError { .. } => return ErrorClass::AuthenticationFailure, + ProviderError::RateLimitError { .. } => return ErrorClass::RateLimit, + ProviderError::ProviderServerError { + status_code: Some(429), + .. + } => return ErrorClass::RateLimit, + ProviderError::ProviderServerError { + status_code: Some(408), + .. + } + | ProviderError::ProviderTimeoutError { .. } + | ProviderError::StreamError { .. } => return ErrorClass::NetworkError, + ProviderError::ProviderServerError { .. } => return ErrorClass::ServerError, + ProviderError::ContextLimitError { .. } => return ErrorClass::ContextTooLong, + ProviderError::ModelNotFoundError { .. } => return ErrorClass::TaskNotFound, + ProviderError::InvalidRequestError { .. } => return ErrorClass::ParameterError, + ProviderError::QuotaExceededError { .. } + | ProviderError::ContentFilteredError { .. } => { + return ErrorClass::Unretryable; + } + ProviderError::UnknownError { + status_code: Some(429), + .. + } => return ErrorClass::RateLimit, + ProviderError::UnknownError { + status_code: Some(408), + .. + } => return ErrorClass::NetworkError, + ProviderError::UnknownError { + status_code: Some(500..=599), + .. + } => return ErrorClass::ServerError, + ProviderError::UnknownError { .. } => {} + } + } + + if e.chain().any(|cause| { + cause + .downcast_ref::() + .is_some() + }) { + return ErrorClass::NetworkError; + } + + if e.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + error.is_timeout() + || error.is_connect() + || error.status() == Some(reqwest::StatusCode::REQUEST_TIMEOUT) + }) + }) { + return ErrorClass::NetworkError; + } + + if e.chain().any(|cause| { + cause.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + ErrorKind::TimedOut + | ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::BrokenPipe + | ErrorKind::UnexpectedEof + ) + }) + }) { + return ErrorClass::NetworkError; + } + + let msg = e.to_string().to_lowercase(); + // TODO: Expand the error of ContextTooLong + if msg.contains("context_too_long") { + ErrorClass::ContextTooLong + } else if msg.contains("401") + || msg.contains("authentication failure") + || msg.contains("token timeout") + || msg.contains("unauthorized") + || msg.contains("api key") + { + ErrorClass::AuthenticationFailure + } else if msg.contains("404") + && (msg.contains("feature not available") + || msg.contains("fine-tuning feature not available")) + { + ErrorClass::FeatureUnavailable + } else if msg.contains("404") + && (msg.contains("task does not exist") + || msg.contains("does not exist") + || msg.contains("not found")) + { + ErrorClass::TaskNotFound + } else if msg.contains("429") || msg.contains("rate limit") { + ErrorClass::RateLimit + } else if msg.contains("434") || msg.contains("no api permission") || msg.contains("beta phase") + { + ErrorClass::NoApiPermission + } else if msg.contains("435") + || msg.contains("file size exceeds 100mb") + || msg.contains("smaller than 100mb") + { + ErrorClass::FileTooLarge + } else if msg.contains("400") + && (msg.contains("file content anomaly") + || msg.contains("jsonl file content") + || msg.contains("jsonl")) + { + ErrorClass::FileContentAnomaly + } else if msg.contains("408") + || msg.contains("request timeout") + || msg.contains("request timed out") + || msg.contains("operation timed out") + || msg.contains("timed out") + || msg.contains("deadline has elapsed") + || msg.contains("deadline exceeded") + || msg.contains("provider timeout") + || msg.contains("stream idle timeout") + || msg.contains("network error") + || msg.contains("network is unreachable") + || msg.contains("network unreachable") + || msg.contains("host unreachable") + || msg.contains("destination unreachable") + || msg.contains("unreachable host") + || msg.contains("no route to host") + || msg.contains("connection refused") + || msg.contains("connection reset") + || msg.contains("connection closed") + || msg.contains("connection aborted") + || msg.contains("connection timed out") + || msg.contains("connection failure") + || msg.contains("connection failed") + || msg.contains("failed to connect") + || msg.contains("connect error") + || msg.contains("error trying to connect") + || msg.contains("error sending request") + || msg.contains("dns error") + || msg.contains("failed to lookup address information") + || msg.contains("temporary failure in name resolution") + || msg.contains("name or service not known") + || msg.contains("nodename nor servname") + || msg.contains("could not resolve host") + || msg.contains("unexpected eof") + || msg.contains("invalidcontenttype") + || msg.contains("invalid content-type") + || msg.contains("invalid header value") + || msg.contains("text/event-stream") + // Stream-level decode/decrypt errors (e.g. TLS decrypt failure, chunk + // deserialization). These are typically transient — the proxy or + // TLS-terminator that sits between us and the provider may have had a + // hiccup; retrying usually succeeds. + || msg.contains("error decoding") + || msg.contains("decoding response") + || msg.contains("cannot decrypt") + || msg.contains("decrypt error") + || msg.contains("decrypterror") + || msg.contains("stream error") + || msg.contains("failed to decode") + { + ErrorClass::NetworkError + } else if msg.contains("400") + || msg.contains("parameter error") + || msg.contains("invalid parameter") + || msg.contains("bad request") + { + ErrorClass::ParameterError + } else if msg.starts_with('5') + || msg.contains("500") + || msg.contains("502") + || msg.contains("503") + || msg.contains("504") + || msg.contains("internal server error") + || msg.contains("server error occurred while processing the request") + { + ErrorClass::ServerError + } else { + ErrorClass::Unretryable + } +} + +pub(crate) fn provider_retry_decision( + error: &anyhow::Error, + retry_count: &mut usize, + context_compacted: &mut bool, +) -> ProviderRetryDecision { + match classify_error(error) { + ErrorClass::ContextTooLong => { + if *context_compacted { + ProviderRetryDecision::Fail + } else { + *context_compacted = true; + ProviderRetryDecision::CompactAndRetry + } + } + ErrorClass::RateLimit => { + if *retry_count >= MAX_RETRIES { + ProviderRetryDecision::Fail + } else { + *retry_count += 1; + ProviderRetryDecision::RetryAfter(RATE_LIMIT_RETRY_DELAY) + } + } + ErrorClass::ServerError | ErrorClass::NetworkError => { + if *retry_count >= MAX_RETRIES { + ProviderRetryDecision::Fail + } else { + *retry_count += 1; + ProviderRetryDecision::RetryAfter(retry_backoff_duration(*retry_count)) + } + } + ErrorClass::ParameterError + | ErrorClass::FileContentAnomaly + | ErrorClass::AuthenticationFailure + | ErrorClass::FeatureUnavailable + | ErrorClass::TaskNotFound + | ErrorClass::NoApiPermission + | ErrorClass::FileTooLarge + | ErrorClass::Unretryable => ProviderRetryDecision::Fail, + } +} + +pub(crate) async fn wait_for_provider_retry( + on_event: &Option, + cancel_token: Option<&CancellationToken>, + provider: &str, + model: &str, + attempt: usize, + backoff: Duration, +) -> Result<(), AgentError> { + let backoff_ms = backoff.as_millis().min(u128::from(u64::MAX)) as u64; + emit_query_event( + on_event, + QueryEvent::ProviderRetryStatus(ProviderRetryStatus { + provider: provider.to_string(), + model: model.to_string(), + attempt, + backoff_ms, + phase: QueryProviderRetryPhase::Scheduled, + message: format!("Retrying provider request in {:.1}s", backoff.as_secs_f64()), + }), + ) + .await; + + if let Some(cancel_token) = cancel_token { + tokio::select! { + biased; + () = cancel_token.cancelled() => return Err(AgentError::Aborted), + () = sleep(backoff) => {} + } + } else { + sleep(backoff).await; + } + + emit_query_event( + on_event, + QueryEvent::ProviderRetryStatus(ProviderRetryStatus { + provider: provider.to_string(), + model: model.to_string(), + attempt, + backoff_ms: 0, + phase: QueryProviderRetryPhase::Resumed, + message: "Retrying provider request now".to_string(), + }), + ) + .await; + + Ok(()) +} + +fn retry_backoff_duration(attempt: usize) -> Duration { + let exponent = attempt.saturating_sub(1).min(10) as u32; + let multiplier = 2u64.pow(exponent); + Duration::from_millis(INITIAL_RETRY_BACKOFF_MS.saturating_mul(multiplier)) +} diff --git a/crates/core/src/query/stream_consumer.rs b/crates/core/src/query/stream_consumer.rs new file mode 100644 index 00000000..4098e3ab --- /dev/null +++ b/crates/core/src/query/stream_consumer.rs @@ -0,0 +1,660 @@ +//! Consume one provider completion stream into an assembled model turn. +//! +//! Hides `StreamEvent` matching, hosted-tool normalization, and assistant +//! content assembly so the query loop only sees a structured outcome (or a +//! retryable / fatal stream error). + +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; +use std::pin::Pin; + +use futures::Stream; +use futures::StreamExt; +use tokio_util::sync::CancellationToken; +use tracing::warn; + +use crate::ContentBlock; +use crate::SessionState; +use crate::tools::ToolCall; +use crate::tools::ToolContent; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::ResponseContent; +use devo_protocol::ResponseExtra; +use devo_protocol::StopReason; +use devo_protocol::StreamEvent; +use devo_provider::ModelProviderSDK; + +use super::event::EventCallback; +use super::event::QueryEvent; +use super::event::emit_query_event; + +/// Structured result of one successful provider stream attempt. +pub(crate) struct AssembledModelTurn { + pub assistant_content: Vec, + pub tool_calls: Vec, + pub stop_reason: Option, + pub has_hosted_tool_uses: bool, + pub has_provider_reasoning: bool, + pub has_visible_assistant_text: bool, +} + +/// Failure while creating or consuming a provider stream. +pub(crate) enum ProviderAttemptError { + /// Stream creation failed before any events. + Create(anyhow::Error), + /// Stream failed before any useful content; safe to classify for retry. + Retryable(anyhow::Error), + /// Stream failed after partial content was observed. + Fatal(anyhow::Error), +} + +type ProviderEventStream = Pin> + Send>>; + +/// Create a provider stream and assemble the model turn. +pub(crate) async fn run_provider_attempt( + provider: &dyn ModelProviderSDK, + request: ModelRequest, + session: &mut SessionState, + on_event: &Option, + cancel_token: Option<&CancellationToken>, + model_slug: &str, +) -> Result { + let stream = match provider.completion_stream(request).await { + Ok(stream) => stream, + Err(error) => { + warn!( + provider = provider.name(), + model = %model_slug, + turn = session.turn_count, + error = ?error, + "failed to create provider stream" + ); + return Err(ProviderAttemptError::Create(error)); + } + }; + + consume_provider_stream( + stream, + session, + on_event, + cancel_token, + provider.name(), + model_slug, + ) + .await +} + +struct StreamAccumulation { + assistant_text: String, + reasoning_text: String, + tool_uses: Vec<(usize, String, String, serde_json::Value, String, bool)>, + hosted_tool_inputs: HashMap, + emitted_tool_use_starts: HashSet, + emitted_hosted_tool_starts: HashSet, + emitted_hosted_tool_results: HashSet, + final_response: Option, + stop_reason: Option, +} + +async fn consume_provider_stream( + mut stream: ProviderEventStream, + session: &mut SessionState, + on_event: &Option, + cancel_token: Option<&CancellationToken>, + provider_name: &str, + model_slug: &str, +) -> Result { + let mut acc = StreamAccumulation { + assistant_text: String::new(), + reasoning_text: String::new(), + tool_uses: Vec::new(), + hosted_tool_inputs: HashMap::new(), + emitted_tool_use_starts: HashSet::new(), + emitted_hosted_tool_starts: HashSet::new(), + emitted_hosted_tool_results: HashSet::new(), + final_response: None, + stop_reason: None, + }; + + loop { + tokio::select! { + biased; + _ = async { + if let Some(ct) = cancel_token { + ct.cancelled().await + } else { + std::future::pending::<()>().await + } + } => { + break; + } + event = stream.next() => { + let Some(event) = event else { break; }; + match event { + Ok(StreamEvent::TextStart { .. }) => {} + Ok(StreamEvent::TextDelta { text, .. }) => { + acc.assistant_text.push_str(&text); + emit_query_event(on_event, QueryEvent::TextDelta(text)).await; + } + Ok(StreamEvent::ReasoningStart { .. }) => {} + Ok(StreamEvent::ReasoningDelta { text, .. }) => { + acc.reasoning_text.push_str(&text); + emit_query_event(on_event, QueryEvent::ReasoningDelta(text)).await; + } + Ok(StreamEvent::ReasoningDone { .. }) => { + emit_query_event(on_event, QueryEvent::ReasoningCompleted).await; + } + Ok(StreamEvent::ToolCallStart { + index, + id, + name, + input, + }) => { + acc.tool_uses.push((index, id, name, input, String::new(), false)); + } + Ok(StreamEvent::HostedToolCallStart { + index, + id, + name, + input, + }) => { + let id = normalize_hosted_tool_id(index, id, &name); + let name = normalize_hosted_tool_name(name); + acc.hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); + emit_hosted_tool_start( + on_event, + &mut acc.emitted_hosted_tool_starts, + &id, + &name, + &input, + ) + .await; + } + Ok(StreamEvent::HostedToolCallDone { + index, + id, + name, + input, + output, + status, + }) => { + let id = normalize_hosted_tool_id(index, id, &name); + let name = normalize_hosted_tool_name(name); + let previous_input = acc.hosted_tool_inputs + .get(&id) + .map(|(_, _, previous_input)| previous_input); + let input = hosted_tool_input_or_previous(input, previous_input); + acc.hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); + emit_hosted_tool_start( + on_event, + &mut acc.emitted_hosted_tool_starts, + &id, + &name, + &input, + ) + .await; + emit_hosted_tool_result( + on_event, + &mut acc.emitted_hosted_tool_results, + &session.cwd, + HostedToolResultEvent { + id: &id, + name: &name, + input: &input, + output, + status, + }, + ) + .await; + } + Ok(StreamEvent::ToolCallInputDelta { + index, + partial_json, + }) => { + if let Some(tool_use) = acc.tool_uses + .iter_mut() + .rev() + .find(|(tool_index, ..)| *tool_index == index) + { + tool_use.4.push_str(&partial_json); + tool_use.5 = true; + } + } + Ok(StreamEvent::MessageDone { response }) => { + acc.stop_reason = response.stop_reason.clone(); + acc.final_response = Some(response.clone()); + + session.total_input_tokens += response.usage.input_tokens; + session.total_output_tokens += response.usage.output_tokens; + session.total_tokens += response.usage.display_total_tokens(); + session.total_cache_creation_tokens += + response.usage.cache_creation_input_tokens.unwrap_or(0); + session.total_cache_read_tokens += + response.usage.cache_read_input_tokens.unwrap_or(0); + session.last_input_tokens = response.usage.input_tokens; + session.last_turn_tokens = response.usage.display_total_tokens(); + + emit_query_event( + on_event, + QueryEvent::Usage { + usage: response.usage.clone(), + }, + ) + .await; + } + Ok(StreamEvent::UsageDelta(usage)) => { + emit_query_event(on_event, QueryEvent::UsageDelta { usage }).await; + } + Err(error) => { + warn!( + provider = provider_name, + model = %model_slug, + turn = session.turn_count, + error = ?error, + "stream error" + ); + if !acc.assistant_text.is_empty() + || !acc.reasoning_text.is_empty() + || !acc.tool_uses.is_empty() + || !acc.hosted_tool_inputs.is_empty() + || acc.final_response.is_some() + { + return Err(ProviderAttemptError::Fatal(error)); + } + return Err(ProviderAttemptError::Retryable(error)); + } + } + } + } + } + + assemble_model_turn(session, on_event, acc).await +} + +async fn assemble_model_turn( + session: &SessionState, + on_event: &Option, + acc: StreamAccumulation, +) -> Result { + let StreamAccumulation { + mut assistant_text, + mut reasoning_text, + mut tool_uses, + mut hosted_tool_inputs, + mut emitted_tool_use_starts, + mut emitted_hosted_tool_starts, + mut emitted_hosted_tool_results, + final_response, + stop_reason, + } = acc; + let mut response_assistant_content = Vec::new(); + let mut final_response_tool_use_ids = HashSet::new(); + let mut has_provider_reasoning_content = false; + let mut has_hosted_tool_uses = false; + + if let Some(response) = &final_response { + let has_provider_reasoning = response + .content + .iter() + .any(|block| matches!(block, ResponseContent::ProviderReasoning { .. })); + if assistant_text.is_empty() { + assistant_text = response + .content + .iter() + .filter_map(|block| match block { + ResponseContent::Text(text) => Some(text.as_str()), + ResponseContent::ToolUse { .. } + | ResponseContent::HostedToolUse { .. } + | ResponseContent::ProviderReasoning { .. } => None, + }) + .collect(); + } + if tool_uses.is_empty() { + tool_uses = response + .content + .iter() + .enumerate() + .filter_map(|(index, block)| match block { + ResponseContent::ToolUse { id, name, input } => Some(( + index, + id.clone(), + name.clone(), + input.clone(), + String::new(), + false, + )), + ResponseContent::Text(_) + | ResponseContent::HostedToolUse { .. } + | ResponseContent::ProviderReasoning { .. } => None, + }) + .collect(); + } + for (index, block) in response.content.iter().enumerate() { + match block { + ResponseContent::Text(text) => { + if !text.is_empty() { + response_assistant_content.push(ContentBlock::Text { text: text.clone() }); + } + } + ResponseContent::ToolUse { id, name, input } => { + final_response_tool_use_ids.insert(id.clone()); + response_assistant_content.push(ContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } + ResponseContent::HostedToolUse { + id, + name, + input, + output, + status, + } => { + let id = normalize_hosted_tool_id(index, id.clone(), name); + let name = normalize_hosted_tool_name(name.clone()); + let previous_input = hosted_tool_inputs + .get(&id) + .map(|(_, _, previous_input)| previous_input); + let input = hosted_tool_input_or_previous(input.clone(), previous_input); + has_hosted_tool_uses = true; + response_assistant_content.push(ContentBlock::HostedToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + output: output.clone(), + status: status.clone(), + }); + hosted_tool_inputs.insert(id.clone(), (index, name.clone(), input.clone())); + emit_hosted_tool_start( + on_event, + &mut emitted_hosted_tool_starts, + &id, + &name, + &input, + ) + .await; + if output.is_some() || status.is_some() { + emit_hosted_tool_result( + on_event, + &mut emitted_hosted_tool_results, + &session.cwd, + HostedToolResultEvent { + id: &id, + name: &name, + input: &input, + output: output.clone(), + status: status.clone(), + }, + ) + .await; + } + } + ResponseContent::ProviderReasoning { provider, payload } => { + has_provider_reasoning_content = true; + response_assistant_content.push(ContentBlock::ProviderReasoning { + provider: provider.clone(), + payload: payload.clone(), + }); + } + } + } + if reasoning_text.is_empty() && has_provider_reasoning { + let final_reasoning = response_assistant_content + .iter() + .filter_map(|block| match block { + ContentBlock::ProviderReasoning { payload, .. } => { + payload.get("thinking").and_then(serde_json::Value::as_str) + } + ContentBlock::Text { .. } + | ContentBlock::Reasoning { .. } + | ContentBlock::ToolUse { .. } + | ContentBlock::HostedToolUse { .. } + | ContentBlock::ToolResult { .. } => None, + }) + .collect::(); + if !final_reasoning.is_empty() { + emit_query_event( + on_event, + QueryEvent::ReasoningDelta(final_reasoning.clone()), + ) + .await; + emit_query_event(on_event, QueryEvent::ReasoningCompleted).await; + reasoning_text = final_reasoning; + } + } + if reasoning_text.is_empty() && !has_provider_reasoning { + let final_reasoning = response + .metadata + .extras + .iter() + .filter_map(|extra| match extra { + ResponseExtra::ReasoningText { text } => Some(text.as_str()), + ResponseExtra::ProviderSpecific { .. } => None, + }) + .collect::(); + if !final_reasoning.is_empty() { + emit_query_event( + on_event, + QueryEvent::ReasoningDelta(final_reasoning.clone()), + ) + .await; + emit_query_event(on_event, QueryEvent::ReasoningCompleted).await; + reasoning_text = final_reasoning; + } + } + } + + let pending_hosted_tools = hosted_tool_inputs + .iter() + .map(|(id, (_index, name, input))| (id.clone(), name.clone(), input.clone())) + .collect::>(); + for (id, name, input) in pending_hosted_tools { + emit_hosted_tool_start( + on_event, + &mut emitted_hosted_tool_starts, + &id, + &name, + &input, + ) + .await; + emit_hosted_tool_result( + on_event, + &mut emitted_hosted_tool_results, + &session.cwd, + HostedToolResultEvent { + id: &id, + name: &name, + input: &input, + output: None, + status: Some("completed".to_string()), + }, + ) + .await; + } + + let mut assistant_content: Vec = response_assistant_content; + + if !reasoning_text.trim().is_empty() && !has_provider_reasoning_content { + assistant_content.insert( + 0, + ContentBlock::Reasoning { + text: reasoning_text, + }, + ); + } + + let has_visible_assistant_text = !assistant_text.trim().is_empty(); + + if assistant_content.is_empty() && !assistant_text.is_empty() { + assistant_content.push(ContentBlock::Text { + text: assistant_text, + }); + } + + let final_tool_inputs: HashMap = final_response + .as_ref() + .map(|response| { + response + .content + .iter() + .filter_map(|block| match block { + ResponseContent::ToolUse { id, input, .. } => Some((id.clone(), input.clone())), + ResponseContent::Text(_) + | ResponseContent::HostedToolUse { .. } + | ResponseContent::ProviderReasoning { .. } => None, + }) + .collect() + }) + .unwrap_or_default(); + + let mut tool_calls = Vec::with_capacity(tool_uses.len()); + for (_index, id, name, initial_input, json_str, saw_delta) in tool_uses { + let input = if saw_delta { + serde_json::from_str(&json_str) + .unwrap_or_else(|_| final_tool_inputs.get(&id).cloned().unwrap_or(initial_input)) + } else { + final_tool_inputs.get(&id).cloned().unwrap_or(initial_input) + }; + if emitted_tool_use_starts.insert(id.clone()) { + emit_query_event( + on_event, + QueryEvent::ToolUseStart { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }, + ) + .await; + } + if !final_response_tool_use_ids.contains(&id) { + assistant_content.push(ContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } + tool_calls.push(ToolCall { id, name, input }); + } + + Ok(AssembledModelTurn { + assistant_content, + tool_calls, + stop_reason, + has_hosted_tool_uses, + has_provider_reasoning: has_provider_reasoning_content, + has_visible_assistant_text, + }) +} + +fn normalize_hosted_tool_id(index: usize, id: String, name: &str) -> String { + if id.is_empty() { + format!("hosted_{}_{index}", name.replace('-', "_")) + } else { + id + } +} + +fn normalize_hosted_tool_name(name: String) -> String { + if name.is_empty() { + "web_search".to_string() + } else { + name + } +} + +fn hosted_tool_input_or_previous( + input: serde_json::Value, + previous: Option<&serde_json::Value>, +) -> serde_json::Value { + if matches!(&input, serde_json::Value::Object(map) if map.is_empty()) { + previous.cloned().unwrap_or(input) + } else { + input + } +} + +async fn emit_hosted_tool_start( + on_event: &Option, + emitted_tool_use_starts: &mut HashSet, + id: &str, + name: &str, + input: &serde_json::Value, +) { + if emitted_tool_use_starts.insert(id.to_string()) { + emit_query_event( + on_event, + QueryEvent::ToolUseStart { + id: id.to_string(), + name: name.to_string(), + input: input.clone(), + }, + ) + .await; + } +} + +struct HostedToolResultEvent<'a> { + id: &'a str, + name: &'a str, + input: &'a serde_json::Value, + output: Option, + status: Option, +} + +async fn emit_hosted_tool_result( + on_event: &Option, + emitted_tool_results: &mut HashSet, + session_cwd: &Path, + event: HostedToolResultEvent<'_>, +) { + let HostedToolResultEvent { + id, + name, + input, + output, + status, + } = event; + if !emitted_tool_results.insert(id.to_string()) { + return; + } + + let text = hosted_tool_result_text(status.as_deref()); + let content = if output.is_some() { + ToolContent::Mixed { + text: Some(text.clone()), + json: output.clone(), + } + } else { + ToolContent::Text(text.clone()) + }; + let summary = crate::tools::tool_summary::tool_summary(name, input, session_cwd); + emit_query_event( + on_event, + QueryEvent::ToolResult { + tool_use_id: id.to_string(), + tool_name: name.to_string(), + input: input.clone(), + content, + display_content: Some(text), + is_error: hosted_tool_status_is_error(status.as_deref()), + summary, + }, + ) + .await; +} + +fn hosted_tool_result_text(status: Option<&str>) -> String { + let status = status + .filter(|status| !status.is_empty()) + .unwrap_or("completed"); + format!("status: {status}") +} + +fn hosted_tool_status_is_error(status: Option<&str>) -> bool { + status + .map(str::to_ascii_lowercase) + .is_some_and(|status| matches!(status.as_str(), "error" | "errored" | "failed")) +} diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs new file mode 100644 index 00000000..445fb8ac --- /dev/null +++ b/crates/core/src/query/tests.rs @@ -0,0 +1,4423 @@ +//! Unit tests for the query loop and its submodules. + +use devo_protocol::Usage; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +use crate::EventCallback; +use crate::ProviderRetryStatus; +use crate::QueryProviderRetryPhase; +use crate::tools::ToolAgentScope; +use crate::tools::ToolContent; +use crate::tools::ToolPreparationFeedback; +use crate::tools::ToolRegistry; +use crate::tools::ToolRuntime; +use crate::tools::ToolRuntimeContext; +use crate::tools::json_schema::JsonSchema; +use crate::tools::registry::ToolExposure; +use crate::tools::registry::ToolRegistryBuilder; +use crate::tools::router::PermissionChecker; +use crate::tools::router::ToolExecutionOptions; +use crate::tools::tool_handler::ToolHandler; +use crate::tools::tool_spec::ToolExecutionMode; +use crate::tools::tool_spec::ToolOutputMode; +use crate::tools::tool_spec::ToolSpec; +use anyhow::Result; +use async_trait::async_trait; +use devo_protocol::CollaborationMode; +use devo_protocol::ModelRequest; +use devo_protocol::ModelResponse; +use devo_protocol::RequestContent; +use devo_protocol::RequestMessage; +use devo_protocol::ResponseContent; +use devo_protocol::ResponseExtra; +use devo_protocol::ResponseMetadata; +use devo_protocol::StopReason; +use devo_protocol::StreamEvent; +use devo_protocol::ThreadGoal; +use devo_protocol::ThreadGoalStatus; +use devo_provider::ModelProviderSDK; +use devo_safety::PermissionMode; +use futures::Stream; +use pretty_assertions::assert_eq; +use serde_json::json; +use tokio_util::sync::CancellationToken; + +use super::QueryEvent; +use super::QueryOptions; +use super::hosted_tools_for_web_search; +use super::insert_subagent_request_reminders; +use super::query; +use super::test_model_connection; +use super::truncate_tool_result_for_model; +use crate::AgentError; +use crate::ContentBlock; +use crate::Message; +use crate::Model; +use crate::ReasoningEffort; +use crate::Role; +use crate::context::ContextualUserFragment; +use crate::context::compaction_summary::CompactionSummary; +use crate::history::compaction::CompactionKind; +use crate::response_item::ResponseItem; + +#[test] +fn assistant_content_visibility_requires_visible_content() { + assert!(!super::assistant_content_has_visible_content(&[])); + assert!(!super::assistant_content_has_visible_content(&[ + ContentBlock::Text { + text: " \n\t".to_string(), + }, + ])); + assert!(!super::assistant_content_has_visible_content(&[ + ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: String::new(), + is_error: false, + }, + ])); + + for content in [ + vec![ContentBlock::Text { + text: "visible".to_string(), + }], + vec![ContentBlock::Reasoning { + text: "reasoning".to_string(), + }], + vec![ContentBlock::ProviderReasoning { + provider: "test".to_string(), + payload: serde_json::json!({"thinking":"hidden"}), + }], + vec![ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "read".to_string(), + input: serde_json::json!({"filePath":"README.md"}), + }], + vec![ContentBlock::HostedToolUse { + id: "hosted-1".to_string(), + name: "web_search".to_string(), + input: serde_json::json!({"query":"docs"}), + output: None, + status: None, + }], + vec![ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "result".to_string(), + is_error: false, + }], + ] { + assert!(super::assistant_content_has_visible_content(&content)); + } +} + +#[test] +fn hosted_tools_follow_resolved_web_search_mode() { + let hosted = hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Provider); + assert_eq!(hosted.len(), 1); + assert!(matches!( + hosted.as_slice(), + [devo_protocol::HostedToolDefinition::WebSearch(_)] + )); + + assert_eq!( + hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Disabled), + Vec::new() + ); + assert_eq!( + hosted_tools_for_web_search(&devo_config::ResolvedWebSearchConfig::Local( + devo_config::ResolvedLocalWebSearchConfig { + provider_id: "test".to_string(), + kind: devo_config::LocalWebSearchProviderKind::Exa, + api_key: "secret".to_string(), + base_url: None, + max_results: None, + }, + )), + Vec::new() + ); +} +use crate::ReasoningCapability; +use crate::ReasoningImplementation; + +#[test] +fn network_errors_are_retryable() { + let cases = [ + anyhow::anyhow!("request timed out while connecting"), + anyhow::anyhow!( + "error sending request for url (https://api.example.test): connection refused" + ), + anyhow::anyhow!("dns error: failed to lookup address information"), + anyhow::anyhow!("network is unreachable"), + anyhow::anyhow!( + "anthropic stream error for model deepseek-v4-flash: invalid header value: \"text/html; charset=utf-8\"; debug=InvalidContentType(\"text/html; charset=utf-8\")" + ), + anyhow::anyhow!("Invalid status code: 408 Request Timeout"), + anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "socket timed out", + )), + anyhow::Error::new(devo_provider::error::ProviderError::ProviderTimeoutError { + message: "provider request timed out".into(), + provider_name: Some("test-provider".into()), + }), + anyhow::Error::new(devo_provider::timeout::stream_idle_timeout_provider_error( + "openai", + "gpt-test", + devo_provider::timeout::StreamIdleTimeoutError { + idle_timeout: std::time::Duration::from_secs(60), + }, + )), + anyhow::Error::new(devo_provider::timeout::StreamIdleTimeoutError { + idle_timeout: std::time::Duration::from_secs(60), + }), + anyhow::anyhow!( + "openai stream idle timeout for model gpt-test: provider stream idle timeout after 60s without receiving data" + ), + ]; + + for error in cases { + assert_eq!( + super::classify_error(&error), + super::ErrorClass::NetworkError + ); + + let mut retry_count = 0; + let mut context_compacted = false; + assert!(matches!( + super::provider_retry_decision(&error, &mut retry_count, &mut context_compacted), + super::ProviderRetryDecision::RetryAfter(_) + )); + assert_eq!(retry_count, 1); + assert!(!context_compacted); + } +} + +#[test] +fn token_timeout_remains_authentication_failure() { + let error = anyhow::anyhow!("token timeout"); + + assert_eq!( + super::classify_error(&error), + super::ErrorClass::AuthenticationFailure + ); + + let mut retry_count = 0; + let mut context_compacted = false; + assert!(matches!( + super::provider_retry_decision(&error, &mut retry_count, &mut context_compacted), + super::ProviderRetryDecision::Fail + )); + assert_eq!(retry_count, 0); + assert!(!context_compacted); +} +use crate::ReasoningVariant; +use crate::ReasoningVariantConfig; +use crate::SessionConfig; +use crate::SessionState; +use crate::TruncationMode; +use crate::TruncationPolicyConfig; +use crate::TurnConfig; + +#[test] +fn model_tool_result_truncation_preserves_content_within_budget() { + assert_eq!( + truncate_tool_result_for_model( + "short".to_string(), + Some("read"), + TruncationPolicyConfig::bytes(100).into(), + ), + "short" + ); +} + +#[test] +fn model_tool_result_truncation_uses_byte_policy() { + assert_eq!( + truncate_tool_result_for_model( + "abcdefghijklmnopqrstuvwxyz".to_string(), + Some("read"), + TruncationPolicyConfig::bytes(20).into(), + ), + "abcde\n...[truncated]" + ); +} + +#[test] +fn model_tool_result_truncation_uses_token_policy_byte_budget() { + assert_eq!( + truncate_tool_result_for_model( + "abcdefghijklmnopqrstuvwxyz".to_string(), + Some("read"), + TruncationPolicyConfig::tokens(5).into(), + ), + "abcde\n...[truncated]" + ); +} + +#[test] +fn model_tool_result_truncation_preserves_utf8_boundaries() { + let truncated = truncate_tool_result_for_model( + "éééééabcdefghij".to_string(), + Some("read"), + TruncationPolicyConfig::bytes(18).into(), + ); + + assert_eq!(truncated, "é\n...[truncated]"); + assert!(truncated.len() <= 18); +} + +#[test] +fn model_tool_result_truncation_preserves_agent_coordination_results() { + let content = "abcdefghijklmnopqrstuvwxyz".to_string(); + + for tool_name in [ + Some("await_task"), + Some("wait_agent"), + Some("subagent_result"), + ] { + assert_eq!( + truncate_tool_result_for_model( + content.clone(), + tool_name, + TruncationPolicyConfig::bytes(20).into(), + ), + content + ); + } +} + +const HOSTED_DSML_TEXT: &str = "<||DSML||tool_calls>\n<||DSML||invoke name=\"web_search\">\n<||DSML||parameter name=\"query\" string=\"true\">current Rust docs\n\n"; + +struct SingleToolUseProvider { + requests: AtomicUsize, +} + +struct CapturingToolUseProvider { + requests: Arc>>, + calls: AtomicUsize, +} + +struct InterleavedToolUseProvider { + requests: AtomicUsize, +} + +struct ParallelToolUseProvider { + requests: AtomicUsize, +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for SingleToolUseProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let request_number = self.requests.fetch_add(1, Ordering::SeqCst); + + let events = if request_number == 0 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }), + Ok(StreamEvent::ToolCallInputDelta { + index: 0, + partial_json: r#"{"value":1}"#.into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ResponseContent::ToolUse { + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({ "value": 1 }), + }], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + } else { + vec![ + Ok(StreamEvent::TextDelta { + index: 0, + text: "done".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-2".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(events))) + } + + fn name(&self) -> &str { + "test-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for CapturingToolUseProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + let request_number = self.calls.fetch_add(1, Ordering::SeqCst); + + let events = if request_number == 0 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ResponseContent::ToolUse { + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + } else { + vec![Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-2".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + })] + }; + + Ok(Box::pin(futures::stream::iter(events))) + } + + fn name(&self) -> &str { + "capturing-tool-use-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for InterleavedToolUseProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let request_number = self.requests.fetch_add(1, Ordering::SeqCst); + + let events = if request_number == 0 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }), + Ok(StreamEvent::ToolCallStart { + index: 1, + id: "tool-2".into(), + name: "mutating_tool".into(), + input: json!({}), + }), + Ok(StreamEvent::ToolCallInputDelta { + index: 0, + partial_json: r#"{"value":1}"#.into(), + }), + Ok(StreamEvent::ToolCallInputDelta { + index: 1, + partial_json: r#"{"value":2}"#.into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ + ResponseContent::ToolUse { + id: "tool-1".into(), + name: "mutating_tool".into(), + input: json!({}), + }, + ResponseContent::ToolUse { + id: "tool-2".into(), + name: "mutating_tool".into(), + input: json!({}), + }, + ], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + } else { + vec![ + Ok(StreamEvent::TextDelta { + index: 0, + text: "done".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-2".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + }; + + Ok(Box::pin(futures::stream::iter(events))) + } + + fn name(&self) -> &str { + "interleaved-test-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for ParallelToolUseProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let request_number = self.requests.fetch_add(1, Ordering::SeqCst); + + let events = if request_number == 0 { + vec![ + Ok(StreamEvent::ToolCallStart { + index: 0, + id: "slow".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 50, + "output": "slow complete", + }), + }), + Ok(StreamEvent::ToolCallStart { + index: 1, + id: "fast".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 5, + "output": "fast complete", + }), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-1".into(), + content: vec![ + ResponseContent::ToolUse { + id: "slow".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 50, + "output": "slow complete", + }), + }, + ResponseContent::ToolUse { + id: "fast".into(), + name: "parallel_tool".into(), + input: json!({ + "delay_ms": 5, + "output": "fast complete", + }), + }, + ], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ] + } else { + vec![Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-2".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + })] + }; + + Ok(Box::pin(futures::stream::iter(events))) + } + + fn name(&self) -> &str { + "parallel-tool-provider" + } +} + +struct MutatingTool; + +struct CapturingProvider { + requests: Arc>>, +} + +struct OpenAiCapturingProvider { + requests: Arc>>, +} + +struct HostedWebSearchProvider { + requests: Arc>>, +} + +struct HostedDsmlTextProvider { + requests: Arc>>, +} + +struct HostedWebFetchProvider { + requests: Arc>>, +} + +fn final_text_stream(text: &str) -> Pin> + Send>> { + Box::pin(futures::stream::iter(vec![Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-final".into(), + content: vec![ResponseContent::Text(text.to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + })])) +} + +struct TransientStreamCreateProvider { + attempts: AtomicUsize, +} + +struct TransientStreamEventProvider { + attempts: AtomicUsize, +} + +struct RateLimitedStreamCreateProvider { + attempts: AtomicUsize, +} + +enum CompactionProviderOutcome { + Summary, + Error, +} + +struct CompactionProvider { + completion_calls: AtomicUsize, + outcome: CompactionProviderOutcome, +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for CapturingProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: "resp".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "capturing-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for OpenAiCapturingProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: "resp".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "openai" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for HostedWebSearchProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + let request_count = { + let mut requests = self.requests.lock().expect("lock requests"); + requests.push(request); + requests.len() + }; + if request_count > 1 { + return Ok(final_text_stream("done")); + } + let input = json!({ "query": "current Rust docs" }); + let output = Some(json!({ + "results": [ + { + "title": "Rust documentation", + "url": "https://example.test/rust" + } + ] + })); + Ok(Box::pin(futures::stream::iter(vec![ + Ok(StreamEvent::HostedToolCallStart { + index: 0, + id: "hosted_ws_1".into(), + name: "web_search".into(), + input: input.clone(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp".into(), + content: vec![ + ResponseContent::HostedToolUse { + id: "hosted_ws_1".into(), + name: "web_search".into(), + input: input.clone(), + output: None, + status: None, + }, + ResponseContent::HostedToolUse { + id: "hosted_ws_1".into(), + name: "web_search".into(), + input, + output, + status: Some("completed".into()), + }, + ], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ]))) + } + + fn name(&self) -> &str { + "hosted-web-search-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for HostedDsmlTextProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + let request_count = { + let mut requests = self.requests.lock().expect("lock requests"); + requests.push(request); + requests.len() + }; + if request_count > 1 { + return Ok(final_text_stream("done")); + } + Ok(Box::pin(futures::stream::iter(vec![ + Ok(StreamEvent::TextDelta { + index: 0, + text: HOSTED_DSML_TEXT.to_string(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-dsml".into(), + content: vec![ResponseContent::Text(HOSTED_DSML_TEXT.to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ]))) + } + + fn name(&self) -> &str { + "hosted-dsml-text-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for HostedWebFetchProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + let request_count = { + let mut requests = self.requests.lock().expect("lock requests"); + requests.push(request); + requests.len() + }; + if request_count > 1 { + return Ok(final_text_stream("done")); + } + let input = json!({ "url": "https://example.test/docs" }); + let output = Some(json!({ + "title": "Docs", + "url": "https://example.test/docs" + })); + Ok(Box::pin(futures::stream::iter(vec![ + Ok(StreamEvent::HostedToolCallStart { + index: 0, + id: "hosted_wf_1".into(), + name: "web_fetch".into(), + input: input.clone(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp".into(), + content: vec![ + ResponseContent::HostedToolUse { + id: "hosted_wf_1".into(), + name: "web_fetch".into(), + input: input.clone(), + output: None, + status: None, + }, + ResponseContent::HostedToolUse { + id: "hosted_wf_1".into(), + name: "web_fetch".into(), + input, + output, + status: Some("completed".into()), + }, + ], + stop_reason: Some(StopReason::ToolUse), + usage: Usage::default(), + metadata: Default::default(), + }, + }), + ]))) + } + + fn name(&self) -> &str { + "hosted-web-fetch-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for TransientStreamCreateProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + return Err(anyhow::anyhow!("503 service unavailable")); + } + + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: "resp".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "transient-stream-create-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for TransientStreamEventProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); + if attempt == 0 { + return Ok(Box::pin(futures::stream::iter(vec![Err(anyhow::anyhow!( + "500 internal server error" + ))]))); + } + + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: "resp".into(), + content: vec![ResponseContent::Text("done".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "transient-stream-event-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for RateLimitedStreamCreateProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); + if attempt < 2 { + return Err(anyhow::anyhow!("429 rate limit exceeded")); + } + + Ok(final_text_stream("done")) + } + + fn name(&self) -> &str { + "rate-limited-stream-create-provider" + } +} + +#[async_trait] +impl devo_provider::ModelProviderSDK for CompactionProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + self.completion_calls.fetch_add(1, Ordering::SeqCst); + match &self.outcome { + CompactionProviderOutcome::Summary => Ok(ModelResponse { + id: "compaction-response".to_string(), + content: vec![ResponseContent::Text("summary".to_string())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: Default::default(), + }), + CompactionProviderOutcome::Error => Err(anyhow::anyhow!("compaction provider failed")), + } + } + + async fn completion_stream( + &self, + _request: ModelRequest, + ) -> Result> + Send>>> { + unreachable!("tests call the non-streaming compaction path only") + } + + fn name(&self) -> &str { + "compaction-provider" + } +} + +#[async_trait] +impl ToolHandler for MutatingTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + // Leak a static spec for test purposes + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "write", + "write tool", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + Ok(crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text("ok".into()), + "ok", + )) + } +} + +struct DisplayContentTool; + +struct LargeToolResultTool { + content: String, + display_content: Option, +} + +struct CountingWebSearchTool { + executions: Arc, +} + +struct CountingWebFetchTool { + executions: Arc, +} + +#[async_trait] +impl ToolHandler for CountingWebSearchTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "web_search", + "Search the web.", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + self.executions.fetch_add(1, Ordering::SeqCst); + Ok(crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text("local search".into()), + "local search", + )) + } +} + +#[async_trait] +impl ToolHandler for CountingWebFetchTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "webfetch", + "Fetch a URL.", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + self.executions.fetch_add(1, Ordering::SeqCst); + Ok(crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text("local fetch".into()), + "local fetch", + )) + } +} + +#[async_trait] +impl ToolHandler for DisplayContentTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "read", + "read tool", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + let mut result = crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text("canonical".into()), + "done", + ); + result.display_content = Some("display".to_string()); + Ok(result) + } +} + +#[async_trait] +impl ToolHandler for LargeToolResultTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "read", + "read tool", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + let mut result = crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text(self.content.clone()), + "done", + ); + result.display_content = self.display_content.clone(); + Ok(result) + } +} + +struct StreamingMutatingTool; + +struct ParallelDelayTool; + +#[async_trait] +impl ToolHandler for StreamingMutatingTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "write", + "write tool", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result { + Ok(crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text("stream complete".into()), + "done", + )) + } +} + +#[async_trait] +impl ToolHandler for ParallelDelayTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec::new( + "read", + "read tool", + crate::tools::JsonSchema::object(Default::default(), None, None), + ))) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + input: serde_json::Value, + _progress: Option, + ) -> Result { + let delay_ms = input + .get("delay_ms") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; + let output = input + .get("output") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + Ok(crate::tools::contracts::ToolResult::success( + crate::tools::contracts::ToolResultContent::Text(output.to_string()), + "done", + )) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum RecordedCompactionEvent { + Started, + Completed, + Failed { message: String }, +} + +fn recorded_compaction_events(events: &[QueryEvent]) -> Vec { + events + .iter() + .filter_map(|event| match event { + QueryEvent::ContextCompactionStarted => Some(RecordedCompactionEvent::Started), + QueryEvent::ContextCompactionCompleted => Some(RecordedCompactionEvent::Completed), + QueryEvent::ContextCompactionFailed { message } => { + Some(RecordedCompactionEvent::Failed { + message: message.clone(), + }) + } + QueryEvent::ProviderRetryStatus(_) + | QueryEvent::TextDelta(_) + | QueryEvent::ReasoningDelta(_) + | QueryEvent::ReasoningCompleted + | QueryEvent::UsageDelta { .. } + | QueryEvent::ToolUseStart { .. } + | QueryEvent::ToolExecutionStart { .. } + | QueryEvent::ToolProgress { .. } + | QueryEvent::ToolResult { .. } + | QueryEvent::TurnComplete { .. } + | QueryEvent::Usage { .. } => None, + }) + .collect() +} + +fn recording_callback(events: &Arc>>) -> EventCallback { + let captured_events = Arc::clone(events); + Arc::new(move |event| { + let captured_events = Arc::clone(&captured_events); + Box::pin(async move { + captured_events.lock().expect("lock events").push(event); + }) + }) +} + +fn compaction_test_session(total_input_tokens: usize) -> SessionState { + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("x".repeat(80_004))); + session.push_message(Message::user("latest")); + session.total_input_tokens = total_input_tokens; + session +} + +#[tokio::test] +async fn automatic_compaction_emits_started_then_completed_when_history_is_replaced() { + let provider = Arc::new(CompactionProvider { + completion_calls: AtomicUsize::new(0), + outcome: CompactionProviderOutcome::Summary, + }); + let provider_sdk: Arc = provider.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let on_event = Some(recording_callback(&events)); + let mut session = compaction_test_session(/*total_input_tokens*/ 200_000); + + super::summarize_and_compact( + &mut session, + &on_event, + &provider_sdk, + "compaction-model", + "compaction-request-model", + /*max_tokens*/ 4096, + CompactionKind::Auto, + ) + .await; + + assert_eq!( + recorded_compaction_events(&events.lock().expect("lock events")), + vec![ + RecordedCompactionEvent::Started, + RecordedCompactionEvent::Completed, + ] + ); + assert_eq!(provider.completion_calls.load(Ordering::SeqCst), 1); + let ResponseItem::Message(expected_summary) = + CompactionSummary::new("summary").to_response_item() + else { + unreachable!("compaction summaries are messages"); + }; + assert_eq!( + session.prompt_source_messages(), + &[expected_summary, Message::user("latest")] + ); +} + +#[tokio::test] +async fn automatic_compaction_emits_failed_when_compaction_is_skipped() { + let provider = Arc::new(CompactionProvider { + completion_calls: AtomicUsize::new(0), + outcome: CompactionProviderOutcome::Summary, + }); + let provider_sdk: Arc = provider.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let on_event = Some(recording_callback(&events)); + let mut session = compaction_test_session(/*total_input_tokens*/ 0); + let original_messages = session.prompt_source_messages().to_vec(); + + super::summarize_and_compact( + &mut session, + &on_event, + &provider_sdk, + "compaction-model", + "compaction-request-model", + /*max_tokens*/ 4096, + CompactionKind::Auto, + ) + .await; + + assert_eq!( + recorded_compaction_events(&events.lock().expect("lock events")), + vec![ + RecordedCompactionEvent::Started, + RecordedCompactionEvent::Failed { + message: "Context compaction skipped: nothing to compact".to_string(), + }, + ] + ); + assert_eq!(provider.completion_calls.load(Ordering::SeqCst), 0); + assert_eq!(session.prompt_source_messages(), original_messages); +} + +#[tokio::test(start_paused = true)] +async fn proactive_compaction_emits_failed_when_compaction_errors() { + let provider = Arc::new(CompactionProvider { + completion_calls: AtomicUsize::new(0), + outcome: CompactionProviderOutcome::Error, + }); + let provider_sdk: Arc = provider.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let on_event = Some(recording_callback(&events)); + let mut session = compaction_test_session(/*total_input_tokens*/ 0); + let original_messages = session.prompt_source_messages().to_vec(); + + super::summarize_and_compact( + &mut session, + &on_event, + &provider_sdk, + "compaction-model", + "compaction-request-model", + /*max_tokens*/ 4096, + CompactionKind::Proactive, + ) + .await; + + assert_eq!( + recorded_compaction_events(&events.lock().expect("lock events")), + vec![ + RecordedCompactionEvent::Started, + RecordedCompactionEvent::Failed { + message: "summarization failed: compaction provider failed".to_string(), + }, + ] + ); + assert_eq!(provider.completion_calls.load(Ordering::SeqCst), 5); + assert_eq!(session.prompt_source_messages(), original_messages); +} + +#[tokio::test] +async fn query_retries_transient_stream_creation_errors() { + let provider = Arc::new(TransientStreamCreateProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider_sdk, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should retry and succeed"); + + assert_eq!(provider.attempts.load(Ordering::SeqCst), 2); + assert_eq!( + session.messages.last(), + Some(&Message::assistant_text("done")) + ); +} + +#[tokio::test(start_paused = true)] +async fn query_retries_transient_stream_event_errors_before_content() { + let provider = Arc::new(TransientStreamEventProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let turn_config = TurnConfig::new(Model::default(), None); + let model = turn_config.model.slug.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let captured_events = Arc::clone(&events); + let callback: EventCallback = Arc::new(move |event| { + let captured_events = Arc::clone(&captured_events); + Box::pin(async move { + captured_events.lock().expect("lock events").push(event); + }) + }); + + query( + &mut session, + &turn_config, + provider_sdk, + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should retry and succeed"); + + let retry_statuses = events + .lock() + .expect("lock events") + .iter() + .filter_map(|event| match event { + QueryEvent::ProviderRetryStatus(status) => Some(status.clone()), + QueryEvent::ContextCompactionStarted + | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionFailed { .. } + | QueryEvent::TextDelta(_) + | QueryEvent::ReasoningDelta(_) + | QueryEvent::ReasoningCompleted + | QueryEvent::UsageDelta { .. } + | QueryEvent::ToolUseStart { .. } + | QueryEvent::ToolExecutionStart { .. } + | QueryEvent::ToolProgress { .. } + | QueryEvent::ToolResult { .. } + | QueryEvent::TurnComplete { .. } + | QueryEvent::Usage { .. } => None, + }) + .collect::>(); + assert_eq!( + retry_statuses, + vec![ + ProviderRetryStatus { + provider: "transient-stream-event-provider".to_string(), + model: model.clone(), + attempt: 1, + backoff_ms: 250, + phase: QueryProviderRetryPhase::Scheduled, + message: "Retrying provider request in 0.2s".to_string(), + }, + ProviderRetryStatus { + provider: "transient-stream-event-provider".to_string(), + model, + attempt: 1, + backoff_ms: 0, + phase: QueryProviderRetryPhase::Resumed, + message: "Retrying provider request now".to_string(), + }, + ] + ); + assert_eq!(provider.attempts.load(Ordering::SeqCst), 2); + let assistant_messages = session + .messages + .iter() + .filter(|message| message.role == Role::Assistant) + .cloned() + .collect::>(); + assert_eq!(assistant_messages, vec![Message::assistant_text("done")]); +} + +#[tokio::test(start_paused = true)] +async fn query_waits_sixty_seconds_for_each_rate_limit_retry() { + let provider = Arc::new(RateLimitedStreamCreateProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let turn_config = TurnConfig::new(Model::default(), None); + let model = turn_config.model.slug.clone(); + let events = Arc::new(Mutex::new(Vec::new())); + let captured_events = Arc::clone(&events); + let callback: EventCallback = Arc::new(move |event| { + let captured_events = Arc::clone(&captured_events); + Box::pin(async move { + captured_events.lock().expect("lock events").push(event); + }) + }); + let started_at = tokio::time::Instant::now(); + + query( + &mut session, + &turn_config, + provider_sdk, + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should retry and succeed"); + + assert_eq!( + tokio::time::Instant::now().duration_since(started_at), + std::time::Duration::from_secs(120) + ); + let retry_statuses = events + .lock() + .expect("lock events") + .iter() + .filter_map(|event| match event { + QueryEvent::ProviderRetryStatus(status) => Some(status.clone()), + QueryEvent::ContextCompactionStarted + | QueryEvent::ContextCompactionCompleted + | QueryEvent::ContextCompactionFailed { .. } + | QueryEvent::TextDelta(_) + | QueryEvent::ReasoningDelta(_) + | QueryEvent::ReasoningCompleted + | QueryEvent::UsageDelta { .. } + | QueryEvent::ToolUseStart { .. } + | QueryEvent::ToolExecutionStart { .. } + | QueryEvent::ToolProgress { .. } + | QueryEvent::ToolResult { .. } + | QueryEvent::TurnComplete { .. } + | QueryEvent::Usage { .. } => None, + }) + .collect::>(); + assert_eq!( + retry_statuses, + vec![ + ProviderRetryStatus { + provider: "rate-limited-stream-create-provider".to_string(), + model: model.clone(), + attempt: 1, + backoff_ms: 60_000, + phase: QueryProviderRetryPhase::Scheduled, + message: "Retrying provider request in 60.0s".to_string(), + }, + ProviderRetryStatus { + provider: "rate-limited-stream-create-provider".to_string(), + model: model.clone(), + attempt: 1, + backoff_ms: 0, + phase: QueryProviderRetryPhase::Resumed, + message: "Retrying provider request now".to_string(), + }, + ProviderRetryStatus { + provider: "rate-limited-stream-create-provider".to_string(), + model: model.clone(), + attempt: 2, + backoff_ms: 60_000, + phase: QueryProviderRetryPhase::Scheduled, + message: "Retrying provider request in 60.0s".to_string(), + }, + ProviderRetryStatus { + provider: "rate-limited-stream-create-provider".to_string(), + model, + attempt: 2, + backoff_ms: 0, + phase: QueryProviderRetryPhase::Resumed, + message: "Retrying provider request now".to_string(), + }, + ] + ); + assert_eq!(provider.attempts.load(Ordering::SeqCst), 3); +} + +#[tokio::test(start_paused = true)] +async fn query_cancels_stream_creation_retry_backoff() { + let provider = Arc::new(TransientStreamCreateProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider_sdk, + registry, + &runtime, + None, + QueryOptions { + cancel_token: Some(cancel_token), + ..QueryOptions::default() + }, + ) + .await; + + assert!(matches!(result, Err(AgentError::Aborted))); + assert_eq!(provider.attempts.load(Ordering::SeqCst), 1); +} + +#[tokio::test(start_paused = true)] +async fn query_cancels_stream_event_retry_backoff() { + let provider = Arc::new(TransientStreamEventProvider { + attempts: AtomicUsize::new(0), + }); + let provider_sdk: Arc = provider.clone(); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let result = query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider_sdk, + registry, + &runtime, + None, + QueryOptions { + cancel_token: Some(cancel_token), + ..QueryOptions::default() + }, + ) + .await; + + assert!(matches!(result, Err(AgentError::Aborted))); + assert_eq!(provider.attempts.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn query_exposes_stable_tools_and_appends_subagent_warning() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let mut builder = ToolRegistryBuilder::new(); + builder.push_spec_with_exposure( + ToolSpec::new( + "ToolSearch", + "Search available tools.", + JsonSchema::object(Default::default(), None, None), + ), + ToolExposure::Direct, + ); + builder.push_spec_with_exposure( + ToolSpec::new( + "web_search", + "Search the web.", + JsonSchema::object(Default::default(), None, None), + ), + ToolExposure::Direct, + ); + for (name, description) in [ + ("spawn_agent", "Create a child agent."), + ("send_message", "Send input to a child agent."), + ("await_task", "Wait for task completion."), + ("list_tasks", "List child tasks."), + ("cancel_task", "Cancel a child task."), + ] { + builder.push_spec_with_exposure( + ToolSpec::new( + name, + description, + JsonSchema::object(Default::default(), None, None), + ), + ToolExposure::Direct, + ); + } + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_with_context( + Arc::clone(®istry), + PermissionChecker::always_allow(), + ToolRuntimeContext { + agent_scope: ToolAgentScope::Subagent, + ..ToolRuntimeContext::default() + }, + ); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("work on the delegated task")); + let mut turn_config = TurnConfig::new( + Model { + base_instructions: "base system".to_string(), + ..Model::default() + }, + None, + ); + turn_config.web_search = + devo_config::ResolvedWebSearchConfig::Local(devo_config::ResolvedLocalWebSearchConfig { + provider_id: "test".to_string(), + kind: devo_config::LocalWebSearchProviderKind::Exa, + api_key: "secret".to_string(), + base_url: None, + max_results: None, + }); + + query( + &mut session, + &turn_config, + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + let request = &captured[0]; + let tool_names = request + .tools + .as_ref() + .expect("tools should be present") + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert_eq!(tool_names, vec!["ToolSearch", "web_search"]); + let system = request.system.as_deref().expect("system prompt"); + let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); + assert!(system.contains("base system")); + assert!(system.contains(&mode_prompt)); + assert!(system.contains("Sources:")); + assert!( + !request + .system + .as_deref() + .unwrap_or_default() + .contains("web_search") + ); + assert!( + !request + .system + .as_deref() + .unwrap_or_default() + .contains("spawn_agent") + ); + + assert!( + request + .messages + .iter() + .all(|message| !message_contains(message, "web_search: Search the web.")) + ); + let subagent_reminder_index = + request_message_index_containing(request, "You are running as a sub-agent"); + let task_index = request_message_index_containing(request, "work on the delegated task"); + assert!(subagent_reminder_index < task_index); + assert!( + request + .messages + .iter() + .any(|message| message_contains(message, "")) + ); +} + +#[tokio::test] +async fn query_adds_web_search_prompt_for_provider_hosted_search() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("search current docs")); + let mut turn_config = TurnConfig::new( + Model { + base_instructions: "base system".to_string(), + ..Model::default() + }, + None, + ); + turn_config.web_search = devo_config::ResolvedWebSearchConfig::Provider; + + query( + &mut session, + &turn_config, + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + let request = &captured[0]; + let system = request.system.as_deref().expect("system prompt"); + + assert!(system.contains("base system")); + assert!(system.contains("Sources:")); + assert!(system.contains("The current month is ")); + assert!(matches!( + request.hosted_tools.as_slice(), + [devo_protocol::HostedToolDefinition::WebSearch(_)] + )); + assert!( + request + .tools + .as_ref() + .is_none_or(|tools| tools.iter().all(|tool| tool.name != "web_search")) + ); +} + +/// Trace: L2-DES-RESEARCH-001 +/// Verifies: provider-hosted web_search emits normal tool events with hosted output. +#[tokio::test] +async fn provider_hosted_web_search_emits_tool_events_without_local_execution() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(HostedWebSearchProvider { + requests: Arc::clone(&requests), + }); + let executions = Arc::new(AtomicUsize::new(0)); + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler( + "web_search", + Arc::new(CountingWebSearchTool { + executions: Arc::clone(&executions), + }), + ); + builder.push_spec(ToolSpec { + name: "web_search".into(), + description: "Search the web.".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("search current docs")); + let mut turn_config = TurnConfig::new(Model::default(), None); + turn_config.web_search = devo_config::ResolvedWebSearchConfig::Provider; + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) + }); + + query( + &mut session, + &turn_config, + provider, + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!(executions.load(Ordering::SeqCst), 0); + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let request = &captured[0]; + assert!(matches!( + request.hosted_tools.as_slice(), + [devo_protocol::HostedToolDefinition::WebSearch(_)] + )); + assert!( + request + .tools + .as_ref() + .is_none_or(|tools| tools.iter().all(|tool| tool.name != "web_search")) + ); + let continuation = &captured[1]; + assert!(continuation.messages.iter().any(|message| { + message.content.iter().any(|content| { + matches!( + content, + RequestContent::HostedToolUse { + id, + name, + input, + output: Some(_), + status, + } if id == "hosted_ws_1" + && name == "web_search" + && input == &json!({ "query": "current Rust docs" }) + && status.as_deref() == Some("completed") + ) + }) + })); + + let events = seen.lock().unwrap(); + let starts = events + .iter() + .filter_map(|event| match event { + QueryEvent::ToolUseStart { id, name, input } => { + Some((id.as_str(), name.as_str(), input.clone())) + } + _ => None, + }) + .collect::>(); + assert_eq!( + starts, + vec![( + "hosted_ws_1", + "web_search", + json!({ "query": "current Rust docs" }) + )] + ); + let results = events + .iter() + .filter_map(|event| match event { + QueryEvent::ToolResult { + tool_use_id, + tool_name, + input, + content, + is_error, + .. + } => Some(( + tool_use_id.as_str(), + tool_name.as_str(), + input.clone(), + content, + *is_error, + )), + _ => None, + }) + .collect::>(); + assert_eq!(results.len(), 1); + let (tool_use_id, tool_name, input, content, is_error) = &results[0]; + assert_eq!(*tool_use_id, "hosted_ws_1"); + assert_eq!(*tool_name, "web_search"); + assert_eq!(input, &json!({ "query": "current Rust docs" })); + assert!(!*is_error); + assert!(matches!( + *content, + ToolContent::Mixed { + text: Some(text), + json: Some(json), + } if text == "status: completed" + && json == &json!({ + "results": [ + { + "title": "Rust documentation", + "url": "https://example.test/rust" + } + ] + }) + )); + assert!(events.iter().any(|event| matches!( + event, + QueryEvent::TurnComplete { + stop_reason: StopReason::EndTurn + } + ))); + assert!(session.messages.iter().all(|message| { + message.content.iter().all(|block| { + !matches!( + block, + ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. } + ) + }) + })); +} + +/// Trace: L2-DES-RESEARCH-001 +/// Verifies: DSML text that represents a provider-hosted web_search does not end the query loop. +#[tokio::test] +async fn provider_hosted_dsml_text_tool_call_continues_query_loop() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(HostedDsmlTextProvider { + requests: Arc::clone(&requests), + }); + let mut builder = ToolRegistryBuilder::new(); + for (name, description) in [ + ("spawn_agent", "Create a child agent."), + ("await_task", "Wait for task completion."), + ] { + builder.push_spec_with_exposure( + ToolSpec::new( + name, + description, + JsonSchema::object(Default::default(), None, None), + ), + ToolExposure::Direct, + ); + } + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("search current docs")); + let mut turn_config = TurnConfig::new(Model::default(), None); + turn_config.web_search = devo_config::ResolvedWebSearchConfig::Provider; + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) + }); + + query( + &mut session, + &turn_config, + provider, + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should continue after DSML text and complete"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let request = &captured[0]; + assert!(matches!( + request.hosted_tools.as_slice(), + [devo_protocol::HostedToolDefinition::WebSearch(_)] + )); + let continuation = &captured[1]; + assert!(continuation.messages.iter().any(|message| { + message_contains(message, "DSML tagged tool-call text") + && message_contains(message, "spawn_agent") + && message_contains(message, "await_task") + && message_contains(message, "web_search") + })); + + let assistant_messages = session + .messages + .iter() + .filter(|message| message.role == Role::Assistant) + .cloned() + .collect::>(); + assert_eq!( + assistant_messages, + vec![ + Message { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: HOSTED_DSML_TEXT.to_string(), + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: "done".to_string(), + }], + }, + ] + ); + + let turn_completes = seen + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + QueryEvent::TurnComplete { stop_reason } => Some(stop_reason.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(turn_completes, vec![StopReason::EndTurn]); +} + +/// Trace: L2-DES-RESEARCH-001 +/// Verifies: provider-hosted web_fetch emits normal tool events with hosted output. +#[tokio::test] +async fn provider_hosted_web_fetch_emits_tool_events_without_local_execution() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(HostedWebFetchProvider { + requests: Arc::clone(&requests), + }); + let executions = Arc::new(AtomicUsize::new(0)); + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler( + "webfetch", + Arc::new(CountingWebFetchTool { + executions: Arc::clone(&executions), + }), + ); + builder.push_spec(ToolSpec { + name: "webfetch".into(), + description: "Fetch a URL.".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Mixed, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("fetch docs")); + let mut turn_config = TurnConfig::new(Model::default(), None); + turn_config.web_fetch = devo_config::ResolvedWebFetchConfig::Provider; + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) + }); + + query( + &mut session, + &turn_config, + provider, + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!(executions.load(Ordering::SeqCst), 0); + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let request = &captured[0]; + assert!(matches!( + request.hosted_tools.as_slice(), + [devo_protocol::HostedToolDefinition::WebFetch(_)] + )); + assert!( + request + .tools + .as_ref() + .is_none_or(|tools| tools.iter().all(|tool| tool.name != "webfetch")) + ); + let continuation = &captured[1]; + assert!(continuation.messages.iter().any(|message| { + message.content.iter().any(|content| { + matches!( + content, + RequestContent::HostedToolUse { + id, + name, + input, + output: Some(_), + status, + } if id == "hosted_wf_1" + && name == "web_fetch" + && input == &json!({ "url": "https://example.test/docs" }) + && status.as_deref() == Some("completed") + ) + }) + })); + + let events = seen.lock().unwrap(); + let starts = events + .iter() + .filter_map(|event| match event { + QueryEvent::ToolUseStart { id, name, input } => { + Some((id.as_str(), name.as_str(), input.clone())) + } + _ => None, + }) + .collect::>(); + assert_eq!( + starts, + vec![( + "hosted_wf_1", + "web_fetch", + json!({ "url": "https://example.test/docs" }) + )] + ); + let results = events + .iter() + .filter_map(|event| match event { + QueryEvent::ToolResult { + tool_use_id, + tool_name, + input, + content, + is_error, + .. + } => Some(( + tool_use_id.as_str(), + tool_name.as_str(), + input.clone(), + content, + *is_error, + )), + _ => None, + }) + .collect::>(); + assert_eq!(results.len(), 1); + let (tool_use_id, tool_name, input, content, is_error) = &results[0]; + assert_eq!(*tool_use_id, "hosted_wf_1"); + assert_eq!(*tool_name, "web_fetch"); + assert_eq!(input, &json!({ "url": "https://example.test/docs" })); + assert!(!*is_error); + assert!(matches!( + *content, + ToolContent::Mixed { + text: Some(text), + json: Some(json), + } if text == "status: completed" + && json == &json!({ + "title": "Docs", + "url": "https://example.test/docs" + }) + )); +} + +#[test] +fn subagent_reminder_insertion_preserves_tool_result_adjacency() { + let mut messages = vec![ + RequestMessage { + role: Role::User.as_str().to_string(), + content: vec![RequestContent::Text { + text: "child task input".to_string(), + }], + }, + RequestMessage { + role: Role::Assistant.as_str().to_string(), + content: vec![RequestContent::ToolUse { + id: "tool-1".to_string(), + name: "read".to_string(), + input: json!({}), + }], + }, + RequestMessage { + role: Role::User.as_str().to_string(), + content: vec![RequestContent::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "tool output".to_string(), + is_error: None, + }], + }, + ]; + + insert_subagent_request_reminders(&mut messages); + + assert!(message_contains( + &messages[0], + "You are running as a sub-agent" + )); + assert!(message_contains(&messages[1], "child task input")); + assert!( + matches!(messages[2].content.as_slice(), [RequestContent::ToolUse { id, .. }] if id == "tool-1") + ); + assert!( + matches!(messages[3].content.as_slice(), [RequestContent::ToolResult { tool_use_id, .. }] if tool_use_id == "tool-1") + ); +} + +fn request_message_index_containing(request: &ModelRequest, needle: &str) -> usize { + request + .messages + .iter() + .position(|message| message_contains(message, needle)) + .unwrap_or_else(|| panic!("expected request message containing {needle:?}: {request:?}")) +} + +fn message_contains(message: &RequestMessage, needle: &str) -> bool { + message + .content + .iter() + .any(|content| matches!(content, RequestContent::Text { text } if text.contains(needle))) +} + +fn active_goal(objective: &str) -> ThreadGoal { + ThreadGoal { + thread_id: devo_protocol::SessionId::new(), + objective: objective.to_string(), + status: ThreadGoalStatus::Active, + token_budget: Some(10_000), + tokens_used: 250, + time_used_seconds: 0, + created_at: 1, + updated_at: 1, + } +} + +#[tokio::test] +async fn query_uses_session_permission_mode_for_mutating_tools() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(MutatingTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: "A test-only mutating tool.".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let deny_checker = PermissionChecker::new(|request| { + let n = request.tool_name; + Box::pin(async move { Err(format!("{n} denied")) }) + }); + let runtime = ToolRuntime::new(Arc::clone(®istry), deny_checker); + + let mut session = SessionState::new( + SessionConfig { + permission_mode: PermissionMode::Deny, + ..Default::default() + }, + std::env::temp_dir(), + ); + session.push_message(Message::user("run the tool")); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should complete and append a tool_result"); + + let tool_result_message = session + .messages + .iter() + .find(|message| { + message + .content + .iter() + .any(|block| matches!(block, ContentBlock::ToolResult { .. })) + }) + .expect("tool_result message should be appended"); + let ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } = &tool_result_message.content[0] + else { + panic!("expected tool_result content block"); + }; + + assert_eq!(tool_use_id, "tool-1"); + assert!( + *is_error, + "denied permission should surface as a tool error" + ); + assert!( + content.contains("permission denied"), + "expected tool_result to mention permission denial, got: {content}" + ); +} + +#[tokio::test] +async fn query_resolves_reasoning_model_variant_before_building_request() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "kimi-k2.5".into(), + display_name: "Kimi K2.5".into(), + provider: devo_protocol::ProviderWireApi::OpenAIChatCompletions, + description: None, + reasoning_capability: ReasoningCapability::Toggle, + default_reasoning_effort: Some(ReasoningEffort::Medium), + reasoning_implementation: Some(ReasoningImplementation::ModelVariant( + ReasoningVariantConfig { + variants: vec![ + ReasoningVariant { + selection_value: "disabled".into(), + model_slug: "kimi-k2.5".into(), + reasoning_effort: None, + label: "Off".into(), + description: "Use the standard model".into(), + extra_body: None, + }, + ReasoningVariant { + selection_value: "enabled".into(), + model_slug: "kimi-k2.5-thinking".into(), + reasoning_effort: Some(ReasoningEffort::Medium), + label: "On".into(), + description: "Use the reasoning model".into(), + extra_body: None, + }, + ], + }, + )), + base_instructions: String::new(), + context_window: 200_000, + effective_context_window_percent: None, + truncation_policy: TruncationPolicyConfig { + mode: TruncationMode::Tokens, + limit: 10_000, + }, + input_modalities: vec![], + supports_image_detail_original: false, + channel: None, + temperature: None, + top_p: None, + top_k: None, + max_tokens: None, + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + + query( + &mut session, + &TurnConfig::with_request_model( + model, + "vendor/kimi-k2.5".into(), + HashMap::from([( + "kimi-k2.5-thinking".into(), + "vendor/kimi-k2.5-thinking".into(), + )]) + .into(), + Some("enabled".into()), + ), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].model, "vendor/kimi-k2.5-thinking"); + assert_eq!(captured[0].request_thinking, None); +} + +#[tokio::test] +async fn query_sends_turn_config_request_model_to_provider() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "catalog-slug".into(), + display_name: "Catalog Model".into(), + base_instructions: "catalog instructions".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + + query( + &mut session, + &TurnConfig::with_request_model( + model, + "vendor/model-name".into(), + HashMap::new().into(), + /*reasoning_effort_selection*/ None, + ), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].model, "vendor/model-name"); + assert_eq!( + session + .session_context + .as_ref() + .expect("session context") + .model + .slug, + "catalog-slug" + ); +} + +/// Trace: L2-DES-CONTEXT-001 +/// Verifies: Plan turns append the active Plan collaboration prompt to the provider system prompt. +#[tokio::test] +async fn query_appends_plan_mode_reminder_to_system_prompt() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "model-a".into(), + base_instructions: "base instructions".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.collaboration_mode = CollaborationMode::Plan; + session.push_message(Message::user("plan this")); + + query( + &mut session, + &TurnConfig::new(model, None), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + let system = captured[0].system.as_deref().expect("system prompt"); + let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); + assert!(system.contains("base instructions")); + assert!(system.contains(&mode_prompt)); + let mode_index = request_message_index_containing(&captured[0], ""); + assert!(message_contains( + &captured[0].messages[mode_index], + "plan" + )); +} + +/// Trace: L2-DES-CONTEXT-001 +/// Verifies: Returning from Plan to Build uses Build system prompt and a lightweight mode diff. +#[tokio::test] +async fn query_inserts_mode_change_prompt_when_returning_to_build_mode() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "model-a".into(), + base_instructions: "base instructions".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.collaboration_mode = CollaborationMode::Plan; + session.push_message(Message::user("plan this")); + + query( + &mut session, + &TurnConfig::new(model.clone(), None), + Arc::clone(&provider), + Arc::clone(®istry), + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("plan query should succeed"); + + session.collaboration_mode = CollaborationMode::Build; + session.push_message(Message::user("implement this")); + query( + &mut session, + &TurnConfig::new(model, None), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("build query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + assert_eq!(captured[0].system, captured[1].system); + let system = captured[1].system.as_deref().expect("system prompt"); + let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); + assert!(system.contains("base instructions")); + assert!(system.contains(&mode_prompt)); + + let mode_change_index = + request_message_index_containing(&captured[1], "plan -> build"); + let request_index = request_message_index_containing(&captured[1], "implement this"); + assert!(mode_change_index < request_index); + assert!(message_contains( + &captured[1].messages[mode_change_index], + "plan" + )); + assert!(message_contains( + &captured[1].messages[mode_change_index], + "build" + )); + assert!(message_contains( + &captured[1].messages[mode_change_index], + "any previous instructions for other modes (e.g. Plan mode) are no longer active." + )); + assert!(!message_contains( + &captured[1].messages[mode_change_index], + "" + )); + assert!(!message_contains( + &captured[1].messages[mode_change_index], + "" + )); +} + +#[tokio::test] +async fn query_inserts_goal_context_before_latest_user_request() { + // Trace: L2-DES-GOAL-001 + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "model-a".into(), + base_instructions: "base instructions".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.set_active_goal(active_goal("ship /goal")); + session.push_message(Message::user("finish implementation")); + + query( + &mut session, + &TurnConfig::new(model, None), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + assert!( + !captured[0] + .system + .as_deref() + .unwrap_or_default() + .contains("ship /goal") + ); + let messages = &captured[0].messages; + let goal_index = messages + .iter() + .position(|message| message_contains(message, "ship /goal")) + .expect("goal context message"); + let request_index = messages + .iter() + .position(|message| message_contains(message, "finish implementation")) + .expect("latest user request message"); + assert!(goal_index < request_index); +} + +#[tokio::test] +async fn autonomous_goal_context_is_latest_request_after_completed_turn() { + // Trace: L2-DES-GOAL-001 + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "model-a".into(), + base_instructions: "base instructions".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.set_active_goal(active_goal("continue the active goal")); + session.push_message(Message::user("older user prompt")); + session.push_message(Message::assistant_text("older assistant reply")); + + query( + &mut session, + &TurnConfig::new(model, None), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + let messages = &captured[0].messages; + let goal_index = messages + .iter() + .position(|message| message_contains(message, "continue the active goal")) + .expect("goal context message"); + let assistant_index = messages + .iter() + .position(|message| message_contains(message, "older assistant reply")) + .expect("assistant history message"); + assert!(goal_index > assistant_index); + assert_eq!(goal_index, messages.len() - 1); +} + +#[tokio::test] +async fn query_locks_system_prompt_and_environment_prefix_per_session() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let temp_root = std::env::temp_dir().join(format!("devo-query-lock-{}", uuid::Uuid::new_v4())); + let second_cwd = temp_root.join("nested"); + let first_model = Model { + slug: "model-a".into(), + base_instructions: "base-a".into(), + ..Model::default() + }; + let second_model = Model { + slug: "model-b".into(), + base_instructions: "base-b".into(), + ..Model::default() + }; + + let mut session = SessionState::new(SessionConfig::default(), temp_root.clone()); + session.push_message(Message::user("hello")); + + query( + &mut session, + &TurnConfig::new(first_model, None), + Arc::clone(&provider), + Arc::clone(®istry), + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("first query should succeed"); + + session.cwd = second_cwd; + session.push_message(Message::user("follow up")); + + query( + &mut session, + &TurnConfig::new(second_model, Some("enabled".into())), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("second query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let mode_prompt = crate::collaboration_mode_prompts::mode_introductions_prompt(); + let expected_system = format!("base-a\n\n{mode_prompt}"); + assert_eq!( + captured[0].system.as_deref(), + Some(expected_system.as_str()) + ); + assert_eq!( + captured[1].system.as_deref(), + Some(expected_system.as_str()) + ); + + let first_prefix = &captured[0].messages[0]; + let second_prefix = &captured[1].messages[0]; + assert_eq!(first_prefix.role, second_prefix.role); + let devo_protocol::RequestContent::Text { text: first_text } = &first_prefix.content[0] else { + panic!("expected text prefix"); + }; + let devo_protocol::RequestContent::Text { text: second_text } = &second_prefix.content[0] + else { + panic!("expected text prefix"); + }; + assert_eq!(first_text, second_text); +} + +#[tokio::test] +async fn query_inserts_context_diff_before_changed_turn_input() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + let first_model = Model { + slug: "model-a".into(), + ..Model::default() + }; + let second_model = Model { + slug: "model-b".into(), + ..Model::default() + }; + + session.push_message(Message::user("hello")); + query( + &mut session, + &TurnConfig::new(first_model, None), + Arc::clone(&provider), + Arc::clone(®istry), + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("first query should succeed"); + + session.push_message(Message::user("follow up")); + query( + &mut session, + &TurnConfig::new(second_model, Some("enabled".into())), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("second query should succeed"); + + let diff_message = &session.messages[session.messages.len() - 3]; + let user_message = &session.messages[session.messages.len() - 2]; + assert_eq!(user_message, &Message::user("follow up")); + let ContentBlock::Text { text } = &diff_message.content[0] else { + panic!("expected text diff message"); + }; + assert!(text.contains("")); + assert!(text.contains("")); + assert!(text.contains("model")); + assert!(text.contains("model-a")); + assert!(text.contains("model-b")); +} + +#[tokio::test] +async fn query_skips_context_diff_when_turn_metadata_unchanged() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "model-a".into(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + + session.push_message(Message::user("hello")); + query( + &mut session, + &TurnConfig::new(model.clone(), None), + Arc::clone(&provider), + Arc::clone(®istry), + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("first query should succeed"); + + session.push_message(Message::user("follow up")); + query( + &mut session, + &TurnConfig::new(model, None), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("second query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let follow_up_index = request_message_index_containing(&captured[1], "follow up"); + assert!( + follow_up_index > 0, + "follow-up user message should not be the first prompt message" + ); + assert!( + !message_contains( + &captured[1].messages[follow_up_index - 1], + "" + ), + "unchanged metadata after a completed turn should not insert a new context_changes before the next user message" + ); +} + +#[tokio::test] +async fn query_inserts_interrupted_notice_before_next_user_message() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + session.push_message(Message::assistant_text("partial")); + session.mark_last_turn_interrupted(); + session.push_message(Message::user("continue please")); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let abort_index = session + .messages + .iter() + .position(|message| { + message.content.iter().any(|block| { + matches!( + block, + ContentBlock::Text { text } if text.contains("") + ) + }) + }) + .expect("interrupted notice should be inserted"); + let continue_index = session + .messages + .iter() + .position(|message| { + message.content.iter().any(|block| { + matches!( + block, + ContentBlock::Text { text } if text.contains("continue please") + ) + }) + }) + .expect("user message should remain"); + assert!(abort_index < continue_index); + assert!(!session.last_turn_interrupted); +} + +#[tokio::test] +async fn query_pairs_interrupted_tool_result_when_cancel_fires_during_tool() { + #[derive(Debug)] + struct HangingMutatingTool; + + #[async_trait] + impl ToolHandler for HangingMutatingTool { + fn spec(&self) -> &crate::tools::tool_spec::ToolSpec { + Box::leak(Box::new(crate::tools::tool_spec::ToolSpec { + name: "mutating_tool".into(), + description: "hangs until cancelled".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + })) + } + + async fn handle( + &self, + _ctx: crate::tools::contracts::ToolContext, + _input: serde_json::Value, + _progress: Option, + ) -> Result + { + std::future::pending::<()>().await; + unreachable!("tool should be cancelled") + } + } + + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(HangingMutatingTool)); + builder.push_spec(crate::tools::tool_spec::ToolSpec { + name: "mutating_tool".into(), + description: "hangs until cancelled".into(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let cancel_token = CancellationToken::new(); + let cancel_for_task = cancel_token.clone(); + let runtime = ToolRuntime::new_with_context_and_options( + Arc::clone(®istry), + PermissionChecker::always_allow(), + ToolRuntimeContext::default(), + ToolExecutionOptions { + cancel_token: cancel_token.clone(), + ..ToolExecutionOptions::default() + }, + ); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + let turn_config = TurnConfig::new(Model::default(), None); + let provider: Arc = Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }); + + let result = { + let mut query_future = std::pin::pin!(query( + &mut session, + &turn_config, + provider, + registry, + &runtime, + None, + QueryOptions { + cancel_token: Some(cancel_token), + ..QueryOptions::default() + }, + )); + tokio::select! { + result = &mut query_future => { + panic!("query completed before cancel: {result:?}"); + } + () = tokio::time::sleep(std::time::Duration::from_millis(50)) => { + cancel_for_task.cancel(); + } + } + query_future.await + }; + assert!(matches!(result, Err(AgentError::Aborted))); + + let tool_use = session.messages.iter().find(|message| { + message + .content + .iter() + .any(|block| matches!(block, ContentBlock::ToolUse { id, .. } if id == "tool-1")) + }); + assert!(tool_use.is_some(), "tool call should be retained"); + + let tool_result = session.messages.iter().find_map(|message| { + message.content.iter().find_map(|block| match block { + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } if tool_use_id == "tool-1" => Some((content.clone(), *is_error)), + _ => None, + }) + }); + let (content, is_error) = tool_result.expect("interrupted tool result should exist"); + assert!(is_error); + assert_eq!(content, crate::tools::INTERRUPTED_TOOL_RESULT_MESSAGE); +} + +#[tokio::test] +async fn query_drops_orphaned_tool_calls_from_prompt_history() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + + session.push_message(Message::user("first")); + session.push_message(Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Text { + text: "Calling tool".into(), + }, + ContentBlock::ToolUse { + id: "call-1".into(), + name: "bash".into(), + input: json!({ "cmd": "pwd" }), + }, + ], + }); + session.push_message(Message::user("follow up")); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + assert!( + captured[0] + .messages + .iter() + .flat_map(|message| message.content.iter()) + .all(|content| !matches!(content, devo_protocol::RequestContent::ToolUse { .. })), + "expected orphaned tool calls to be removed from prompt history" + ); +} + +#[tokio::test] +async fn test_model_connection_sends_minimal_request() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = CapturingProvider { + requests: Arc::clone(&requests), + }; + let model = Model { + slug: "glm-4.5".into(), + reasoning_capability: devo_protocol::ReasoningCapability::Toggle, + top_p: Some(0.95), + ..Model::default() + }; + let preview = test_model_connection( + &provider, + &model, + devo_protocol::ModelProfileKey::CatalogSlug(model.slug.clone()), + "renamed-provider-model", + "Reply with OK only.", + ) + .await + .expect("probe request should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(preview, "done"); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0].model_slug, + devo_protocol::ModelProfileKey::CatalogSlug("glm-4.5".to_string()) + ); + assert_eq!(captured[0].model, "renamed-provider-model"); + assert_eq!(captured[0].request_thinking.as_deref(), Some("enabled")); + assert_eq!(captured[0].system, None); + assert!(captured[0].tools.is_none()); + assert_eq!(captured[0].messages.len(), 1); + assert_eq!(captured[0].sampling.top_p, Some(0.95)); +} + +#[tokio::test] +async fn query_persists_streamed_reasoning_for_follow_up_request() { + struct ReasoningProvider { + requests: Arc>>, + } + + #[async_trait] + impl devo_provider::ModelProviderSDK for ReasoningProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + Ok(Box::pin(futures::stream::iter(vec![ + Ok(StreamEvent::ReasoningStart { index: 0 }), + Ok(StreamEvent::ReasoningDelta { + index: 0, + text: "plan".into(), + }), + Ok(StreamEvent::TextStart { index: 1 }), + Ok(StreamEvent::TextDelta { + index: 1, + text: "final".into(), + }), + Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: "resp-3".into(), + content: vec![ResponseContent::Text("final".into())], + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata { + extras: vec![ResponseExtra::ReasoningText { + text: "plan".into(), + }], + }, + }, + }), + ]))) + } + + fn name(&self) -> &str { + "reasoning-provider" + } + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = Arc::new(ReasoningProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let seen_events = Arc::new(Mutex::new(Vec::new())); + let callback_events = Arc::clone(&seen_events); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events.lock().expect("lock callback").push(event); + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider.clone(), + Arc::clone(®istry), + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("first query should succeed"); + + { + let events = seen_events.lock().expect("lock events"); + assert!(events.iter().any(|event| matches!( + event, + QueryEvent::ReasoningDelta(text) if text == "plan" + ))); + } + + let assistant_message = session + .messages + .iter() + .find(|message| matches!(message.role, Role::Assistant)) + .expect("assistant message"); + assert_eq!( + assistant_message, + &Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Reasoning { + text: "plan".into(), + }, + ContentBlock::Text { + text: "final".into(), + }, + ], + } + ); + + session.push_message(Message::user("follow up")); + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("second query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let replayed_assistant = captured[1] + .messages + .iter() + .find(|message| message.role == "assistant") + .expect("assistant replay"); + assert_eq!( + serde_json::to_value(replayed_assistant).expect("serialize assistant replay"), + json!({ + "role": "assistant", + "content": [ + { "type": "reasoning", "text": "plan" }, + { "type": "text", "text": "final" } + ] + }) + ); +} + +#[tokio::test] +async fn query_round_trips_provider_reasoning_without_plain_reasoning() { + struct SignedReasoningProvider { + requests: Arc>>, + calls: AtomicUsize, + } + + #[async_trait] + impl devo_provider::ModelProviderSDK for SignedReasoningProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let content = if call == 0 { + vec![ + ResponseContent::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "signed plan", + "signature": "sig_123" + }), + }, + ResponseContent::Text("first".into()), + ] + } else { + vec![ResponseContent::Text("second".into())] + }; + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: format!("resp-{call}"), + content, + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "signed-reasoning-provider" + } + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = Arc::new(SignedReasoningProvider { + requests: Arc::clone(&requests), + calls: AtomicUsize::new(0), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let seen_events = Arc::new(Mutex::new(Vec::new())); + let callback_events = Arc::clone(&seen_events); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events.lock().expect("lock callback").push(event); + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider.clone(), + Arc::clone(®istry), + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("first query should succeed"); + + { + let events = seen_events.lock().expect("lock events"); + assert!(events.iter().any(|event| matches!( + event, + QueryEvent::ReasoningDelta(text) if text == "signed plan" + ))); + assert!( + events + .iter() + .any(|event| matches!(event, QueryEvent::ReasoningCompleted)) + ); + } + + let assistant_message = session + .messages + .iter() + .find(|message| matches!(message.role, Role::Assistant)) + .expect("assistant message"); + assert_eq!( + assistant_message, + &Message { + role: Role::Assistant, + content: vec![ + ContentBlock::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "signed plan", + "signature": "sig_123" + }), + }, + ContentBlock::Text { + text: "first".into(), + }, + ], + } + ); + + session.push_message(Message::user("follow up")); + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("second query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let second_request_content = captured[1] + .messages + .iter() + .flat_map(|message| message.content.iter()) + .collect::>(); + assert!(second_request_content.iter().any(|content| matches!( + content, + RequestContent::ProviderReasoning { provider, payload } + if provider == "anthropic" + && payload["thinking"] == json!("signed plan") + && payload["signature"] == json!("sig_123") + ))); + assert!( + second_request_content + .iter() + .all(|content| !matches!(content, RequestContent::Reasoning { .. })) + ); +} + +#[tokio::test] +async fn query_continues_deepseek_v4_thinking_only_end_turn_once() { + struct ThinkingOnlyThenTextProvider { + requests: Arc>>, + calls: AtomicUsize, + } + + #[async_trait] + impl devo_provider::ModelProviderSDK for ThinkingOnlyThenTextProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let content = if call == 0 { + vec![ResponseContent::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "internal plan", + "signature": "sig_plan" + }), + }] + } else { + vec![ResponseContent::Text("visible answer".into())] + }; + let message_done = Ok(StreamEvent::MessageDone { + response: ModelResponse { + id: format!("resp-{call}"), + content, + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }); + let events = if call == 0 { + vec![message_done] + } else { + vec![ + Ok(StreamEvent::TextDelta { + index: 0, + text: "visible answer".into(), + }), + message_done, + ] + }; + Ok(Box::pin(futures::stream::iter(events))) + } + + fn name(&self) -> &str { + "thinking-only-then-text-provider" + } + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = Arc::new(ThinkingOnlyThenTextProvider { + requests: Arc::clone(&requests), + calls: AtomicUsize::new(0), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "deepseek-v4-pro".into(), + provider: devo_protocol::ProviderWireApi::AnthropicMessages, + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + let seen_events = Arc::new(Mutex::new(Vec::new())); + let callback_events = Arc::clone(&seen_events); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let callback_events = Arc::clone(&callback_events); + Box::pin(async move { + callback_events.lock().expect("lock callback").push(event); + }) + }); + + query( + &mut session, + &TurnConfig::new(model, None), + provider, + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should continue once and finish with text"); + + let session_message_tail = session.messages[session.messages.len() - 4..].to_vec(); + assert_eq!( + session_message_tail, + vec![ + Message::user("hello"), + Message { + role: Role::Assistant, + content: vec![ContentBlock::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "internal plan", + "signature": "sig_plan" + }), + }], + }, + Message::user(super::DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT), + Message::assistant_text("visible answer"), + ] + ); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let second_request_messages = &captured[1].messages; + let second_request_tail = &second_request_messages[second_request_messages.len() - 3..]; + assert_eq!( + serde_json::to_value(second_request_tail).expect("serialize second request messages"), + json!([ + { + "role": "user", + "content": [{ + "type": "text", + "text": "hello" + }] + }, + { + "role": "assistant", + "content": [{ + "type": "provider_reasoning", + "provider": "anthropic", + "payload": { + "type": "thinking", + "thinking": "internal plan", + "signature": "sig_plan" + } + }] + }, + { + "role": "user", + "content": [{ + "type": "text", + "text": super::DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT + }] + } + ]) + ); + + let events = seen_events.lock().expect("lock events"); + let turn_complete_count = events + .iter() + .filter(|event| matches!(event, QueryEvent::TurnComplete { .. })) + .count(); + assert_eq!(turn_complete_count, 1); + assert!(events.iter().any(|event| matches!( + event, + QueryEvent::TextDelta(text) if text == "visible answer" + ))); +} + +#[tokio::test] +async fn query_preserves_provider_reasoning_and_hosted_tool_order() { + struct OrderedHostedProvider { + requests: Arc>>, + calls: AtomicUsize, + } + + #[async_trait] + impl devo_provider::ModelProviderSDK for OrderedHostedProvider { + async fn completion(&self, _request: ModelRequest) -> Result { + unreachable!("tests stream responses only") + } + + async fn completion_stream( + &self, + request: ModelRequest, + ) -> Result> + Send>>> { + self.requests.lock().expect("lock requests").push(request); + let call = self.calls.fetch_add(1, Ordering::SeqCst); + let content = if call == 0 { + vec![ + ResponseContent::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "before tool", + "signature": "sig_before" + }), + }, + ResponseContent::HostedToolUse { + id: "srvtool_1".into(), + name: "web_search".into(), + input: json!({"query": "desktop gui 2026"}), + output: None, + status: None, + }, + ResponseContent::HostedToolUse { + id: "srvtool_1".into(), + name: "web_search".into(), + input: json!({}), + output: Some(json!([{"title": "result"}])), + status: Some("completed".into()), + }, + ResponseContent::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "after tool", + "signature": "sig_after" + }), + }, + ResponseContent::Text("final".into()), + ] + } else { + vec![ResponseContent::Text("second".into())] + }; + Ok(Box::pin(futures::stream::iter(vec![Ok( + StreamEvent::MessageDone { + response: ModelResponse { + id: format!("resp-{call}"), + content, + stop_reason: Some(StopReason::EndTurn), + usage: Usage::default(), + metadata: ResponseMetadata::default(), + }, + }, + )]))) + } + + fn name(&self) -> &str { + "ordered-hosted-provider" + } + } + + let requests = Arc::new(Mutex::new(Vec::new())); + let provider = Arc::new(OrderedHostedProvider { + requests: Arc::clone(&requests), + calls: AtomicUsize::new(0), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider.clone(), + Arc::clone(®istry), + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("first query should succeed"); + + let assistant_message = session + .messages + .iter() + .find(|message| matches!(message.role, Role::Assistant)) + .expect("assistant message"); + assert_eq!( + assistant_message.content, + vec![ + ContentBlock::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "before tool", + "signature": "sig_before" + }), + }, + ContentBlock::HostedToolUse { + id: "srvtool_1".into(), + name: "web_search".into(), + input: json!({"query": "desktop gui 2026"}), + output: None, + status: None, + }, + ContentBlock::HostedToolUse { + id: "srvtool_1".into(), + name: "web_search".into(), + input: json!({"query": "desktop gui 2026"}), + output: Some(json!([{"title": "result"}])), + status: Some("completed".into()), + }, + ContentBlock::ProviderReasoning { + provider: "anthropic".into(), + payload: json!({ + "type": "thinking", + "thinking": "after tool", + "signature": "sig_after" + }), + }, + ContentBlock::Text { + text: "final".into(), + }, + ] + ); + + session.push_message(Message::user("follow up")); + query( + &mut session, + &TurnConfig::new(Model::default(), None), + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("second query should succeed"); + + let captured = requests.lock().expect("lock requests"); + let replayed_content = captured[1] + .messages + .iter() + .find(|message| message.role == "assistant") + .expect("assistant replay") + .content + .clone(); + assert_eq!( + serde_json::to_value(&replayed_content).expect("serialize replayed content"), + json!([ + { + "type": "provider_reasoning", + "provider": "anthropic", + "payload": { + "type": "thinking", + "thinking": "before tool", + "signature": "sig_before" + } + }, + { + "type": "hosted_tool_use", + "id": "srvtool_1", + "name": "web_search", + "input": { "query": "desktop gui 2026" } + }, + { + "type": "hosted_tool_use", + "id": "srvtool_1", + "name": "web_search", + "input": { "query": "desktop gui 2026" }, + "output": [{ "title": "result" }], + "status": "completed" + }, + { + "type": "provider_reasoning", + "provider": "anthropic", + "payload": { + "type": "thinking", + "thinking": "after tool", + "signature": "sig_after" + } + }, + { + "type": "text", + "text": "final" + } + ]) + ); +} + +#[tokio::test] +async fn query_disables_openai_thinking_when_reasoning_context_is_missing() { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(OpenAiCapturingProvider { + requests: Arc::clone(&requests), + }); + let registry = Arc::new(ToolRegistry::new()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + slug: "deepseek-v4-flash".into(), + provider: devo_protocol::ProviderWireApi::OpenAIChatCompletions, + reasoning_capability: ReasoningCapability::Toggle, + base_instructions: String::new(), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::assistant_text("legacy assistant reply")); + session.push_message(Message::user("follow up")); + + query( + &mut session, + &TurnConfig::new(model, Some("enabled".into())), + Arc::clone(&provider), + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0].model_slug, + devo_protocol::ModelProfileKey::CatalogSlug("deepseek-v4-flash".to_string()) + ); + assert_eq!(captured[0].request_thinking.as_deref(), Some("enabled")); + // Toggle capability does not set reasoning_effort on the request. + assert_eq!(captured[0].reasoning_effort, None); +} + +#[tokio::test] +async fn query_tool_result_summary_is_set() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(MutatingTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { summary, .. } = event { + seen_clone.lock().unwrap().push(summary); + } + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + let summaries = seen.lock().unwrap(); + assert!( + !summaries.is_empty(), + "should have at least one ToolResult summary" + ); + for summary in summaries.iter() { + assert!(!summary.is_empty(), "summary should not be empty"); + } +} + +#[tokio::test] +async fn query_tool_result_event_includes_final_tool_input() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + tool_name, input, .. + } = event + { + seen_clone.lock().unwrap().push((tool_name, input)); + } + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().unwrap().as_slice(), + &[(String::from("mutating_tool"), json!({ "value": 1 }))] + ); +} + +#[tokio::test] +async fn query_tool_result_event_matches_input_delta_by_tool_index() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tools")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + tool_use_id, input, .. + } = event + { + seen_clone.lock().unwrap().push((tool_use_id, input)); + } + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(InterleavedToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().unwrap().as_slice(), + &[ + (String::from("tool-1"), json!({ "value": 1 })), + (String::from("tool-2"), json!({ "value": 2 })), + ] + ); +} + +#[tokio::test] +async fn query_truncates_model_visible_tool_results_but_emits_raw_tool_result_events() { + let full_content = "abcdefghijklmnopqrstuvwxyz".to_string(); + let display_content = "raw display abcdefghijklmnopqrstuvwxyz".to_string(); + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler( + "mutating_tool", + Arc::new(LargeToolResultTool { + content: full_content.clone(), + display_content: Some(display_content.clone()), + }), + ); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let requests = Arc::new(Mutex::new(Vec::new())); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + content, + display_content, + .. + } = event + { + seen_clone + .lock() + .expect("lock seen events") + .push((content.into_string(), display_content)); + } + }) + }); + + query( + &mut session, + &TurnConfig::new( + Model { + truncation_policy: TruncationPolicyConfig::bytes(20), + ..Model::default() + }, + None, + ), + Arc::new(CapturingToolUseProvider { + requests: Arc::clone(&requests), + calls: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().expect("lock seen events").as_slice(), + &[(full_content.clone(), Some(display_content))] + ); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 2); + let model_visible_tool_result = captured[1] + .messages + .iter() + .flat_map(|message| &message.content) + .find_map(|content| match content { + RequestContent::ToolResult { content, .. } => Some(content.as_str()), + RequestContent::Text { .. } + | RequestContent::Reasoning { .. } + | RequestContent::ProviderReasoning { .. } + | RequestContent::HostedToolUse { .. } + | RequestContent::ToolUse { .. } => None, + }) + .expect("continuation request should include tool result"); + assert_eq!(model_visible_tool_result, "abcde\n...[truncated]"); +} + +#[tokio::test] +async fn query_tool_start_event_includes_final_tool_input() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tools")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolUseStart { id, input, .. } = event { + seen_clone.lock().unwrap().push((id, input)); + } + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(InterleavedToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().unwrap().as_slice(), + &[ + (String::from("tool-1"), json!({ "value": 1 })), + (String::from("tool-2"), json!({ "value": 2 })), + ] + ); +} + +#[tokio::test] +#[ignore = "legacy progress mechanism replaced by L3 contracts"] +async fn query_emits_tool_result_display_content() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(DisplayContentTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + if let QueryEvent::ToolResult { + content, + display_content, + .. + } = event + { + seen_clone.lock().unwrap().push((content, display_content)); + } + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert!(matches!( + &seen[0], + (crate::tools::ToolContent::Text(text), Some(display)) + if text == "canonical" && display == "display" + )); +} + +#[tokio::test] +#[ignore = "legacy progress mechanism replaced by L3 contracts"] +async fn query_emits_tool_progress_before_tool_result() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("mutating_tool", Arc::new(StreamingMutatingTool)); + builder.push_spec(ToolSpec { + name: "mutating_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::Mutating, + capability_tags: vec![], + supports_parallel: false, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tool")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + seen_clone.lock().unwrap().push(event); + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(SingleToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + let events = seen.lock().unwrap(); + let progress_index = events + .iter() + .position(|event| { + matches!( + event, + QueryEvent::ToolProgress { + tool_use_id, + progress: crate::tools::ToolProgress::OutputDelta { delta }, + } if tool_use_id == "tool-1" && delta == "stream chunk\n" + ) + }) + .expect("tool progress event should be emitted"); + let result_index = events + .iter() + .position(|event| { + matches!( + event, + QueryEvent::ToolResult { + tool_use_id, + content, + is_error, + .. + } if tool_use_id == "tool-1" + && matches!(content, crate::tools::ToolContent::Text(text) if text == "stream complete") + && !is_error + ) + }) + .expect("tool result event should be emitted"); + + assert!( + progress_index < result_index, + "tool progress should arrive before final result" + ); +} + +#[tokio::test] +async fn query_emits_parallel_tool_results_as_each_tool_finishes() { + let mut builder = ToolRegistryBuilder::new(); + builder.register_handler("parallel_tool", Arc::new(ParallelDelayTool)); + builder.push_spec(ToolSpec { + name: "parallel_tool".into(), + description: String::new(), + input_schema: JsonSchema::object(Default::default(), None, None), + output_mode: ToolOutputMode::Text, + execution_mode: ToolExecutionMode::ReadOnly, + capability_tags: vec![], + supports_parallel: true, + preparation_feedback: ToolPreparationFeedback::None, + display_name: None, + supports_cancellation: None, + supports_streaming: None, + }); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("run the tools")); + + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_clone = Arc::clone(&seen); + let callback: EventCallback = Arc::new(move |event: QueryEvent| { + let seen_clone = Arc::clone(&seen_clone); + Box::pin(async move { + match event { + QueryEvent::ToolUseStart { id, .. } => { + seen_clone + .lock() + .expect("lock events") + .push(format!("start:{id}")); + } + QueryEvent::ToolResult { + tool_use_id, + content, + .. + } => { + let content = content.into_string(); + seen_clone + .lock() + .expect("lock events") + .push(format!("result:{tool_use_id}:{content}")); + } + _ => {} + } + }) + }); + + query( + &mut session, + &TurnConfig::new(Model::default(), None), + Arc::new(ParallelToolUseProvider { + requests: AtomicUsize::new(0), + }), + registry, + &runtime, + Some(callback), + QueryOptions::default(), + ) + .await + .expect("query should complete"); + + assert_eq!( + seen.lock().expect("lock events").as_slice(), + &[ + "start:slow".to_string(), + "start:fast".to_string(), + "result:fast:fast complete".to_string(), + "result:slow:slow complete".to_string(), + ] + ); + + let tool_result_ids = session + .messages + .iter() + .flat_map(|message| &message.content) + .filter_map(|block| match block { + ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(tool_result_ids, vec!["slow", "fast"]); +} diff --git a/crates/core/src/query/turn_continuation.rs b/crates/core/src/query/turn_continuation.rs new file mode 100644 index 00000000..cd66552b --- /dev/null +++ b/crates/core/src/query/turn_continuation.rs @@ -0,0 +1,191 @@ +//! Model-turn continuation policy for the agent loop. +//! +//! After a model response is assembled, the query loop asks this module whether +//! to execute local tools, inject a continuation message, end the turn, or fail. +//! Provider-specific quirks (DeepSeek thinking-only, residual DSML text) live here +//! so the main loop stays free of model-name branches. + +use crate::AgentError; +use crate::ContentBlock; +use crate::Message; +use devo_protocol::HostedToolDefinition; +use devo_protocol::StopReason; +use devo_protocol::ToolDefinition; + +pub(crate) const DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT: &str = "Your previous response contained only hidden reasoning and no user-visible answer. Provide the final answer to the user's original request now. Do not reveal or summarize hidden reasoning; return only user-visible content."; +const MAX_DSML_TEXT_TOOL_CALL_CONTINUATIONS: usize = 3; +const DSML_TEXT_TOOL_CALL_CONTINUATION_REMINDER: &str = "Your previous assistant message contained DSML tagged tool-call text. Those tags were emitted as ordinary text and no tool was executed. Do not repeat the DSML block. Continue now by using the provider's native hosted tool interface when you need a hosted tool, by invoking one of the available local tools when appropriate, or by producing normal prose if no tool is needed."; +const DSML_TOOL_CALL_MARKERS: [&str; 4] = [ + "<|DSML|tool_calls>", + "<||DSML||tool_calls>", + "<|DSML|tool_calls>", + "<||DSML||tool_calls>", +]; +const MAX_TOKENS_CONTINUATION_PROMPT: &str = "Please continue from where you left off."; + +/// Snapshot of one completed model turn used to decide loop continuation. +pub(crate) struct ModelTurnSnapshot<'a> { + pub stop_reason: Option, + pub assistant_content: &'a [ContentBlock], + pub has_visible_assistant_text: bool, + pub has_local_tool_calls: bool, + pub has_hosted_tool_uses: bool, + pub has_provider_reasoning: bool, + pub request_tools: &'a [ToolDefinition], + pub hosted_tools: &'a [HostedToolDefinition], +} + +/// Decision returned to the query loop after a model turn. +pub(crate) enum TurnContinuation { + /// Local tool calls are present; caller should execute them. + RunTools, + /// Continue the query loop without injecting an extra message. + Continue, + /// Inject a user/system message, then continue the query loop. + ContinueWithMessage(Message), + /// No further model calls; optionally emit turn-complete. + Complete { stop_reason: Option }, + /// Unrecoverable model/provider quirk. + Fail(AgentError), +} + +/// Stateful policy that owns per-turn counters for model quirks. +pub(crate) struct TurnContinuationPolicy { + thinking_only_enabled: bool, + thinking_only_used: bool, + dsml_text_continuations: usize, +} + +impl TurnContinuationPolicy { + pub(crate) fn for_models(model_slug: &str, request_model: &str) -> Self { + Self { + thinking_only_enabled: model_enables_thinking_only_continuation(model_slug) + || model_enables_thinking_only_continuation(request_model), + thinking_only_used: false, + dsml_text_continuations: 0, + } + } + + pub(crate) fn decide(&mut self, snap: ModelTurnSnapshot<'_>) -> TurnContinuation { + if snap.has_local_tool_calls { + return TurnContinuation::RunTools; + } + + if self.thinking_only_enabled + && snap.stop_reason == Some(StopReason::EndTurn) + && !snap.has_visible_assistant_text + && !snap.has_hosted_tool_uses + && snap.has_provider_reasoning + { + if self.thinking_only_used { + return TurnContinuation::Fail(AgentError::Provider(anyhow::anyhow!( + "deepseek-v4 returned thinking-only end_turn after continuation; no user-visible text was produced" + ))); + } + self.thinking_only_used = true; + tracing::debug!( + "deepseek-v4 returned thinking-only end_turn; injecting continuation prompt" + ); + return TurnContinuation::ContinueWithMessage(Message::user( + DEEPSEEK_THINKING_ONLY_CONTINUATION_PROMPT, + )); + } + + if snap.has_hosted_tool_uses && snap.stop_reason == Some(StopReason::ToolUse) { + tracing::debug!("hosted tool use returned without local calls, continuing query loop"); + return TurnContinuation::Continue; + } + + if assistant_content_contains_dsml_tool_call_text(snap.assistant_content) { + if self.dsml_text_continuations >= MAX_DSML_TEXT_TOOL_CALL_CONTINUATIONS { + return TurnContinuation::Fail(AgentError::Provider(anyhow::anyhow!( + "provider returned DSML text tool calls {MAX_DSML_TEXT_TOOL_CALL_CONTINUATIONS} times without structured or hosted tool results" + ))); + } + self.dsml_text_continuations += 1; + tracing::debug!( + "DSML text tool call returned without structured tool result; continuing query loop" + ); + return TurnContinuation::ContinueWithMessage( + dsml_text_tool_call_continuation_message(snap.request_tools, snap.hosted_tools), + ); + } + + if snap.stop_reason == Some(StopReason::MaxTokens) { + tracing::debug!("max_tokens reached injecting continuation prompt"); + return TurnContinuation::ContinueWithMessage(Message::user( + MAX_TOKENS_CONTINUATION_PROMPT, + )); + } + + TurnContinuation::Complete { + stop_reason: snap.stop_reason, + } + } +} + +fn model_enables_thinking_only_continuation(model: &str) -> bool { + model.starts_with("deepseek-v4-") +} + +pub(crate) fn assistant_content_has_visible_content(content: &[ContentBlock]) -> bool { + content.iter().any(|block| match block { + ContentBlock::Text { text } + | ContentBlock::Reasoning { text } + | ContentBlock::ToolResult { content: text, .. } => !text.trim().is_empty(), + ContentBlock::ProviderReasoning { .. } + | ContentBlock::ToolUse { .. } + | ContentBlock::HostedToolUse { .. } => true, + }) +} + +fn assistant_content_contains_dsml_tool_call_text(content: &[ContentBlock]) -> bool { + content.iter().any(|block| match block { + ContentBlock::Text { text } => DSML_TOOL_CALL_MARKERS + .iter() + .any(|marker| text.contains(marker)), + ContentBlock::Reasoning { .. } + | ContentBlock::ProviderReasoning { .. } + | ContentBlock::ToolUse { .. } + | ContentBlock::HostedToolUse { .. } + | ContentBlock::ToolResult { .. } => false, + }) +} + +fn dsml_text_tool_call_continuation_message( + request_tools: &[ToolDefinition], + hosted_tools: &[HostedToolDefinition], +) -> Message { + let mut reminder = String::from("\n"); + reminder.push_str(DSML_TEXT_TOOL_CALL_CONTINUATION_REMINDER); + let local_tool_names = request_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + if !local_tool_names.is_empty() { + reminder.push_str("\n\nAvailable local tools: "); + reminder.push_str(&local_tool_names.join(", ")); + reminder.push('.'); + } + let hosted_tool_names = hosted_tools + .iter() + .map(hosted_tool_name_for_reminder) + .collect::>(); + if !hosted_tool_names.is_empty() { + reminder.push_str("\nAvailable hosted tools: "); + reminder.push_str(&hosted_tool_names.join(", ")); + reminder.push_str(". Hosted tools must be invoked through provider-native server tool calls, not by writing DSML tags in text."); + } + if local_tool_names.contains(&"spawn_agent") && local_tool_names.contains(&"await_task") { + reminder.push_str("\nFor research work with separable subtasks, prefer spawning independent agents first and then waiting for their results."); + } + reminder.push_str("\n"); + Message::user(reminder) +} + +fn hosted_tool_name_for_reminder(tool: &HostedToolDefinition) -> &'static str { + match tool { + HostedToolDefinition::WebSearch(_) => "web_search", + HostedToolDefinition::WebFetch(_) => "web_fetch", + } +} diff --git a/crates/core/src/response_item.rs b/crates/core/src/response_item.rs index 2d7e8334..6c913d3e 100644 --- a/crates/core/src/response_item.rs +++ b/crates/core/src/response_item.rs @@ -7,6 +7,18 @@ use devo_protocol::{ContentBlock, Message, RequestContent, RequestMessage, Role} /// This is the core IR that the history management system operates on, /// bridging provider-agnostic protocol types with normalization and /// compaction workflows. +/// +/// # Why `ResponseItem` is separate from [`Message`] +/// +/// [`Message`] is the session/protocol shape: one role turn that may bundle +/// several [`ContentBlock`]s (text, reasoning, tool use, tool result) together. +/// That is the natural unit for "append what the assistant just said." +/// +/// [`ResponseItem`] is a flatter working representation for history algorithms +/// that need to operate on atomic records—pairing tool calls with outputs, +/// dropping reasoning before summarization, estimating tokens per item, or +/// trimming a turn from the tail. Mixed-content messages are therefore split +/// by [`message_to_response_items`]; see that function for a concrete example. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum ResponseItem { /// Model reasoning / thinking output. @@ -185,6 +197,42 @@ impl From<&ResponseItem> for RequestMessage { /// A `Message` can contain multiple content blocks of different types. /// This split representation is useful for normalization (e.g. pairing /// tool calls with their outputs) and modality-based filtering. +/// +/// # Example: splitting a mixed assistant turn +/// +/// Session storage keeps one assistant [`Message`] for a single model turn: +/// +/// ```text +/// Message { +/// role: Assistant, +/// content: [ +/// Reasoning { "hmm" }, +/// Text { "hello" }, +/// ToolUse { id: "tu-1", name: "bash", input: { "cmd": "ls" } }, +/// ] +/// } +/// ``` +/// +/// History algorithms need each of those blocks as a standalone record, so +/// this function yields three items: +/// +/// ```text +/// [ +/// ResponseItem::Reason { text: "hmm" }, +/// ResponseItem::Message { role: Assistant, content: [Text("hello")] }, +/// ResponseItem::ToolCall { id: "tu-1", name: "bash", input: { "cmd": "ls" } }, +/// ] +/// ``` +/// +/// A following user message that only carries `ToolResult { tool_use_id: "tu-1", ... }` +/// becomes `ResponseItem::ToolCallOutput { tool_use_id: "tu-1", ... }`. The history +/// is then a flat sequence where tool calls and outputs are adjacent and easy to +/// pair, filter, or drop independently of the original message boundaries. +/// +/// When converting back to provider `RequestMessage`s, consecutive assistant +/// fragments produced by this split are merged again (see +/// `merge_consecutive_assistant_messages` in `history`) so the wire format still +/// satisfies provider adjacency rules. pub fn message_to_response_items(msg: Message) -> Vec { let role = msg.role; let content = msg.content; diff --git a/crates/core/src/tools/client_terminal_shell.rs b/crates/core/src/tools/client_terminal_shell.rs index 9c37a738..8f840664 100644 --- a/crates/core/src/tools/client_terminal_shell.rs +++ b/crates/core/src/tools/client_terminal_shell.rs @@ -1,3 +1,4 @@ +use devo_protocol::approx_bytes_for_tokens; use std::path::PathBuf; use std::time::Duration; @@ -56,7 +57,7 @@ pub(crate) async fn execute_with_client_terminal( args, env, cwd: Some(request.workdir.clone()), - output_byte_limit: Some(request.max_output_tokens.saturating_mul(4)), + output_byte_limit: Some(approx_bytes_for_tokens(request.max_output_tokens)), }, ctx.cancel_token.clone(), ) diff --git a/crates/core/src/tools/shell_exec.rs b/crates/core/src/tools/shell_exec.rs index 90ca54bd..3ddd58fe 100644 --- a/crates/core/src/tools/shell_exec.rs +++ b/crates/core/src/tools/shell_exec.rs @@ -1,3 +1,4 @@ +use devo_protocol::approx_bytes_for_tokens; use portable_pty::{Child, CommandBuilder, ExitStatus, PtySize, native_pty_system}; use serde_json::json; use std::path::PathBuf; @@ -472,7 +473,7 @@ pub(crate) fn truncate_output(text: &str, max_output_tokens: usize) -> String { if max_output_tokens == 0 { return String::new(); } - let max_chars = max_output_tokens.saturating_mul(4); + let max_chars = approx_bytes_for_tokens(max_output_tokens); if text.len() <= max_chars { return text.to_string(); } diff --git a/crates/core/src/tools/unified_exec/process.rs b/crates/core/src/tools/unified_exec/process.rs index 300ef9af..582602ef 100644 --- a/crates/core/src/tools/unified_exec/process.rs +++ b/crates/core/src/tools/unified_exec/process.rs @@ -1,3 +1,5 @@ +use devo_protocol::approx_bytes_for_tokens; +use devo_protocol::approx_tokens_from_byte_count; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -561,15 +563,13 @@ pub async fn collect_output( } fn approximate_token_count(byte_len: usize) -> usize { - if byte_len == 0 { - 0 - } else { - byte_len.div_ceil(4) - } + approx_tokens_from_byte_count(byte_len) + .try_into() + .unwrap_or(usize::MAX) } fn formatted_truncate_tokens(content: &str, max_output_tokens: usize) -> (String, bool) { - let max_bytes = max_output_tokens.saturating_mul(4); + let max_bytes = approx_bytes_for_tokens(max_output_tokens); if content.len() <= max_bytes { return (content.to_string(), false); } diff --git a/crates/core/tests/context_limit_compaction.rs b/crates/core/tests/context_limit_compaction.rs index 4be485aa..cf81b136 100644 --- a/crates/core/tests/context_limit_compaction.rs +++ b/crates/core/tests/context_limit_compaction.rs @@ -12,6 +12,7 @@ use devo_core::Model; use devo_core::ModelRequest; use devo_core::ModelResponse; use devo_core::QueryEvent; +use devo_core::QueryOptions; use devo_core::ResponseContent; use devo_core::SessionConfig; use devo_core::SessionState; @@ -113,6 +114,7 @@ async fn context_limit_error_compacts_and_retries_query() { registry, &runtime, Some(callback), + QueryOptions::default(), ) .await .expect("query should compact and retry"); diff --git a/crates/core/tests/real_llm_e2e.rs b/crates/core/tests/real_llm_e2e.rs index 3fa5c48a..1a149489 100644 --- a/crates/core/tests/real_llm_e2e.rs +++ b/crates/core/tests/real_llm_e2e.rs @@ -11,6 +11,7 @@ use devo_core::Message; use devo_core::Model; use devo_core::ProviderWireApi; use devo_core::QueryEvent; +use devo_core::QueryOptions; use devo_core::ReasoningCapability; use devo_core::SessionConfig; use devo_core::SessionState; @@ -138,6 +139,7 @@ async fn run_query( registry, &runtime, Some(callback), + QueryOptions::default(), ) .await .context("execute live query")?; diff --git a/crates/protocol/src/conversation.rs b/crates/protocol/src/conversation.rs index 58a47e42..43bfbfd3 100644 --- a/crates/protocol/src/conversation.rs +++ b/crates/protocol/src/conversation.rs @@ -225,6 +225,14 @@ pub enum ContentBlock { }, } +/// One role-tagged turn in the conversation (session / protocol shape). +/// +/// A single `Message` may bundle several [`ContentBlock`]s—text, reasoning, +/// tool use, and tool results—because that matches how a model or user turn is +/// stored and exchanged. History management in `devo-core` uses a flatter +/// `ResponseItem` IR instead; see `message_to_response_items` there for why a +/// mixed assistant message is split into adjacent atomic items and later merged +/// again when building provider requests. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] pub struct Message { pub role: Role, diff --git a/crates/server/src/runtime/handlers/compaction.rs b/crates/server/src/runtime/handlers/compaction.rs index a136c86c..c65542fc 100644 --- a/crates/server/src/runtime/handlers/compaction.rs +++ b/crates/server/src/runtime/handlers/compaction.rs @@ -1,4 +1,5 @@ use super::super::*; +use devo_protocol::approx_tokens_from_byte_count; impl ServerRuntime { pub(crate) async fn handle_session_compact( @@ -193,14 +194,17 @@ impl ServerRuntime { ) = { let mut core_session = runtime_session.core_session.lock().await; core_session.set_prompt_messages(new_messages); - let compacted_prompt_token_estimate = core_session + let prompt_bytes = core_session .prompt_source_messages() .iter() .map(|message| { serde_json::to_string(message).map_or(0, |json| json.len()) }) - .sum::() - .div_ceil(4); + .sum::(); + let compacted_prompt_token_estimate = + approx_tokens_from_byte_count(prompt_bytes) + .try_into() + .unwrap_or(usize::MAX); core_session.prompt_token_estimate = compacted_prompt_token_estimate; ( core_session.total_input_tokens, diff --git a/crates/server/src/runtime/handlers/rollback_workspace.rs b/crates/server/src/runtime/handlers/rollback_workspace.rs index 7e0d189f..610e45ba 100644 --- a/crates/server/src/runtime/handlers/rollback_workspace.rs +++ b/crates/server/src/runtime/handlers/rollback_workspace.rs @@ -92,7 +92,9 @@ impl ServerRuntime { )) .await; let retry_workspace_version = - crate::workspace_changes::current_git_workspace_version(workspace_root).await.ok(); + crate::workspace_changes::current_git_workspace_version(workspace_root) + .await + .ok(); return Err(WorkspaceRestoreFailure { response: self.error_response( request_id.clone(), @@ -137,16 +139,15 @@ impl ServerRuntime { ProtocolErrorCode::InternalError, format!("failed to verify workspace restore: {error}"), ), - retry_workspace_version: crate::workspace_changes::current_git_workspace_version( - PathBuf::from( + retry_workspace_version: + crate::workspace_changes::current_git_workspace_version(PathBuf::from( checkpoint .workspace_root .as_deref() .expect("Git checkpoint has workspace root"), - ), - ) - .await - .ok(), + )) + .await + .ok(), completion_pending: None, }); } diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index 2fb85067..e30db30c 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -4,7 +4,7 @@ use devo_core::tools::{ AgentToolCoordinator, ClientFilesystem, ClientTerminal, ToolAgentScope, ToolCall, ToolExecutionOptions, ToolRuntime, ToolRuntimeContext, }; -use devo_core::{Message, QueryEvent, QueryOptions, TurnConfig, query_with_options}; +use devo_core::{Message, QueryEvent, QueryOptions, TurnConfig, query}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -173,7 +173,7 @@ impl ServerRuntime { Some(turn_id), devo_protocol::canonical::usage::UsagePurpose::Compaction, ); - let mut query_future = std::pin::pin!(query_with_options( + let mut query_future = std::pin::pin!(query( &mut state.core, turn_config, provider, From b95c391bb52b4764b8ee3f08dbfad575f7cd8704 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 30 Jul 2026 11:58:50 +0800 Subject: [PATCH 16/17] feat(tui): add /rename and /delete, and make Proposed Plan revise an inline input --- .../web/content/docs/using-devo/plan-mode.mdx | 6 +- .../content/docs/using-devo/plan-mode.zh.mdx | 2 +- crates/client/src/client_core.rs | 7 + crates/client/src/stdio.rs | 7 + crates/client/src/websocket.rs | 9 + crates/core/models.json | 454 +++++++++--------- crates/core/src/query/mod.rs | 5 + crates/core/src/query/tests.rs | 72 +++ crates/protocol/src/model.rs | 23 + crates/protocol/src/slash_command.rs | 31 +- .../runtime/handlers/acp_slash_commands.rs | 2 + crates/tui/src/app_command.rs | 20 + crates/tui/src/app_event.rs | 3 - crates/tui/src/bottom_pane/mod.rs | 2 + .../bottom_pane/proposed_plan_actions_view.rs | 406 ++++++++++++++++ crates/tui/src/chatwidget/input.rs | 4 - crates/tui/src/chatwidget/session_history.rs | 87 ++-- crates/tui/src/chatwidget/slash_commands.rs | 23 + crates/tui/src/chatwidget/worker_events.rs | 7 + crates/tui/src/chatwidget_tests.rs | 97 +++- crates/tui/src/events.rs | 5 + crates/tui/src/history_cell.rs | 2 +- crates/tui/src/interactive.rs | 7 + crates/tui/src/onboarding_widget.rs | 15 +- crates/tui/src/onboarding_widget_tests.rs | 66 +++ crates/tui/src/worker.rs | 92 ++++ 26 files changed, 1145 insertions(+), 309 deletions(-) create mode 100644 crates/tui/src/bottom_pane/proposed_plan_actions_view.rs diff --git a/apps/web/content/docs/using-devo/plan-mode.mdx b/apps/web/content/docs/using-devo/plan-mode.mdx index c6deb56a..392ed184 100644 --- a/apps/web/content/docs/using-devo/plan-mode.mdx +++ b/apps/web/content/docs/using-devo/plan-mode.mdx @@ -77,9 +77,9 @@ After a `Proposed Plan` appears, Devo opens a small action menu. Choose `Implement Plan` when the plan is ready. Devo switches back to `BUILD` mode and submits an implementation turn using that plan. -Choose `修改建议` when the plan needs changes. This is the current TUI label for -the revise-plan action. It keeps the composer in `PLAN` mode and lets you type -feedback such as: +Choose `Revise Plan` when the plan needs changes. When that option is +highlighted, type feedback directly in the menu and press Enter. Devo stays in +`PLAN` mode and submits a revise turn with your feedback, for example: ```text Keep the config migration out of scope. Add a test for Windows paths. diff --git a/apps/web/content/docs/using-devo/plan-mode.zh.mdx b/apps/web/content/docs/using-devo/plan-mode.zh.mdx index 83e6bb73..a36d62e0 100644 --- a/apps/web/content/docs/using-devo/plan-mode.zh.mdx +++ b/apps/web/content/docs/using-devo/plan-mode.zh.mdx @@ -63,7 +63,7 @@ Plan Mode turn 完成后,Devo 可以在 transcript 中渲染 `Proposed Plan` c 选择 `Implement Plan` 表示计划可执行。Devo 切回 `BUILD` mode,并使用该计划提交实现 turn。 -选择 `修改建议` 表示计划需要修改。这是当前 TUI 对 revise-plan action 的标签。它会保持 composer 在 `PLAN` mode,并让你输入反馈,例如: +选择 `Revise Plan` 表示计划需要修改。高亮该选项后,可直接在菜单中输入反馈并按 Enter。Devo 保持 `PLAN` mode,并用你的反馈提交 revise turn,例如: ```text Keep the config migration out of scope. Add a test for Windows paths. diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index cf30d2a9..4fcbe5c5 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -459,6 +459,13 @@ impl ServerClientCore { self.request_devo("session/title/update", params).await } + pub(crate) async fn session_delete( + &mut self, + params: AcpDeleteSessionParams, + ) -> Result { + self.request(ACP_SESSION_DELETE_METHOD, params).await + } + pub(crate) async fn session_metadata_update( &mut self, params: SessionMetadataUpdateParams, diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 3604f38c..32a17b6f 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -171,6 +171,13 @@ impl StdioServerClient { self.core.session_title_update(params).await } + pub async fn session_delete( + &mut self, + params: AcpDeleteSessionParams, + ) -> Result { + self.core.session_delete(params).await + } + pub async fn session_metadata_update( &mut self, params: SessionMetadataUpdateParams, diff --git a/crates/client/src/websocket.rs b/crates/client/src/websocket.rs index ed8e9fe7..68808ced 100644 --- a/crates/client/src/websocket.rs +++ b/crates/client/src/websocket.rs @@ -146,6 +146,15 @@ impl WebSocketServerClient { self.core.request_devo("session/title/update", params).await } + pub async fn session_delete( + &mut self, + params: AcpDeleteSessionParams, + ) -> Result { + self.core + .request(devo_protocol::ACP_SESSION_DELETE_METHOD, params) + .await + } + pub async fn session_metadata_update( &mut self, params: SessionMetadataUpdateParams, diff --git a/crates/core/models.json b/crates/core/models.json index 0e50fd15..ba803f3d 100644 --- a/crates/core/models.json +++ b/crates/core/models.json @@ -1,243 +1,235 @@ [ - { - "slug": "kimi-k3", - "display_name": "kimi-k3", - "channel": "Kimi", - "provider": "openai_chat_completions", - "description": "Kimi's flagship model", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": [ - "low", - "high", - "max" - ], - "context_window": 1048576, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text", "image" - ] + { + "slug": "kimi-k3", + "display_name": "kimi-k3", + "channel": "Kimi", + "provider": "openai_chat_completions", + "description": "Kimi's flagship model", + "reasoning_capability": "toggle", + "default_reasoning_level": "high", + "supported_reasoning_levels": ["low", "high", "max"], + "context_window": 1048576, + "max_tokens": 8096, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "MiniMax-M3", - "display_name": "MiniMax-M3", - "channel": "MiniMax", - "provider": "openai_chat_completions", - "description": "MiniMax M3 model with 1M-token context by MiniMax", - "reasoning_capability": "toggle", - "default_reasoning_level": "", - "supported_reasoning_levels": [], - "temperature": 1.0, - "top_p": 0.95, - "context_window": 1000000, - "max_tokens": 8192, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" - ], - "reasoning_implementation": { - "model_variant": { - "variants": [ - { - "selection_value": "disabled", - "model_slug": "MiniMax-M3", - "reasoning_effort": null, - "extra_body": { - "thinking": { - "type": "disabled" - } - }, - "label": "Off", - "description": "Disable thinking" - }, - { - "selection_value": "enabled", - "model_slug": "MiniMax-M3", - "reasoning_effort": "medium", - "label": "On", - "description": "Enable adaptive thinking" - } - ] - } - } + "input_modalities": ["text", "image"] + }, + { + "slug": "laguna-s-2.1", + "display_name": "laguna-s-2.1", + "channel": "Poolside", + "provider": "openai_chat_completions", + "description": "Poolside's flagship model", + "reasoning_capability": "toggle", + "default_reasoning_level": "", + "supported_reasoning_levels": [], + "context_window": 1048576, + "max_tokens": 8096, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "glm-5.2", - "display_name": "glm-5.2", - "channel": "GLM", - "provider": "openai_chat_completions", - "description": "flagship open-weight model released by z.ai", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": [ - "none", - "high", - "max" - ], - "temperature": 1.0, - "top_p": 0.95, - "context_window": 1000000, - "max_tokens": 131000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" + "input_modalities": ["text"], + "reasoning_implementation": { + "model_variant": { + "variants": [ + { + "model_slug": "laguna-s-2.1", + "selection_value": "disabled", + "extra_body": { + "chat_template_kwargs": { + "enable_thinking": false + } + }, + "label": "Off", + "description": "Disable thinking" + }, + { + "model_slug": "laguna-s-2.1", + "selection_value": "enabled", + "label": "On", + "description": "Enable adaptive thinking" + } ] + } + } + }, + { + "slug": "MiniMax-M3", + "display_name": "MiniMax-M3", + "channel": "MiniMax", + "provider": "openai_chat_completions", + "description": "MiniMax M3 model with 1M-token context by MiniMax", + "reasoning_capability": "toggle", + "default_reasoning_level": "", + "supported_reasoning_levels": [], + "temperature": 1.0, + "top_p": 0.95, + "context_window": 1000000, + "max_tokens": 8192, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "deepseek-v4-flash", - "display_name": "deepseek-v4-flash", - "channel": "DeepSeek", - "provider": "openai_chat_completions", - "description": "deepseek v4 flash model released by deepseek at 2026.", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": [ - "high", - "max" - ], - "context_window": 1048565, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" + "input_modalities": ["text"], + "reasoning_implementation": { + "model_variant": { + "variants": [ + { + "selection_value": "disabled", + "model_slug": "MiniMax-M3", + "reasoning_effort": null, + "extra_body": { + "thinking": { + "type": "disabled" + } + }, + "label": "Off", + "description": "Disable thinking" + }, + { + "selection_value": "enabled", + "model_slug": "MiniMax-M3", + "reasoning_effort": "medium", + "label": "On", + "description": "Enable adaptive thinking" + } ] + } + } + }, + { + "slug": "glm-5.2", + "display_name": "glm-5.2", + "channel": "GLM", + "provider": "openai_chat_completions", + "description": "flagship open-weight model released by z.ai", + "reasoning_capability": "toggle", + "default_reasoning_level": "high", + "supported_reasoning_levels": ["none", "high", "max"], + "temperature": 1.0, + "top_p": 0.95, + "context_window": 1000000, + "max_tokens": 131000, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "deepseek-v4-pro", - "display_name": "deepseek-v4-pro", - "channel": "DeepSeek", - "provider": "openai_chat_completions", - "description": "deepseek v4 pro model released by deepseek at 2026.", - "reasoning_capability": "toggle", - "default_reasoning_level": "high", - "supported_reasoning_levels": [ - "high", - "max" - ], - "context_window": 1048565, - "max_tokens": 8096, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" - ] + "input_modalities": ["text"] + }, + { + "slug": "deepseek-v4-flash", + "display_name": "deepseek-v4-flash", + "channel": "DeepSeek", + "provider": "openai_chat_completions", + "description": "deepseek v4 flash model released by deepseek at 2026.", + "reasoning_capability": "toggle", + "default_reasoning_level": "high", + "supported_reasoning_levels": ["high", "max"], + "context_window": 1048565, + "max_tokens": 8096, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "mimo-v2.5-pro", - "display_name": "mimo-v2.5-pro", - "channel": "Xiaomi MiMo", - "provider": "openai_chat_completions", - "description": "Xiaomi MiMo v2.5 pro model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "medium", - "supported_reasoning_levels": [ - "low", - "medium", - "max" - ], - "context_window": 1000000, - "max_tokens": 128000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" - ] + "input_modalities": ["text"] + }, + { + "slug": "deepseek-v4-pro", + "display_name": "deepseek-v4-pro", + "channel": "DeepSeek", + "provider": "openai_chat_completions", + "description": "deepseek v4 pro model released by deepseek at 2026.", + "reasoning_capability": "toggle", + "default_reasoning_level": "high", + "supported_reasoning_levels": ["high", "max"], + "context_window": 1048565, + "max_tokens": 8096, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "mimo-v2.5", - "display_name": "mimo-v2.5", - "channel": "Xiaomi MiMo", - "provider": "openai_chat_completions", - "description": "Xiaomi MiMo v2.5 model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "medium", - "supported_reasoning_levels": [ - "low", - "medium", - "max" - ], - "context_window": 1000000, - "max_tokens": 128000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" - ] + "input_modalities": ["text"] + }, + { + "slug": "mimo-v2.5-pro", + "display_name": "mimo-v2.5-pro", + "channel": "Xiaomi MiMo", + "provider": "openai_chat_completions", + "description": "Xiaomi MiMo v2.5 pro model.", + "reasoning_capability": "toggle", + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "max"], + "context_window": 1000000, + "max_tokens": 128000, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "gpt-5.5", - "display_name": "gpt-5.5", - "channel": "OpenAI", - "provider": "openai_chat_completions", - "description": "OpenAI GPT-5.5 model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "medium", - "supported_reasoning_levels": [ - "none", - "low", - "medium", - "high", - "xhigh" - ], - "context_window": 1000000, - "max_tokens": 128000, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" - ] + "input_modalities": ["text"] + }, + { + "slug": "mimo-v2.5", + "display_name": "mimo-v2.5", + "channel": "Xiaomi MiMo", + "provider": "openai_chat_completions", + "description": "Xiaomi MiMo v2.5 model.", + "reasoning_capability": "toggle", + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["low", "medium", "max"], + "context_window": 1000000, + "max_tokens": 128000, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 }, - { - "slug": "Hunyuan3", - "display_name": "Hunyuan3", - "channel": "Tencent", - "provider": "openai_chat_completions", - "description": "Tencent Hunyuan3 model.", - "reasoning_capability": "toggle", - "default_reasoning_level": "low", - "supported_reasoning_levels": [ - "low", - "high" - ], - "context_window": 256000, - "max_tokens": 8192, - "effective_context_window_percent": 95, - "truncation_policy": { - "mode": "tokens", - "limit": 10000 - }, - "input_modalities": [ - "text" - ], - "base_instructions": "You are Devo, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.\n\n# Personality\n\nYou are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.\n\n## Values\nYou are guided by these core values:\n- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.\n- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.\n- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.\n\n## Interaction Style\nYou communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\n\nYou avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.\n\n## Escalation\nYou may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.\n\n# General\n\n- For codebase investigation, prefer `code_search` before literal search tools when it is available. Use `code_search` for architecture questions, implementation discovery, module/symbol lookup, related-code lookup, and natural-language code searches. Use `grep` for exact text or regex searches. Use `find` for filenames and paths.\n- Parallelize tool calls whenever possible - especially file reads, such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`. Use `multi_tool_use.parallel` to parallelize tool calls and only this.\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Frontend tasks\n\nWhen doing frontend design tasks, avoid collapsing into \"AI slop\" or safe, average-looking layouts.\nAim for interfaces that feel intentional, bold, and a bit surprising.\n- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).\n- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.\n- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.\n- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\n- Ensure the page loads properly on both desktop and mobile\n\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\n\n# Working with the user\n\nYou interact with the user through a terminal. You have 2 ways of communicating with the users:\n- Share intermediary updates in `commentary` channel. \n- After you have completed all your work, send a message to the `final` channel.\nYou are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.\n\n## Autonomy and persistence\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\n\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\n\n## Formatting rules\n\n- You may format with GitHub-flavored Markdown.\n- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.\n- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.\n- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.\n- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.\n- File References: When referencing files in your response follow the below rules:\n * Use markdown links (not inline code) for clickable files.\n * Each file reference should have a stand-alone path; use inline code for non-clickable paths (for example, directories).\n * For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).\n * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n- Don’t use emojis or em dashes unless explicitly instructed.\n\n## Final answer instructions\n- Balance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.\n- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.\n- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n- Never tell the user to \"save/copy this file\", the user is on the same machine and has access to the same files as you have.\n- If the user asks for a code explanation, structure your answer with code references.\n- When given a simple task, just provide the outcome in a short answer without strong formatting.\n- When you make big or complex changes, state the solution first, then walk the user through what you did and why.\n- For casual chit-chat, just chat.\n- If you weren't able to do something, for example run tests, tell the user.\n- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n\n## Intermediary updates \n\n- Intermediary updates go to the `commentary` channel.\n- User updates are short updates while you are working, they are NOT final answers.\n- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work. \n- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.\n- You provide user updates frequently, every 20s.\n- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at \"Got it -\" or \"Understood -\" etc.\n- When exploring, e.g. searching, reading files you provide user updates as you go, every 20s, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.\n- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).\n- Before performing file edits of any kind, you provide updates explaining what edits you are making.\n- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.\n- Tone of your updates MUST match your personality.\nIf your response does not include a tool call, it will be considered a final answer and the task will be terminated. You must not end the interaction until you have fully resolved the user's request. Therefore, proactively use available tools whenever they can help you gather information, verify facts, perform actions, or improve the quality and completeness of your answer. Do not stop prematurely or provide a partial response when further tool usage could help you better satisfy the user.\nTool calls must only be made through the provided tool calling interface. Do not write tool calls manually as XML, JSON, markdown, or plain text. If you need to use a tool, invoke it using the native tool calling mechanism only. Any manually formatted tool call text will be treated as invalid output." - } + "input_modalities": ["text"] + }, + { + "slug": "gpt-5.5", + "display_name": "gpt-5.5", + "channel": "OpenAI", + "provider": "openai_chat_completions", + "description": "OpenAI GPT-5.5 model.", + "reasoning_capability": "toggle", + "default_reasoning_level": "medium", + "supported_reasoning_levels": ["none", "low", "medium", "high", "xhigh"], + "context_window": 1000000, + "max_tokens": 128000, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "input_modalities": ["text"] + }, + { + "slug": "Hunyuan3", + "display_name": "Hunyuan3", + "channel": "Tencent", + "provider": "openai_chat_completions", + "description": "Tencent Hunyuan3 model.", + "reasoning_capability": "toggle", + "default_reasoning_level": "low", + "supported_reasoning_levels": ["low", "high"], + "context_window": 256000, + "max_tokens": 8192, + "effective_context_window_percent": 95, + "truncation_policy": { + "mode": "tokens", + "limit": 10000 + }, + "input_modalities": ["text"], + "base_instructions": "You are Devo, a coding agent. You and the user share the same workspace and collaborate to achieve the user's goals.\n\n# Personality\n\nYou are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.\n\n## Values\nYou are guided by these core values:\n- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.\n- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.\n- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.\n\n## Interaction Style\nYou communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.\n\nYou avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.\n\n## Escalation\nYou may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.\n\n# General\n\n- For codebase investigation, prefer `code_search` before literal search tools when it is available. Use `code_search` for architecture questions, implementation discovery, module/symbol lookup, related-code lookup, and natural-language code searches. Use `grep` for exact text or regex searches. Use `find` for filenames and paths.\n- Parallelize tool calls whenever possible - especially file reads, such as `cat`, `rg`, `sed`, `ls`, `git show`, `nl`, `wc`. Use `multi_tool_use.parallel` to parallelize tool calls and only this.\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Frontend tasks\n\nWhen doing frontend design tasks, avoid collapsing into \"AI slop\" or safe, average-looking layouts.\nAim for interfaces that feel intentional, bold, and a bit surprising.\n- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).\n- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.\n- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.\n- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\n- Ensure the page loads properly on both desktop and mobile\n\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\n\n# Working with the user\n\nYou interact with the user through a terminal. You have 2 ways of communicating with the users:\n- Share intermediary updates in `commentary` channel. \n- After you have completed all your work, send a message to the `final` channel.\nYou are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.\n\n## Autonomy and persistence\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\n\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\n\n## Formatting rules\n\n- You may format with GitHub-flavored Markdown.\n- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.\n- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.\n- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.\n- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.\n- File References: When referencing files in your response follow the below rules:\n * Use markdown links (not inline code) for clickable files.\n * Each file reference should have a stand-alone path; use inline code for non-clickable paths (for example, directories).\n * For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).\n * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n- Don’t use emojis or em dashes unless explicitly instructed.\n\n## Final answer instructions\n- Balance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.\n- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.\n- The user does not see command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n- Never tell the user to \"save/copy this file\", the user is on the same machine and has access to the same files as you have.\n- If the user asks for a code explanation, structure your answer with code references.\n- When given a simple task, just provide the outcome in a short answer without strong formatting.\n- When you make big or complex changes, state the solution first, then walk the user through what you did and why.\n- For casual chit-chat, just chat.\n- If you weren't able to do something, for example run tests, tell the user.\n- If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n\n## Intermediary updates \n\n- Intermediary updates go to the `commentary` channel.\n- User updates are short updates while you are working, they are NOT final answers.\n- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work. \n- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.\n- You provide user updates frequently, every 20s.\n- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at \"Got it -\" or \"Understood -\" etc.\n- When exploring, e.g. searching, reading files you provide user updates as you go, every 20s, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.\n- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).\n- Before performing file edits of any kind, you provide updates explaining what edits you are making.\n- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.\n- Tone of your updates MUST match your personality.\nIf your response does not include a tool call, it will be considered a final answer and the task will be terminated. You must not end the interaction until you have fully resolved the user's request. Therefore, proactively use available tools whenever they can help you gather information, verify facts, perform actions, or improve the quality and completeness of your answer. Do not stop prematurely or provide a partial response when further tool usage could help you better satisfy the user.\nTool calls must only be made through the provided tool calling interface. Do not write tool calls manually as XML, JSON, markdown, or plain text. If you need to use a tool, invoke it using the native tool calling mechanism only. Any manually formatted tool call text will be treated as invalid output." + } ] diff --git a/crates/core/src/query/mod.rs b/crates/core/src/query/mod.rs index 1d09c995..48f42939 100644 --- a/crates/core/src/query/mod.rs +++ b/crates/core/src/query/mod.rs @@ -365,6 +365,11 @@ pub async fn query( if !turn_config.web_fetch.is_local() { request_tools.retain(|tool| tool.name != "webfetch"); } + // Non-OpenAI models often emit malformed apply_patch input, so only expose + // the tool to OpenAI-channel models (see Model::supports_apply_patch). + if !turn_config.model.supports_apply_patch() { + request_tools.retain(|tool| tool.name != "apply_patch"); + } if session.session_context.is_none() { session.session_context = Some(SessionContext::capture( diff --git a/crates/core/src/query/tests.rs b/crates/core/src/query/tests.rs index 445fb8ac..e4094859 100644 --- a/crates/core/src/query/tests.rs +++ b/crates/core/src/query/tests.rs @@ -2216,6 +2216,78 @@ async fn provider_hosted_web_fetch_emits_tool_events_without_local_execution() { )); } +#[tokio::test] +async fn query_exposes_apply_patch_only_for_openai_channel() { + // Non-OpenAI models often produce malformed apply_patch input, so the tool + // is gated to the OpenAI channel only. + async fn tool_names_for_channel(channel: Option<&str>) -> Vec { + let requests = Arc::new(Mutex::new(Vec::new())); + let provider: Arc = Arc::new(CapturingProvider { + requests: Arc::clone(&requests), + }); + let mut builder = ToolRegistryBuilder::new(); + builder.push_spec_with_exposure( + ToolSpec::new( + "apply_patch", + "Apply a patch.", + JsonSchema::object(Default::default(), None, None), + ), + ToolExposure::Direct, + ); + builder.push_spec_with_exposure( + ToolSpec::new( + "write", + "Write a file.", + JsonSchema::object(Default::default(), None, None), + ), + ToolExposure::Direct, + ); + let registry = Arc::new(builder.build()); + let runtime = ToolRuntime::new_without_permissions(Arc::clone(®istry)); + let model = Model { + channel: channel.map(str::to_string), + ..Model::default() + }; + let mut session = SessionState::new(SessionConfig::default(), std::env::temp_dir()); + session.push_message(Message::user("hello")); + + query( + &mut session, + &TurnConfig::new(model, None), + provider, + registry, + &runtime, + None, + QueryOptions::default(), + ) + .await + .expect("query should succeed"); + + let captured = requests.lock().expect("lock requests"); + assert_eq!(captured.len(), 1); + captured[0] + .tools + .as_ref() + .expect("tools should be present") + .iter() + .map(|tool| tool.name.clone()) + .collect() + } + + assert_eq!( + tool_names_for_channel(Some("OpenAI")).await, + vec!["apply_patch".to_string(), "write".to_string()] + ); + assert_eq!( + tool_names_for_channel(Some("Poolside")).await, + vec!["write".to_string()] + ); + assert_eq!( + tool_names_for_channel(/*channel*/ None).await, + vec!["write".to_string()] + ); +} + #[test] fn subagent_reminder_insertion_preserves_tool_result_adjacency() { let mut messages = vec![ diff --git a/crates/protocol/src/model.rs b/crates/protocol/src/model.rs index 804d7763..6046cac3 100644 --- a/crates/protocol/src/model.rs +++ b/crates/protocol/src/model.rs @@ -300,6 +300,15 @@ impl Model { self.provider } + /// Whether this model should receive the `apply_patch` tool in requests. + /// + /// Only OpenAI-channel models get `apply_patch`. Other models tend to misuse + /// the patch dialect and produce frequent format errors; they should use + /// `edit` / `write` instead. + pub fn supports_apply_patch(&self) -> bool { + self.channel.as_deref() == Some("OpenAI") + } + pub fn reasoning_effort_options(&self) -> Vec { match &self.reasoning_capability { ReasoningCapability::Levels(levels) => levels @@ -693,6 +702,20 @@ mod tests { assert_eq!(resolved.slug, "test"); } + #[test] + fn supports_apply_patch_only_for_openai_channel() { + let mut openai = model("gpt-5.5"); + openai.channel = Some("OpenAI".into()); + assert_eq!(openai.supports_apply_patch(), true); + + let mut poolside = model("laguna-s-2.1"); + poolside.channel = Some("Poolside".into()); + assert_eq!(poolside.supports_apply_patch(), false); + + let unset = model("custom"); + assert_eq!(unset.supports_apply_patch(), false); + } + #[test] fn provider_wire_api_as_str_returns_canonical_values() { for (wire_api, expected) in [ diff --git a/crates/protocol/src/slash_command.rs b/crates/protocol/src/slash_command.rs index d2987da1..83039625 100644 --- a/crates/protocol/src/slash_command.rs +++ b/crates/protocol/src/slash_command.rs @@ -13,6 +13,8 @@ pub enum SlashCommand { Compact, Resume, New, + Rename, + Delete, Status, Permissions, ShowReasoning, @@ -33,6 +35,8 @@ impl SlashCommand { SlashCommand::Compact => "compact the current session context", SlashCommand::Resume => "resume a saved chat", SlashCommand::New => "start a new chat", + SlashCommand::Rename => "rename the current session", + SlashCommand::Delete => "delete the current session and start a new one", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Permissions => { "choose what Devo is allowed to do (also sets the OS sandbox)" @@ -59,6 +63,8 @@ impl SlashCommand { SlashCommand::Compact => "compact", SlashCommand::Resume => "resume", SlashCommand::New => "new", + SlashCommand::Rename => "rename", + SlashCommand::Delete => "delete", SlashCommand::Status => "status", SlashCommand::Permissions => "permissions", SlashCommand::ShowReasoning => "show-reasoning", @@ -73,7 +79,7 @@ impl SlashCommand { pub fn supports_inline_args(self) -> bool { matches!( self, - SlashCommand::Model | SlashCommand::Btw | SlashCommand::Goal + SlashCommand::Model | SlashCommand::Btw | SlashCommand::Goal | SlashCommand::Rename ) } @@ -81,6 +87,7 @@ impl SlashCommand { match self { SlashCommand::Btw => Some(""), SlashCommand::Goal => Some(""), + SlashCommand::Rename => Some(""), SlashCommand::Theme | SlashCommand::Model | SlashCommand::Skills @@ -88,6 +95,7 @@ impl SlashCommand { | SlashCommand::Compact | SlashCommand::Resume | SlashCommand::New + | SlashCommand::Delete | SlashCommand::Status | SlashCommand::Permissions | SlashCommand::ShowReasoning @@ -105,6 +113,7 @@ impl SlashCommand { | SlashCommand::Compact | SlashCommand::Diff | SlashCommand::New + | SlashCommand::Delete | SlashCommand::Resume | SlashCommand::Permissions ) @@ -124,6 +133,8 @@ impl SlashCommand { | SlashCommand::Compact | SlashCommand::Resume | SlashCommand::New + | SlashCommand::Rename + | SlashCommand::Delete | SlashCommand::Status | SlashCommand::Permissions | SlashCommand::ShowReasoning @@ -147,6 +158,8 @@ impl FromStr for SlashCommand { "compact" => Ok(Self::Compact), "resume" => Ok(Self::Resume), "new" => Ok(Self::New), + "rename" => Ok(Self::Rename), + "delete" => Ok(Self::Delete), "status" => Ok(Self::Status), "permissions" | "approvals" => Ok(Self::Permissions), "show-reasoning" | "reasoning-view" => Ok(Self::ShowReasoning), @@ -169,6 +182,8 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { ("compact", SlashCommand::Compact), ("resume", SlashCommand::Resume), ("new", SlashCommand::New), + ("rename", SlashCommand::Rename), + ("delete", SlashCommand::Delete), ("status", SlashCommand::Status), ("permissions", SlashCommand::Permissions), ("show-reasoning", SlashCommand::ShowReasoning), @@ -241,6 +256,8 @@ mod tests { assert!(!SlashCommand::Model.available_over_acp()); assert!(!SlashCommand::Btw.available_over_acp()); assert!(!SlashCommand::Exit.available_over_acp()); + assert!(!SlashCommand::Rename.available_over_acp()); + assert!(!SlashCommand::Delete.available_over_acp()); } #[test] @@ -248,7 +265,19 @@ mod tests { assert!(!SlashCommand::Model.available_during_task()); assert!(!SlashCommand::Permissions.available_during_task()); assert!(!SlashCommand::Theme.available_during_task()); + assert!(!SlashCommand::Delete.available_during_task()); assert!(SlashCommand::Status.available_during_task()); assert!(SlashCommand::Goal.available_during_task()); + assert!(SlashCommand::Rename.available_during_task()); + } + + #[test] + fn rename_and_delete_slash_commands_parse_and_describe() { + assert_eq!("rename".parse::(), Ok(SlashCommand::Rename)); + assert_eq!("delete".parse::(), Ok(SlashCommand::Delete)); + assert!(SlashCommand::Rename.supports_inline_args()); + assert!(!SlashCommand::Delete.supports_inline_args()); + assert_eq!(SlashCommand::Rename.parameter_hint(), Some("")); + assert_eq!(SlashCommand::Delete.parameter_hint(), None); } } diff --git a/crates/server/src/runtime/handlers/acp_slash_commands.rs b/crates/server/src/runtime/handlers/acp_slash_commands.rs index d72ad059..28b1fe44 100644 --- a/crates/server/src/runtime/handlers/acp_slash_commands.rs +++ b/crates/server/src/runtime/handlers/acp_slash_commands.rs @@ -105,6 +105,8 @@ impl ServerRuntime { | SlashCommand::Mcp | SlashCommand::Resume | SlashCommand::New + | SlashCommand::Rename + | SlashCommand::Delete | SlashCommand::Status | SlashCommand::Permissions | SlashCommand::ShowReasoning diff --git a/crates/tui/src/app_command.rs b/crates/tui/src/app_command.rs index dc3b112e..0d2d4a54 100644 --- a/crates/tui/src/app_command.rs +++ b/crates/tui/src/app_command.rs @@ -103,6 +103,10 @@ pub(crate) enum AppCommand { SwitchSession { session_id: SessionId, }, + RenameSession { + title: String, + }, + DeleteSession, RollbackToUserTurn { user_turn_index: u32, }, @@ -196,6 +200,10 @@ pub(crate) enum AppCommandView<'a> { SwitchSession { session_id: SessionId, }, + RenameSession { + title: &'a str, + }, + DeleteSession, RollbackToUserTurn { user_turn_index: u32, }, @@ -321,6 +329,14 @@ impl AppCommand { Self::SwitchSession { session_id } } + pub(crate) fn rename_session(title: String) -> Self { + Self::RenameSession { title } + } + + pub(crate) fn delete_session() -> Self { + Self::DeleteSession + } + pub(crate) fn rollback_to_user_turn(user_turn_index: u32) -> Self { Self::RollbackToUserTurn { user_turn_index } } @@ -351,6 +367,8 @@ impl AppCommand { Self::UpdateSandboxProfile { .. } => "update_sandbox_profile", Self::BrowseInputHistory { .. } => "browse_input_history", Self::SwitchSession { .. } => "switch_session", + Self::RenameSession { .. } => "rename_session", + Self::DeleteSession => "delete_session", Self::RollbackToUserTurn { .. } => "rollback_to_user_turn", Self::ForkAtUserTurn { .. } => "fork_at_user_turn", } @@ -439,6 +457,8 @@ impl AppCommand { Self::SwitchSession { session_id } => AppCommandView::SwitchSession { session_id: *session_id, }, + Self::RenameSession { title } => AppCommandView::RenameSession { title }, + Self::DeleteSession => AppCommandView::DeleteSession, Self::RollbackToUserTurn { user_turn_index } => AppCommandView::ThreadRollback { num_turns: *user_turn_index, }, diff --git a/crates/tui/src/app_event.rs b/crates/tui/src/app_event.rs index 643d07c8..5781da44 100644 --- a/crates/tui/src/app_event.rs +++ b/crates/tui/src/app_event.rs @@ -80,9 +80,6 @@ pub(crate) enum AppEvent { /// Submit the current composer text. SubmitUserInput { text: String }, - /// Focus the composer for feedback on a proposed plan. - PreparePlanSuggestionInput, - /// Send a command request to the host/worker adapter. Command(AppCommand), diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index a5236be7..c3f70e2f 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -35,6 +35,7 @@ mod paste_burst; mod pending_thread_approvals; pub(crate) mod popup_consts; mod prompt_args; +mod proposed_plan_actions_view; mod reference_popup; mod request_user_input_overlay; pub(crate) mod scroll_state; @@ -51,6 +52,7 @@ use chat_composer::ChatComposerConfig; use chat_composer::InputResult as ComposerInputResult; pub(crate) use custom_prompt_view::CustomPromptView; pub(crate) use input_mode::InputMode; +pub(crate) use proposed_plan_actions_view::ProposedPlanActionsView; use crate::app_command::AppCommand; use crate::app_command::InputHistoryDirection; diff --git a/crates/tui/src/bottom_pane/proposed_plan_actions_view.rs b/crates/tui/src/bottom_pane/proposed_plan_actions_view.rs new file mode 100644 index 00000000..b59bab79 --- /dev/null +++ b/crates/tui/src/bottom_pane/proposed_plan_actions_view.rs @@ -0,0 +1,406 @@ +//! Proposed Plan action menu: implement the plan or type inline revise feedback. +//! +//! Shown after a Proposed Plan completes. Option 1 submits a Build turn; option 2 +//! is an inline input — when highlighted, the user types feedback and presses +//! Enter to submit a Plan-mode revise turn. + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthStr; + +use crate::app_event_sender::AppEventSender; +use crate::render::renderable::Renderable; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::standard_popup_hint_line; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; + +const IMPLEMENT_NAME: &str = "Implement Plan"; +const IMPLEMENT_DESCRIPTION: &str = "Switch to Build mode and start implementing this plan."; +const REVISE_NAME: &str = "Revise Plan"; +const REVISE_PLACEHOLDER: &str = "Type feedback and press Enter"; +const REVISE_DESCRIPTION: &str = "Keep Plan mode and revise this plan."; + +/// Callback invoked when the user chooses Implement Plan. +pub(crate) type ImplementAction = Box; + +/// Callback invoked when the user submits non-empty revise feedback. +pub(crate) type ReviseAction = Box; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SelectedRow { + Implement, + Revise, +} + +/// Bottom-pane popup for Proposed Plan follow-up actions. +pub(crate) struct ProposedPlanActionsView { + app_event_tx: AppEventSender, + accent_color: Color, + selected: SelectedRow, + revise_text: String, + on_implement: ImplementAction, + on_revise: ReviseAction, + complete: bool, +} + +impl ProposedPlanActionsView { + pub(crate) fn new( + app_event_tx: AppEventSender, + accent_color: Color, + on_implement: ImplementAction, + on_revise: ReviseAction, + ) -> Self { + Self { + app_event_tx, + accent_color, + selected: SelectedRow::Implement, + revise_text: String::new(), + on_implement, + on_revise, + complete: false, + } + } + + fn move_up(&mut self) { + self.selected = SelectedRow::Implement; + } + + fn move_down(&mut self) { + self.selected = SelectedRow::Revise; + } + + fn accept(&mut self) { + match self.selected { + SelectedRow::Implement => { + (self.on_implement)(&self.app_event_tx); + self.complete = true; + } + SelectedRow::Revise => { + let text = self.revise_text.trim().to_string(); + if text.is_empty() { + return; + } + (self.on_revise)(&self.app_event_tx, text); + self.complete = true; + } + } + } + + fn append_revise_char(&mut self, c: char) { + if !c.is_control() { + self.revise_text.push(c); + } + } + + fn revise_row_label(&self, selected: bool) -> (String, bool) { + if selected { + if self.revise_text.is_empty() { + (REVISE_PLACEHOLDER.to_string(), /*is_placeholder*/ true) + } else { + (self.revise_text.clone(), /*is_placeholder*/ false) + } + } else { + (REVISE_NAME.to_string(), /*is_placeholder*/ false) + } + } +} + +impl BottomPaneView for ProposedPlanActionsView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{0010}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::NONE, + .. + } if self.selected == SelectedRow::Implement => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } + | KeyEvent { + code: KeyCode::Char('\u{000e}'), + modifiers: KeyModifiers::NONE, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::NONE, + .. + } if self.selected == SelectedRow::Implement => self.move_down(), + KeyEvent { + code: KeyCode::Backspace, + .. + } if self.selected == SelectedRow::Revise => { + self.revise_text.pop(); + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + self.on_ctrl_c(); + } + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => self.accept(), + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if self.selected == SelectedRow::Revise + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + self.append_revise_char(c); + } + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if self.selected == SelectedRow::Implement + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + match c { + '1' => { + self.selected = SelectedRow::Implement; + self.accept(); + } + '2' => { + self.selected = SelectedRow::Revise; + } + _ => {} + } + } + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn handle_paste(&mut self, pasted: String) -> bool { + if self.selected != SelectedRow::Revise || pasted.is_empty() { + return false; + } + let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); + if cleaned.is_empty() { + return false; + } + self.revise_text.push_str(&cleaned); + true + } + + fn selected_index(&self) -> Option { + Some(match self.selected { + SelectedRow::Implement => 0, + SelectedRow::Revise => 1, + }) + } +} + +impl Renderable for ProposedPlanActionsView { + fn desired_height(&self, _width: u16) -> u16 { + // title + subtitle + gap + implement (2) + revise (1 or 2) + gap before footer + footer + let revise_lines: u16 = if self.selected == SelectedRow::Revise { + 1 + } else { + 2 + }; + menu_surface_padding_height() + .saturating_add(2) // title + subtitle + .saturating_add(1) // header/list gap + .saturating_add(2) // implement name + description + .saturating_add(revise_lines) + .saturating_add(1) // gap before footer + .saturating_add(1) // footer hint + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let content_area = render_menu_surface(content_area, buf); + + let revise_lines: u16 = if self.selected == SelectedRow::Revise { + 1 + } else { + 2 + }; + let [title_area, subtitle_area, _, implement_area, revise_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(revise_lines), + ]) + .areas(content_area); + + Paragraph::new(Line::from("Proposed Plan".bold())).render(title_area, buf); + Paragraph::new(Line::from("Choose how to continue.".dim())).render(subtitle_area, buf); + + render_static_row( + implement_area, + buf, + /*index*/ 1, + IMPLEMENT_NAME, + Some(IMPLEMENT_DESCRIPTION), + self.selected == SelectedRow::Implement, + /*is_placeholder*/ false, + self.accent_color, + ); + + let (revise_label, is_placeholder) = + self.revise_row_label(self.selected == SelectedRow::Revise); + let revise_description = + (self.selected != SelectedRow::Revise).then_some(REVISE_DESCRIPTION); + render_static_row( + revise_area, + buf, + /*index*/ 2, + &revise_label, + revise_description, + self.selected == SelectedRow::Revise, + is_placeholder, + self.accent_color, + ); + + if footer_area.height > 0 { + Paragraph::new(standard_popup_hint_line()).render(footer_area, buf); + } + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + if self.selected != SelectedRow::Revise || area.height == 0 || area.width == 0 { + return None; + } + + let [content_area, _] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let content_area = super::selection_popup_common::menu_surface_inset(content_area); + let revise_lines: u16 = 1; + let [_, _, _, _, revise_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(revise_lines), + ]) + .areas(content_area); + + let prefix = "› 2. "; + let text_width = UnicodeWidthStr::width(self.revise_text.as_str()) as u16; + let x = revise_area + .x + .saturating_add(UnicodeWidthStr::width(prefix) as u16) + .saturating_add(text_width) + .min( + revise_area + .x + .saturating_add(revise_area.width.saturating_sub(1)), + ); + Some((x, revise_area.y)) + } +} + +#[allow(clippy::too_many_arguments)] +fn render_static_row( + area: Rect, + buf: &mut Buffer, + index: usize, + name: &str, + description: Option<&str>, + selected: bool, + is_placeholder: bool, + accent_color: Color, +) { + if area.height == 0 || area.width == 0 { + return; + } + + let marker = if selected { '›' } else { ' ' }; + let marker_style = if selected { + Style::default().fg(accent_color).bold() + } else { + Style::default() + }; + let name_style = if is_placeholder { + Style::default().dim() + } else if selected { + Style::default().bold() + } else { + Style::default() + }; + + let name_line = Line::from(vec![ + Span::styled(marker.to_string(), marker_style), + Span::raw(format!(" {index}. ")), + Span::styled(name.to_string(), name_style), + ]); + Paragraph::new(name_line).render( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: 1, + }, + buf, + ); + + if let Some(description) = description + && area.height > 1 + { + let indent = " "; + Paragraph::new(Line::from(format!("{indent}{description}").dim())).render( + Rect { + x: area.x, + y: area.y.saturating_add(1), + width: area.width, + height: 1, + }, + buf, + ); + } +} diff --git a/crates/tui/src/chatwidget/input.rs b/crates/tui/src/chatwidget/input.rs index 247f7bee..00dfb2b7 100644 --- a/crates/tui/src/chatwidget/input.rs +++ b/crates/tui/src/chatwidget/input.rs @@ -235,10 +235,6 @@ impl ChatWidget { match event { AppEvent::Redraw => self.frame_requester.schedule_frame(), AppEvent::SubmitUserInput { text } => self.submit_text(text), - AppEvent::PreparePlanSuggestionInput => { - self.bottom_pane.set_input_mode(InputMode::Plan); - self.set_status_message("Type plan feedback"); - } AppEvent::ModelSelected { model } => { self.handle_model_picker_selection(model); } diff --git a/crates/tui/src/chatwidget/session_history.rs b/crates/tui/src/chatwidget/session_history.rs index ac24348e..7ff7ab76 100644 --- a/crates/tui/src/chatwidget/session_history.rs +++ b/crates/tui/src/chatwidget/session_history.rs @@ -17,10 +17,7 @@ use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::InputMode; -use crate::bottom_pane::list_selection_view::ListSelectionView; -use crate::bottom_pane::list_selection_view::SelectionItem; -use crate::bottom_pane::list_selection_view::SelectionViewParams; -use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::bottom_pane::ProposedPlanActionsView; use crate::events::PlanStep; use crate::events::PlanStepStatus; use crate::events::TranscriptItem; @@ -169,53 +166,49 @@ impl ChatWidget { } self.pending_proposed_plan_actions = false; - let cwd = Some(self.session.cwd.clone()); - let model = self.user_turn_model(); - let model_binding_id = self.user_turn_model_binding_id(); - let reasoning_effort_selection = self.reasoning_effort_selection.clone(); - let implement_item = SelectionItem { - name: "Implement Plan".to_string(), - description: Some("Switch to Build mode and start implementing this plan.".to_string()), - actions: vec![Box::new(move |tx: &AppEventSender| { - tx.send(AppEvent::Command( - AppCommand::user_turn_with_collaboration_mode( - vec![InputItem::Text { - text: "Implement Plan".to_string(), - }], - cwd.clone(), - model.clone(), - model_binding_id.clone(), - reasoning_effort_selection.clone(), - /*sandbox*/ None, - Some("on-request".to_string()), - CollaborationMode::Build, - ), - )); - })], - dismiss_on_select: true, - ..SelectionItem::default() - }; - let revise_item = SelectionItem { - name: "修改建议".to_string(), - description: Some("Keep Plan mode and type feedback in the input.".to_string()), - actions: vec![Box::new(|tx: &AppEventSender| { - tx.send(AppEvent::PreparePlanSuggestionInput); - })], - dismiss_on_select: true, - ..SelectionItem::default() - }; + let implement_cwd = Some(self.session.cwd.clone()); + let implement_model = self.user_turn_model(); + let implement_model_binding_id = self.user_turn_model_binding_id(); + let implement_reasoning = self.reasoning_effort_selection.clone(); + let revise_cwd = Some(self.session.cwd.clone()); + let revise_model = self.user_turn_model(); + let revise_model_binding_id = self.user_turn_model_binding_id(); + let revise_reasoning = self.reasoning_effort_selection.clone(); self.bottom_pane - .open_popup_view(Box::new(ListSelectionView::new( - SelectionViewParams { - title: Some("Proposed Plan".to_string()), - subtitle: Some("Choose how to continue.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items: vec![implement_item, revise_item], - ..SelectionViewParams::default() - }, + .open_popup_view(Box::new(ProposedPlanActionsView::new( self.app_event_tx.clone(), self.active_accent_color(), + Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::Command( + AppCommand::user_turn_with_collaboration_mode( + vec![InputItem::Text { + text: "Implement Plan".to_string(), + }], + implement_cwd.clone(), + implement_model.clone(), + implement_model_binding_id.clone(), + implement_reasoning.clone(), + /*sandbox*/ None, + Some("on-request".to_string()), + CollaborationMode::Build, + ), + )); + }), + Box::new(move |tx: &AppEventSender, text: String| { + tx.send(AppEvent::Command( + AppCommand::user_turn_with_collaboration_mode( + vec![InputItem::Text { text }], + revise_cwd.clone(), + revise_model.clone(), + revise_model_binding_id.clone(), + revise_reasoning.clone(), + /*sandbox*/ None, + Some("on-request".to_string()), + CollaborationMode::Plan, + ), + )); + }), ))); self.set_status_message("Choose plan action"); } diff --git a/crates/tui/src/chatwidget/slash_commands.rs b/crates/tui/src/chatwidget/slash_commands.rs index 96fb0228..f81318b5 100644 --- a/crates/tui/src/chatwidget/slash_commands.rs +++ b/crates/tui/src/chatwidget/slash_commands.rs @@ -30,6 +30,7 @@ impl ChatWidget { SlashCommand::Theme => "theme", SlashCommand::Compact => "session", SlashCommand::New => "session", + SlashCommand::Delete => "session", SlashCommand::Resume => "session", SlashCommand::Permissions => "permissions", SlashCommand::Diff => "diff", @@ -40,6 +41,7 @@ impl ChatWidget { | SlashCommand::Status | SlashCommand::Clear | SlashCommand::ShowReasoning + | SlashCommand::Rename | SlashCommand::Btw => { return; } @@ -154,6 +156,27 @@ impl ChatWidget { })); self.set_status_message("New session requested"); } + SlashCommand::Rename => { + let trimmed = argument.trim(); + if trimmed.is_empty() { + self.add_to_history(history_cell::new_info_event( + "Usage: /rename ".to_string(), + None, + )); + self.set_status_message("Usage: /rename "); + return; + } + self.app_event_tx + .send(AppEvent::Command(AppCommand::rename_session( + trimmed.to_string(), + ))); + self.set_status_message("Renaming session"); + } + SlashCommand::Delete => { + self.app_event_tx + .send(AppEvent::Command(AppCommand::delete_session())); + self.set_status_message("Deleting session"); + } SlashCommand::Resume => { self.resume_browser = None; self.resume_browser_loading = true; diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index 38ae0b42..17f61798 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -1370,6 +1370,13 @@ impl ChatWidget { )); self.set_status_message("Session renamed"); } + WorkerEvent::SessionDeleted { session_id } => { + self.add_to_history(history_cell::new_info_event( + format!("deleted session {session_id}"), + None, + )); + self.set_status_message("Session deleted"); + } WorkerEvent::SessionCompactionStarted => { if self.status_message != "Session compaction in progress" { self.add_to_history(history_cell::new_live_aligned_info_event( diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index c1c9f1d6..396afed0 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -2010,6 +2010,69 @@ fn goal_slash_command_emits_set_goal_objective() { ); } +#[test] +fn rename_slash_command_emits_rename_session() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.handle_paste("/rename My New Title".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app_event_rx.try_recv().expect("rename command event"), + AppEvent::Command(AppCommand::RenameSession { + title: "My New Title".to_string(), + }) + ); +} + +#[test] +fn rename_slash_command_without_title_shows_usage() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.handle_paste("/rename".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(app_event_rx.try_recv().is_err()); + let rendered = widget + .transcript_overlay_lines(100) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /rename "), + "expected rename usage hint:\n{rendered}" + ); +} + +#[test] +fn delete_slash_command_emits_delete_session() { + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, mut app_event_rx) = widget_with_model(model, PathBuf::from(".")); + + widget.handle_paste("/delete".to_string()); + widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + app_event_rx.try_recv().expect("delete command event"), + AppEvent::Command(AppCommand::DeleteSession) + ); +} + #[test] fn goal_control_slash_commands_emit_goal_app_commands() { fn event_for_slash(input: &str) -> AppEvent { @@ -2604,7 +2667,7 @@ fn proposed_plan_cell_header_has_actions_without_bullet() { assert!(lines.first().is_some_and(|line| line == "Proposed Plan")); assert!(!rendered.contains("• Proposed Plan")); assert!(rendered.contains("Implement Plan")); - assert!(rendered.contains("修改建议")); + assert!(rendered.contains("Revise Plan")); } #[test] @@ -2624,11 +2687,6 @@ fn proposed_plan_implement_action_sends_build_turn() { item_id: plan_id, final_text: "## Summary\n\nBuild the feature.".to_string(), }); - widget.handle_app_event(AppEvent::PreparePlanSuggestionInput); - assert_eq!( - widget.input_mode_for_test(), - crate::bottom_pane::InputMode::Plan - ); widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let event = app_event_rx.try_recv().expect("implement event is emitted"); @@ -2657,14 +2715,14 @@ fn proposed_plan_implement_action_sends_build_turn() { } #[test] -fn proposed_plan_revise_action_switches_composer_to_plan_mode() { +fn proposed_plan_revise_action_submits_plan_turn_with_feedback() { let cwd = std::env::current_dir().expect("current directory is available"); let model = Model { slug: "test-model".to_string(), display_name: "Test Model".to_string(), ..Model::default() }; - let (mut widget, mut app_event_rx) = widget_with_model(model, cwd); + let (mut widget, mut app_event_rx) = widget_with_model(model, cwd.clone()); let plan_id = ItemId::new(); widget @@ -2674,15 +2732,34 @@ fn proposed_plan_revise_action_switches_composer_to_plan_mode() { final_text: "## Summary\n\nBuild the feature.".to_string(), }); widget.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + for ch in "Skip migrations".chars() { + widget.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } widget.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let event = app_event_rx.try_recv().expect("revise event is emitted"); - assert_eq!(event, AppEvent::PreparePlanSuggestionInput); - widget.handle_app_event(event); + widget.handle_app_event(event.clone()); assert_eq!( widget.input_mode_for_test(), crate::bottom_pane::InputMode::Plan ); + let AppEvent::Command(AppCommand::UserTurn { + input, + cwd: event_cwd, + collaboration_mode, + .. + }) = event + else { + panic!("expected plan user turn"); + }; + assert_eq!(event_cwd, Some(cwd)); + assert_eq!(collaboration_mode, devo_protocol::CollaborationMode::Plan); + assert_eq!( + input, + vec![InputItem::Text { + text: "Skip migrations".to_string(), + }] + ); assert!(app_event_rx.try_recv().is_err()); } diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index a43fd38a..29719528 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -595,6 +595,11 @@ pub(crate) enum WorkerEvent { /// The new session title. title: String, }, + /// The current session was deleted. + SessionDeleted { + /// The deleted session identifier. + session_id: String, + }, /// The active session or its context-compaction transcript item started compaction. SessionCompactionStarted, /// The active session completed a proactive compaction request. diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index 9ecfaa47..3581375c 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -655,7 +655,7 @@ impl HistoryCell for ProposedPlanCell { Span::styled(" ", style), Span::styled("Implement Plan", style).bold(), Span::styled(" | ", style.dim()), - Span::styled("修改建议", style).bold(), + Span::styled("Revise Plan", style).bold(), ]) .style(style), ); diff --git a/crates/tui/src/interactive.rs b/crates/tui/src/interactive.rs index 5457de78..235e8751 100644 --- a/crates/tui/src/interactive.rs +++ b/crates/tui/src/interactive.rs @@ -959,6 +959,7 @@ fn handle_worker_event( | WorkerEvent::ReferenceSearchUpdated { .. } | WorkerEvent::NewSessionPrepared { .. } | WorkerEvent::SessionRenamed { .. } + | WorkerEvent::SessionDeleted { .. } | WorkerEvent::SessionTitleUpdated { .. } | WorkerEvent::ContextCompactionCompleted { .. } | WorkerEvent::InputHistoryLoaded { .. } @@ -1212,6 +1213,12 @@ fn handle_app_command( AppCommand::Compact => { worker.compact_session()?; } + AppCommand::RenameSession { title } => { + worker.rename_session(title.clone())?; + } + AppCommand::DeleteSession => { + worker.delete_session()?; + } AppCommand::ShowGoal => { worker.show_goal()?; } diff --git a/crates/tui/src/onboarding_widget.rs b/crates/tui/src/onboarding_widget.rs index b933c5ea..f1a7c44a 100644 --- a/crates/tui/src/onboarding_widget.rs +++ b/crates/tui/src/onboarding_widget.rs @@ -32,7 +32,7 @@ use devo_protocol::Model; use devo_protocol::ProviderModelBinding; use devo_protocol::ProviderVendor; use devo_protocol::ProviderWireApi; -use devo_protocol::ReasoningEffortPreset; +use devo_protocol::ReasoningEffortOption; use crate::app_command::AppCommand; use crate::app_event::AppEvent; @@ -583,25 +583,24 @@ impl OnboardingWidget { fn reasoning_effort_items(&self, slug: &str) -> Vec { self.model_by_slug(slug) - .map(Model::reasoning_effort_options) + .map(|model| model.effective_reasoning_capability().options()) .unwrap_or_default() .into_iter() .map(Self::reasoning_effort_item) .collect() } - fn reasoning_effort_item(preset: ReasoningEffortPreset) -> ReasoningEffortItem { + fn reasoning_effort_item(option: ReasoningEffortOption) -> ReasoningEffortItem { ReasoningEffortItem { - label: preset.effort.label().to_string(), - value: preset.effort.label().to_ascii_lowercase(), - description: preset.description, + label: option.label, + value: option.value, + description: option.description, } } fn default_reasoning_effort_index(&self, slug: &str, items: &[ReasoningEffortItem]) -> usize { self.model_by_slug(slug) - .and_then(|model| model.default_reasoning_effort) - .map(|effort| effort.label().to_ascii_lowercase()) + .and_then(Model::default_reasoning_effort_selection) .and_then(|value| items.iter().position(|item| item.value == value)) .unwrap_or(0) } diff --git a/crates/tui/src/onboarding_widget_tests.rs b/crates/tui/src/onboarding_widget_tests.rs index 42819502..c86e0993 100644 --- a/crates/tui/src/onboarding_widget_tests.rs +++ b/crates/tui/src/onboarding_widget_tests.rs @@ -96,6 +96,29 @@ fn deepseek_model() -> Model { .into() } +fn toggle_only_model() -> Model { + devo_core::ModelPreset { + slug: "laguna-s-2.1".to_string(), + display_name: "laguna-s-2.1".to_string(), + reasoning_capability: ReasoningCapability::Toggle, + supported_reasoning_levels: Vec::new(), + default_reasoning_effort: Some(ReasoningEffort::Medium), + ..devo_core::ModelPreset::default() + } + .into() +} + +fn toggle_only_provider_vendor() -> ProviderVendor { + ProviderVendor { + name: "Poolside".to_string(), + base_url: Some("https://api.poolside.ai".to_string()), + credential: Some("poolside_api_key".to_string()), + headers: None, + wire_apis: vec![ProviderWireApi::OpenAIChatCompletions], + enabled: true, + } +} + fn deepseek_provider_vendor() -> ProviderVendor { ProviderVendor { name: "Deepseek".to_string(), @@ -521,6 +544,7 @@ fn onboarding_invocation_and_reasoning_popups_render_inline_and_use_model_preset let reasoning_view = rendered_rows(&widget, 160, 60).join("\n"); assert_eq!(reasoning_view.contains("Reason Effort: High"), true); + assert_eq!(reasoning_view.contains(" Off"), true); assert_eq!(reasoning_view.contains("> High"), true); assert_eq!(reasoning_view.contains(" Max"), true); assert_eq!(reasoning_view.contains("Medium"), false); @@ -545,6 +569,48 @@ fn onboarding_invocation_and_reasoning_popups_render_inline_and_use_model_preset assert_eq!(payload["api_key"], serde_json::Value::Null); } +#[test] +fn onboarding_toggle_model_reasoning_popup_shows_off_and_on() { + let models = vec![toggle_only_model()]; + let (app_event_tx, mut app_event_rx) = mpsc::unbounded_channel(); + let mut widget = OnboardingWidget::new( + &models, + AppEventSender::new(app_event_tx), + FrameRequester::test_dummy(), + true, + ); + assert_eq!( + next_shell_command(&mut app_event_rx), + "provider list".to_string() + ); + + widget.on_provider_vendors_listed(vec![toggle_only_provider_vendor()]); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + widget.handle_key_event(press(KeyCode::Enter)); + + let reasoning_view = rendered_rows(&widget, 160, 60).join("\n"); + assert_eq!(reasoning_view.contains("Reason Effort: On"), true); + assert_eq!(reasoning_view.contains(" Off"), true); + assert_eq!(reasoning_view.contains("> On"), true); + assert_eq!(reasoning_view.contains("Medium"), false); + + widget.handle_key_event(press(KeyCode::Enter)); + + let command = next_shell_command(&mut app_event_rx); + let payload = command + .strip_prefix("onboard ") + .expect("onboard command prefix"); + let payload: serde_json::Value = serde_json::from_str(payload).expect("valid onboarding json"); + + assert_eq!( + payload["default_reasoning_effort"], + serde_json::Value::String("enabled".to_string()) + ); +} + #[test] fn onboarding_invocation_popup_keeps_active_section_visible_when_short() { let widget = widget_at_invocation_method_popup(); diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index f4d18e98..d3325b98 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -48,6 +48,7 @@ use devo_protocol::SpawnAgentParams; use devo_protocol::ThreadGoalStatus; use devo_protocol::TurnFailedPayload; use devo_server::ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD; +use devo_server::AcpDeleteSessionParams; use devo_server::ApprovalDecisionPayload; use devo_server::ApprovalRequestPayload; use devo_server::ApprovalResponseParams; @@ -331,6 +332,8 @@ enum OperationCommand { SwitchSession(SessionId), /// Rename the current active session. RenameSession(String), + /// Delete the current active session and prepare a fresh local session. + DeleteSession, /// Roll back the active session using the server-selected user-turn cut mode. RollbackUserTurn { user_turn_index: u32, @@ -670,6 +673,13 @@ impl QueryWorkerHandle { .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) } + /// Deletes the current active session and prepares a fresh local session. + pub(crate) fn delete_session(&self) -> Result<()> { + self.command_tx + .send(OperationCommand::DeleteSession) + .map_err(|_| anyhow::anyhow!("interactive worker is no longer running")) + } + pub(crate) fn rollback_to_user_turn(&self, user_turn_index: u32) -> Result<()> { self.command_tx .send(OperationCommand::RollbackUserTurn { @@ -1770,6 +1780,88 @@ async fn run_worker_inner( } } } + Some(OperationCommand::DeleteSession) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + match pause_active_goal_before_session_leave( + &mut client, + active_session_id, + active_turn_id, + ) + .await + { + Ok(()) => {} + Err(error) => { + emit_goal_leave_failure(event_tx, error); + continue; + } + } + match client + .session_delete(AcpDeleteSessionParams { + session_id: active_session_id, + meta: None, + }) + .await + { + Ok(_) => { + let _ = event_tx.send(WorkerEvent::SessionDeleted { + session_id: active_session_id.to_string(), + }); + active_turn_id = None; + session_id = None; + active_reference_search_id = None; + session_cwd = config.cwd.clone(); + input_history_cursor = None; + turn_count = 0; + total_input_tokens = 0; + total_output_tokens = 0; + total_tokens = 0; + total_cache_read_tokens = 0; + last_query_total_tokens = 0; + last_query_input_tokens = 0; + has_authoritative_usage_totals = true; + let _ = event_tx.send(WorkerEvent::NewSessionPrepared { + cwd: session_cwd.clone(), + model: model.clone(), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), + reasoning_effort: None, + active_agent_label: None, + last_query_total_tokens, + last_query_input_tokens, + total_cache_read_tokens, + }); + let _ = + emit_skills_list(&mut client, &session_cwd, event_tx, false) + .await; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } Some(OperationCommand::RollbackUserTurn { user_turn_index, mode, From c4f7d7e0c3b07be3d4a0add5f1871fda978d62bd Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Thu, 30 Jul 2026 12:57:01 +0800 Subject: [PATCH 17/17] fix(tui): restore Plan mode and decision UI on session resume --- crates/client/src/client_core.rs | 2 + crates/client/src/stdio.rs | 3 + crates/protocol/src/acp_session.rs | 1 + crates/protocol/src/session.rs | 23 + crates/server/src/db.rs | 2 + crates/server/src/persistence.rs | 17 +- crates/server/src/projection.rs | 41 +- crates/server/src/runtime/agents.rs | 1 + crates/server/src/runtime/handlers/session.rs | 2 + .../server/src/runtime/turn_exec/finalize.rs | 8 +- crates/server/src/runtime/turn_exec/query.rs | 1 + crates/server/tests/persistence_resume.rs | 99 +- crates/server/tests/protocol_contract.rs | 2 + .../tests/provider_failure_reporting.rs | 4 +- crates/tui/src/bottom_pane/command_popup.rs | 2 + crates/tui/src/bottom_pane/mod.rs | 5 + crates/tui/src/chatwidget.rs | 5 + crates/tui/src/chatwidget/restored_session.rs | 23 +- crates/tui/src/chatwidget/worker_events.rs | 33 +- crates/tui/src/chatwidget_tests.rs | 221 +- crates/tui/src/events.rs | 3 + crates/tui/src/history_cell.rs | 21 +- crates/tui/src/style.rs | 16 - crates/tui/src/worker.rs | 3645 +++++++++-------- 24 files changed, 2303 insertions(+), 1877 deletions(-) diff --git a/crates/client/src/client_core.rs b/crates/client/src/client_core.rs index 4fcbe5c5..9732ba61 100644 --- a/crates/client/src/client_core.rs +++ b/crates/client/src/client_core.rs @@ -1046,6 +1046,7 @@ fn acp_session_metadata_from_start_params( last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), } } @@ -1083,6 +1084,7 @@ fn acp_session_metadata_from_session_info(session_info: &AcpSessionInfo) -> Sess last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), } } diff --git a/crates/client/src/stdio.rs b/crates/client/src/stdio.rs index 32a17b6f..5258b2c5 100644 --- a/crates/client/src/stdio.rs +++ b/crates/client/src/stdio.rs @@ -679,6 +679,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; assert_eq!(session, expected); @@ -770,6 +771,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }] ); @@ -819,6 +821,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; let mut stdout_lines = BufReader::new(stdout).lines(); diff --git a/crates/protocol/src/acp_session.rs b/crates/protocol/src/acp_session.rs index 21099734..3f3266e4 100644 --- a/crates/protocol/src/acp_session.rs +++ b/crates/protocol/src/acp_session.rs @@ -198,6 +198,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; let info = acp_session_info_from_metadata(&session); diff --git a/crates/protocol/src/session.rs b/crates/protocol/src/session.rs index f56861db..e4c6ac7d 100644 --- a/crates/protocol/src/session.rs +++ b/crates/protocol/src/session.rs @@ -16,6 +16,7 @@ use crate::TurnId; use crate::TurnUsage; use crate::parse_command::ParsedCommand; use crate::protocol::FileChange; +use crate::turn::CollaborationMode; use crate::turn::TurnMetadata; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -86,6 +87,13 @@ pub struct SessionMetadata { /// instead. pub last_query_total_tokens: usize, pub status: SessionRuntimeStatus, + /// Collaboration mode restored from the latest completed turn context. + /// + /// Defaults to [`CollaborationMode::Build`] for older payloads that omit + /// the field. Resume and session-switch clients use this to rehydrate the + /// composer (Plan vs Build) without waiting for the next turn start. + #[serde(default)] + pub collaboration_mode: CollaborationMode, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] @@ -161,6 +169,19 @@ pub enum SessionHistoryMetadata { explanation: Option, steps: Vec, }, + /// Markdown Proposed Plan from Plan mode (not the `update_plan` checklist). + /// + /// Body text lives on [`SessionHistoryItem::body`]. Resume uses this to + /// rebuild the Proposed Plan cell and reopen Implement/Revise actions. + ProposedPlan, + /// Collaboration mode for a restored turn-summary row (`▣ PLAN · …`). + /// + /// Defaults to [`CollaborationMode::Build`] when older payloads omit the + /// field. Resume uses this so Plan turns do not render as Build. + TurnSummary { + #[serde(default)] + collaboration_mode: CollaborationMode, + }, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)] @@ -406,6 +427,7 @@ mod tests { }), last_query_total_tokens: 21, status: SessionRuntimeStatus::Idle, + collaboration_mode: CollaborationMode::Plan, }; let json = serde_json::to_string(&metadata).expect("serialize"); @@ -443,6 +465,7 @@ mod tests { assert_eq!(restored.last_query_usage, None); assert_eq!(restored.last_query_total_tokens, 30); assert_eq!(restored.total_input_tokens, 100); + assert_eq!(restored.collaboration_mode, CollaborationMode::Build); } #[test] diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs index 383617dd..423c16e6 100644 --- a/crates/server/src/db.rs +++ b/crates/server/src/db.rs @@ -1131,6 +1131,7 @@ fn parse_session_metadata_row( last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }) } @@ -1415,6 +1416,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), } } diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index f316dcef..1330ac38 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -1307,6 +1307,9 @@ impl ReplayState { core_session.latest_turn_context = self .latest_turn_context .or_else(|| record.latest_turn_context.clone()); + if let Some(latest_turn_context) = core_session.latest_turn_context.as_ref() { + core_session.collaboration_mode = latest_turn_context.collaboration_mode; + } core_session.turn_count = self.turns_seen as usize; core_session.total_input_tokens = self.total_input_tokens; core_session.total_output_tokens = self.total_output_tokens; @@ -1415,6 +1418,7 @@ impl ReplayState { .map(devo_protocol::TurnUsage::display_total_tokens) .unwrap_or(0), status: SessionRuntimeStatus::Idle, + collaboration_mode: core_session.collaboration_mode, }; let config = core_session.config.clone(); @@ -1748,6 +1752,11 @@ impl ReplayState { intra_record_order += 1; } + let collaboration_mode = turn + .turn_context + .as_ref() + .map(|context| context.collaboration_mode) + .unwrap_or_default(); self.pending_items.push(ReplayHistoryItem { turn_id: turn.id, turn_kind: turn.kind.clone(), @@ -1764,7 +1773,7 @@ impl ReplayState { title: turn.model.clone(), body: outcome.to_string(), tool_io: None, - metadata: None, + metadata: Some(crate::SessionHistoryMetadata::TurnSummary { collaboration_mode }), duration_ms: duration_secs, }), }); @@ -2189,6 +2198,11 @@ pub(crate) fn session_metadata_from_record( last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: record + .latest_turn_context + .as_ref() + .map(|context| context.collaboration_mode) + .unwrap_or_default(), } } @@ -2855,6 +2869,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }, None, ) diff --git a/crates/server/src/projection.rs b/crates/server/src/projection.rs index 170a80d7..6aeeed3c 100644 --- a/crates/server/src/projection.rs +++ b/crates/server/src/projection.rs @@ -164,17 +164,17 @@ pub(crate) fn history_item_from_turn_item(item: &TurnItem) -> Option { - let metadata = parse_plan_history_metadata(text); - let mut item = SessionHistoryItem::new( - None, - SessionHistoryItemKind::Assistant, - String::new(), - text.clone(), - ); - if let Some(metadata) = metadata { - item = item.with_metadata(metadata); - } - Some(item) + let metadata = + parse_plan_history_metadata(text).unwrap_or(SessionHistoryMetadata::ProposedPlan); + Some( + SessionHistoryItem::new( + None, + SessionHistoryItemKind::Assistant, + String::new(), + text.clone(), + ) + .with_metadata(metadata), + ) } TurnItem::ContextCompaction(TextItem { .. }) => None, TurnItem::Reasoning(TextItem { text }) => Some(SessionHistoryItem::new( @@ -457,6 +457,11 @@ impl SessionProjector for DefaultProjection { last_query_usage: None, last_query_total_tokens: 0, status, + collaboration_mode: session + .latest_turn_context + .as_ref() + .map(|context| context.collaboration_mode) + .unwrap_or_default(), } } } @@ -664,6 +669,20 @@ mod tests { assert_eq!(steps[1].status, SessionPlanStepStatus::InProgress); } + #[test] + fn proposed_plan_markdown_emits_proposed_plan_metadata() { + let item = TurnItem::Plan(TextItem { + text: "## Approach\n\n1. Inspect\n2. Patch\n".to_string(), + }); + + let history_item = history_item_from_turn_item(&item).expect("history item"); + assert_eq!( + history_item.metadata, + Some(SessionHistoryMetadata::ProposedPlan) + ); + assert_eq!(history_item.body, "## Approach\n\n1. Inspect\n2. Patch\n"); + } + #[test] fn command_execution_turn_item_emits_explored_metadata() { let item = TurnItem::CommandExecution(CommandExecutionItem { diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index aa6f9a9e..c6b7710c 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -168,6 +168,7 @@ impl ServerRuntime { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; let child_session = RuntimeSession { runtime_context, diff --git a/crates/server/src/runtime/handlers/session.rs b/crates/server/src/runtime/handlers/session.rs index 8434ea63..d41fc969 100644 --- a/crates/server/src/runtime/handlers/session.rs +++ b/crates/server/src/runtime/handlers/session.rs @@ -89,6 +89,7 @@ impl ServerRuntime { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; if let Some(record) = &record && let Err(error) = self.rollout_store.append_session_meta(record) @@ -1002,6 +1003,7 @@ impl ServerRuntime { .map(devo_protocol::TurnUsage::display_total_tokens) .unwrap_or(0), status: SessionRuntimeStatus::Idle, + collaboration_mode: core_session.collaboration_mode, }; drop(source_core_session); diff --git a/crates/server/src/runtime/turn_exec/finalize.rs b/crates/server/src/runtime/turn_exec/finalize.rs index 7a1785b1..9521a901 100644 --- a/crates/server/src/runtime/turn_exec/finalize.rs +++ b/crates/server/src/runtime/turn_exec/finalize.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use chrono::Utc; use devo_core::{SessionId, TurnError, TurnStatus, TurnUsage}; -use devo_protocol::{SessionHistoryItem, SessionHistoryItemKind, TurnFailedPayload}; +use devo_protocol::{ + SessionHistoryItem, SessionHistoryItemKind, SessionHistoryMetadata, TurnFailedPayload, +}; use super::super::ServerRuntime; use super::super::subagent_usage::ParentUsageSnapshot; @@ -473,7 +475,9 @@ fn append_terminal_history_items( title: final_turn.model.clone(), body: outcome.to_string(), tool_io: None, - metadata: None, + metadata: Some(SessionHistoryMetadata::TurnSummary { + collaboration_mode: state.core.collaboration_mode, + }), duration_ms: duration_secs, }); } diff --git a/crates/server/src/runtime/turn_exec/query.rs b/crates/server/src/runtime/turn_exec/query.rs index e30db30c..40126757 100644 --- a/crates/server/src/runtime/turn_exec/query.rs +++ b/crates/server/src/runtime/turn_exec/query.rs @@ -62,6 +62,7 @@ impl ServerRuntime { }; state.core.config.token_budget = turn_config.token_budget(); state.core.collaboration_mode = collaboration_mode; + state.summary.collaboration_mode = collaboration_mode; if let Some(goal) = turn_goal { state.core.set_active_goal(goal); } else { diff --git a/crates/server/tests/persistence_resume.rs b/crates/server/tests/persistence_resume.rs index f98f1102..d315ddd1 100644 --- a/crates/server/tests/persistence_resume.rs +++ b/crates/server/tests/persistence_resume.rs @@ -414,6 +414,100 @@ async fn runtime_rebuilds_sessions_from_rollout_and_resume_works() -> Result<()> Ok(()) } +#[tokio::test] +async fn resume_restores_plan_collaboration_mode_from_latest_turn() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path())?; + let (connection_id, mut notifications_rx) = initialize_connection(&runtime).await?; + + let start_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 1, + "method": "session/start", + "params": { + "cwd": data_root.path(), + "ephemeral": false, + "title": "Plan mode session", + "model": "test-model" + } + }), + ) + .await + .context("session/start response")?; + let session_id = serde_json::from_value::< + devo_server::SuccessResponse, + >(start_response)? + .result + .session + .session_id; + + let turn_start_response = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 2, + "method": "_devo/turn/start", + "params": { + "session_id": session_id, + "input": [{ "type": "text", "text": "draft a plan" }], + "sandbox": null, + "approval_policy": null, + "cwd": null, + "collaboration_mode": "plan" + } + }), + ) + .await + .context("turn/start response")?; + let _: devo_server::SuccessResponse = + serde_json::from_value(turn_start_response)?; + + wait_for_turn_completed(&mut notifications_rx).await?; + + let rebuilt_runtime = build_runtime(data_root.path())?; + rebuilt_runtime.load_persisted_sessions().await?; + let (rebuilt_connection_id, _rebuilt_notifications_rx) = + initialize_connection(&rebuilt_runtime).await?; + + let resume_response = rebuilt_runtime + .handle_incoming( + rebuilt_connection_id, + serde_json::json!({ + "id": 3, + "method": "_devo/session/resume", + "params": { + "session_id": session_id + } + }), + ) + .await + .context("session/resume response")?; + let resume_result = serde_json::from_value::< + devo_server::SuccessResponse, + >(resume_response)? + .result; + + assert_eq!( + resume_result.session.collaboration_mode, + devo_protocol::CollaborationMode::Plan + ); + let turn_summary = resume_result + .history_items + .iter() + .rev() + .find(|item| item.kind == SessionHistoryItemKind::TurnSummary) + .expect("resumed plan turn should include a turn summary"); + assert_eq!( + turn_summary.metadata, + Some(devo_protocol::SessionHistoryMetadata::TurnSummary { + collaboration_mode: devo_protocol::CollaborationMode::Plan, + }) + ); + Ok(()) +} + #[tokio::test] async fn runtime_generates_final_title_and_persists_explicit_rename() -> Result<()> { let data_root = TempDir::new()?; @@ -1066,7 +1160,9 @@ async fn failed_turn_resume_restores_terminal_history_without_prompt_contaminati title: "test-model".to_string(), body: "failed".to_string(), tool_io: None, - metadata: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::TurnSummary { + collaboration_mode: devo_protocol::CollaborationMode::Build, + }), duration_ms: Some(2), }; let terminal_index = resume @@ -2735,5 +2831,6 @@ fn sample_indexed_session( last_query_usage: None, last_query_total_tokens: 0, status: devo_protocol::SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), } } diff --git a/crates/server/tests/protocol_contract.rs b/crates/server/tests/protocol_contract.rs index 1a5f7ef4..12141433 100644 --- a/crates/server/tests/protocol_contract.rs +++ b/crates/server/tests/protocol_contract.rs @@ -365,6 +365,7 @@ fn session_title_updated_event_serializes_expected_kind() { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }, }); @@ -401,6 +402,7 @@ fn session_compaction_events_serialize_expected_kinds() { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; let started = ServerEvent::SessionCompactionStarted(devo_server::SessionEventPayload { diff --git a/crates/server/tests/provider_failure_reporting.rs b/crates/server/tests/provider_failure_reporting.rs index d29a2d4a..25b7e14b 100644 --- a/crates/server/tests/provider_failure_reporting.rs +++ b/crates/server/tests/provider_failure_reporting.rs @@ -285,7 +285,9 @@ async fn exhausted_provider_retries_persist_for_history_but_do_not_enter_context title: failed_turn.model, body: "failed".to_string(), tool_io: None, - metadata: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::TurnSummary { + collaboration_mode: devo_protocol::CollaborationMode::Build, + }), duration_ms: duration_secs, }, ] diff --git a/crates/tui/src/bottom_pane/command_popup.rs b/crates/tui/src/bottom_pane/command_popup.rs index 303cff4d..4f0ba4df 100644 --- a/crates/tui/src/bottom_pane/command_popup.rs +++ b/crates/tui/src/bottom_pane/command_popup.rs @@ -332,6 +332,8 @@ mod tests { "compact", "resume", "new", + "rename", + "delete", "status", "permissions", "show-reasoning", diff --git a/crates/tui/src/bottom_pane/mod.rs b/crates/tui/src/bottom_pane/mod.rs index c3f70e2f..8341a1ff 100644 --- a/crates/tui/src/bottom_pane/mod.rs +++ b/crates/tui/src/bottom_pane/mod.rs @@ -478,6 +478,11 @@ impl BottomPane { self.push_view(view); } + #[cfg(test)] + pub(crate) fn has_view_for_test(&self) -> bool { + !self.view_stack.is_empty() + } + pub(crate) fn open_request_user_input( &mut self, session_id: SessionId, diff --git a/crates/tui/src/chatwidget.rs b/crates/tui/src/chatwidget.rs index 003a36b2..55feca35 100644 --- a/crates/tui/src/chatwidget.rs +++ b/crates/tui/src/chatwidget.rs @@ -375,6 +375,11 @@ impl ChatWidget { .status_widget() .map(crate::status_indicator_widget::StatusIndicatorWidget::header) } + + #[cfg(test)] + pub(crate) fn has_bottom_pane_view_for_test(&self) -> bool { + self.bottom_pane.has_view_for_test() + } } impl ChatWidget { diff --git a/crates/tui/src/chatwidget/restored_session.rs b/crates/tui/src/chatwidget/restored_session.rs index 81eddb4f..82b7531c 100644 --- a/crates/tui/src/chatwidget/restored_session.rs +++ b/crates/tui/src/chatwidget/restored_session.rs @@ -154,6 +154,13 @@ impl ChatWidget { ); true } + SessionHistoryMetadata::ProposedPlan => { + self.add_history_entry_without_redraw(Box::new( + history_cell::new_proposed_plan(item.body.clone(), &self.session.cwd), + )); + true + } + SessionHistoryMetadata::TurnSummary { .. } => false, SessionHistoryMetadata::Edited { changes } => { self.add_restored_file_change_item(item, changes.clone()); true @@ -257,19 +264,20 @@ impl ChatWidget { } } devo_protocol::SessionHistoryItemKind::TurnSummary => { + let input_mode = turn_summary_input_mode(item); let summary = match item.body.as_str() { "failed" => history_cell::TurnSummaryCell::new_failed( - InputMode::Build, + input_mode, item.title.clone(), self.active_accent_color(), ), "interrupted" => history_cell::TurnSummaryCell::new_interrupted( - InputMode::Build, + input_mode, item.title.clone(), self.active_accent_color(), ), _ => history_cell::TurnSummaryCell::new( - InputMode::Build, + input_mode, item.title.clone(), item.duration_ms, self.active_accent_color(), @@ -582,3 +590,12 @@ impl ChatWidget { self.add_history_entry_without_redraw(Box::new(exec)); } } + +fn turn_summary_input_mode(item: &SessionHistoryItem) -> InputMode { + match item.metadata { + Some(SessionHistoryMetadata::TurnSummary { collaboration_mode }) => { + InputMode::from_collaboration_mode(collaboration_mode) + } + _ => InputMode::Build, + } +} diff --git a/crates/tui/src/chatwidget/worker_events.rs b/crates/tui/src/chatwidget/worker_events.rs index 17f61798..4dd1333d 100644 --- a/crates/tui/src/chatwidget/worker_events.rs +++ b/crates/tui/src/chatwidget/worker_events.rs @@ -5,7 +5,11 @@ use std::time::Instant; +use devo_protocol::CollaborationMode; use devo_protocol::ProviderRetryPhase; +use devo_protocol::SessionHistoryItem; +use devo_protocol::SessionHistoryItemKind; +use devo_protocol::SessionHistoryMetadata; use devo_protocol::parse_command::ParsedCommand; use devo_protocol::protocol::ExecCommandSource; use devo_protocol::protocol::FileChange; @@ -1275,6 +1279,7 @@ impl ChatWidget { rich_history_items, loaded_item_count, pending_texts, + collaboration_mode, } => { self.resume_browser_loading = false; self.finish_session_resume(); @@ -1291,7 +1296,11 @@ impl ChatWidget { self.next_history_flush_index = 0; self.active_text_items.clear(); self.committed_server_assistant_in_turn = false; - self.current_turn_mode = InputMode::Build; + self.active_proposed_plan = None; + self.pending_proposed_plan_actions = false; + let restored_mode = InputMode::from_collaboration_mode(collaboration_mode); + self.current_turn_mode = restored_mode; + self.bottom_pane.set_input_mode(restored_mode); self.queued_input_modes.clear(); self.promoted_input_modes.clear(); self.stream_chunking_policy.reset(); @@ -1323,7 +1332,14 @@ impl ChatWidget { self.queued_input_modes.push_back(InputMode::Build); } self.busy = false; - self.set_status_message("Session switched"); + if collaboration_mode == CollaborationMode::Plan + && history_awaits_proposed_plan_decision(&rich_history_items) + { + self.pending_proposed_plan_actions = true; + self.maybe_open_proposed_plan_actions(); + } else { + self.set_status_message("Session switched"); + } } WorkerEvent::GoalStatusLoaded { goal } => { self.show_goal_status(goal); @@ -1474,3 +1490,16 @@ impl ChatWidget { } } } + +fn history_awaits_proposed_plan_decision(items: &[SessionHistoryItem]) -> bool { + for item in items.iter().rev() { + if matches!( + item.kind, + SessionHistoryItemKind::TurnSummary | SessionHistoryItemKind::Error + ) { + continue; + } + return matches!(item.metadata, Some(SessionHistoryMetadata::ProposedPlan)); + } + false +} diff --git a/crates/tui/src/chatwidget_tests.rs b/crates/tui/src/chatwidget_tests.rs index 396afed0..eda1db33 100644 --- a/crates/tui/src/chatwidget_tests.rs +++ b/crates/tui/src/chatwidget_tests.rs @@ -6,6 +6,7 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use devo_protocol::ApprovalDecisionValue; use devo_protocol::ApprovalScopeValue; +use devo_protocol::CollaborationMode; use devo_protocol::InputItem; use devo_protocol::ItemId; use devo_protocol::Model; @@ -714,6 +715,7 @@ fn session_switched_clears_resume_blocking_state() { rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Build, }); assert!(!widget.is_resuming_session_for_test()); @@ -2608,7 +2610,7 @@ fn proposed_plan_keeps_assistant_preamble_before_plan() { .expect("assistant preamble is rendered"); let plan_index = lines .iter() - .position(|line| line.contains("Proposed Plan")) + .position(|line| line.contains("Build the feature")) .expect("proposed plan is rendered"); assert!( preamble_index < plan_index, @@ -2655,7 +2657,7 @@ fn proposed_plan_completion_does_not_duplicate_boundary_preamble() { } #[test] -fn proposed_plan_cell_header_has_actions_without_bullet() { +fn proposed_plan_cell_renders_markdown_body_only() { let cwd = std::env::current_dir().expect("current directory is available"); let cell = crate::history_cell::new_proposed_plan( "## Summary\n\nBuild the feature.".to_string(), @@ -2664,10 +2666,195 @@ fn proposed_plan_cell_header_has_actions_without_bullet() { let lines = line_texts(cell.display_lines(100)); let rendered = lines.join("\n"); - assert!(lines.first().is_some_and(|line| line == "Proposed Plan")); - assert!(!rendered.contains("• Proposed Plan")); - assert!(rendered.contains("Implement Plan")); - assert!(rendered.contains("Revise Plan")); + assert!(rendered.contains("Summary")); + assert!(rendered.contains("Build the feature.")); + assert!(!rendered.contains("Proposed Plan")); + assert!(!rendered.contains("Implement Plan")); + assert!(!rendered.contains("Revise Plan")); +} + +#[test] +fn session_switch_restores_plan_mode_and_proposed_plan_actions() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-plan".to_string(), + cwd, + title: Some("Plan session".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: vec![devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::Assistant, + title: String::new(), + body: "## Approach\n\n1. Inspect\n2. Patch\n".to_string(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::ProposedPlan), + duration_ms: None, + }], + loaded_item_count: 1, + pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Plan, + }); + + assert_eq!( + widget.input_mode_for_test(), + crate::bottom_pane::InputMode::Plan + ); + assert!(widget.has_bottom_pane_view_for_test()); + assert_eq!(widget.status_message_for_test(), "Choose plan action"); + + let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert!( + rendered.contains("Inspect") && rendered.contains("Patch"), + "expected Proposed Plan body after resume:\n{rendered}" + ); +} + +#[test] +fn session_switch_restores_plan_turn_summary_label() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-plan-summary".to_string(), + cwd, + title: Some("Plan session".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::Assistant, + title: String::new(), + body: "## Approach\n\n1. Inspect\n2. Patch\n".to_string(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::ProposedPlan), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::TurnSummary, + title: "Test Model".to_string(), + body: String::new(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::TurnSummary { + collaboration_mode: CollaborationMode::Plan, + }), + duration_ms: Some(5), + }, + ], + loaded_item_count: 2, + pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Plan, + }); + + assert_eq!( + widget.input_mode_for_test(), + crate::bottom_pane::InputMode::Plan + ); + let rendered = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); + assert!( + rendered.contains("▣ PLAN · Test Model"), + "expected Plan mode in restored turn summary:\n{rendered}" + ); + assert!( + !rendered.contains("▣ BUILD · Test Model"), + "did not expect Build mode in restored plan turn summary:\n{rendered}" + ); +} + +#[test] +fn session_switch_after_implement_stays_in_build_without_plan_actions() { + let cwd = std::env::current_dir().expect("current directory is available"); + let model = Model { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + ..Model::default() + }; + let (mut widget, _app_event_rx) = widget_with_model(model, cwd.clone()); + + widget.handle_worker_event(crate::events::WorkerEvent::SessionSwitched { + session_id: "session-build".to_string(), + cwd, + title: Some("Build session".to_string()), + model: Some("test-model".to_string()), + model_binding_id: None, + reasoning_effort_selection: None, + reasoning_effort: None, + active_agent_label: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cache_read_tokens: 0, + last_query_total_tokens: 0, + last_query_input_tokens: 0, + prompt_token_estimate: 0, + history_items: Vec::new(), + rich_history_items: vec![ + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::Assistant, + title: String::new(), + body: "## Approach\n\n1. Inspect\n2. Patch\n".to_string(), + tool_io: None, + metadata: Some(devo_protocol::SessionHistoryMetadata::ProposedPlan), + duration_ms: None, + }, + devo_protocol::SessionHistoryItem { + tool_call_id: None, + kind: devo_protocol::SessionHistoryItemKind::User, + title: String::new(), + body: "Implement Plan".to_string(), + tool_io: None, + metadata: None, + duration_ms: None, + }, + ], + loaded_item_count: 2, + pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Build, + }); + + assert_eq!( + widget.input_mode_for_test(), + crate::bottom_pane::InputMode::Build + ); + assert!(!widget.has_bottom_pane_view_for_test()); + assert_eq!(widget.status_message_for_test(), "Session switched"); } #[test] @@ -2814,6 +3001,7 @@ fn session_switch_restores_plan_metadata_into_progress() { }], loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); assert_eq!(widget.last_plan_progress_for_test(), Some((1, 2))); @@ -2864,6 +3052,7 @@ fn session_switch_restores_explored_metadata_into_history() { }], loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -2927,6 +3116,7 @@ fn session_switch_restores_edited_metadata_into_history() { }], loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -2998,6 +3188,7 @@ fn session_switch_merges_consecutive_explored_items() { ], loaded_item_count: 2, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3055,6 +3246,7 @@ fn session_switch_restores_error_via_tool_result_cell_style() { }], loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -3136,6 +3328,7 @@ fn rich_session_restore_orders_terminal_error_before_single_failed_footer() { ], loaded_item_count: 4, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let history = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -3213,6 +3406,7 @@ fn live_and_resume_error_share_same_rendering_chain() { }], loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let resume_blob = scrollback_plain_lines(&resume_widget.drain_scrollback_lines(80)) .into_iter() @@ -3885,6 +4079,7 @@ fn session_switch_restores_header_and_spacing_before_user_input() { rich_history_items: Vec::new(), loaded_item_count: 2, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let committed_lines = widget.drain_scrollback_lines(80); @@ -3974,6 +4169,7 @@ fn restored_user_spacing_matches_live_turn_batch_spacing() { ], loaded_item_count: 2, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let restored_rows = scrollback_plain_lines(&restored_widget.drain_scrollback_lines(80)); @@ -4047,6 +4243,7 @@ fn rich_session_switch_restores_user_spacing_before_assistant_response() { ], loaded_item_count: 2, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let committed_rows = scrollback_plain_lines(&widget.drain_scrollback_lines(80)); @@ -5467,6 +5664,7 @@ fn restored_reasoning_text_is_visible_in_transcript() { rich_history_items: Vec::new(), loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let scrollback = widget.drain_scrollback_lines(80); @@ -5958,6 +6156,7 @@ fn session_switch_updates_session_identity_projection() { rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); assert_eq!(widget.current_cwd(), resumed_cwd.as_path()); @@ -6001,6 +6200,7 @@ fn status_summary_uses_last_turn_total_when_idle_and_live_estimate_while_busy() rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let idle_summary = widget.status_summary_text(); @@ -6079,6 +6279,7 @@ fn session_compacted_updates_context_bar_to_compacted_prompt_estimate() { rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); widget.handle_worker_event(crate::events::WorkerEvent::SessionCompacted { @@ -6127,6 +6328,7 @@ fn usage_updated_keeps_context_bar_on_last_query_not_cumulative_totals() { rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let idle_summary = widget.status_summary_text(); @@ -7235,6 +7437,7 @@ fn session_switch_sets_active_agent_footer_label() { rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let rows = rendered_rows(&widget, 160, 16); @@ -7277,6 +7480,7 @@ fn new_session_prepared_appends_header_after_existing_history_and_resets_status( rich_history_items: Vec::new(), loaded_item_count: 0, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); widget.add_to_history(crate::history_cell::new_info_event( "old session line".to_string(), @@ -8362,6 +8566,7 @@ fn restored_session_transcript_overlay_preserves_paired_tool_io() { ], loaded_item_count: 2, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -8428,6 +8633,7 @@ fn legacy_restored_session_without_tool_io_keeps_existing_tool_result_rendering( ], loaded_item_count: 2, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let transcript = line_texts(widget.transcript_overlay_lines(100)).join("\n"); @@ -9694,6 +9900,7 @@ fn session_switch_without_rich_edited_metadata_degrades_to_tool_result_path() { rich_history_items: Vec::new(), loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); @@ -9749,6 +9956,7 @@ fn session_switch_restores_added_file_content_in_edited_block() { }], loaded_item_count: 1, pending_texts: Vec::new(), + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(100)).join("\n"); @@ -9804,6 +10012,7 @@ fn session_switch_without_rich_edited_metadata_still_restores_edited_block() { }], loaded_item_count: 1, pending_texts: vec![], + collaboration_mode: CollaborationMode::Build, }); let blob = scrollback_plain_lines(&widget.drain_scrollback_lines(80)).join("\n"); diff --git a/crates/tui/src/events.rs b/crates/tui/src/events.rs index 29719528..9a560611 100644 --- a/crates/tui/src/events.rs +++ b/crates/tui/src/events.rs @@ -9,6 +9,7 @@ use devo_core::SessionId; use devo_protocol::AcpAvailableCommand; use devo_protocol::AcpCost; use devo_protocol::AcpSessionConfigOption; +use devo_protocol::CollaborationMode; use devo_protocol::ProviderModelBinding; use devo_protocol::ProviderRetryPhase; use devo_protocol::ProviderVendor; @@ -587,6 +588,8 @@ pub(crate) enum WorkerEvent { loaded_item_count: u64, /// Pending turn input texts queued for the next turn. pending_texts: Vec, + /// Collaboration mode restored from the resumed session metadata. + collaboration_mode: CollaborationMode, }, /// The current session title changed. SessionRenamed { diff --git a/crates/tui/src/history_cell.rs b/crates/tui/src/history_cell.rs index 3581375c..5fd6ab88 100644 --- a/crates/tui/src/history_cell.rs +++ b/crates/tui/src/history_cell.rs @@ -27,7 +27,6 @@ use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::startup_header::StartupHeaderData; use crate::startup_header::build_startup_header; -use crate::style::proposed_plan_style; use crate::style::user_message_style; use crate::text_formatting::truncate_text; use crate::theme::ThemeSet; @@ -630,7 +629,6 @@ impl ProposedPlanCell { impl HistoryCell for ProposedPlanCell { fn display_lines(&self, width: u16) -> Vec> { - let style = proposed_plan_style(); let content_width = width.saturating_sub(4).max(1) as usize; let mut body = Vec::new(); append_markdown( @@ -643,24 +641,7 @@ impl HistoryCell for ProposedPlanCell { body.push(Line::from("(empty)").dim()); } - let mut lines = vec![Line::from("Proposed Plan").bold(), Line::from("")]; - lines.extend( - prefix_lines(body, Span::styled(" ", style), Span::styled(" ", style)) - .into_iter() - .map(|line| line.style(style)), - ); - lines.push(Line::from("").style(style)); - lines.push( - Line::from(vec![ - Span::styled(" ", style), - Span::styled("Implement Plan", style).bold(), - Span::styled(" | ", style.dim()), - Span::styled("Revise Plan", style).bold(), - ]) - .style(style), - ); - lines.push(Line::from("").style(style)); - lines + prefix_lines(body, Span::raw(" "), Span::raw(" ")) } } diff --git a/crates/tui/src/style.rs b/crates/tui/src/style.rs index fc8fd4c5..26a71bd8 100644 --- a/crates/tui/src/style.rs +++ b/crates/tui/src/style.rs @@ -9,10 +9,6 @@ pub fn user_message_style() -> Style { user_message_style_for(default_bg()) } -pub fn proposed_plan_style() -> Style { - proposed_plan_style_for(default_bg()) -} - /// Returns the style for a user-authored message using the provided terminal background. pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { match terminal_bg { @@ -21,13 +17,6 @@ pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { } } -pub fn proposed_plan_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { - match terminal_bg { - Some(bg) => Style::default().bg(proposed_plan_bg(bg)), - None => Style::default(), - } -} - #[allow(clippy::disallowed_methods)] pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { let (top, alpha) = if is_light(terminal_bg) { @@ -37,8 +26,3 @@ pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color { }; best_color(blend(top, terminal_bg, alpha)) } - -#[allow(clippy::disallowed_methods)] -pub fn proposed_plan_bg(terminal_bg: (u8, u8, u8)) -> Color { - user_message_bg(terminal_bg) -} diff --git a/crates/tui/src/worker.rs b/crates/tui/src/worker.rs index d3325b98..e6d8769b 100644 --- a/crates/tui/src/worker.rs +++ b/crates/tui/src/worker.rs @@ -921,6 +921,7 @@ async fn run_worker_inner( rich_history_items: resumed.history_items.clone(), loaded_item_count: resumed.loaded_item_count, pending_texts: resumed.pending_texts, + collaboration_mode: resumed.session.collaboration_mode, }); model = resumed.session.model.clone().unwrap_or(model); model_binding_id = resumed.session.model_binding_id.clone(); @@ -954,871 +955,664 @@ async fn run_worker_inner( loop { tokio::select! { - maybe_command = command_rx.recv() => { - match maybe_command { - Some(OperationCommand::SubmitInput { - input, - approval_policy, - collaboration_mode, - }) => { - let active_session_id = prepare_session_for_command( - &mut client, - &config.cwd, - &mut model, - &mut model_binding_id, - &mut reasoning_effort_selection, - &mut session_id, - permission_preset, - initial_sandbox_profile.as_deref(), - event_tx, - ) - .await?; - - // Start the turn via `_devo/turn/start`. The bundled server implements - // this extension; streaming and completion arrive as server - // notifications (`turn/started`, item deltas, `turn/completed`, etc.). - let start_result = client.turn_start(TurnStartParams { - session_id: active_session_id, - input, - model: Some(model.clone()), - model_binding_id: model_binding_id.clone(), - reasoning_effort_selection: reasoning_effort_selection.clone(), - sandbox: None, - approval_policy, - cwd: None, - collaboration_mode, - execution_mode: TurnExecutionMode::Regular, - }).await; - match start_result { - Ok(result) => { - handle_turn_start_result(result, &mut active_turn_id); - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - } - Some( - OperationCommand::ExecuteShellCommand { command } - | OperationCommand::SubmitShellInput { command }, - ) => { - if active_turn_id.is_some() { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "cannot run shell command while a turn is in progress".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - } - let shell_start = next_shell_command_exec_start( - session_id, - session_cwd.clone(), - command, - &mut next_shell_process_index, - ); - active_shell_process_ids.insert(shell_start.process_id.clone()); - let _ = event_tx.send(shell_start.started_event); - match client.command_exec(shell_start.params).await { - Ok(_) => {} - Err(error) => { - active_shell_process_ids.remove(&shell_start.process_id); - let _ = event_tx.send(WorkerEvent::ToolResult { - tool_use_id: shell_start.process_id, - title: "Shell".to_string(), - preview: error.to_string(), - is_error: true, - truncated: false, - }); - } - } - } - Some(OperationCommand::SetModel { - model: next_model, - model_binding_id: next_model_binding_id, - }) => { - model = next_model; - model_binding_id = next_model_binding_id; - input_history_cursor = None; - if let Some(active_session_id) = session_id { - let _ = client - .session_metadata_update(devo_server::SessionMetadataUpdateParams { - session_id: active_session_id, - model: Some(model.clone()), - model_binding_id: model_binding_id.clone(), - reasoning_effort_selection: reasoning_effort_selection.clone(), - }) - .await; - } - } - Some(OperationCommand::SetReasoningEffort(next_reasoning_effort_selection)) => { - reasoning_effort_selection = next_reasoning_effort_selection; - if let Some(active_session_id) = session_id { - let _ = client - .session_metadata_update(devo_server::SessionMetadataUpdateParams { + maybe_command = command_rx.recv() => { + match maybe_command { + Some(OperationCommand::SubmitInput { + input, + approval_policy, + collaboration_mode, + }) => { + let active_session_id = prepare_session_for_command( + &mut client, + &config.cwd, + &mut model, + &mut model_binding_id, + &mut reasoning_effort_selection, + &mut session_id, + permission_preset, + initial_sandbox_profile.as_deref(), + event_tx, + ) + .await?; + + // Start the turn via `_devo/turn/start`. The bundled server implements + // this extension; streaming and completion arrive as server + // notifications (`turn/started`, item deltas, `turn/completed`, etc.). + let start_result = client.turn_start(TurnStartParams { session_id: active_session_id, + input, model: Some(model.clone()), model_binding_id: model_binding_id.clone(), reasoning_effort_selection: reasoning_effort_selection.clone(), - }) - .await; - } - } - Some(OperationCommand::ValidateProvider { - provider_vendor, - model_binding, - api_key, - }) => { - match tokio::time::timeout( - Duration::from_secs(25), - client.provider_validate(ProviderValidateParams { - provider_vendor, - model_binding, - api_key, - }), - ) - .await - { - Ok(Ok(result)) => { - let _ = event_tx.send(WorkerEvent::ProviderValidationSucceeded { - reply_preview: result.reply_preview, - }); + sandbox: None, + approval_policy, + cwd: None, + collaboration_mode, + execution_mode: TurnExecutionMode::Regular, + }).await; + match start_result { + Ok(result) => { + handle_turn_start_result(result, &mut active_turn_id); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } } - Ok(Err(error)) => { - let message = error.to_string(); - let hint = - devo_provider::recovery_hint_for_message(&message); - let _ = event_tx.send(WorkerEvent::ProviderValidationFailed { - message, - hint, - }); + Some( + OperationCommand::ExecuteShellCommand { command } + | OperationCommand::SubmitShellInput { command }, + ) => { + if active_turn_id.is_some() { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "cannot run shell command while a turn is in progress".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + } + let shell_start = next_shell_command_exec_start( + session_id, + session_cwd.clone(), + command, + &mut next_shell_process_index, + ); + active_shell_process_ids.insert(shell_start.process_id.clone()); + let _ = event_tx.send(shell_start.started_event); + match client.command_exec(shell_start.params).await { + Ok(_) => {} + Err(error) => { + active_shell_process_ids.remove(&shell_start.process_id); + let _ = event_tx.send(WorkerEvent::ToolResult { + tool_use_id: shell_start.process_id, + title: "Shell".to_string(), + preview: error.to_string(), + is_error: true, + truncated: false, + }); + } + } } - Err(_) => { - let message = - "provider validation request timed out".to_string(); - let hint = - devo_provider::recovery_hint_for_message(&message); - let _ = event_tx.send(WorkerEvent::ProviderValidationFailed { - message, - hint, - }); + Some(OperationCommand::SetModel { + model: next_model, + model_binding_id: next_model_binding_id, + }) => { + model = next_model; + model_binding_id = next_model_binding_id; + input_history_cursor = None; + if let Some(active_session_id) = session_id { + let _ = client + .session_metadata_update(devo_server::SessionMetadataUpdateParams { + session_id: active_session_id, + model: Some(model.clone()), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), + }) + .await; + } } - } - } - Some(OperationCommand::ListProviderVendors) => { - match tokio::time::timeout( - Duration::from_secs(5), - client.provider_vendor_list(ProviderVendorListParams::default()), - ) - .await - { - Ok(Ok(result)) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorsListed { - provider_vendors: result.provider_vendors, - }); + Some(OperationCommand::SetReasoningEffort(next_reasoning_effort_selection)) => { + reasoning_effort_selection = next_reasoning_effort_selection; + if let Some(active_session_id) = session_id { + let _ = client + .session_metadata_update(devo_server::SessionMetadataUpdateParams { + session_id: active_session_id, + model: Some(model.clone()), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), + }) + .await; + } } - Ok(Err(error)) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); + Some(OperationCommand::ValidateProvider { + provider_vendor, + model_binding, + api_key, + }) => { + match tokio::time::timeout( + Duration::from_secs(25), + client.provider_validate(ProviderValidateParams { + provider_vendor, + model_binding, + api_key, + }), + ) + .await + { + Ok(Ok(result)) => { + let _ = event_tx.send(WorkerEvent::ProviderValidationSucceeded { + reply_preview: result.reply_preview, + }); + } + Ok(Err(error)) => { + let message = error.to_string(); + let hint = + devo_provider::recovery_hint_for_message(&message); + let _ = event_tx.send(WorkerEvent::ProviderValidationFailed { + message, + hint, + }); + } + Err(_) => { + let message = + "provider validation request timed out".to_string(); + let hint = + devo_provider::recovery_hint_for_message(&message); + let _ = event_tx.send(WorkerEvent::ProviderValidationFailed { + message, + hint, + }); + } + } } - Err(_) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "provider list request timed out".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); + Some(OperationCommand::ListProviderVendors) => { + match tokio::time::timeout( + Duration::from_secs(5), + client.provider_vendor_list(ProviderVendorListParams::default()), + ) + .await + { + Ok(Ok(result)) => { + let _ = event_tx.send(WorkerEvent::ProviderVendorsListed { + provider_vendors: result.provider_vendors, + }); + } + Ok(Err(error)) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + Err(_) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "provider list request timed out".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } } - } - } - Some(OperationCommand::UpsertProviderVendor { - provider_vendor, - model_binding, - default_model_binding, - api_key, - }) => { - match tokio::time::timeout( - Duration::from_secs(5), - client.provider_vendor_upsert(ProviderVendorUpsertParams { + Some(OperationCommand::UpsertProviderVendor { provider_vendor, model_binding, default_model_binding, api_key, - }), - ) - .await - { - Ok(Ok(result)) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorUpserted { - provider_vendor: result.provider_vendor, - model_binding: result.model_binding, - }); - } - Ok(Err(error)) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorUpsertFailed { - message: error.to_string(), - }); - } - Err(_) => { - let _ = event_tx.send(WorkerEvent::ProviderVendorUpsertFailed { - message: "provider upsert request timed out".to_string(), - }); - } - } - } - Some(OperationCommand::ReconfigureProvider { - wire_api: _, - model: next_model, - base_url: _, - api_key: _, - }) => { - // Recreate the client so new provider credentials take effect - // without requiring the whole app to restart. - model = next_model; - model_binding_id = None; - client.shutdown().await?; - client = spawn_client( - &config.cwd, - config.server_log_level.clone(), - ) - .await?; - client.initialize(&config.client_capabilities).await?; - session_id = None; - child_agent_sessions.clear(); - btw_agent_sessions.clear(); - visible_acp_terminal_ids.clear(); - visible_acp_terminal_session_ids.clear(); - private_acp_terminal_ids.clear(); - pending_acp_terminal_output.clear(); - polled_acp_terminal_output.clear(); - active_turn_id = None; - active_reference_search_id = None; - last_query_total_tokens = 0; - } - Some(OperationCommand::ListSessions) => { - match tokio::time::timeout( - Duration::from_secs(5), - client.session_list(), - ) - .await - { - Ok(Ok(result)) => { - let sessions = result - .iter() - .map(|session| SessionListEntry { - session_id: session.session_id, - title: session - .title - .clone() - .unwrap_or_else(|| "(untitled)".to_string()), - updated_at: session - .updated_at - .format("%Y-%m-%d %H:%M:%S UTC") - .to_string(), - is_active: Some(session.session_id) == session_id, - }) - .collect(); - let _ = event_tx.send(WorkerEvent::SessionsListed { sessions }); + }) => { + match tokio::time::timeout( + Duration::from_secs(5), + client.provider_vendor_upsert(ProviderVendorUpsertParams { + provider_vendor, + model_binding, + default_model_binding, + api_key, + }), + ) + .await + { + Ok(Ok(result)) => { + let _ = event_tx.send(WorkerEvent::ProviderVendorUpserted { + provider_vendor: result.provider_vendor, + model_binding: result.model_binding, + }); + } + Ok(Err(error)) => { + let _ = event_tx.send(WorkerEvent::ProviderVendorUpsertFailed { + message: error.to_string(), + }); + } + Err(_) => { + let _ = event_tx.send(WorkerEvent::ProviderVendorUpsertFailed { + message: "provider upsert request timed out".to_string(), + }); + } + } } - Ok(Err(error)) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); + Some(OperationCommand::ReconfigureProvider { + wire_api: _, + model: next_model, + base_url: _, + api_key: _, + }) => { + // Recreate the client so new provider credentials take effect + // without requiring the whole app to restart. + model = next_model; + model_binding_id = None; + client.shutdown().await?; + client = spawn_client( + &config.cwd, + config.server_log_level.clone(), + ) + .await?; + client.initialize(&config.client_capabilities).await?; + session_id = None; + child_agent_sessions.clear(); + btw_agent_sessions.clear(); + visible_acp_terminal_ids.clear(); + visible_acp_terminal_session_ids.clear(); + private_acp_terminal_ids.clear(); + pending_acp_terminal_output.clear(); + polled_acp_terminal_output.clear(); + active_turn_id = None; + active_reference_search_id = None; + last_query_total_tokens = 0; } - Err(_) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "session list request timed out".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); + Some(OperationCommand::ListSessions) => { + match tokio::time::timeout( + Duration::from_secs(5), + client.session_list(), + ) + .await + { + Ok(Ok(result)) => { + let sessions = result + .iter() + .map(|session| SessionListEntry { + session_id: session.session_id, + title: session + .title + .clone() + .unwrap_or_else(|| "(untitled)".to_string()), + updated_at: session + .updated_at + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + is_active: Some(session.session_id) == session_id, + }) + .collect(); + let _ = event_tx.send(WorkerEvent::SessionsListed { sessions }); + } + Ok(Err(error)) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + Err(_) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "session list request timed out".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } } - } - } - Some(OperationCommand::ListSkills) => { - if let Err(error) = - emit_skills_list(&mut client, &session_cwd, event_tx, true).await - { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - Some(OperationCommand::ReferenceSearchRequested { query }) => { - match emit_reference_search_update( - &mut client, - &session_cwd, - &mut active_reference_search_id, - query, - event_tx, - ) - .await - { - Ok(()) => {} - Err(error) => { - tracing::warn!(?error, "reference search request failed"); + Some(OperationCommand::ListSkills) => { + if let Err(error) = + emit_skills_list(&mut client, &session_cwd, event_tx, true).await + { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } } - } - } - Some(OperationCommand::ReferenceSearchCancelled) => { - if let Some(search_id) = active_reference_search_id.take() { - let _ = client - .reference_search_cancel(ReferenceSearchCancelParams { search_id }) - .await; - } - } - Some(OperationCommand::CompactSession) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - }; - if active_turn_id.is_some() { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "cannot compact while a turn is in progress".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - } - match client - .session_compact(SessionCompactParams { - session_id: active_session_id, - }) - .await - { - Ok(result) => { - model = result - .session - .model - .clone() - .unwrap_or(model); - model_binding_id = result.session.model_binding_id.clone(); - reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); - let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); + Some(OperationCommand::ReferenceSearchRequested { query }) => { + match emit_reference_search_update( + &mut client, + &session_cwd, + &mut active_reference_search_id, + query, + event_tx, + ) + .await + { + Ok(()) => {} + Err(error) => { + tracing::warn!(?error, "reference search request failed"); + } + } } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); + Some(OperationCommand::ReferenceSearchCancelled) => { + if let Some(search_id) = active_reference_search_id.take() { + let _ = client + .reference_search_cancel(ReferenceSearchCancelParams { search_id }) + .await; + } } - } - } - Some(OperationCommand::ShowGoal) => { - let goal = if let Some(active_session_id) = session_id { - match client - .goal_status(GoalStatusParams { - session_id: active_session_id, - }) - .await - { - Ok(result) => result.goal, - Err(error) => { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), + Some(OperationCommand::CompactSession) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + if active_turn_id.is_some() { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "cannot compact while a turn is in progress".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, }); continue; } - } - } else { - None - }; - let _ = event_tx.send(WorkerEvent::GoalStatusLoaded { goal }); - } - Some(OperationCommand::EditGoal) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: "No goal is currently set.".to_string(), - }); - continue; - }; - match client - .goal_status(GoalStatusParams { - session_id: active_session_id, - }) - .await - { - Ok(result) => match result.goal { - Some(goal) => { - let _ = event_tx.send(WorkerEvent::GoalEditLoaded { goal }); + match client + .session_compact(SessionCompactParams { + session_id: active_session_id, + }) + .await + { + Ok(result) => { + model = result + .session + .model + .clone() + .unwrap_or(model); + model_binding_id = result.session.model_binding_id.clone(); + reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); + let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } } - None => { + } + Some(OperationCommand::ShowGoal) => { + let goal = if let Some(active_session_id) = session_id { + match client + .goal_status(GoalStatusParams { + session_id: active_session_id, + }) + .await + { + Ok(result) => result.goal, + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + continue; + } + } + } else { + None + }; + let _ = event_tx.send(WorkerEvent::GoalStatusLoaded { goal }); + } + Some(OperationCommand::EditGoal) => { + let Some(active_session_id) = session_id else { let _ = event_tx.send(WorkerEvent::GoalOperationFailed { message: "No goal is currently set.".to_string(), }); + continue; + }; + match client + .goal_status(GoalStatusParams { + session_id: active_session_id, + }) + .await + { + Ok(result) => match result.goal { + Some(goal) => { + let _ = event_tx.send(WorkerEvent::GoalEditLoaded { goal }); + } + None => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: "No goal is currently set.".to_string(), + }); + } + }, + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + } } - }, - Err(error) => { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), - }); } - } - } - Some(OperationCommand::SetGoalObjective { objective, mode }) => { - let active_session_id = prepare_session_for_command( - &mut client, - &config.cwd, - &mut model, - &mut model_binding_id, - &mut reasoning_effort_selection, - &mut session_id, - permission_preset, - initial_sandbox_profile.as_deref(), - event_tx, - ) - .await?; - - if matches!(mode, GoalObjectiveMode::ConfirmIfExists) { - match client - .goal_status(GoalStatusParams { - session_id: active_session_id, - }) - .await - { - Ok(result) => { - if let Some(current_goal) = result.goal { - let _ = event_tx.send( - WorkerEvent::GoalReplaceConfirmationRequested { - current_goal, - objective, - }, - ); - continue; + Some(OperationCommand::SetGoalObjective { objective, mode }) => { + let active_session_id = prepare_session_for_command( + &mut client, + &config.cwd, + &mut model, + &mut model_binding_id, + &mut reasoning_effort_selection, + &mut session_id, + permission_preset, + initial_sandbox_profile.as_deref(), + event_tx, + ) + .await?; + + if matches!(mode, GoalObjectiveMode::ConfirmIfExists) { + match client + .goal_status(GoalStatusParams { + session_id: active_session_id, + }) + .await + { + Ok(result) => { + if let Some(current_goal) = result.goal { + let _ = event_tx.send( + WorkerEvent::GoalReplaceConfirmationRequested { + current_goal, + objective, + }, + ); + continue; + } + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + continue; + } } } - Err(error) => { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), - }); - continue; + + if matches!(mode, GoalObjectiveMode::ReplaceExisting) { + match client + .goal_clear(GoalClearParams { + session_id: active_session_id, + }) + .await + { + Ok(_) => {} + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + continue; + } + } } - } - } - if matches!(mode, GoalObjectiveMode::ReplaceExisting) { - match client - .goal_clear(GoalClearParams { - session_id: active_session_id, - }) - .await - { - Ok(_) => {} - Err(error) => { + let (status, token_budget) = match mode { + GoalObjectiveMode::ConfirmIfExists | GoalObjectiveMode::ReplaceExisting => { + (Some(ThreadGoalStatus::Active), None) + } + GoalObjectiveMode::UpdateExisting { + status, + token_budget, + } => (Some(status), token_budget), + }; + match client + .goal_set(GoalSetParams { + session_id: active_session_id, + objective: Some(objective), + status, + token_budget, + }) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::GoalUpdated { + goal: result.goal, + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + } + } + } + Some(OperationCommand::SetGoalStatus { status }) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: "no active session exists yet; set a goal first".to_string(), + }); + continue; + }; + if status == ThreadGoalStatus::BudgetLimited { let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), + message: "budget-limited status is controlled by the system".to_string(), }); continue; } - } - } - - let (status, token_budget) = match mode { - GoalObjectiveMode::ConfirmIfExists | GoalObjectiveMode::ReplaceExisting => { - (Some(ThreadGoalStatus::Active), None) - } - GoalObjectiveMode::UpdateExisting { - status, - token_budget, - } => (Some(status), token_budget), - }; - match client - .goal_set(GoalSetParams { - session_id: active_session_id, - objective: Some(objective), - status, - token_budget, - }) - .await - { - Ok(result) => { - let _ = event_tx.send(WorkerEvent::GoalUpdated { - goal: result.goal, - }); - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), - }); - } - } - } - Some(OperationCommand::SetGoalStatus { status }) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: "no active session exists yet; set a goal first".to_string(), - }); - continue; - }; - if status == ThreadGoalStatus::BudgetLimited { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: "budget-limited status is controlled by the system".to_string(), - }); - continue; - } - match client - .goal_set(GoalSetParams { - session_id: active_session_id, - objective: None, - status: Some(status), - token_budget: None, - }) - .await - { - Ok(result) => { - let _ = event_tx.send(WorkerEvent::GoalUpdated { - goal: result.goal, - }); - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), - }); - } - } - } - Some(OperationCommand::ClearGoal) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::GoalCleared { cleared: false }); - continue; - }; - match client - .goal_clear(GoalClearParams { - session_id: active_session_id, - }) - .await - { - Ok(result) => { - let _ = event_tx.send(WorkerEvent::GoalCleared { - cleared: result.cleared, - }); - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::GoalOperationFailed { - message: error.to_string(), - }); - } - } - } - Some(OperationCommand::SetSkillEnabled { path, enabled }) => { - match client - .skills_set_enabled(SkillSetEnabledParams { path, enabled }) - .await - { - Ok(result) => { - emit_skills_list_result(result.skills, event_tx, false); - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - } - Some(OperationCommand::StartNewSession) => { - if let Some(active_session_id) = session_id { - match pause_active_goal_before_session_leave( - &mut client, - active_session_id, - active_turn_id, - ) - .await - { - Ok(()) => {} - Err(error) => { - emit_goal_leave_failure(event_tx, error); - continue; + match client + .goal_set(GoalSetParams { + session_id: active_session_id, + objective: None, + status: Some(status), + token_budget: None, + }) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::GoalUpdated { + goal: result.goal, + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + } } } - } - active_turn_id = None; - session_id = None; - active_reference_search_id = None; - session_cwd = config.cwd.clone(); - input_history_cursor = None; - turn_count = 0; - total_input_tokens = 0; - total_output_tokens = 0; - total_tokens = 0; - total_cache_read_tokens = 0; - last_query_total_tokens = 0; - last_query_input_tokens = 0; - has_authoritative_usage_totals = true; - let _ = event_tx.send(WorkerEvent::NewSessionPrepared { - cwd: session_cwd.clone(), - model: model.clone(), - model_binding_id: model_binding_id.clone(), - reasoning_effort_selection: reasoning_effort_selection.clone(), - reasoning_effort: None, - active_agent_label: None, - last_query_total_tokens, - last_query_input_tokens, - total_cache_read_tokens, - }); - let _ = emit_skills_list(&mut client, &session_cwd, event_tx, false).await; - } - Some(OperationCommand::SwitchSession(next_session_id)) => { - if let Some(active_session_id) = - session_id.filter(|session_id| *session_id != next_session_id) - { - match pause_active_goal_before_session_leave( - &mut client, - active_session_id, - active_turn_id, - ) - .await - { - Ok(()) => {} - Err(error) => { - emit_goal_leave_failure(event_tx, error); + Some(OperationCommand::ClearGoal) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::GoalCleared { cleared: false }); continue; + }; + match client + .goal_clear(GoalClearParams { + session_id: active_session_id, + }) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::GoalCleared { + cleared: result.cleared, + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::GoalOperationFailed { + message: error.to_string(), + }); + } } } - } - active_reference_search_id = None; - match client - .session_resume(SessionResumeParams { - session_id: next_session_id, - }) - .await - { - Ok(result) => { - active_turn_id = None; - session_id = Some(next_session_id); - child_agent_sessions.clear(); - btw_agent_sessions.clear(); - visible_acp_terminal_ids.clear(); - visible_acp_terminal_session_ids.clear(); - private_acp_terminal_ids.clear(); - pending_acp_terminal_output.clear(); - polled_acp_terminal_output.clear(); - session_cwd = result.session.cwd.clone(); - input_history_cursor = None; - let active_agent_label = - active_agent_label_from_session(&result.session); - let (last_query_total, last_query_input) = - last_query_tokens_from_resume(&result.session); - - let _ = event_tx.send(WorkerEvent::SessionSwitched { - session_id: next_session_id.to_string(), - cwd: result.session.cwd, - title: result.session.title, - model: result.session.model.clone(), - model_binding_id: result.session.model_binding_id.clone(), - reasoning_effort_selection: result.session.reasoning_effort_selection.clone(), - reasoning_effort: result.session.reasoning_effort, - active_agent_label, - total_input_tokens: result.session.total_input_tokens, - total_output_tokens: result.session.total_output_tokens, - total_tokens: result.session.total_tokens, - total_cache_read_tokens: result.session.total_cache_read_tokens, - last_query_total_tokens: last_query_total, - last_query_input_tokens: last_query_input, - prompt_token_estimate: result.session.prompt_token_estimate, - history_items: project_history_items(&result.history_items), - rich_history_items: result.history_items.clone(), - loaded_item_count: result.loaded_item_count, - pending_texts: result.pending_texts, - }); - model = result - .session - .model - .clone() - .unwrap_or(model); - model_binding_id = result.session.model_binding_id.clone(); - reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); - total_input_tokens = result.session.total_input_tokens; - total_output_tokens = result.session.total_output_tokens; - total_tokens = result.session.total_tokens; - total_cache_read_tokens = result.session.total_cache_read_tokens; - let _ = - emit_skills_list(&mut client, &session_cwd, event_tx, false) - .await; - last_query_total_tokens = last_query_total; - last_query_input_tokens = last_query_input; - has_authoritative_usage_totals = true; - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - } - Some(OperationCommand::RenameSession(title)) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - }; - match client - .session_title_update(SessionTitleUpdateParams { - session_id: active_session_id, - title: title.clone(), - }) - .await - { - Ok(result) => { - let _ = event_tx.send(WorkerEvent::SessionRenamed { - session_id: active_session_id.to_string(), - title: result - .session - .title - .unwrap_or(title), - }); - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - } - Some(OperationCommand::DeleteSession) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - }; - match pause_active_goal_before_session_leave( - &mut client, - active_session_id, - active_turn_id, - ) - .await - { - Ok(()) => {} - Err(error) => { - emit_goal_leave_failure(event_tx, error); - continue; + Some(OperationCommand::SetSkillEnabled { path, enabled }) => { + match client + .skills_set_enabled(SkillSetEnabledParams { path, enabled }) + .await + { + Ok(result) => { + emit_skills_list_result(result.skills, event_tx, false); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } } - } - match client - .session_delete(AcpDeleteSessionParams { - session_id: active_session_id, - meta: None, - }) - .await - { - Ok(_) => { - let _ = event_tx.send(WorkerEvent::SessionDeleted { - session_id: active_session_id.to_string(), - }); + Some(OperationCommand::StartNewSession) => { + if let Some(active_session_id) = session_id { + match pause_active_goal_before_session_leave( + &mut client, + active_session_id, + active_turn_id, + ) + .await + { + Ok(()) => {} + Err(error) => { + emit_goal_leave_failure(event_tx, error); + continue; + } + } + } active_turn_id = None; session_id = None; active_reference_search_id = None; @@ -1843,205 +1637,306 @@ async fn run_worker_inner( last_query_input_tokens, total_cache_read_tokens, }); - let _ = - emit_skills_list(&mut client, &session_cwd, event_tx, false) - .await; + let _ = emit_skills_list(&mut client, &session_cwd, event_tx, false).await; } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); + Some(OperationCommand::SwitchSession(next_session_id)) => { + if let Some(active_session_id) = + session_id.filter(|session_id| *session_id != next_session_id) + { + match pause_active_goal_before_session_leave( + &mut client, + active_session_id, + active_turn_id, + ) + .await + { + Ok(()) => {} + Err(error) => { + emit_goal_leave_failure(event_tx, error); + continue; + } + } + } + active_reference_search_id = None; + match client + .session_resume(SessionResumeParams { + session_id: next_session_id, + }) + .await + { + Ok(result) => { + active_turn_id = None; + session_id = Some(next_session_id); + child_agent_sessions.clear(); + btw_agent_sessions.clear(); + visible_acp_terminal_ids.clear(); + visible_acp_terminal_session_ids.clear(); + private_acp_terminal_ids.clear(); + pending_acp_terminal_output.clear(); + polled_acp_terminal_output.clear(); + session_cwd = result.session.cwd.clone(); + input_history_cursor = None; + let active_agent_label = + active_agent_label_from_session(&result.session); + let (last_query_total, last_query_input) = + last_query_tokens_from_resume(&result.session); + + let _ = event_tx.send(WorkerEvent::SessionSwitched { + session_id: next_session_id.to_string(), + cwd: result.session.cwd, + title: result.session.title, + model: result.session.model.clone(), + model_binding_id: result.session.model_binding_id.clone(), + reasoning_effort_selection: result.session.reasoning_effort_selection.clone(), + reasoning_effort: result.session.reasoning_effort, + active_agent_label, + total_input_tokens: result.session.total_input_tokens, + total_output_tokens: result.session.total_output_tokens, + total_tokens: result.session.total_tokens, + total_cache_read_tokens: result.session.total_cache_read_tokens, + last_query_total_tokens: last_query_total, + last_query_input_tokens: last_query_input, + prompt_token_estimate: result.session.prompt_token_estimate, + history_items: project_history_items(&result.history_items), + rich_history_items: result.history_items.clone(), + loaded_item_count: result.loaded_item_count, + pending_texts: result.pending_texts, + collaboration_mode: result.session.collaboration_mode, + }); + model = result + .session + .model + .clone() + .unwrap_or(model); + model_binding_id = result.session.model_binding_id.clone(); + reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); + total_input_tokens = result.session.total_input_tokens; + total_output_tokens = result.session.total_output_tokens; + total_tokens = result.session.total_tokens; + total_cache_read_tokens = result.session.total_cache_read_tokens; + let _ = + emit_skills_list(&mut client, &session_cwd, event_tx, false) + .await; + last_query_total_tokens = last_query_total; + last_query_input_tokens = last_query_input; + has_authoritative_usage_totals = true; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + Some(OperationCommand::RenameSession(title)) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + match client + .session_title_update(SessionTitleUpdateParams { + session_id: active_session_id, + title: title.clone(), + }) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::SessionRenamed { + session_id: active_session_id.to_string(), + title: result + .session + .title + .unwrap_or(title), + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + Some(OperationCommand::DeleteSession) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + match pause_active_goal_before_session_leave( + &mut client, + active_session_id, + active_turn_id, + ) + .await + { + Ok(()) => {} + Err(error) => { + emit_goal_leave_failure(event_tx, error); + continue; + } + } + match client + .session_delete(AcpDeleteSessionParams { + session_id: active_session_id, + meta: None, + }) + .await + { + Ok(_) => { + let _ = event_tx.send(WorkerEvent::SessionDeleted { + session_id: active_session_id.to_string(), + }); + active_turn_id = None; + session_id = None; + active_reference_search_id = None; + session_cwd = config.cwd.clone(); + input_history_cursor = None; + turn_count = 0; + total_input_tokens = 0; + total_output_tokens = 0; + total_tokens = 0; + total_cache_read_tokens = 0; + last_query_total_tokens = 0; + last_query_input_tokens = 0; + has_authoritative_usage_totals = true; + let _ = event_tx.send(WorkerEvent::NewSessionPrepared { + cwd: session_cwd.clone(), + model: model.clone(), + model_binding_id: model_binding_id.clone(), + reasoning_effort_selection: reasoning_effort_selection.clone(), + reasoning_effort: None, + active_agent_label: None, + last_query_total_tokens, + last_query_input_tokens, + total_cache_read_tokens, + }); + let _ = + emit_skills_list(&mut client, &session_cwd, event_tx, false) + .await; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } } - } - } - Some(OperationCommand::RollbackUserTurn { - user_turn_index, - mode, - }) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - }; - if let Err(error) = pause_active_goal_before_session_leave( - &mut client, - active_session_id, - active_turn_id, - ) - .await - { - emit_goal_leave_failure(event_tx, error); - continue; - } - match client - .session_rollback(SessionRollbackParams { - session_id: active_session_id, + Some(OperationCommand::RollbackUserTurn { user_turn_index, mode, - }) - .await - { - Ok(result) => { - active_turn_id = None; - session_cwd = result.session.cwd.clone(); - input_history_cursor = None; - let active_agent_label = - active_agent_label_from_session(&result.session); - let (last_query_total, last_query_input) = - last_query_tokens_from_resume(&result.session); - let _ = event_tx.send(WorkerEvent::SessionSwitched { - session_id: active_session_id.to_string(), - cwd: result.session.cwd, - title: result.session.title, - model: result.session.model.clone(), - model_binding_id: result.session.model_binding_id.clone(), - reasoning_effort_selection: result.session.reasoning_effort_selection.clone(), - reasoning_effort: result.session.reasoning_effort, - active_agent_label, - total_input_tokens: result.session.total_input_tokens, - total_output_tokens: result.session.total_output_tokens, - total_tokens: result.session.total_tokens, - total_cache_read_tokens: result.session.total_cache_read_tokens, - last_query_total_tokens: last_query_total, - last_query_input_tokens: last_query_input, - prompt_token_estimate: result.session.prompt_token_estimate, - history_items: project_history_items(&result.history_items), - rich_history_items: result.history_items.clone(), - loaded_item_count: result.loaded_item_count, - pending_texts: result.pending_texts, - }); - model = result.session.model.clone().unwrap_or(model); - model_binding_id = result.session.model_binding_id.clone(); - reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); - total_input_tokens = result.session.total_input_tokens; - total_output_tokens = result.session.total_output_tokens; - total_tokens = result.session.total_tokens; - total_cache_read_tokens = result.session.total_cache_read_tokens; - last_query_total_tokens = last_query_total; - last_query_input_tokens = last_query_input; - has_authoritative_usage_totals = true; - } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - } - Some(OperationCommand::ForkAtUserTurn(user_turn_index)) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - continue; - }; - match pause_active_goal_before_session_leave( - &mut client, - active_session_id, - active_turn_id, - ) - .await - { - Ok(()) => {} - Err(error) => { - emit_goal_leave_failure(event_tx, error); - continue; - } - } - match client - .session_fork(devo_server::SessionForkParams { - session_id: active_session_id, - title: None, - cwd: None, - user_turn_index: Some(user_turn_index), - }) - .await - { - Ok(result) => { - let next_session_id = result.session.session_id; + }) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + if let Err(error) = pause_active_goal_before_session_leave( + &mut client, + active_session_id, + active_turn_id, + ) + .await + { + emit_goal_leave_failure(event_tx, error); + continue; + } match client - .session_resume(SessionResumeParams { - session_id: next_session_id, + .session_rollback(SessionRollbackParams { + session_id: active_session_id, + user_turn_index, + mode, }) .await { - Ok(resumed) => { + Ok(result) => { active_turn_id = None; - session_id = Some(next_session_id); - child_agent_sessions.clear(); - btw_agent_sessions.clear(); - visible_acp_terminal_ids.clear(); - visible_acp_terminal_session_ids.clear(); - private_acp_terminal_ids.clear(); - pending_acp_terminal_output.clear(); - polled_acp_terminal_output.clear(); - session_cwd = resumed.session.cwd.clone(); + session_cwd = result.session.cwd.clone(); input_history_cursor = None; let active_agent_label = - active_agent_label_from_session(&resumed.session); + active_agent_label_from_session(&result.session); let (last_query_total, last_query_input) = - last_query_tokens_from_resume(&resumed.session); + last_query_tokens_from_resume(&result.session); let _ = event_tx.send(WorkerEvent::SessionSwitched { - session_id: next_session_id.to_string(), - cwd: resumed.session.cwd, - title: resumed.session.title, - model: resumed.session.model.clone(), - model_binding_id: resumed.session.model_binding_id.clone(), - reasoning_effort_selection: resumed.session.reasoning_effort_selection.clone(), - reasoning_effort: resumed.session.reasoning_effort, + session_id: active_session_id.to_string(), + cwd: result.session.cwd, + title: result.session.title, + model: result.session.model.clone(), + model_binding_id: result.session.model_binding_id.clone(), + reasoning_effort_selection: result.session.reasoning_effort_selection.clone(), + reasoning_effort: result.session.reasoning_effort, active_agent_label, - total_input_tokens: resumed.session.total_input_tokens, - total_output_tokens: resumed.session.total_output_tokens, - total_tokens: resumed.session.total_tokens, - total_cache_read_tokens: resumed.session.total_cache_read_tokens, + total_input_tokens: result.session.total_input_tokens, + total_output_tokens: result.session.total_output_tokens, + total_tokens: result.session.total_tokens, + total_cache_read_tokens: result.session.total_cache_read_tokens, last_query_total_tokens: last_query_total, last_query_input_tokens: last_query_input, - prompt_token_estimate: resumed.session.prompt_token_estimate, - history_items: project_history_items(&resumed.history_items), - rich_history_items: resumed.history_items.clone(), - loaded_item_count: resumed.loaded_item_count, - pending_texts: resumed.pending_texts, - }); - model = resumed.session.model.clone().unwrap_or(model); - model_binding_id = resumed.session.model_binding_id.clone(); - reasoning_effort_selection = resumed.session.reasoning_effort_selection.clone(); - total_input_tokens = resumed.session.total_input_tokens; - total_output_tokens = resumed.session.total_output_tokens; - total_tokens = resumed.session.total_tokens; - total_cache_read_tokens = resumed.session.total_cache_read_tokens; + prompt_token_estimate: result.session.prompt_token_estimate, + history_items: project_history_items(&result.history_items), + rich_history_items: result.history_items.clone(), + loaded_item_count: result.loaded_item_count, + pending_texts: result.pending_texts, + collaboration_mode: result.session.collaboration_mode, + }); + model = result.session.model.clone().unwrap_or(model); + model_binding_id = result.session.model_binding_id.clone(); + reasoning_effort_selection = result.session.reasoning_effort_selection.clone(); + total_input_tokens = result.session.total_input_tokens; + total_output_tokens = result.session.total_output_tokens; + total_tokens = result.session.total_tokens; + total_cache_read_tokens = result.session.total_cache_read_tokens; last_query_total_tokens = last_query_total; last_query_input_tokens = last_query_input; has_authoritative_usage_totals = true; @@ -2061,242 +1956,225 @@ async fn run_worker_inner( } } } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - } - Some(OperationCommand::InterruptTurn) => { - if let (Some(turn_id), Some(active_session_id)) = (active_turn_id, session_id) - && let Err(error) = client - .turn_interrupt(TurnInterruptParams { - session_id: active_session_id, - turn_id, - reason: Some("user requested interrupt".to_string()), - }) + Some(OperationCommand::ForkAtUserTurn(user_turn_index)) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: "no active session exists yet; send a prompt or switch to a saved session first".to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + continue; + }; + match pause_active_goal_before_session_leave( + &mut client, + active_session_id, + active_turn_id, + ) .await - { - let _ = event_tx.send(WorkerEvent::InterruptFailed { - message: error.to_string(), - }); - } - } - Some(OperationCommand::RunBtwQuestion { question }) => { - let Some(active_session_id) = session_id else { - let _ = event_tx.send(WorkerEvent::BtwFailed { - message: "No active session exists yet; send a message first, then try /btw.".to_string(), - }); - continue; - }; - match client - .agent_spawn(btw_spawn_params(active_session_id, &question)) - .await - { - Ok(result) => { - btw_agent_sessions.insert( - result.child_session_id, - BtwQuestionState { - parent_session_id: active_session_id, - question: question.clone(), - latest_answer: None, - }, - ); - let _ = event_tx.send(WorkerEvent::BtwStarted { question }); + { + Ok(()) => {} + Err(error) => { + emit_goal_leave_failure(event_tx, error); + continue; + } + } + match client + .session_fork(devo_server::SessionForkParams { + session_id: active_session_id, + title: None, + cwd: None, + user_turn_index: Some(user_turn_index), + }) + .await + { + Ok(result) => { + let next_session_id = result.session.session_id; + match client + .session_resume(SessionResumeParams { + session_id: next_session_id, + }) + .await + { + Ok(resumed) => { + active_turn_id = None; + session_id = Some(next_session_id); + child_agent_sessions.clear(); + btw_agent_sessions.clear(); + visible_acp_terminal_ids.clear(); + visible_acp_terminal_session_ids.clear(); + private_acp_terminal_ids.clear(); + pending_acp_terminal_output.clear(); + polled_acp_terminal_output.clear(); + session_cwd = resumed.session.cwd.clone(); + input_history_cursor = None; + let active_agent_label = + active_agent_label_from_session(&resumed.session); + let (last_query_total, last_query_input) = + last_query_tokens_from_resume(&resumed.session); + let _ = event_tx.send(WorkerEvent::SessionSwitched { + session_id: next_session_id.to_string(), + cwd: resumed.session.cwd, + title: resumed.session.title, + model: resumed.session.model.clone(), + model_binding_id: resumed.session.model_binding_id.clone(), + reasoning_effort_selection: resumed.session.reasoning_effort_selection.clone(), + reasoning_effort: resumed.session.reasoning_effort, + active_agent_label, + total_input_tokens: resumed.session.total_input_tokens, + total_output_tokens: resumed.session.total_output_tokens, + total_tokens: resumed.session.total_tokens, + total_cache_read_tokens: resumed.session.total_cache_read_tokens, + last_query_total_tokens: last_query_total, + last_query_input_tokens: last_query_input, + prompt_token_estimate: resumed.session.prompt_token_estimate, + history_items: project_history_items(&resumed.history_items), + rich_history_items: resumed.history_items.clone(), + loaded_item_count: resumed.loaded_item_count, + pending_texts: resumed.pending_texts, + collaboration_mode: resumed.session.collaboration_mode, + }); + model = resumed.session.model.clone().unwrap_or(model); + model_binding_id = resumed.session.model_binding_id.clone(); + reasoning_effort_selection = resumed.session.reasoning_effort_selection.clone(); + total_input_tokens = resumed.session.total_input_tokens; + total_output_tokens = resumed.session.total_output_tokens; + total_tokens = resumed.session.total_tokens; + total_cache_read_tokens = resumed.session.total_cache_read_tokens; + last_query_total_tokens = last_query_total; + last_query_input_tokens = last_query_input; + has_authoritative_usage_totals = true; + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } } - Err(error) => { - let _ = event_tx.send(WorkerEvent::BtwFailed { - message: error.to_string(), - }); + Some(OperationCommand::InterruptTurn) => { + if let (Some(turn_id), Some(active_session_id)) = (active_turn_id, session_id) + && let Err(error) = client + .turn_interrupt(TurnInterruptParams { + session_id: active_session_id, + turn_id, + reason: Some("user requested interrupt".to_string()), + }) + .await + { + let _ = event_tx.send(WorkerEvent::InterruptFailed { + message: error.to_string(), + }); + } } - } - } - Some(OperationCommand::SteerTurn { - input, - expected_turn_id, - }) => { - if let Some(active_session_id) = session_id { - match client - .turn_steer(TurnSteerParams { - session_id: active_session_id, - expected_turn_id, - input, - }) - .await - { - Ok(result) => { - let _ = event_tx.send(WorkerEvent::SteerAccepted { - turn_id: result.turn_id, + Some(OperationCommand::RunBtwQuestion { question }) => { + let Some(active_session_id) = session_id else { + let _ = event_tx.send(WorkerEvent::BtwFailed { + message: "No active session exists yet; send a message first, then try /btw.".to_string(), }); + continue; + }; + match client + .agent_spawn(btw_spawn_params(active_session_id, &question)) + .await + { + Ok(result) => { + btw_agent_sessions.insert( + result.child_session_id, + BtwQuestionState { + parent_session_id: active_session_id, + question: question.clone(), + latest_answer: None, + }, + ); + let _ = event_tx.send(WorkerEvent::BtwStarted { question }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::BtwFailed { + message: error.to_string(), + }); + } } - Err(error) => { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); } - } - } - } - Some(OperationCommand::ApprovalRespond { - session_id, - turn_id, - approval_id, - decision, - scope, - }) => { - if let Err(error) = client - .approval_respond(ApprovalResponseParams { + Some(OperationCommand::SteerTurn { + input, + expected_turn_id, + }) => { + if let Some(active_session_id) = session_id { + match client + .turn_steer(TurnSteerParams { + session_id: active_session_id, + expected_turn_id, + input, + }) + .await + { + Ok(result) => { + let _ = event_tx.send(WorkerEvent::SteerAccepted { + turn_id: result.turn_id, + }); + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); + } + } + } + } + Some(OperationCommand::ApprovalRespond { session_id, turn_id, - approval_id: approval_id.into(), + approval_id, decision, scope, - }) - .await - { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - Some(OperationCommand::RequestUserInputRespond { - session_id, - turn_id, - request_id, - response, - }) => { - if let Err(error) = client - .request_user_input_respond(RequestUserInputRespondParams { - session_id, - turn_id, - request_id: request_id.into(), - response, - }) - .await - { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - Some(OperationCommand::UpdatePermissions { preset }) => { - permission_preset = preset; - let Some(active_session_id) = session_id else { - continue; - }; - if let Err(error) = - apply_session_permissions(&mut client, active_session_id, preset).await - { - let _ = event_tx.send(WorkerEvent::TurnFailed { - message: error.to_string(), - hint: None, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: total_input_tokens, - last_query_input_tokens, - }); - } - } - Some(OperationCommand::UpdateSandboxProfile { profile }) => { - let Some(active_session_id) = session_id else { - continue; - }; - if let Err(error) = client - .session_sandbox_profile_update( - devo_server::SessionSandboxProfileUpdateParams { - session_id: active_session_id, - profile, - }, - ) - .await - { - let _ = event_tx.send(WorkerEvent::InterruptFailed { - message: format!("Failed to update sandbox profile: {error}"), - }); - } - } - Some(OperationCommand::BrowseInputHistory(direction)) => { - let text = if let Some(active_session_id) = session_id { - match client - .session_resume(SessionResumeParams { - session_id: active_session_id, - }) - .await - { - Ok(result) => { - let entries = result - .history_items - .iter() - .filter(|item| item.kind == SessionHistoryItemKind::User) - .map(|item| item.body.clone()) - .filter(|body| !body.trim().is_empty()) - .collect::>(); - let total = entries.len(); - match direction { - InputHistoryDirection::Previous => { - if total == 0 { - None - } else { - let next_index = match input_history_cursor { - None => total.saturating_sub(1), - Some(0) => 0, - Some(index) => index.saturating_sub(1), - }; - input_history_cursor = Some(next_index); - entries.get(next_index).cloned() - } - } - InputHistoryDirection::Next => match input_history_cursor { - None => None, - Some(index) if index + 1 >= total => { - input_history_cursor = None; - None - } - Some(index) => { - let next_index = index + 1; - input_history_cursor = Some(next_index); - entries.get(next_index).cloned() - } - }, - } - } - Err(error) => { + }) => { + if let Err(error) = client + .approval_respond(ApprovalResponseParams { + session_id, + turn_id, + approval_id: approval_id.into(), + decision, + scope, + }) + .await + { let _ = event_tx.send(WorkerEvent::TurnFailed { message: error.to_string(), hint: None, @@ -2308,431 +2186,209 @@ async fn run_worker_inner( prompt_token_estimate: total_input_tokens, last_query_input_tokens, }); - None - } - } - } else { - None - }; - let _ = event_tx.send(WorkerEvent::InputHistoryLoaded { direction, text }); - } - Some(OperationCommand::Shutdown) | None => { - tracing::info!("query worker received shutdown command"); - break; - } - } - } - _ = acp_terminal_poll.tick(), if !visible_acp_terminal_ids.is_empty() => { - let terminal_ids = visible_acp_terminal_ids - .iter() - .filter(|terminal_id| !private_acp_terminal_ids.contains(*terminal_id)) - .cloned() - .collect::>(); - for terminal_id in terminal_ids { - match client.acp_terminal_output_snapshot(&terminal_id).await { - Ok(snapshot) => { - if let Some(delta) = acp_terminal_snapshot_delta( - polled_acp_terminal_output - .entry(terminal_id.clone()) - .or_default(), - snapshot.output, - snapshot.truncated, - ) { - if let Some(owner_session_id) = - visible_acp_terminal_session_ids.get(&terminal_id).copied() - && Some(owner_session_id) != session_id - { - let _ = event_tx.send(WorkerEvent::SubagentMonitor { - event: SubagentMonitorEvent::ToolOutputDelta { - session_id: owner_session_id, - tool_use_id: terminal_id.clone(), - delta, - }, - }); - } else { - let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: terminal_id.clone(), - delta, - }); } } - if snapshot.exit_status.is_some() { - visible_acp_terminal_ids.remove(&terminal_id); - visible_acp_terminal_session_ids.remove(&terminal_id); - polled_acp_terminal_output.remove(&terminal_id); - } - } - Err(error) => { - tracing::debug!(%error, terminal_id, "failed to poll ACP terminal output"); - visible_acp_terminal_ids.remove(&terminal_id); - visible_acp_terminal_session_ids.remove(&terminal_id); - polled_acp_terminal_output.remove(&terminal_id); - } - } - } - } - notification = client.recv_notification() => { - match notification { - Some(notification) => { - let method = notification.method; - let params = notification.params; - let normalized_event = client_event_from_notification( - &devo_client::ServerNotificationMessage { - method: method.clone(), - params: params.clone(), - }, - ) - .ok() - .flatten(); - if let Some(ClientEvent::TurnUsageUpdated(payload)) = normalized_event { - saw_usage_update_for_turn = true; - total_input_tokens = payload.total_input_tokens; - total_output_tokens = payload.total_output_tokens; - total_tokens = payload.total_tokens; - total_cache_read_tokens = payload.total_cache_read_tokens; - last_query_total_tokens = payload.usage.display_total_tokens(); - last_query_input_tokens = payload.last_query_input_tokens; - has_authoritative_usage_totals = true; - let _ = event_tx.send(WorkerEvent::UsageUpdated { - total_input_tokens: payload.total_input_tokens, - total_output_tokens: payload.total_output_tokens, - total_tokens: payload.total_tokens, - total_cache_read_tokens: payload.total_cache_read_tokens, - last_query_total_tokens: payload.usage.display_total_tokens(), - last_query_input_tokens: payload.last_query_input_tokens, - }); - continue; - } - if method == ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD { - if let Some(terminal_id) = - params.get("terminalId").and_then(serde_json::Value::as_str) - { - private_acp_terminal_ids.insert(terminal_id.to_string()); - polled_acp_terminal_output.remove(terminal_id); - } - if let Some(event) = acp_terminal_output_event_with_session( - ¶ms, - &visible_acp_terminal_ids, - &mut pending_acp_terminal_output, + Some(OperationCommand::RequestUserInputRespond { session_id, - &visible_acp_terminal_session_ids, - ) { - let _ = event_tx.send(event); - } - continue; - } - if method == ACP_SESSION_UPDATE_METHOD { - let Some(notification) = parse_acp_session_notification(¶ms) else { - continue; - }; - if let Some(metadata) = - session_metadata_from_acp_update(¬ification.update) - && let Some(agent) = subagent_events::agent_from_session(&metadata) - && Some(agent.parent_session_id) == session_id - && child_agent_sessions.insert(agent.session_id) - { - let _ = event_tx.send(WorkerEvent::SubagentDiscovered { agent }); - } - - let notification_session_id = notification.session_id; - if Some(notification_session_id) == session_id { - if let Some(parent_session_id) = session_id { - maybe_discover_spawned_subagent_from_acp_update( - ¬ification.update, - &mut client, - parent_session_id, - &mut child_agent_sessions, - event_tx, - ) - .await; - } - for event in worker_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_acp_terminal_ids, - &mut pending_acp_terminal_output, - Some(&mut visible_acp_terminal_session_ids), - ) { - let _ = event_tx.send(event); - } - continue; - } - - if child_agent_sessions.contains(¬ification_session_id) { - for event in subagent_monitor_events_from_acp_session_notification_with_terminal_state( - notification, - &mut visible_acp_terminal_ids, - &mut pending_acp_terminal_output, - &mut visible_acp_terminal_session_ids, - ) { - let _ = event_tx.send(event); + turn_id, + request_id, + response, + }) => { + if let Err(error) = client + .request_user_input_respond(RequestUserInputRespondParams { + session_id, + turn_id, + request_id: request_id.into(), + response, + }) + .await + { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, + }); } } - continue; - } - let event: ServerEvent = serde_json::from_value(params) - .with_context(|| format!("failed to decode server event for method {method}"))?; - if handle_btw_agent_event( - &method, - &event, - &mut client, - event_tx, - &mut btw_agent_sessions, - ) - .await - { - continue; - } - if let Some(event_session_id) = event.session_id() - && Some(event_session_id) != session_id - { - if child_agent_sessions.contains(&event_session_id) { - for subagent_event in - subagent_monitor_events_from_unwrapped_server_notification( - method.as_str(), - event.clone(), - ) + Some(OperationCommand::UpdatePermissions { preset }) => { + permission_preset = preset; + let Some(active_session_id) = session_id else { + continue; + }; + if let Err(error) = + apply_session_permissions(&mut client, active_session_id, preset).await { - let _ = event_tx.send(subagent_event); - } - } - continue; - } - match method.as_str() { - "turn/started" => { - if let ServerEvent::TurnStarted(payload) = event { - active_turn_id = Some(payload.turn.turn_id); - saw_usage_update_for_turn = false; - model = payload.turn.model.clone(); - model_binding_id = payload.turn.model_binding_id.clone(); - reasoning_effort_selection = payload.turn.reasoning_effort_selection.clone(); - let _ = event_tx.send(WorkerEvent::TurnStarted { - model: payload.turn.model, - model_binding_id: payload.turn.model_binding_id, - reasoning_effort_selection: payload.turn.reasoning_effort_selection, - reasoning_effort: payload.turn.reasoning_effort, - turn_id: payload.turn.turn_id, + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, }); } - latest_completed_agent_message = None; - } - "item/started" => { - if let ServerEvent::ItemStarted(payload) = event { - handle_started_item(payload, event_tx); - } - } - "item/agentMessage/delta" => { - if let ServerEvent::ItemDelta { payload, .. } = event { - if let Some(item_id) = payload.context.item_id { - if let Some(assistant_token_text) = - assistant_token_log_preview(&payload.delta) - { - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item_id, - event_seq = payload.context.seq, - delta_len = payload.delta.len(), - stream_index = ?payload.stream_index, - channel = ?payload.channel, - assistant_token_text = %assistant_token_text, - "server assistant delta" - ); - } else { - tracing::debug!( - stream_elapsed_ms = stream_trace_elapsed_ms(), - item_id = %item_id, - event_seq = payload.context.seq, - delta_len = payload.delta.len(), - stream_index = ?payload.stream_index, - channel = ?payload.channel, - "server assistant delta" - ); - } - let _ = event_tx.send(WorkerEvent::TextItemDelta { - item_id, - kind: TextItemKind::Assistant, - delta: payload.delta, - }); - } else { - let _ = event_tx.send(WorkerEvent::TextDelta(payload.delta)); - } - } } - "item/plan/delta" => { - if let ServerEvent::ItemDelta { payload, .. } = event - && let Some(item_id) = payload.context.item_id + Some(OperationCommand::UpdateSandboxProfile { profile }) => { + let Some(active_session_id) = session_id else { + continue; + }; + if let Err(error) = client + .session_sandbox_profile_update( + devo_server::SessionSandboxProfileUpdateParams { + session_id: active_session_id, + profile, + }, + ) + .await { - let _ = event_tx.send(WorkerEvent::ProposedPlanDelta { - item_id, - delta: payload.delta, + let _ = event_tx.send(WorkerEvent::InterruptFailed { + message: format!("Failed to update sandbox profile: {error}"), }); } } - "item/commandExecution/outputDelta" => { - if let ServerEvent::ItemDelta { payload, .. } = event { - let delta_str = &payload.delta; - if let Ok(val) = - serde_json::from_str::(delta_str) + Some(OperationCommand::BrowseInputHistory(direction)) => { + let text = if let Some(active_session_id) = session_id { + match client + .session_resume(SessionResumeParams { + session_id: active_session_id, + }) + .await { - let tool_use_id = val - .get("tool_use_id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let text = - val.get("text").and_then(|v| v.as_str()).unwrap_or(""); - if !tool_use_id.is_empty() { - let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: tool_use_id.to_string(), - delta: text.to_string(), + Ok(result) => { + let entries = result + .history_items + .iter() + .filter(|item| item.kind == SessionHistoryItemKind::User) + .map(|item| item.body.clone()) + .filter(|body| !body.trim().is_empty()) + .collect::>(); + let total = entries.len(); + match direction { + InputHistoryDirection::Previous => { + if total == 0 { + None + } else { + let next_index = match input_history_cursor { + None => total.saturating_sub(1), + Some(0) => 0, + Some(index) => index.saturating_sub(1), + }; + input_history_cursor = Some(next_index); + entries.get(next_index).cloned() + } + } + InputHistoryDirection::Next => match input_history_cursor { + None => None, + Some(index) if index + 1 >= total => { + input_history_cursor = None; + None + } + Some(index) => { + let next_index = index + 1; + input_history_cursor = Some(next_index); + entries.get(next_index).cloned() + } + }, + } + } + Err(error) => { + let _ = event_tx.send(WorkerEvent::TurnFailed { + message: error.to_string(), + hint: None, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: total_input_tokens, + last_query_input_tokens, }); + None } } - } + } else { + None + }; + let _ = event_tx.send(WorkerEvent::InputHistoryLoaded { direction, text }); + } + Some(OperationCommand::Shutdown) | None => { + tracing::info!("query worker received shutdown command"); + break; } - "command/exec/outputDelta" => { - if let ServerEvent::CommandExecOutputDelta(payload) = event { - let CommandExecOutputDeltaPayload { - process_id, - delta_base64, - .. - } = payload; - match BASE64_STANDARD.decode(delta_base64) { - Ok(bytes) => { - let delta = - String::from_utf8_lossy(&bytes).to_string(); + } + } + _ = acp_terminal_poll.tick(), if !visible_acp_terminal_ids.is_empty() => { + let terminal_ids = visible_acp_terminal_ids + .iter() + .filter(|terminal_id| !private_acp_terminal_ids.contains(*terminal_id)) + .cloned() + .collect::>(); + for terminal_id in terminal_ids { + match client.acp_terminal_output_snapshot(&terminal_id).await { + Ok(snapshot) => { + if let Some(delta) = acp_terminal_snapshot_delta( + polled_acp_terminal_output + .entry(terminal_id.clone()) + .or_default(), + snapshot.output, + snapshot.truncated, + ) { + if let Some(owner_session_id) = + visible_acp_terminal_session_ids.get(&terminal_id).copied() + && Some(owner_session_id) != session_id + { + let _ = event_tx.send(WorkerEvent::SubagentMonitor { + event: SubagentMonitorEvent::ToolOutputDelta { + session_id: owner_session_id, + tool_use_id: terminal_id.clone(), + delta, + }, + }); + } else { let _ = event_tx.send(WorkerEvent::ToolOutputDelta { - tool_use_id: process_id, + tool_use_id: terminal_id.clone(), delta, }); } - Err(error) => { - tracing::warn!( - %error, - "failed to decode command/exec output delta" - ); - } - } - } - } - "command/exec/exited" => { - if let ServerEvent::CommandExecExited(payload) = event { - let CommandExecExitedPayload { - process_id, - exit_code, - .. - } = payload; - if active_shell_process_ids.remove(&process_id) { - let _ = event_tx.send(WorkerEvent::ToolResult { - tool_use_id: process_id, - title: "Shell".to_string(), - preview: String::new(), - is_error: false, - truncated: false, - }); - let _ = event_tx.send(WorkerEvent::ShellCommandFinished { - exit_code, - }); - } - } - } - "item/reasoning/textDelta" | "item/reasoning/summaryTextDelta" => { - if let ServerEvent::ItemDelta { payload, .. } = event { - if let Some(item_id) = payload.context.item_id { - tracing::debug!( - item_id = %item_id, - delta_len = payload.delta.len(), - stream_index = ?payload.stream_index, - channel = ?payload.channel, - "server reasoning delta" - ); - let _ = event_tx.send(WorkerEvent::TextItemDelta { - item_id, - kind: TextItemKind::Reasoning, - delta: payload.delta, - }); - } else { - let _ = event_tx.send(WorkerEvent::ReasoningDelta(payload.delta)); - } - } - } - "item/completed" => { - if let ServerEvent::ItemCompleted(payload) = event { - tracing::debug!( - item_id = %payload.item.item_id, - item_kind = ?payload.item.item_kind, - "server item completed" - ); - if let Some(text) = completed_agent_message_text(&payload) { - latest_completed_agent_message = Some(text); } - // Completed tool items are mapped into compact UI events - // with pre-rendered summaries and previews. - handle_completed_item(payload, event_tx); - } - } - "turn/completed" => { - if let ServerEvent::TurnCompleted(payload) = event { - tracing::debug!( - turn_id = %payload.turn.turn_id, - status = ?payload.turn.status, - "server turn completed" - ); - active_turn_id = None; - let completed = payload.turn.status == TurnStatus::Completed - || payload.turn.status == TurnStatus::Interrupted; - if completed { - turn_count += 1; - if let Some(usage) = &payload.turn.usage { - if !saw_usage_update_for_turn { - last_query_input_tokens = usage.input_tokens as usize; - last_query_total_tokens = usage.display_total_tokens(); - } - if should_apply_terminal_turn_usage_fallback( - saw_usage_update_for_turn, - has_authoritative_usage_totals, - ) { - total_input_tokens += usage.input_tokens as usize; - total_output_tokens += usage.output_tokens as usize; - total_tokens += usage.display_total_tokens(); - total_cache_read_tokens += usage - .cache_read_input_tokens - .unwrap_or(0) as usize; - } - } + if snapshot.exit_status.is_some() { + visible_acp_terminal_ids.remove(&terminal_id); + visible_acp_terminal_session_ids.remove(&terminal_id); + polled_acp_terminal_output.remove(&terminal_id); } - let _ = event_tx.send(WorkerEvent::TurnFinished { - stop_reason: format!("{:?}", payload.turn.status), - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - last_query_total_tokens, - last_query_input_tokens, - prompt_token_estimate: payload - .turn - .usage - .as_ref() - .map(|usage| usage.input_tokens as usize) - .unwrap_or(total_input_tokens), - }); - latest_completed_agent_message = None; } - } - "turn/provider_retry_status" => { - if let ServerEvent::TurnProviderRetryStatus(payload) = event { - let _ = event_tx.send(WorkerEvent::ProviderRetryStatus { - turn_id: payload.turn_id, - attempt: payload.attempt, - backoff_ms: payload.backoff_ms, - provider: payload.provider, - model: payload.model, - phase: payload.phase, - message: payload.message, - }); + Err(error) => { + tracing::debug!(%error, terminal_id, "failed to poll ACP terminal output"); + visible_acp_terminal_ids.remove(&terminal_id); + visible_acp_terminal_session_ids.remove(&terminal_id); + polled_acp_terminal_output.remove(&terminal_id); } } - "turn/usage/updated" => { - if let ServerEvent::TurnUsageUpdated(payload) = event { + } + } + notification = client.recv_notification() => { + match notification { + Some(notification) => { + let method = notification.method; + let params = notification.params; + let normalized_event = client_event_from_notification( + &devo_client::ServerNotificationMessage { + method: method.clone(), + params: params.clone(), + }, + ) + .ok() + .flatten(); + if let Some(ClientEvent::TurnUsageUpdated(payload)) = normalized_event { saw_usage_update_for_turn = true; total_input_tokens = payload.total_input_tokens; total_output_tokens = payload.total_output_tokens; @@ -2749,206 +2405,554 @@ async fn run_worker_inner( last_query_total_tokens: payload.usage.display_total_tokens(), last_query_input_tokens: payload.last_query_input_tokens, }); + continue; } - } - "turn/failed" => { - if let ServerEvent::TurnFailed(TurnFailedPayload { turn, error, .. }) = event { - active_turn_id = None; - let (message, hint) = match error { - Some(error) => { - let hint = error.recovery_hint.or_else(|| { - devo_provider::recovery_hint_for_message( - &error.message, - ) - }); - (error.message, hint) - } - None => { - let message = latest_completed_agent_message - .take() - .unwrap_or_else(|| { - format!( - "turn failed with status {:?}", - turn.status - ) - }); - let hint = - devo_provider::recovery_hint_for_message(&message); - (message, hint) - } + if method == ACP_TERMINAL_OUTPUT_NOTIFICATION_METHOD { + if let Some(terminal_id) = + params.get("terminalId").and_then(serde_json::Value::as_str) + { + private_acp_terminal_ids.insert(terminal_id.to_string()); + polled_acp_terminal_output.remove(terminal_id); + } + if let Some(event) = acp_terminal_output_event_with_session( + ¶ms, + &visible_acp_terminal_ids, + &mut pending_acp_terminal_output, + session_id, + &visible_acp_terminal_session_ids, + ) { + let _ = event_tx.send(event); + } + continue; + } + if method == ACP_SESSION_UPDATE_METHOD { + let Some(notification) = parse_acp_session_notification(¶ms) else { + continue; }; - if let Some(usage) = &turn.usage { - if !saw_usage_update_for_turn { - last_query_input_tokens = usage.input_tokens as usize; - last_query_total_tokens = usage.display_total_tokens(); + if let Some(metadata) = + session_metadata_from_acp_update(¬ification.update) + && let Some(agent) = subagent_events::agent_from_session(&metadata) + && Some(agent.parent_session_id) == session_id + && child_agent_sessions.insert(agent.session_id) + { + let _ = event_tx.send(WorkerEvent::SubagentDiscovered { agent }); + } + + let notification_session_id = notification.session_id; + if Some(notification_session_id) == session_id { + if let Some(parent_session_id) = session_id { + maybe_discover_spawned_subagent_from_acp_update( + ¬ification.update, + &mut client, + parent_session_id, + &mut child_agent_sessions, + event_tx, + ) + .await; } - if should_apply_terminal_turn_usage_fallback( - saw_usage_update_for_turn, - has_authoritative_usage_totals, + for event in worker_events_from_acp_session_notification_with_terminal_state( + notification, + &mut visible_acp_terminal_ids, + &mut pending_acp_terminal_output, + Some(&mut visible_acp_terminal_session_ids), ) { - total_input_tokens += usage.input_tokens as usize; - total_output_tokens += usage.output_tokens as usize; - total_tokens += usage.display_total_tokens(); - total_cache_read_tokens += usage - .cache_read_input_tokens - .unwrap_or(0) as usize; + let _ = event_tx.send(event); } + continue; } - let _ = event_tx.send(WorkerEvent::TurnFailed { - message, - hint, - turn_count, - total_input_tokens, - total_output_tokens, - total_tokens, - total_cache_read_tokens, - prompt_token_estimate: turn - .usage - .as_ref() - .map(|usage| usage.input_tokens as usize) - .unwrap_or(total_input_tokens), - last_query_input_tokens: turn - .usage - .as_ref() - .map(|usage| usage.input_tokens as usize) - .unwrap_or(last_query_input_tokens), - }); - } - } - "turn/plan/updated" => { - if let ServerEvent::TurnPlanUpdated(payload) = event { - let steps = payload - .plan - .into_iter() - .filter_map(|step| { - Some(PlanStep { - text: step.step, - status: parse_plan_step_status(&step.status)?, - }) - }) - .collect::>(); - let _ = event_tx.send(WorkerEvent::PlanUpdated { - explanation: payload - .explanation - .filter(|text| !text.trim().is_empty()), - steps, - }); + + if child_agent_sessions.contains(¬ification_session_id) { + for event in subagent_monitor_events_from_acp_session_notification_with_terminal_state( + notification, + &mut visible_acp_terminal_ids, + &mut pending_acp_terminal_output, + &mut visible_acp_terminal_session_ids, + ) { + let _ = event_tx.send(event); + } + } + continue; } - } - "item/tool/requestUserInput" => { - if let ServerEvent::RequestUserInput(payload) = event - && let Some(turn_id) = payload.request.turn_id + let event: ServerEvent = serde_json::from_value(params) + .with_context(|| format!("failed to decode server event for method {method}"))?; + if handle_btw_agent_event( + &method, + &event, + &mut client, + event_tx, + &mut btw_agent_sessions, + ) + .await { - let _ = event_tx.send(WorkerEvent::RequestUserInput { - session_id: payload.request.session_id, - turn_id, - request_id: payload.request.request_id.to_string(), - questions: payload.questions, - }); - } - } - "inputQueue/updated" => { - if let ServerEvent::InputQueueUpdated(payload) = event { - let _ = event_tx.send(WorkerEvent::InputQueueUpdated { - pending_count: payload.pending_count, - pending_texts: payload.pending_texts, - }); - } - } - "search/updated" => { - if let ServerEvent::ReferenceSearchUpdated(snapshot) = event { - let _ = - event_tx.send(WorkerEvent::ReferenceSearchUpdated { - snapshot, - }); - } - } - "search/completed" => { - if let ServerEvent::ReferenceSearchCompleted(snapshot) = event { - let _ = - event_tx.send(WorkerEvent::ReferenceSearchUpdated { - snapshot, - }); - } - } - "search/failed" => { - if let ServerEvent::ReferenceSearchFailed(payload) = event { - tracing::warn!( - search_id = %payload.search_id, - query = %payload.query, - message = %payload.message, - "reference search failed" - ); - // End the composer loading state instead of waiting forever - // for a completion notification that will never arrive. - let snapshot = ReferenceSearchSnapshot { - search_id: payload.search_id, - query: payload.query, - results: Vec::new(), - total_file_match_count: 0, - scanned_file_count: 0, - file_search_complete: true, - }; - let _ = event_tx.send(WorkerEvent::ReferenceSearchUpdated { - snapshot, - }); + continue; } - } - "session/title/updated" => { - if let ServerEvent::SessionTitleUpdated(payload) = event - && let Some(title) = payload.session.title { - let _ = event_tx.send(WorkerEvent::SessionTitleUpdated { - session_id: payload.session.session_id.to_string(), - title, - }); + if let Some(event_session_id) = event.session_id() + && Some(event_session_id) != session_id + { + if child_agent_sessions.contains(&event_session_id) { + for subagent_event in + subagent_monitor_events_from_unwrapped_server_notification( + method.as_str(), + event.clone(), + ) + { + let _ = event_tx.send(subagent_event); + } } - } - "session/compaction/started" => { - if let ServerEvent::SessionCompactionStarted(_) = event { - let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); - } - } - "session/compaction/completed" => { - if let ServerEvent::SessionCompactionCompleted(payload) = event { - total_input_tokens = payload.session.total_input_tokens; - total_output_tokens = payload.session.total_output_tokens; - total_tokens = payload.session.total_tokens; - let (compacted_last_query_total, compacted_last_query_input) = - last_query_tokens_from_resume(&payload.session); - last_query_total_tokens = if payload.session.prompt_token_estimate > 0 { - payload.session.prompt_token_estimate - } else { - compacted_last_query_total - }; - last_query_input_tokens = if payload.session.prompt_token_estimate > 0 { - payload.session.prompt_token_estimate - } else { - compacted_last_query_input - }; - let _ = event_tx.send(WorkerEvent::SessionCompacted { - total_input_tokens, - total_output_tokens, - total_tokens, - last_query_total_tokens, - last_query_input_tokens, - prompt_token_estimate: payload.session.prompt_token_estimate, - }); + continue; } - } - "session/compaction/failed" => { - if let ServerEvent::SessionCompactionFailed(payload) = event { - let _ = event_tx.send(WorkerEvent::SessionCompactionFailed { - message: payload.message, - }); + match method.as_str() { + "turn/started" => { + if let ServerEvent::TurnStarted(payload) = event { + active_turn_id = Some(payload.turn.turn_id); + saw_usage_update_for_turn = false; + model = payload.turn.model.clone(); + model_binding_id = payload.turn.model_binding_id.clone(); + reasoning_effort_selection = payload.turn.reasoning_effort_selection.clone(); + let _ = event_tx.send(WorkerEvent::TurnStarted { + model: payload.turn.model, + model_binding_id: payload.turn.model_binding_id, + reasoning_effort_selection: payload.turn.reasoning_effort_selection, + reasoning_effort: payload.turn.reasoning_effort, + turn_id: payload.turn.turn_id, + }); + } + latest_completed_agent_message = None; + } + "item/started" => { + if let ServerEvent::ItemStarted(payload) = event { + handle_started_item(payload, event_tx); + } + } + "item/agentMessage/delta" => { + if let ServerEvent::ItemDelta { payload, .. } = event { + if let Some(item_id) = payload.context.item_id { + if let Some(assistant_token_text) = + assistant_token_log_preview(&payload.delta) + { + tracing::debug!( + stream_elapsed_ms = stream_trace_elapsed_ms(), + item_id = %item_id, + event_seq = payload.context.seq, + delta_len = payload.delta.len(), + stream_index = ?payload.stream_index, + channel = ?payload.channel, + assistant_token_text = %assistant_token_text, + "server assistant delta" + ); + } else { + tracing::debug!( + stream_elapsed_ms = stream_trace_elapsed_ms(), + item_id = %item_id, + event_seq = payload.context.seq, + delta_len = payload.delta.len(), + stream_index = ?payload.stream_index, + channel = ?payload.channel, + "server assistant delta" + ); + } + let _ = event_tx.send(WorkerEvent::TextItemDelta { + item_id, + kind: TextItemKind::Assistant, + delta: payload.delta, + }); + } else { + let _ = event_tx.send(WorkerEvent::TextDelta(payload.delta)); + } + } + } + "item/plan/delta" => { + if let ServerEvent::ItemDelta { payload, .. } = event + && let Some(item_id) = payload.context.item_id + { + let _ = event_tx.send(WorkerEvent::ProposedPlanDelta { + item_id, + delta: payload.delta, + }); + } + } + "item/commandExecution/outputDelta" => { + if let ServerEvent::ItemDelta { payload, .. } = event { + let delta_str = &payload.delta; + if let Ok(val) = + serde_json::from_str::(delta_str) + { + let tool_use_id = val + .get("tool_use_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let text = + val.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if !tool_use_id.is_empty() { + let _ = event_tx.send(WorkerEvent::ToolOutputDelta { + tool_use_id: tool_use_id.to_string(), + delta: text.to_string(), + }); + } + } + } + } + "command/exec/outputDelta" => { + if let ServerEvent::CommandExecOutputDelta(payload) = event { + let CommandExecOutputDeltaPayload { + process_id, + delta_base64, + .. + } = payload; + match BASE64_STANDARD.decode(delta_base64) { + Ok(bytes) => { + let delta = + String::from_utf8_lossy(&bytes).to_string(); + let _ = event_tx.send(WorkerEvent::ToolOutputDelta { + tool_use_id: process_id, + delta, + }); + } + Err(error) => { + tracing::warn!( + %error, + "failed to decode command/exec output delta" + ); + } + } + } + } + "command/exec/exited" => { + if let ServerEvent::CommandExecExited(payload) = event { + let CommandExecExitedPayload { + process_id, + exit_code, + .. + } = payload; + if active_shell_process_ids.remove(&process_id) { + let _ = event_tx.send(WorkerEvent::ToolResult { + tool_use_id: process_id, + title: "Shell".to_string(), + preview: String::new(), + is_error: false, + truncated: false, + }); + let _ = event_tx.send(WorkerEvent::ShellCommandFinished { + exit_code, + }); + } + } + } + "item/reasoning/textDelta" | "item/reasoning/summaryTextDelta" => { + if let ServerEvent::ItemDelta { payload, .. } = event { + if let Some(item_id) = payload.context.item_id { + tracing::debug!( + item_id = %item_id, + delta_len = payload.delta.len(), + stream_index = ?payload.stream_index, + channel = ?payload.channel, + "server reasoning delta" + ); + let _ = event_tx.send(WorkerEvent::TextItemDelta { + item_id, + kind: TextItemKind::Reasoning, + delta: payload.delta, + }); + } else { + let _ = event_tx.send(WorkerEvent::ReasoningDelta(payload.delta)); + } + } + } + "item/completed" => { + if let ServerEvent::ItemCompleted(payload) = event { + tracing::debug!( + item_id = %payload.item.item_id, + item_kind = ?payload.item.item_kind, + "server item completed" + ); + if let Some(text) = completed_agent_message_text(&payload) { + latest_completed_agent_message = Some(text); + } + // Completed tool items are mapped into compact UI events + // with pre-rendered summaries and previews. + handle_completed_item(payload, event_tx); + } + } + "turn/completed" => { + if let ServerEvent::TurnCompleted(payload) = event { + tracing::debug!( + turn_id = %payload.turn.turn_id, + status = ?payload.turn.status, + "server turn completed" + ); + active_turn_id = None; + let completed = payload.turn.status == TurnStatus::Completed + || payload.turn.status == TurnStatus::Interrupted; + if completed { + turn_count += 1; + if let Some(usage) = &payload.turn.usage { + if !saw_usage_update_for_turn { + last_query_input_tokens = usage.input_tokens as usize; + last_query_total_tokens = usage.display_total_tokens(); + } + if should_apply_terminal_turn_usage_fallback( + saw_usage_update_for_turn, + has_authoritative_usage_totals, + ) { + total_input_tokens += usage.input_tokens as usize; + total_output_tokens += usage.output_tokens as usize; + total_tokens += usage.display_total_tokens(); + total_cache_read_tokens += usage + .cache_read_input_tokens + .unwrap_or(0) as usize; + } + } + } + let _ = event_tx.send(WorkerEvent::TurnFinished { + stop_reason: format!("{:?}", payload.turn.status), + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + last_query_total_tokens, + last_query_input_tokens, + prompt_token_estimate: payload + .turn + .usage + .as_ref() + .map(|usage| usage.input_tokens as usize) + .unwrap_or(total_input_tokens), + }); + latest_completed_agent_message = None; + } + } + "turn/provider_retry_status" => { + if let ServerEvent::TurnProviderRetryStatus(payload) = event { + let _ = event_tx.send(WorkerEvent::ProviderRetryStatus { + turn_id: payload.turn_id, + attempt: payload.attempt, + backoff_ms: payload.backoff_ms, + provider: payload.provider, + model: payload.model, + phase: payload.phase, + message: payload.message, + }); + } + } + "turn/usage/updated" => { + if let ServerEvent::TurnUsageUpdated(payload) = event { + saw_usage_update_for_turn = true; + total_input_tokens = payload.total_input_tokens; + total_output_tokens = payload.total_output_tokens; + total_tokens = payload.total_tokens; + total_cache_read_tokens = payload.total_cache_read_tokens; + last_query_total_tokens = payload.usage.display_total_tokens(); + last_query_input_tokens = payload.last_query_input_tokens; + has_authoritative_usage_totals = true; + let _ = event_tx.send(WorkerEvent::UsageUpdated { + total_input_tokens: payload.total_input_tokens, + total_output_tokens: payload.total_output_tokens, + total_tokens: payload.total_tokens, + total_cache_read_tokens: payload.total_cache_read_tokens, + last_query_total_tokens: payload.usage.display_total_tokens(), + last_query_input_tokens: payload.last_query_input_tokens, + }); + } + } + "turn/failed" => { + if let ServerEvent::TurnFailed(TurnFailedPayload { turn, error, .. }) = event { + active_turn_id = None; + let (message, hint) = match error { + Some(error) => { + let hint = error.recovery_hint.or_else(|| { + devo_provider::recovery_hint_for_message( + &error.message, + ) + }); + (error.message, hint) + } + None => { + let message = latest_completed_agent_message + .take() + .unwrap_or_else(|| { + format!( + "turn failed with status {:?}", + turn.status + ) + }); + let hint = + devo_provider::recovery_hint_for_message(&message); + (message, hint) + } + }; + if let Some(usage) = &turn.usage { + if !saw_usage_update_for_turn { + last_query_input_tokens = usage.input_tokens as usize; + last_query_total_tokens = usage.display_total_tokens(); + } + if should_apply_terminal_turn_usage_fallback( + saw_usage_update_for_turn, + has_authoritative_usage_totals, + ) { + total_input_tokens += usage.input_tokens as usize; + total_output_tokens += usage.output_tokens as usize; + total_tokens += usage.display_total_tokens(); + total_cache_read_tokens += usage + .cache_read_input_tokens + .unwrap_or(0) as usize; + } + } + let _ = event_tx.send(WorkerEvent::TurnFailed { + message, + hint, + turn_count, + total_input_tokens, + total_output_tokens, + total_tokens, + total_cache_read_tokens, + prompt_token_estimate: turn + .usage + .as_ref() + .map(|usage| usage.input_tokens as usize) + .unwrap_or(total_input_tokens), + last_query_input_tokens: turn + .usage + .as_ref() + .map(|usage| usage.input_tokens as usize) + .unwrap_or(last_query_input_tokens), + }); + } + } + "turn/plan/updated" => { + if let ServerEvent::TurnPlanUpdated(payload) = event { + let steps = payload + .plan + .into_iter() + .filter_map(|step| { + Some(PlanStep { + text: step.step, + status: parse_plan_step_status(&step.status)?, + }) + }) + .collect::>(); + let _ = event_tx.send(WorkerEvent::PlanUpdated { + explanation: payload + .explanation + .filter(|text| !text.trim().is_empty()), + steps, + }); + } + } + "item/tool/requestUserInput" => { + if let ServerEvent::RequestUserInput(payload) = event + && let Some(turn_id) = payload.request.turn_id + { + let _ = event_tx.send(WorkerEvent::RequestUserInput { + session_id: payload.request.session_id, + turn_id, + request_id: payload.request.request_id.to_string(), + questions: payload.questions, + }); + } + } + "inputQueue/updated" => { + if let ServerEvent::InputQueueUpdated(payload) = event { + let _ = event_tx.send(WorkerEvent::InputQueueUpdated { + pending_count: payload.pending_count, + pending_texts: payload.pending_texts, + }); + } + } + "search/updated" => { + if let ServerEvent::ReferenceSearchUpdated(snapshot) = event { + let _ = + event_tx.send(WorkerEvent::ReferenceSearchUpdated { + snapshot, + }); + } + } + "search/completed" => { + if let ServerEvent::ReferenceSearchCompleted(snapshot) = event { + let _ = + event_tx.send(WorkerEvent::ReferenceSearchUpdated { + snapshot, + }); + } + } + "search/failed" => { + if let ServerEvent::ReferenceSearchFailed(payload) = event { + tracing::warn!( + search_id = %payload.search_id, + query = %payload.query, + message = %payload.message, + "reference search failed" + ); + // End the composer loading state instead of waiting forever + // for a completion notification that will never arrive. + let snapshot = ReferenceSearchSnapshot { + search_id: payload.search_id, + query: payload.query, + results: Vec::new(), + total_file_match_count: 0, + scanned_file_count: 0, + file_search_complete: true, + }; + let _ = event_tx.send(WorkerEvent::ReferenceSearchUpdated { + snapshot, + }); + } + } + "session/title/updated" => { + if let ServerEvent::SessionTitleUpdated(payload) = event + && let Some(title) = payload.session.title { + let _ = event_tx.send(WorkerEvent::SessionTitleUpdated { + session_id: payload.session.session_id.to_string(), + title, + }); + } + } + "session/compaction/started" => { + if let ServerEvent::SessionCompactionStarted(_) = event { + let _ = event_tx.send(WorkerEvent::SessionCompactionStarted); + } + } + "session/compaction/completed" => { + if let ServerEvent::SessionCompactionCompleted(payload) = event { + total_input_tokens = payload.session.total_input_tokens; + total_output_tokens = payload.session.total_output_tokens; + total_tokens = payload.session.total_tokens; + let (compacted_last_query_total, compacted_last_query_input) = + last_query_tokens_from_resume(&payload.session); + last_query_total_tokens = if payload.session.prompt_token_estimate > 0 { + payload.session.prompt_token_estimate + } else { + compacted_last_query_total + }; + last_query_input_tokens = if payload.session.prompt_token_estimate > 0 { + payload.session.prompt_token_estimate + } else { + compacted_last_query_input + }; + let _ = event_tx.send(WorkerEvent::SessionCompacted { + total_input_tokens, + total_output_tokens, + total_tokens, + last_query_total_tokens, + last_query_input_tokens, + prompt_token_estimate: payload.session.prompt_token_estimate, + }); + } + } + "session/compaction/failed" => { + if let ServerEvent::SessionCompactionFailed(payload) = event { + let _ = event_tx.send(WorkerEvent::SessionCompactionFailed { + message: payload.message, + }); + } + } + _ => {} } } - _ => {} + None => break, } } - None => break, } - } - } } tracing::info!("query worker shutting down stdio client"); @@ -3841,6 +3845,17 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { index += 1; continue; } + SessionHistoryMetadata::ProposedPlan => { + transcript.push(TranscriptItem::new( + TranscriptItemKind::Assistant, + "Proposed Plan".to_string(), + item.body.clone(), + )); + index += 1; + continue; + } + SessionHistoryMetadata::TurnSummary { .. } + | SessionHistoryMetadata::Edited { .. } => {} SessionHistoryMetadata::Explored { actions } => { let title = item.title.clone(); let body = actions @@ -3852,7 +3867,6 @@ fn project_history_items(items: &[SessionHistoryItem]) -> Vec { index += 1; continue; } - SessionHistoryMetadata::Edited { .. } => {} } } if item.kind == SessionHistoryItemKind::ToolCall @@ -6298,6 +6312,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), } } @@ -6783,6 +6798,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; let entry = SessionListEntry { session_id: summary.session_id, @@ -6827,6 +6843,7 @@ mod tests { last_query_usage: None, last_query_total_tokens: 0, status: SessionRuntimeStatus::Idle, + collaboration_mode: Default::default(), }; let entry = SessionListEntry { session_id: summary.session_id,