first pass of merging in warp (doesn't build)
This commit is contained in:
+223
-119
@@ -5,7 +5,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::prelude::*;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use warp_multi_agent_api::{self as api, response_event::stream_finished};
|
||||
use warp_multi_agent_api::response_event::stream_finished;
|
||||
use warp_multi_agent_api::{self as api};
|
||||
|
||||
use super::schema::{
|
||||
active_mcp_servers, agent_conversations, agent_tasks, ai_document_panes, ai_memory_panes,
|
||||
@@ -14,8 +15,8 @@ use super::schema::{
|
||||
generic_string_objects, ignored_suggestions, mcp_environment_variables,
|
||||
mcp_server_installations, mcp_server_panes, notebook_panes, notebooks, object_actions,
|
||||
object_metadata, object_permissions, pane_branches, pane_leaves, pane_nodes, panels,
|
||||
project_rules, projects, server_experiments, settings_panes, tabs, team_members, team_settings,
|
||||
teams, terminal_panes, user_profiles, welcome_panes, windows, workflow_panes, workflows,
|
||||
project_rules, projects, server_experiments, settings_panes, tab_groups, tabs, team_members,
|
||||
team_settings, teams, terminal_panes, user_profiles, windows, workflow_panes, workflows,
|
||||
workspace_language_server, workspace_metadata, workspace_teams, workspaces,
|
||||
};
|
||||
|
||||
@@ -348,6 +349,8 @@ pub struct Tab {
|
||||
pub window_id: i32,
|
||||
pub custom_title: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub tab_group_id: Option<i32>,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
@@ -356,6 +359,32 @@ pub struct NewTab {
|
||||
pub window_id: i32,
|
||||
pub custom_title: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub tab_group_id: Option<i32>,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
/// Persisted form of a tab group. `name` is optional — untitled groups omit
|
||||
/// it and the UI falls back to a default label.
|
||||
#[derive(Identifiable, Queryable, Associations)]
|
||||
#[diesel(belongs_to(Window))]
|
||||
#[diesel(table_name = tab_groups)]
|
||||
pub struct TabGroup {
|
||||
pub id: i32,
|
||||
pub window_id: i32,
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub collapsed: bool,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = tab_groups)]
|
||||
pub struct NewTabGroup {
|
||||
pub window_id: i32,
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub collapsed: bool,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
/// The panes data model includes pane_nodes, pane_leaves and pane_branches.
|
||||
@@ -466,15 +495,6 @@ pub struct SettingsPane {
|
||||
pub current_page: String,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Selectable)]
|
||||
#[diesel(table_name = welcome_panes)]
|
||||
#[diesel(primary_key(id))]
|
||||
pub struct WelcomePane {
|
||||
pub id: i32,
|
||||
pub kind: String,
|
||||
pub startup_directory: Option<String>,
|
||||
}
|
||||
|
||||
/// Maps to the `ai_memory_panes` table
|
||||
/// (where table name is historical and not worth a migration to change).
|
||||
#[derive(Identifiable, Queryable, Selectable)]
|
||||
@@ -557,9 +577,6 @@ pub const CODE_REVIEW_PANE_KIND: &str = "code_review";
|
||||
/// The [`pane_leaves::kind`] value for execution profile editor panes.
|
||||
pub const EXECUTION_PROFILE_EDITOR_PANE_KIND: &str = "execution_profile_editor";
|
||||
|
||||
/// The [`pane_leaves::kind`] value for the welcome pane.
|
||||
pub const WELCOME_PANE_KIND: &str = "welcome";
|
||||
|
||||
/// The [`pane_leaves::kind`] value for the get-started pane.
|
||||
pub const GET_STARTED_PANE_KIND: &str = "get_started";
|
||||
|
||||
@@ -653,13 +670,6 @@ pub struct NewMCPServerPane {
|
||||
pub id: i32,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = welcome_panes)]
|
||||
pub struct NewWelcomePane {
|
||||
pub id: i32,
|
||||
pub startup_directory: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Identifiable, Queryable, Selectable)]
|
||||
#[diesel(table_name = ambient_agent_panes)]
|
||||
#[diesel(primary_key(id))]
|
||||
@@ -700,7 +710,7 @@ pub struct NewBlock<'a> {
|
||||
pub block_id: &'a str,
|
||||
// Note that there is no pane leaf UUID foreign key relationship because there's no good way to
|
||||
// enforce it: when we remove a pane and subsequently create a new snapshot, the old blocks
|
||||
// will now violate the constaint. While sqlite does have deferred constraints, it doesn't
|
||||
// will now violate the constraint. While sqlite does have deferred constraints, it doesn't
|
||||
// work well with ON DELETE CASCADE (i.e. the cascade happens on the delete, not after the
|
||||
// transaction commit).
|
||||
pub pane_leaf_uuid: Vec<u8>,
|
||||
@@ -942,13 +952,24 @@ impl AgentConversation {
|
||||
///
|
||||
/// A conversation is restorable if:
|
||||
/// - It contains a single task or fewer, OR
|
||||
/// - It contains multiple tasks where every task other than the root task has a parent task ID.
|
||||
/// - It has exactly one parentless (root) task, OR
|
||||
/// - It has multiple parentless tasks but exactly one of them has
|
||||
/// non-empty `messages`. This permits restoring conversations whose
|
||||
/// persisted state was corrupted by the pre-QUALITY-774 optimistic-root
|
||||
/// writer bug, where a stub root row co-existed with the real server
|
||||
/// root row. `AIConversation::new_restored` deterministically picks
|
||||
/// the real root in that shape via its restore-side dedupe.
|
||||
///
|
||||
/// Non-root tasks need not be validated here: any task that does not
|
||||
/// match the parentless predicate has, by construction, a non-empty
|
||||
/// `parent_task_id`.
|
||||
pub fn is_restorable(&self) -> bool {
|
||||
if self.tasks.len() <= 1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the root task(s) - tasks with no parent_task_id or empty parent_task_id
|
||||
// Find parentless (root) tasks - tasks with no dependencies or with an
|
||||
// empty parent_task_id.
|
||||
let root_tasks: Vec<_> = self
|
||||
.tasks
|
||||
.iter()
|
||||
@@ -960,28 +981,18 @@ impl AgentConversation {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Must have exactly one root task
|
||||
if root_tasks.len() != 1 {
|
||||
return false;
|
||||
match root_tasks.len() {
|
||||
// Malformed: no parentless task means no root to anchor restore on.
|
||||
0 => false,
|
||||
// Single root: the normal happy path.
|
||||
1 => true,
|
||||
// Multi-root: only permit the specific [stub + real] shape
|
||||
// produced by the pre-QUALITY-774 optimistic-root writer bug,
|
||||
// where exactly one parentless row carries the real conversation
|
||||
// content. The restore-side dedupe in
|
||||
// `AIConversation::new_restored` will pick that real root.
|
||||
_ => root_tasks.iter().filter(|t| !t.messages.is_empty()).count() == 1,
|
||||
}
|
||||
|
||||
// All non-root tasks must have a non-empty parent_task_id
|
||||
self.tasks.iter().all(|task| {
|
||||
// Root task is always valid
|
||||
if task
|
||||
.dependencies
|
||||
.as_ref()
|
||||
.map(|deps| deps.parent_task_id.is_empty())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-root tasks must have a non-empty parent_task_id
|
||||
task.dependencies
|
||||
.as_ref()
|
||||
.is_some_and(|deps| !deps.parent_task_id.is_empty())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +1016,10 @@ impl<'de> Deserialize<'de> for PersistedAutoexecuteMode {
|
||||
})
|
||||
}
|
||||
}
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
// Serializes to `conversation_data` column in `agent_conversations`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AgentConversationData {
|
||||
@@ -1026,9 +1041,29 @@ pub struct AgentConversationData {
|
||||
/// The display name for this agent, assigned by the orchestrator.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_name: Option<String>,
|
||||
/// Harness type used to render the child agent's shared icon in orchestration UI.
|
||||
#[serde(
|
||||
default,
|
||||
alias = "orchestration_avatar_id",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub orchestration_harness_type: Option<String>,
|
||||
/// The local conversation ID of the parent conversation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent_conversation_id: Option<String>,
|
||||
/// True when this conversation is a parent-side placeholder for a child
|
||||
/// agent executing on a remote worker.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub is_remote_child: bool,
|
||||
/// Legacy marker that previously recorded whether the root task was still
|
||||
/// optimistic when this conversation was persisted. Retained on the struct
|
||||
/// for backward-compatible deserialization of rows written by older builds;
|
||||
/// new writes always emit `None` and restore code ignores the value.
|
||||
///
|
||||
// TODO: Remove this field once no live local DBs still contain
|
||||
// `Some(true)` rows that legacy code paths might trip over.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub root_task_is_optimistic: Option<bool>,
|
||||
/// The server-assigned run identifier (`ai_tasks.id`) for v2 orchestration.
|
||||
/// For local agents this arrives via StreamInit; for cloud agents it will
|
||||
/// come from SpawnAgentResponse once the local→cloud spawn path is wired.
|
||||
@@ -1041,12 +1076,10 @@ pub struct AgentConversationData {
|
||||
/// delivery without re-delivering already-processed events.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_event_sequence: Option<i64>,
|
||||
/// Progressive summary of older conversation messages.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub progressive_summary: Option<String>,
|
||||
/// Number of messages that were summarized into progressive_summary.
|
||||
#[serde(default)]
|
||||
pub messages_summarized_up_to: usize,
|
||||
/// Whether the user has pinned this child agent in the orchestration
|
||||
/// pill bar. Orchestrator conversations always serialize as `false`.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -1073,6 +1106,10 @@ pub fn token_usage_category_display_name(category: &str) -> String {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ModelTokenUsage {
|
||||
/// Identifier used for both display and replay. For warp/byok rows this is the
|
||||
/// server-known model id; for custom endpoint rows this is the resolved alias
|
||||
/// (or fallback label) — the upstream `config_key` is translated into this
|
||||
/// label once at ingestion time and is not retained separately.
|
||||
pub model_id: String,
|
||||
/// Alias for backward compat: old persisted data used `total_tokens` for warp usage.
|
||||
#[serde(default, alias = "total_tokens")]
|
||||
@@ -1080,9 +1117,13 @@ pub struct ModelTokenUsage {
|
||||
#[serde(default)]
|
||||
pub byok_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub custom_endpoint_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub warp_token_usage_by_category: HashMap<TokenUsageCategory, u32>,
|
||||
#[serde(default)]
|
||||
pub byok_token_usage_by_category: HashMap<TokenUsageCategory, u32>,
|
||||
#[serde(default)]
|
||||
pub custom_endpoint_token_usage_by_category: HashMap<TokenUsageCategory, u32>,
|
||||
}
|
||||
|
||||
impl ModelTokenUsage {
|
||||
@@ -1115,16 +1156,37 @@ impl ModelTokenUsage {
|
||||
pub fn to_proto_byok_usage(&self) -> Option<(String, stream_finished::ModelTokenUsage)> {
|
||||
self.to_proto_usage(self.byok_tokens, &self.byok_token_usage_by_category)
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
pub fn to_proto_custom_endpoint_usage(
|
||||
&self,
|
||||
) -> Option<(String, stream_finished::ModelTokenUsage)> {
|
||||
if self.custom_endpoint_tokens == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
self.model_id.clone(),
|
||||
stream_finished::ModelTokenUsage {
|
||||
model_id: self.model_id.clone(),
|
||||
total_tokens: self.custom_endpoint_tokens,
|
||||
token_usage_by_category: self
|
||||
.custom_endpoint_token_usage_by_category
|
||||
.iter()
|
||||
.map(|(cat, tokens)| (cat.clone(), *tokens))
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
pub fn to_proto_combined(&self) -> stream_finished::ModelTokenUsage {
|
||||
stream_finished::ModelTokenUsage {
|
||||
model_id: self.model_id.clone(),
|
||||
total_tokens: self.warp_tokens + self.byok_tokens,
|
||||
total_tokens: self.warp_tokens + self.byok_tokens + self.custom_endpoint_tokens,
|
||||
token_usage_by_category: self
|
||||
.warp_token_usage_by_category
|
||||
.iter()
|
||||
.chain(self.byok_token_usage_by_category.iter())
|
||||
.chain(self.custom_endpoint_token_usage_by_category.iter())
|
||||
.fold(HashMap::new(), |mut acc, (cat, tokens)| {
|
||||
*acc.entry(cat.clone()).or_insert(0) += tokens;
|
||||
acc
|
||||
@@ -1299,25 +1361,124 @@ impl From<&stream_finished::ToolUsageMetadata> for ToolUsageMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of a context-window segment, mirroring the proto
|
||||
/// `ContextWindowSegmentType` enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextWindowSegmentType {
|
||||
#[default]
|
||||
Unknown,
|
||||
SystemPrompt,
|
||||
ToolDefinitions,
|
||||
ConversationHistory,
|
||||
LatestInput,
|
||||
Images,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ContextWindowSegmentType {
|
||||
/// Snake-case identifier used for display-name lookup.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContextWindowSegmentType::Unknown => "unknown",
|
||||
ContextWindowSegmentType::SystemPrompt => "system_prompt",
|
||||
ContextWindowSegmentType::ToolDefinitions => "tool_definitions",
|
||||
ContextWindowSegmentType::ConversationHistory => "conversation_history",
|
||||
ContextWindowSegmentType::LatestInput => "latest_input",
|
||||
ContextWindowSegmentType::Images => "images",
|
||||
ContextWindowSegmentType::Other => "other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for ContextWindowSegmentType {
|
||||
fn from(value: i32) -> Self {
|
||||
match stream_finished::ContextWindowSegmentType::try_from(value) {
|
||||
Ok(stream_finished::ContextWindowSegmentType::SystemPrompt) => Self::SystemPrompt,
|
||||
Ok(stream_finished::ContextWindowSegmentType::ToolDefinitions) => Self::ToolDefinitions,
|
||||
Ok(stream_finished::ContextWindowSegmentType::ConversationHistory) => {
|
||||
Self::ConversationHistory
|
||||
}
|
||||
Ok(stream_finished::ContextWindowSegmentType::LatestInput) => Self::LatestInput,
|
||||
Ok(stream_finished::ContextWindowSegmentType::Images) => Self::Images,
|
||||
Ok(stream_finished::ContextWindowSegmentType::Other) => Self::Other,
|
||||
// Unknown (0) and any unrecognized value map to Unknown.
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ContextWindowSegmentType> for i32 {
|
||||
fn from(value: ContextWindowSegmentType) -> Self {
|
||||
match value {
|
||||
ContextWindowSegmentType::Unknown => {
|
||||
stream_finished::ContextWindowSegmentType::Unknown as i32
|
||||
}
|
||||
ContextWindowSegmentType::SystemPrompt => {
|
||||
stream_finished::ContextWindowSegmentType::SystemPrompt as i32
|
||||
}
|
||||
ContextWindowSegmentType::ToolDefinitions => {
|
||||
stream_finished::ContextWindowSegmentType::ToolDefinitions as i32
|
||||
}
|
||||
ContextWindowSegmentType::ConversationHistory => {
|
||||
stream_finished::ContextWindowSegmentType::ConversationHistory as i32
|
||||
}
|
||||
ContextWindowSegmentType::LatestInput => {
|
||||
stream_finished::ContextWindowSegmentType::LatestInput as i32
|
||||
}
|
||||
ContextWindowSegmentType::Images => {
|
||||
stream_finished::ContextWindowSegmentType::Images as i32
|
||||
}
|
||||
ContextWindowSegmentType::Other => {
|
||||
stream_finished::ContextWindowSegmentType::Other as i32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single portion of the context window, described by its kind and an
|
||||
/// estimated token count. Segment token counts add up to the token total
|
||||
/// represented by `context_window_usage`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ContextWindowSegment {
|
||||
pub segment_type: ContextWindowSegmentType,
|
||||
/// Estimated number of tokens this segment occupies in the context window.
|
||||
pub token_count: u32,
|
||||
}
|
||||
|
||||
impl From<&stream_finished::ContextWindowSegment> for ContextWindowSegment {
|
||||
fn from(segment: &stream_finished::ContextWindowSegment) -> Self {
|
||||
Self {
|
||||
segment_type: segment.segment_type.into(),
|
||||
token_count: segment.token_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ContextWindowSegment> for stream_finished::ContextWindowSegment {
|
||||
fn from(segment: &ContextWindowSegment) -> Self {
|
||||
Self {
|
||||
segment_type: segment.segment_type.into(),
|
||||
token_count: segment.token_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ConversationUsageMetadata {
|
||||
pub was_summarized: bool,
|
||||
pub context_window_usage: f32,
|
||||
pub credits_spent: f32,
|
||||
#[serde(default)]
|
||||
pub platform_credits_spent: f32,
|
||||
#[serde(default)]
|
||||
pub credits_spent_for_last_block: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub token_usage: Vec<ModelTokenUsage>,
|
||||
#[serde(default)]
|
||||
pub tool_usage_metadata: ToolUsageMetadata,
|
||||
#[serde(default)]
|
||||
pub total_cache_read_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_cache_write_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_cache_miss_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_cost_cents: f32,
|
||||
pub context_window_segments: Vec<ContextWindowSegment>,
|
||||
}
|
||||
|
||||
impl ConversationUsageMetadata {
|
||||
@@ -1347,65 +1508,8 @@ pub struct NewMCPServerInstallation {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AgentConversationData;
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
parent_conversation_id: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: Some(42),
|
||||
progressive_summary: None,
|
||||
messages_summarized_up_to: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(roundtripped.last_event_sequence, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_deserializes_legacy_payload_without_last_event_sequence() {
|
||||
// Legacy rows persisted before this feature landed omit the field
|
||||
// entirely. `#[serde(default)]` must accept them as `None`.
|
||||
let legacy_json = r#"{"server_conversation_token":null}"#;
|
||||
let data: AgentConversationData =
|
||||
serde_json::from_str(legacy_json).expect("legacy rows must deserialize");
|
||||
assert_eq!(data.last_event_sequence, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
parent_conversation_id: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
progressive_summary: None,
|
||||
messages_summarized_up_to: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
assert!(
|
||||
!json.contains("last_event_sequence"),
|
||||
"None should be skipped in serialized output: {json}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "model_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Insertable)]
|
||||
#[diesel(table_name = panels)]
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use super::{AgentConversation, AgentConversationData, ModelTokenUsage};
|
||||
|
||||
fn parentless_task(id: &str, message_count: usize) -> api::Task {
|
||||
api::Task {
|
||||
id: id.to_string(),
|
||||
description: String::new(),
|
||||
dependencies: None,
|
||||
messages: (0..message_count)
|
||||
.map(|i| api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: format!("{id}-msg-{i}"),
|
||||
task_id: id.to_string(),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: None,
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
})
|
||||
.collect(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn child_task(id: &str, parent_id: &str) -> api::Task {
|
||||
api::Task {
|
||||
id: id.to_string(),
|
||||
description: String::new(),
|
||||
dependencies: Some(api::task::Dependencies {
|
||||
parent_task_id: parent_id.to_string(),
|
||||
}),
|
||||
messages: vec![],
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_with_tasks(tasks: Vec<api::Task>) -> AgentConversation {
|
||||
AgentConversation {
|
||||
conversation: Default::default(),
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy [stub + real] root shape produced by the pre-QUALITY-774
|
||||
/// optimistic-root writer bug must be considered restorable so the
|
||||
/// restore-side dedupe in `AIConversation::new_restored` can pick the
|
||||
/// real root.
|
||||
#[test]
|
||||
fn is_restorable_accepts_legacy_stub_plus_real_root_shape() {
|
||||
let conversation = conversation_with_tasks(vec![
|
||||
parentless_task("optimistic-stub-uuid", 0),
|
||||
parentless_task("server-root-id", 2),
|
||||
child_task("child-1", "server-root-id"),
|
||||
]);
|
||||
assert!(conversation.is_restorable());
|
||||
}
|
||||
|
||||
/// Multi-root with multiple real roots (each non-empty) is genuinely
|
||||
/// ambiguous and must remain rejected — the dedupe heuristic cannot
|
||||
/// disambiguate between two real roots.
|
||||
#[test]
|
||||
fn is_restorable_rejects_multi_root_with_multiple_real_roots() {
|
||||
let conversation = conversation_with_tasks(vec![
|
||||
parentless_task("root-a", 1),
|
||||
parentless_task("root-b", 1),
|
||||
]);
|
||||
assert!(!conversation.is_restorable());
|
||||
}
|
||||
|
||||
/// Multi-root where every candidate is empty has nothing to anchor
|
||||
/// restore on and must remain rejected.
|
||||
#[test]
|
||||
fn is_restorable_rejects_multi_root_with_no_real_root() {
|
||||
let conversation = conversation_with_tasks(vec![
|
||||
parentless_task("stub-1", 0),
|
||||
parentless_task("stub-2", 0),
|
||||
]);
|
||||
assert!(!conversation.is_restorable());
|
||||
}
|
||||
|
||||
/// Normal happy path: a single parentless root plus well-formed child
|
||||
/// tasks remains restorable.
|
||||
#[test]
|
||||
fn is_restorable_accepts_single_root_plus_subtasks() {
|
||||
let conversation = conversation_with_tasks(vec![
|
||||
parentless_task("root", 1),
|
||||
child_task("child-1", "root"),
|
||||
child_task("child-2", "root"),
|
||||
]);
|
||||
assert!(conversation.is_restorable());
|
||||
}
|
||||
|
||||
/// Empty or single-task conversations are trivially restorable.
|
||||
#[test]
|
||||
fn is_restorable_accepts_empty_and_single_task_conversations() {
|
||||
assert!(conversation_with_tasks(vec![]).is_restorable());
|
||||
assert!(conversation_with_tasks(vec![parentless_task("root", 0)]).is_restorable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: Some("claude".to_string()),
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: Some(42),
|
||||
pinned: false,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(roundtripped.last_event_sequence, Some(42));
|
||||
assert_eq!(
|
||||
roundtripped.orchestration_harness_type.as_deref(),
|
||||
Some("claude")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_accepts_legacy_orchestration_avatar_id() {
|
||||
let legacy_json = r#"{"orchestration_avatar_id":"orbit"}"#;
|
||||
let data: AgentConversationData =
|
||||
serde_json::from_str(legacy_json).expect("legacy rows must deserialize");
|
||||
|
||||
assert_eq!(data.orchestration_harness_type.as_deref(), Some("orbit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_remote_child_marker() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: true,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
|
||||
assert!(roundtripped.is_remote_child);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_optimistic_root_marker() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: Some(true),
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(roundtripped.root_task_is_optimistic, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_deserializes_legacy_payload_without_last_event_sequence() {
|
||||
// Legacy rows persisted before this feature landed omit the field
|
||||
// entirely. `#[serde(default)]` must accept them as `None`.
|
||||
let legacy_json = r#"{"server_conversation_token":null}"#;
|
||||
let data: AgentConversationData =
|
||||
serde_json::from_str(legacy_json).expect("legacy rows must deserialize");
|
||||
assert_eq!(data.last_event_sequence, None);
|
||||
assert_eq!(data.orchestration_harness_type, None);
|
||||
assert!(!data.is_remote_child);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_skips_serializing_none_last_event_sequence() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
assert!(
|
||||
!json.contains("last_event_sequence"),
|
||||
"None should be skipped in serialized output: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_roundtrips_pinned() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: true,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize");
|
||||
assert!(roundtripped.pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_skips_serializing_unpinned() {
|
||||
let data = AgentConversationData {
|
||||
server_conversation_token: None,
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: None,
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
};
|
||||
let json = serde_json::to_string(&data).expect("serialize");
|
||||
assert!(
|
||||
!json.contains("pinned"),
|
||||
"Unpinned default should be skipped: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_conversation_data_legacy_rows_default_to_unpinned() {
|
||||
let legacy_json = r#"{"server_conversation_token":null}"#;
|
||||
let data: AgentConversationData =
|
||||
serde_json::from_str(legacy_json).expect("legacy rows must deserialize");
|
||||
assert!(!data.pinned);
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
#[test]
|
||||
fn model_token_usage_replays_custom_endpoint_usage_by_model_id() {
|
||||
let usage = ModelTokenUsage {
|
||||
model_id: "Friendly alias".to_string(),
|
||||
custom_endpoint_tokens: 6,
|
||||
custom_endpoint_token_usage_by_category: HashMap::from([("primary_agent".to_string(), 6)]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (key, proto) = usage
|
||||
.to_proto_custom_endpoint_usage()
|
||||
.expect("custom endpoint usage should serialize for replay");
|
||||
|
||||
assert_eq!(key, "Friendly alias");
|
||||
assert_eq!(proto.model_id, "Friendly alias");
|
||||
assert_eq!(proto.total_tokens, 6);
|
||||
assert_eq!(proto.token_usage_by_category.get("primary_agent"), Some(&6));
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
#[test]
|
||||
fn model_token_usage_replay_skips_non_custom_endpoint_entries() {
|
||||
let warp_only = ModelTokenUsage {
|
||||
model_id: "warp-model".to_string(),
|
||||
warp_tokens: 4,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(warp_only.to_proto_custom_endpoint_usage().is_none());
|
||||
}
|
||||
@@ -354,12 +354,25 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tab_groups (id) {
|
||||
id -> Integer,
|
||||
window_id -> Integer,
|
||||
name -> Nullable<Text>,
|
||||
color -> Nullable<Text>,
|
||||
collapsed -> Bool,
|
||||
pinned -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
tabs (id) {
|
||||
id -> Integer,
|
||||
window_id -> Integer,
|
||||
custom_title -> Nullable<Text>,
|
||||
color -> Nullable<Text>,
|
||||
tab_group_id -> Nullable<Integer>,
|
||||
pinned -> Bool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,14 +435,6 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
welcome_panes (id) {
|
||||
id -> Integer,
|
||||
kind -> Text,
|
||||
startup_directory -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
windows (id) {
|
||||
id -> Integer,
|
||||
@@ -509,6 +514,8 @@ diesel::joinable!(pane_branches -> pane_nodes (pane_node_id));
|
||||
diesel::joinable!(pane_leaves -> pane_nodes (pane_node_id));
|
||||
diesel::joinable!(pane_nodes -> tabs (tab_id));
|
||||
diesel::joinable!(panels -> tabs (tab_id));
|
||||
diesel::joinable!(tab_groups -> windows (window_id));
|
||||
diesel::joinable!(tabs -> tab_groups (tab_group_id));
|
||||
diesel::joinable!(tabs -> windows (window_id));
|
||||
diesel::joinable!(team_members -> teams (team_id));
|
||||
diesel::joinable!(team_settings -> teams (team_id));
|
||||
@@ -521,6 +528,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
||||
pane_leaves,
|
||||
pane_nodes,
|
||||
panels,
|
||||
tab_groups,
|
||||
tabs,
|
||||
windows,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user