481 lines
17 KiB
Rust
481 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use chrono::{Local, Utc};
|
|
use galaxy_cli::agent::Harness;
|
|
use galaxyui::{App, EntityId, SingletonEntity};
|
|
use persistence::model::{AgentConversationData, ConversationUsageMetadata};
|
|
use warp_multi_agent_api as api;
|
|
|
|
use super::{ConversationDetailsData, PanelMode};
|
|
use crate::ai::agent::api::ServerConversationToken;
|
|
use crate::ai::agent::conversation::{
|
|
AIAgentHarness, AIConversation, AIConversationId, ServerAIConversationMetadata,
|
|
};
|
|
use crate::ai::ambient_agents::task::{AgentConfigSnapshot, HarnessConfig, TaskPrincipalInfo};
|
|
use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskState};
|
|
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
|
use crate::auth::UserUid;
|
|
use crate::cloud_object::{Revision, ServerMetadata, ServerPermissions};
|
|
use crate::server::ids::ServerId;
|
|
use crate::workspaces::user_profiles::UserProfileWithUID;
|
|
|
|
fn create_test_task(task_id: &str) -> AmbientAgentTask {
|
|
let now = Utc::now();
|
|
AmbientAgentTask {
|
|
task_id: task_id.parse().unwrap(),
|
|
parent_run_id: None,
|
|
title: "Task".to_string(),
|
|
state: AmbientAgentTaskState::Succeeded,
|
|
prompt: "test".to_string(),
|
|
created_at: now,
|
|
started_at: None,
|
|
updated_at: now,
|
|
run_time: Some("PT1S".parse().unwrap()),
|
|
status_message: None,
|
|
source: None,
|
|
session_id: None,
|
|
session_link: None,
|
|
creator: Some(TaskPrincipalInfo {
|
|
creator_type: "USER".to_string(),
|
|
uid: "user-1".to_string(),
|
|
display_name: Some("User 1".to_string()),
|
|
}),
|
|
executor: None,
|
|
conversation_id: None,
|
|
request_usage: None,
|
|
agent_config_snapshot: None,
|
|
artifacts: vec![],
|
|
is_sandbox_running: false,
|
|
last_event_sequence: None,
|
|
children: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_conversation_prefers_server_creator_profile() {
|
|
App::test((), |mut app| async move {
|
|
let conversation_id = AIConversationId::new();
|
|
let mut conversation = create_restored_conversation(
|
|
conversation_id,
|
|
"root-task",
|
|
"/tmp/server-creator-profile",
|
|
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,
|
|
..Default::default()
|
|
},
|
|
);
|
|
conversation.set_server_metadata(create_test_server_metadata(
|
|
"server-token-creator-profile",
|
|
Some("fallback-uid-that-should-not-render".to_string()),
|
|
Some(UserProfileWithUID {
|
|
firebase_uid: UserUid::new("creator-profile-uid"),
|
|
display_name: Some("ZL".to_string()),
|
|
email: "zl@example.com".to_string(),
|
|
photo_url: "https://example.com/zl.png".to_string(),
|
|
}),
|
|
));
|
|
|
|
app.update(|ctx| {
|
|
let data = ConversationDetailsData::from_conversation(&conversation, ctx);
|
|
let creator = data
|
|
.creator
|
|
.as_ref()
|
|
.expect("server creator profile should be preserved");
|
|
|
|
assert_eq!(creator.display_name, "ZL");
|
|
assert_eq!(
|
|
creator.photo_url.as_deref(),
|
|
Some("https://example.com/zl.png")
|
|
);
|
|
assert_eq!(creator.uid.as_deref(), Some("creator-profile-uid"));
|
|
});
|
|
});
|
|
}
|
|
|
|
fn create_message_with_directory(id: &str, task_id: &str, directory: &str) -> api::Message {
|
|
api::Message {
|
|
fetched_memories: vec![],
|
|
id: id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
server_message_data: String::new(),
|
|
citations: vec![],
|
|
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
|
query: "test query".to_string(),
|
|
context: Some(api::InputContext {
|
|
directory: Some(api::input_context::Directory {
|
|
pwd: directory.to_string(),
|
|
home: String::new(),
|
|
pwd_file_symbols_indexed: false,
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
referenced_attachments: HashMap::new(),
|
|
mode: None,
|
|
intended_agent: Default::default(),
|
|
})),
|
|
request_id: "request-1".to_string(),
|
|
timestamp: None,
|
|
}
|
|
}
|
|
|
|
fn create_agent_output_message(id: &str, task_id: &str) -> api::Message {
|
|
api::Message {
|
|
fetched_memories: vec![],
|
|
id: id.to_string(),
|
|
task_id: task_id.to_string(),
|
|
server_message_data: String::new(),
|
|
citations: vec![],
|
|
message: Some(api::message::Message::AgentOutput(
|
|
api::message::AgentOutput {
|
|
text: "done".to_string(),
|
|
},
|
|
)),
|
|
request_id: "request-1".to_string(),
|
|
timestamp: None,
|
|
}
|
|
}
|
|
|
|
fn create_restored_conversation(
|
|
conversation_id: AIConversationId,
|
|
root_task_id: &str,
|
|
directory: &str,
|
|
conversation_data: AgentConversationData,
|
|
) -> AIConversation {
|
|
let task = api::Task {
|
|
id: root_task_id.to_string(),
|
|
messages: vec![
|
|
create_message_with_directory("message-1", root_task_id, directory),
|
|
create_agent_output_message("message-2", root_task_id),
|
|
],
|
|
dependencies: None,
|
|
description: String::new(),
|
|
summary: String::new(),
|
|
server_data: String::new(),
|
|
};
|
|
|
|
AIConversation::new_restored(conversation_id, vec![task], Some(conversation_data))
|
|
.expect("restored conversation should build")
|
|
}
|
|
|
|
fn create_test_server_metadata(
|
|
server_token: &str,
|
|
creator_uid: Option<String>,
|
|
creator: Option<UserProfileWithUID>,
|
|
) -> ServerAIConversationMetadata {
|
|
ServerAIConversationMetadata {
|
|
title: "test conversation".to_string(),
|
|
working_directory: None,
|
|
harness: AIAgentHarness::Oz,
|
|
usage: ConversationUsageMetadata {
|
|
was_summarized: false,
|
|
context_window_usage: 0.0,
|
|
credits_spent: 0.0,
|
|
platform_credits_spent: 0.0,
|
|
credits_spent_for_last_block: None,
|
|
token_usage: vec![],
|
|
tool_usage_metadata: Default::default(),
|
|
context_window_segments: Vec::new(),
|
|
..Default::default()
|
|
},
|
|
metadata: ServerMetadata {
|
|
uid: ServerId::default(),
|
|
revision: Revision::now(),
|
|
metadata_last_updated_ts: Utc::now().into(),
|
|
trashed_ts: None,
|
|
folder_id: None,
|
|
is_welcome_object: false,
|
|
creator_uid,
|
|
last_editor_uid: None,
|
|
current_editor_uid: None,
|
|
},
|
|
creator,
|
|
permissions: ServerPermissions::mock_personal(),
|
|
ambient_agent_task_id: None,
|
|
server_conversation_token: ServerConversationToken::new(server_token.to_string()),
|
|
artifacts: vec![],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_task_includes_linked_directory_when_run_id_matches() {
|
|
App::test((), |mut app| async move {
|
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let task_id = "550e8400-e29b-41d4-a716-000000004000";
|
|
let directory = "/tmp/run-id-directory";
|
|
|
|
let conversation = create_restored_conversation(
|
|
conversation_id,
|
|
"root-task",
|
|
directory,
|
|
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: Some(task_id.to_string()),
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
pinned: false,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
history_model.update(&mut app, |model, ctx| {
|
|
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
|
|
});
|
|
|
|
let task = create_test_task(task_id);
|
|
app.update(|ctx| {
|
|
let data = ConversationDetailsData::from_task(&task, None, None, ctx);
|
|
assert!(matches!(
|
|
data.mode,
|
|
PanelMode::Task {
|
|
directory: Some(ref task_directory),
|
|
..
|
|
} if task_directory == directory
|
|
));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_conversation_metadata_passes_harness_through() {
|
|
for harness in [
|
|
None,
|
|
Some(Harness::Oz),
|
|
Some(Harness::Claude),
|
|
Some(Harness::Gemini),
|
|
Some(Harness::Unknown),
|
|
] {
|
|
let data = ConversationDetailsData::from_conversation_metadata(
|
|
AIConversationId::new(),
|
|
"Title".to_string(),
|
|
None,
|
|
Utc::now().with_timezone(&Local),
|
|
None,
|
|
None,
|
|
None,
|
|
vec![],
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
harness,
|
|
);
|
|
assert_eq!(
|
|
data.harness, harness,
|
|
"harness {harness:?} should pass through"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_task_resolves_harness() {
|
|
App::test((), |mut app| async move {
|
|
let _history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
// Base task has `agent_config_snapshot: None`; cloning lets us mutate per case.
|
|
let base_task = create_test_task("550e8400-e29b-41d4-a716-000000004020");
|
|
|
|
app.update(|ctx| {
|
|
// No snapshot → harness unknown.
|
|
let data = ConversationDetailsData::from_task(&base_task, None, None, ctx);
|
|
assert_eq!(data.harness, None);
|
|
|
|
// Snapshot without an explicit harness → default to Warp Agent.
|
|
let mut task = base_task.clone();
|
|
task.agent_config_snapshot = Some(AgentConfigSnapshot::default());
|
|
let data = ConversationDetailsData::from_task(&task, None, None, ctx);
|
|
assert_eq!(data.harness, Some(Harness::Oz));
|
|
|
|
// Snapshot with explicit harness_type.
|
|
for harness in [
|
|
Harness::Oz,
|
|
Harness::Claude,
|
|
Harness::Gemini,
|
|
Harness::Unknown,
|
|
] {
|
|
let mut task = base_task.clone();
|
|
task.agent_config_snapshot = Some(AgentConfigSnapshot {
|
|
harness: Some(HarnessConfig::from_harness_type(harness)),
|
|
..Default::default()
|
|
});
|
|
let data = ConversationDetailsData::from_task(&task, None, None, ctx);
|
|
assert_eq!(data.harness, Some(harness), "harness {harness:?}");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_task_populates_executor() {
|
|
App::test((), |mut app| async move {
|
|
let _history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
let mut task = create_test_task("550e8400-e29b-41d4-a716-000000004030");
|
|
task.executor = Some(TaskPrincipalInfo {
|
|
creator_type: "service_account".to_string(),
|
|
uid: "agent-uid".to_string(),
|
|
display_name: Some("Deploy Agent".to_string()),
|
|
});
|
|
|
|
app.update(|ctx| {
|
|
let data = ConversationDetailsData::from_task(&task, None, None, ctx);
|
|
assert_eq!(
|
|
data.executor
|
|
.as_ref()
|
|
.map(|executor| executor.display_name.as_str()),
|
|
Some("Deploy Agent")
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_conversation_populates_local_conversation_fields() {
|
|
// Locks in that `ConversationDetailsData::from_conversation` works on native
|
|
// and surfaces the conversation-derived fields the conversation details panel
|
|
// renders for local Warp Agent runs (APP-3595).
|
|
App::test((), |mut app| async move {
|
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let directory = "/tmp/local-conversation-directory";
|
|
let conversation = create_restored_conversation(
|
|
conversation_id,
|
|
"root-task",
|
|
directory,
|
|
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,
|
|
run_id: None,
|
|
autoexecute_override: None,
|
|
last_event_sequence: None,
|
|
is_remote_child: false,
|
|
root_task_is_optimistic: None,
|
|
pinned: false,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
history_model.update(&mut app, |model, ctx| {
|
|
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
|
|
});
|
|
|
|
app.update(|ctx| {
|
|
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
|
.conversation(&conversation_id)
|
|
.expect("conversation should be present");
|
|
let data = ConversationDetailsData::from_conversation(conversation, ctx);
|
|
|
|
// Mode should be Conversation with the working directory and no server-side
|
|
// conversation id (since this conversation was restored without a server token).
|
|
match &data.mode {
|
|
PanelMode::Conversation {
|
|
directory: panel_directory,
|
|
server_conversation_id,
|
|
ai_conversation_id,
|
|
status,
|
|
} => {
|
|
assert_eq!(panel_directory.as_deref(), Some(directory));
|
|
assert!(server_conversation_id.is_none());
|
|
// `from_conversation` does not have access to the in-memory
|
|
// AIConversationId; that field is populated only by the
|
|
// management view path (`from_conversation_metadata`).
|
|
assert!(ai_conversation_id.is_none());
|
|
assert!(status.is_some());
|
|
}
|
|
PanelMode::Task { .. } => {
|
|
panic!("expected Conversation mode for a local conversation")
|
|
}
|
|
}
|
|
|
|
assert_eq!(data.title, "test query");
|
|
assert_eq!(data.source_prompt.as_deref(), Some("test query"));
|
|
assert!(data.credits.is_some());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_task_includes_linked_directory_when_server_token_matches() {
|
|
App::test((), |mut app| async move {
|
|
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
|
|
|
let conversation_id = AIConversationId::new();
|
|
let server_token = "server-token-123";
|
|
let directory = "/tmp/server-token-directory";
|
|
|
|
let conversation = create_restored_conversation(
|
|
conversation_id,
|
|
"root-task",
|
|
directory,
|
|
AgentConversationData {
|
|
server_conversation_token: Some(server_token.to_string()),
|
|
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,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
history_model.update(&mut app, |model, ctx| {
|
|
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
|
|
});
|
|
|
|
let mut task = create_test_task("550e8400-e29b-41d4-a716-000000004001");
|
|
task.conversation_id = Some(server_token.to_string());
|
|
|
|
app.update(|ctx| {
|
|
let data = ConversationDetailsData::from_task(&task, None, None, ctx);
|
|
assert!(matches!(
|
|
data.mode,
|
|
PanelMode::Task {
|
|
directory: Some(ref task_directory),
|
|
..
|
|
} if task_directory == directory
|
|
));
|
|
});
|
|
});
|
|
}
|