mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Wire up ACP session lifecycle: create, load, list, and stream (#115)
Replace the old chat/sessions commands and frontend stores with direct ACP integration. Sessions are created lazily, loaded via list_sessions and load_session, and messages stream through the existing ACP event pipeline. - Add acp::create_session, list_sessions, load_session Tauri commands - Simplify chatSessionStore to drive state from ACP responses - Add ChatLoadingSkeleton for session loading transitions - Add shared/api/acp.ts frontend bindings - Remove legacy chat.rs, sessions.rs, catchup.rs, and store.rs - Support GOOSE_LOCAL_BINARY env var for local dev - Add tests for session loading and draft persistence Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
+28
-20
@@ -79,17 +79,21 @@ dev:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
|
||||
rc=$?
|
||||
if [[ $rc -eq 2 ]]; then
|
||||
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
|
||||
exit 1
|
||||
if [[ -n "${GOOSE_BIN:-}" ]]; then
|
||||
echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}"
|
||||
else
|
||||
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
|
||||
rc=$?
|
||||
if [[ $rc -eq 2 ]]; then
|
||||
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
}
|
||||
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
|
||||
echo "Using local goose binary: ${GOOSE_BIN}"
|
||||
fi
|
||||
exit $rc
|
||||
}
|
||||
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
|
||||
echo "Using local goose binary: ${GOOSE_BIN}"
|
||||
fi
|
||||
|
||||
# Derive a stable port from the working directory so the same worktree
|
||||
@@ -125,17 +129,21 @@ dev-debug:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
|
||||
rc=$?
|
||||
if [[ $rc -eq 2 ]]; then
|
||||
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
|
||||
exit 1
|
||||
if [[ -n "${GOOSE_BIN:-}" ]]; then
|
||||
echo "Using explicitly set GOOSE_BIN: ${GOOSE_BIN}"
|
||||
else
|
||||
LOCAL_GOOSE_BIN="$(./scripts/ensure-local-goose.sh --check-bin)" || {
|
||||
rc=$?
|
||||
if [[ $rc -eq 2 ]]; then
|
||||
echo "❌ Local goose binary is not ready. Run 'just setup' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit $rc
|
||||
}
|
||||
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
|
||||
echo "Using local goose binary: ${GOOSE_BIN}"
|
||||
fi
|
||||
exit $rc
|
||||
}
|
||||
if [[ -n "${LOCAL_GOOSE_BIN}" ]]; then
|
||||
export GOOSE_BIN="${LOCAL_GOOSE_BIN}"
|
||||
echo "Using local goose binary: ${GOOSE_BIN}"
|
||||
fi
|
||||
|
||||
VITE_PORT=$(python3 -c "import hashlib,os; h=int(hashlib.sha256(os.getcwd().encode()).hexdigest(),16); print(10000 + h % 55000)")
|
||||
|
||||
@@ -24,6 +24,11 @@ const EXCEPTIONS = {
|
||||
limit: 575,
|
||||
justification: "Added search-as-you-type filtering for sidebar sessions.",
|
||||
},
|
||||
"src-tauri/src/services/acp/manager.rs": {
|
||||
limit: 520,
|
||||
justification:
|
||||
"ACP manager command dispatch loop — each new command variant adds ~10 lines of boilerplate.",
|
||||
},
|
||||
};
|
||||
|
||||
// Directories excluded from size checks (imported library code)
|
||||
|
||||
@@ -4,11 +4,8 @@ use std::sync::Arc;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::services::acp::{
|
||||
make_composite_key, AcpRunningSession, AcpService, AcpSessionRegistry, GooseAcpManager,
|
||||
};
|
||||
use crate::services::sessions::SessionStore;
|
||||
use crate::types::messages::{
|
||||
MessageCompletionStatus, MessageContent, MessageMetadata, ToolCallStatus,
|
||||
make_composite_key, AcpRunningSession, AcpService, AcpSessionInfo, AcpSessionRegistry,
|
||||
GooseAcpManager,
|
||||
};
|
||||
|
||||
const DEPRECATED_PROVIDER_IDS: &[&str] = &["claude-code", "codex", "gemini-cli"];
|
||||
@@ -109,7 +106,6 @@ pub async fn discover_acp_providers(
|
||||
pub async fn acp_send_message(
|
||||
app_handle: AppHandle,
|
||||
registry: State<'_, Arc<AcpSessionRegistry>>,
|
||||
session_store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
provider_id: String,
|
||||
prompt: String,
|
||||
@@ -126,7 +122,6 @@ pub async fn acp_send_message(
|
||||
AcpService::send_prompt(
|
||||
app_handle,
|
||||
Arc::clone(®istry),
|
||||
Arc::clone(&session_store),
|
||||
session_id,
|
||||
provider_id,
|
||||
prompt,
|
||||
@@ -142,7 +137,6 @@ pub async fn acp_send_message(
|
||||
#[tauri::command]
|
||||
pub async fn acp_prepare_session(
|
||||
app_handle: AppHandle,
|
||||
session_store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
provider_id: String,
|
||||
working_dir: Option<String>,
|
||||
@@ -152,15 +146,36 @@ pub async fn acp_prepare_session(
|
||||
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
|
||||
let working_dir = resolve_working_dir(working_dir, ¤t_dir)?;
|
||||
|
||||
AcpService::prepare_session(
|
||||
app_handle,
|
||||
session_id,
|
||||
provider_id,
|
||||
working_dir,
|
||||
persona_id,
|
||||
Arc::clone(&session_store),
|
||||
)
|
||||
.await
|
||||
AcpService::prepare_session(app_handle, session_id, provider_id, working_dir, persona_id).await
|
||||
}
|
||||
|
||||
/// List all sessions known to the goose binary.
|
||||
#[tauri::command]
|
||||
pub async fn acp_list_sessions(app_handle: AppHandle) -> Result<Vec<AcpSessionInfo>, String> {
|
||||
let manager = GooseAcpManager::start(app_handle).await?;
|
||||
manager.list_sessions().await
|
||||
}
|
||||
|
||||
/// Load an existing session, replaying its messages as Tauri events.
|
||||
///
|
||||
/// The goose binary sends `SessionNotification` events for each message in
|
||||
/// the session history. The frontend's `useAcpStream` hook picks these up
|
||||
/// the same way it handles live streaming.
|
||||
#[tauri::command]
|
||||
pub async fn acp_load_session(
|
||||
app_handle: AppHandle,
|
||||
session_id: String,
|
||||
goose_session_id: String,
|
||||
working_dir: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let current_dir = std::env::current_dir()
|
||||
.map_err(|error| format!("Failed to determine current working directory: {error}"))?;
|
||||
let working_dir = resolve_working_dir(working_dir, ¤t_dir)?;
|
||||
|
||||
let manager = GooseAcpManager::start(app_handle).await?;
|
||||
manager
|
||||
.load_session(session_id, goose_session_id, working_dir)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -228,30 +243,14 @@ mod tests {
|
||||
pub async fn acp_cancel_session(
|
||||
app_handle: AppHandle,
|
||||
registry: State<'_, Arc<AcpSessionRegistry>>,
|
||||
session_store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
persona_id: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let key = make_composite_key(&session_id, persona_id.as_deref());
|
||||
let assistant_message_id = registry.cancel(&key);
|
||||
let _assistant_message_id = registry.cancel(&key);
|
||||
let manager = GooseAcpManager::start(app_handle).await?;
|
||||
let was_cancelled = manager.cancel_session(key).await?;
|
||||
|
||||
if let Some(message_id) = assistant_message_id.as_deref() {
|
||||
let _ = session_store.update_message(&session_id, message_id, |message| {
|
||||
for block in &mut message.content {
|
||||
if let MessageContent::ToolRequest { status, .. } = block {
|
||||
*status = ToolCallStatus::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = message
|
||||
.metadata
|
||||
.get_or_insert_with(MessageMetadata::default);
|
||||
metadata.completion_status = Some(MessageCompletionStatus::Stopped);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(was_cancelled)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::services::sessions::SessionStore;
|
||||
use crate::types::messages::*;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_send_message(
|
||||
session_store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
message: Message,
|
||||
) -> Result<Message, String> {
|
||||
// 1. Store the user message
|
||||
session_store.add_message(&session_id, message.clone())?;
|
||||
|
||||
// 2. TODO: Connect to goosed via HTTP/SSE for real inference
|
||||
// For now, return a mock assistant response
|
||||
let response = Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
role: MessageRole::Assistant,
|
||||
created: chrono::Utc::now().timestamp_millis(),
|
||||
content: vec![MessageContent::Text {
|
||||
text: "I'm Goose, your AI coding assistant. The chat backend is being set up — real inference coming soon! For now, I can confirm the message pipeline is working end-to-end.".to_string(),
|
||||
}],
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
session_store.add_message(&session_id, response.clone())?;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
pub mod acp;
|
||||
pub mod agents;
|
||||
pub mod chat;
|
||||
pub mod doctor;
|
||||
pub mod git;
|
||||
pub mod projects;
|
||||
pub mod sessions;
|
||||
pub mod skills;
|
||||
pub mod system;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::services::sessions::SessionStore;
|
||||
use crate::types::agents::{Session, SessionUpdate};
|
||||
use crate::types::messages::Message;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
agent_id: Option<String>,
|
||||
project_id: Option<String>,
|
||||
) -> Session {
|
||||
store.create_session(agent_id, project_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_sessions(store: State<'_, Arc<SessionStore>>) -> Vec<Session> {
|
||||
store.list_sessions()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_session_messages(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
) -> Vec<Message> {
|
||||
store.get_messages(&session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
update: SessionUpdate,
|
||||
) -> Result<(), String> {
|
||||
store.update_session(&session_id, update)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
store.delete_session(&session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_archived_sessions(store: State<'_, Arc<SessionStore>>) -> Vec<Session> {
|
||||
store.list_archived_sessions()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn archive_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
store.archive_session(&session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unarchive_session(
|
||||
store: State<'_, Arc<SessionStore>>,
|
||||
session_id: String,
|
||||
) -> Result<(), String> {
|
||||
store.unarchive_session(&session_id)
|
||||
}
|
||||
@@ -6,14 +6,10 @@ use std::sync::Arc;
|
||||
|
||||
use services::acp::AcpSessionRegistry;
|
||||
use services::personas::PersonaStore;
|
||||
use services::sessions::SessionStore;
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Clean up stale ACP session files older than 24 hours.
|
||||
services::acp::TauriStore::cleanup_stale_sessions(std::time::Duration::from_secs(24 * 60 * 60));
|
||||
|
||||
let acp_registry = Arc::new(AcpSessionRegistry::new());
|
||||
let acp_registry_for_exit = Arc::clone(&acp_registry);
|
||||
|
||||
@@ -34,7 +30,6 @@ pub fn run() {
|
||||
.build(),
|
||||
)
|
||||
.manage(PersonaStore::new())
|
||||
.manage(Arc::new(SessionStore::new()))
|
||||
.manage(acp_registry)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::agents::list_personas,
|
||||
@@ -47,19 +42,12 @@ pub fn run() {
|
||||
commands::agents::save_persona_avatar,
|
||||
commands::agents::save_persona_avatar_bytes,
|
||||
commands::agents::get_avatars_dir,
|
||||
commands::sessions::create_session,
|
||||
commands::sessions::list_sessions,
|
||||
commands::sessions::get_session_messages,
|
||||
commands::sessions::update_session,
|
||||
commands::sessions::delete_session,
|
||||
commands::sessions::list_archived_sessions,
|
||||
commands::sessions::archive_session,
|
||||
commands::sessions::unarchive_session,
|
||||
commands::chat::chat_send_message,
|
||||
commands::acp::discover_acp_providers,
|
||||
commands::acp::acp_prepare_session,
|
||||
commands::acp::acp_send_message,
|
||||
commands::acp::acp_cancel_session,
|
||||
commands::acp::acp_list_sessions,
|
||||
commands::acp::acp_load_session,
|
||||
commands::acp::acp_list_running,
|
||||
commands::acp::acp_cancel_all,
|
||||
commands::skills::create_skill,
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
use crate::types::messages::{Message, MessageContent, MessageRole};
|
||||
|
||||
/// Build catch-up context for a persona from intervening messages.
|
||||
///
|
||||
/// When the user sends a message to persona X and there have been
|
||||
/// intervening messages to/from other personas since persona X last
|
||||
/// responded, this function produces a context block so persona X
|
||||
/// stays informed.
|
||||
///
|
||||
/// Returns `None` if there are no intervening messages to summarize.
|
||||
pub fn build_catchup_context(
|
||||
messages: &[Message],
|
||||
persona_id: &str,
|
||||
current_message_id: &str,
|
||||
) -> Option<String> {
|
||||
// Algorithm:
|
||||
// 1. Find the index of the last assistant message FROM this persona
|
||||
// 2. Collect all messages after that point, excluding:
|
||||
// - The current user prompt being sent now
|
||||
// - Messages from this persona itself
|
||||
// 3. Format as catch-up text
|
||||
// 4. Return None if no intervening messages
|
||||
|
||||
if messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find last assistant message from this persona
|
||||
let last_own_idx = messages.iter().rposition(|m| {
|
||||
matches!(m.role, MessageRole::Assistant)
|
||||
&& (m
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.persona_id.as_deref())
|
||||
== Some(persona_id))
|
||||
});
|
||||
|
||||
// Start collecting from after the last own message (or from beginning)
|
||||
let start_idx = match last_own_idx {
|
||||
Some(idx) => idx + 1,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for msg in &messages[start_idx..] {
|
||||
if msg.id == current_message_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(msg.role, MessageRole::User) {
|
||||
let target_persona_id = msg
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.target_persona_id.as_deref());
|
||||
if target_persona_id == Some(persona_id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip assistant messages from this persona itself
|
||||
if matches!(msg.role, MessageRole::Assistant) {
|
||||
let msg_persona_id = msg
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.persona_id.as_deref());
|
||||
if msg_persona_id == Some(persona_id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let text = extract_text(msg);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let truncated = truncate_text(&text, 200);
|
||||
|
||||
match msg.role {
|
||||
MessageRole::User => {
|
||||
let target_name = msg
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.target_persona_name.as_deref())
|
||||
.unwrap_or("Unknown");
|
||||
lines.push(format!("- User \u{2192} {}: {}", target_name, truncated));
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
let name = msg
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.persona_name.as_deref().or(meta.persona_id.as_deref()))
|
||||
.unwrap_or("Unknown");
|
||||
lines.push(format!("- {}: {}", name, truncated));
|
||||
}
|
||||
MessageRole::System => {
|
||||
lines.push(format!("- System: {}", truncated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!(
|
||||
"[Context from other participants since your last message]\n{}",
|
||||
lines.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
/// Extract concatenated text content from a message.
|
||||
fn extract_text(msg: &Message) -> String {
|
||||
msg.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
MessageContent::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Truncate text to `max_chars` characters, appending "..." if truncated.
|
||||
fn truncate_text(text: &str, max_chars: usize) -> String {
|
||||
match text.char_indices().nth(max_chars) {
|
||||
Some((byte_idx, _)) => format!("{}...", &text[..byte_idx]),
|
||||
None => text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_catchup_context;
|
||||
use crate::types::messages::{Message, MessageContent, MessageMetadata, MessageRole};
|
||||
|
||||
fn text_message(
|
||||
id: &str,
|
||||
role: MessageRole,
|
||||
text: &str,
|
||||
metadata: Option<MessageMetadata>,
|
||||
) -> Message {
|
||||
Message {
|
||||
id: id.to_string(),
|
||||
role,
|
||||
created: 0,
|
||||
content: vec![MessageContent::Text {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_user_messages_already_targeted_at_current_persona() {
|
||||
let messages = vec![
|
||||
text_message(
|
||||
"assistant-a",
|
||||
MessageRole::Assistant,
|
||||
"Earlier reply",
|
||||
Some(MessageMetadata {
|
||||
persona_id: Some("persona-a".to_string()),
|
||||
persona_name: Some("Persona A".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
text_message(
|
||||
"user-to-a",
|
||||
MessageRole::User,
|
||||
"Please continue",
|
||||
Some(MessageMetadata {
|
||||
target_persona_id: Some("persona-a".to_string()),
|
||||
target_persona_name: Some("Persona A".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
text_message(
|
||||
"assistant-b",
|
||||
MessageRole::Assistant,
|
||||
"Something happened elsewhere",
|
||||
Some(MessageMetadata {
|
||||
persona_id: Some("persona-b".to_string()),
|
||||
persona_name: Some("Persona B".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
let context = build_catchup_context(&messages, "persona-a", "current-user-message")
|
||||
.expect("expected catch-up context");
|
||||
|
||||
assert!(!context.contains("Please continue"));
|
||||
assert!(context.contains("Persona B: Something happened elsewhere"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_user_messages_for_other_personas_in_catchup() {
|
||||
let messages = vec![text_message(
|
||||
"user-to-b",
|
||||
MessageRole::User,
|
||||
"Can Persona B take this one?",
|
||||
Some(MessageMetadata {
|
||||
target_persona_id: Some("persona-b".to_string()),
|
||||
target_persona_name: Some("Persona B".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
)];
|
||||
|
||||
let context = build_catchup_context(&messages, "persona-a", "current-user-message")
|
||||
.expect("expected catch-up context");
|
||||
|
||||
assert!(context.contains("User → Persona B: Can Persona B take this one?"));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use acp_client::{MessageWriter, Store};
|
||||
use acp_client::MessageWriter;
|
||||
use agent_client_protocol::{
|
||||
Agent, ClientSideConnection, ExtRequest, Implementation, InitializeRequest, ProtocolVersion,
|
||||
};
|
||||
@@ -20,22 +20,31 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
use super::goose_serve::{GooseServeProcess, WS_BRIDGE_BUFFER_BYTES};
|
||||
use dispatcher::{SessionEventDispatcher, SessionRoute};
|
||||
pub use session_ops::AcpSessionInfo;
|
||||
use session_ops::{
|
||||
cancel_session_inner, prepare_session_inner, send_prompt_inner, ManagerState,
|
||||
PrepareSessionInput,
|
||||
cancel_session_inner, list_sessions_inner, load_session_inner, prepare_session_inner,
|
||||
send_prompt_inner, ManagerState, PrepareSessionInput,
|
||||
};
|
||||
|
||||
enum ManagerCommand {
|
||||
ListProviders {
|
||||
response: oneshot::Sender<Result<Vec<GooseAcpProvider>, String>>,
|
||||
},
|
||||
ListSessions {
|
||||
response: oneshot::Sender<Result<Vec<AcpSessionInfo>, String>>,
|
||||
},
|
||||
LoadSession {
|
||||
local_session_id: String,
|
||||
goose_session_id: String,
|
||||
working_dir: PathBuf,
|
||||
response: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
PrepareSession {
|
||||
composite_key: String,
|
||||
local_session_id: String,
|
||||
provider_id: String,
|
||||
working_dir: PathBuf,
|
||||
existing_agent_session_id: Option<String>,
|
||||
store: Arc<dyn Store>,
|
||||
response: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
SendPrompt {
|
||||
@@ -44,7 +53,6 @@ enum ManagerCommand {
|
||||
provider_id: String,
|
||||
working_dir: PathBuf,
|
||||
existing_agent_session_id: Option<String>,
|
||||
store: Arc<dyn Store>,
|
||||
writer: Arc<dyn MessageWriter>,
|
||||
prompt: String,
|
||||
images: Vec<(String, String)>,
|
||||
@@ -98,6 +106,38 @@ impl GooseAcpManager {
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
pub async fn list_sessions(&self) -> Result<Vec<AcpSessionInfo>, String> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(ManagerCommand::ListSessions {
|
||||
response: response_tx,
|
||||
})
|
||||
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|_| "Goose ACP manager dropped list sessions request".to_string())?
|
||||
}
|
||||
|
||||
pub async fn load_session(
|
||||
&self,
|
||||
local_session_id: String,
|
||||
goose_session_id: String,
|
||||
working_dir: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(ManagerCommand::LoadSession {
|
||||
local_session_id,
|
||||
goose_session_id,
|
||||
working_dir,
|
||||
response: response_tx,
|
||||
})
|
||||
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
|
||||
response_rx
|
||||
.await
|
||||
.map_err(|_| "Goose ACP manager dropped load session request".to_string())?
|
||||
}
|
||||
|
||||
pub async fn prepare_session(
|
||||
&self,
|
||||
composite_key: String,
|
||||
@@ -105,7 +145,6 @@ impl GooseAcpManager {
|
||||
provider_id: String,
|
||||
working_dir: PathBuf,
|
||||
existing_agent_session_id: Option<String>,
|
||||
store: Arc<dyn Store>,
|
||||
) -> Result<(), String> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
@@ -115,7 +154,6 @@ impl GooseAcpManager {
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
response: response_tx,
|
||||
})
|
||||
.map_err(|_| "Goose ACP manager is unavailable".to_string())?;
|
||||
@@ -144,7 +182,6 @@ impl GooseAcpManager {
|
||||
provider_id: String,
|
||||
working_dir: PathBuf,
|
||||
existing_agent_session_id: Option<String>,
|
||||
store: Arc<dyn Store>,
|
||||
writer: Arc<dyn MessageWriter>,
|
||||
prompt: String,
|
||||
images: Vec<(String, String)>,
|
||||
@@ -157,7 +194,6 @@ impl GooseAcpManager {
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
writer,
|
||||
prompt,
|
||||
images,
|
||||
@@ -361,13 +397,41 @@ fn run_manager_thread(
|
||||
let _ = response.send(result);
|
||||
});
|
||||
}
|
||||
ManagerCommand::ListSessions { response } => {
|
||||
let connection = Arc::clone(&connection);
|
||||
tokio::task::spawn_local(async move {
|
||||
let result = list_sessions_inner(&connection).await;
|
||||
let _ = response.send(result);
|
||||
});
|
||||
}
|
||||
ManagerCommand::LoadSession {
|
||||
local_session_id,
|
||||
goose_session_id,
|
||||
working_dir,
|
||||
response,
|
||||
} => {
|
||||
let connection = Arc::clone(&connection);
|
||||
let dispatcher = dispatcher.clone();
|
||||
let state = Arc::clone(&state);
|
||||
tokio::task::spawn_local(async move {
|
||||
let result = load_session_inner(
|
||||
&connection,
|
||||
&dispatcher,
|
||||
&state,
|
||||
&local_session_id,
|
||||
&goose_session_id,
|
||||
working_dir,
|
||||
)
|
||||
.await;
|
||||
let _ = response.send(result);
|
||||
});
|
||||
}
|
||||
ManagerCommand::PrepareSession {
|
||||
composite_key,
|
||||
local_session_id,
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
response,
|
||||
} => {
|
||||
let connection = Arc::clone(&connection);
|
||||
@@ -384,7 +448,6 @@ fn run_manager_thread(
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -398,7 +461,6 @@ fn run_manager_thread(
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
writer,
|
||||
prompt,
|
||||
images,
|
||||
@@ -417,7 +479,6 @@ fn run_manager_thread(
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
writer,
|
||||
prompt,
|
||||
images,
|
||||
|
||||
@@ -13,13 +13,18 @@ use async_trait::async_trait;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::services::acp::payloads::{ModelStatePayload, SessionInfoPayload};
|
||||
use crate::services::acp::payloads::{
|
||||
DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload,
|
||||
ToolCallPayload, ToolResultPayload, ToolTitlePayload,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct SessionRoute {
|
||||
pub(super) local_session_id: String,
|
||||
pub(super) writer: Option<Arc<dyn MessageWriter>>,
|
||||
pub(super) canceled: bool,
|
||||
/// Tracks the current assistant message ID during replay (no writer).
|
||||
pub(super) replay_message_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct SessionEventDispatcher {
|
||||
@@ -44,6 +49,7 @@ impl SessionEventDispatcher {
|
||||
local_session_id: local_session_id.to_string(),
|
||||
writer: None,
|
||||
canceled: false,
|
||||
replay_message_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +66,7 @@ impl SessionEventDispatcher {
|
||||
local_session_id: local_session_id.to_string(),
|
||||
writer: Some(writer),
|
||||
canceled: false,
|
||||
replay_message_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -90,6 +97,24 @@ impl SessionEventDispatcher {
|
||||
.is_some_and(|route| route.canceled)
|
||||
}
|
||||
|
||||
/// Finalize any in-progress replay message and clear the replay state.
|
||||
/// Called after `load_session` completes to mark the last replayed
|
||||
/// assistant message as done.
|
||||
pub(super) async fn finalize_replay(&self, goose_session_id: &str) {
|
||||
let mut routes = self.routes.lock().await;
|
||||
if let Some(route) = routes.get_mut(goose_session_id) {
|
||||
if let Some(message_id) = route.replay_message_id.take() {
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:done",
|
||||
DonePayload {
|
||||
session_id: route.local_session_id.clone(),
|
||||
message_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn emit_session_info(&self, local_session_id: &str, info: &SessionInfoUpdate) {
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:session_info",
|
||||
@@ -233,30 +258,165 @@ impl Client for SessionEventDispatcher {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let Some(writer) = route.writer else {
|
||||
// Live streaming path — writer is present
|
||||
if let Some(writer) = route.writer {
|
||||
match ¬ification.update {
|
||||
SessionUpdate::AgentMessageChunk(chunk) => {
|
||||
if let AcpContentBlock::Text(text) = &chunk.content {
|
||||
writer.append_text(&text.text).await;
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCall(tool_call) => {
|
||||
writer
|
||||
.record_tool_call(&tool_call.tool_call_id.0, &tool_call.title)
|
||||
.await;
|
||||
}
|
||||
SessionUpdate::ToolCallUpdate(update) => {
|
||||
if let Some(title) = &update.fields.title {
|
||||
writer
|
||||
.update_tool_call_title(&update.tool_call_id.0, title)
|
||||
.await;
|
||||
}
|
||||
if let Some(content) = &update.fields.content {
|
||||
if let Some(result) = extract_content_preview(content) {
|
||||
writer.record_tool_result(&result).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
}
|
||||
|
||||
// Replay path — no writer, emit Tauri events directly.
|
||||
// This handles messages replayed by load_session.
|
||||
let local_session_id = route.local_session_id.clone();
|
||||
match ¬ification.update {
|
||||
SessionUpdate::UserMessageChunk(chunk) => {
|
||||
if let AcpContentBlock::Text(text) = &chunk.content {
|
||||
// Finalize any in-progress assistant message first
|
||||
{
|
||||
let mut routes = self.routes.lock().await;
|
||||
if let Some(route) = routes.get_mut(&goose_session_id) {
|
||||
if let Some(prev_msg_id) = route.replay_message_id.take() {
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:done",
|
||||
DonePayload {
|
||||
session_id: local_session_id.clone(),
|
||||
message_id: prev_msg_id,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message_id = uuid::Uuid::new_v4().to_string();
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:replay_user_message",
|
||||
serde_json::json!({
|
||||
"sessionId": local_session_id,
|
||||
"messageId": message_id,
|
||||
"text": text.text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
SessionUpdate::AgentMessageChunk(chunk) => {
|
||||
if let AcpContentBlock::Text(text) = &chunk.content {
|
||||
writer.append_text(&text.text).await;
|
||||
// Check if we already have a replay message in progress
|
||||
let replay_msg_id = {
|
||||
let routes = self.routes.lock().await;
|
||||
routes
|
||||
.get(&goose_session_id)
|
||||
.and_then(|r| r.replay_message_id.clone())
|
||||
};
|
||||
|
||||
let message_id = if let Some(id) = replay_msg_id {
|
||||
id
|
||||
} else {
|
||||
// Start a new assistant message
|
||||
let new_id = uuid::Uuid::new_v4().to_string();
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:message_created",
|
||||
MessageCreatedPayload {
|
||||
session_id: local_session_id.clone(),
|
||||
message_id: new_id.clone(),
|
||||
persona_id: None,
|
||||
persona_name: None,
|
||||
},
|
||||
);
|
||||
let mut routes = self.routes.lock().await;
|
||||
if let Some(route) = routes.get_mut(&goose_session_id) {
|
||||
route.replay_message_id = Some(new_id.clone());
|
||||
}
|
||||
new_id
|
||||
};
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:text",
|
||||
TextPayload {
|
||||
session_id: local_session_id,
|
||||
message_id: message_id.clone(),
|
||||
text: text.text.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCall(tool_call) => {
|
||||
writer
|
||||
.record_tool_call(&tool_call.tool_call_id.0, &tool_call.title)
|
||||
.await;
|
||||
let replay_msg_id = {
|
||||
let routes = self.routes.lock().await;
|
||||
routes
|
||||
.get(&goose_session_id)
|
||||
.and_then(|r| r.replay_message_id.clone())
|
||||
};
|
||||
|
||||
if let Some(message_id) = replay_msg_id {
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:tool_call",
|
||||
ToolCallPayload {
|
||||
session_id: local_session_id,
|
||||
message_id,
|
||||
tool_call_id: tool_call.tool_call_id.0.to_string(),
|
||||
title: tool_call.title.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
SessionUpdate::ToolCallUpdate(update) => {
|
||||
if let Some(title) = &update.fields.title {
|
||||
writer
|
||||
.update_tool_call_title(&update.tool_call_id.0, title)
|
||||
.await;
|
||||
}
|
||||
if let Some(content) = &update.fields.content {
|
||||
if let Some(result) = extract_content_preview(content) {
|
||||
writer.record_tool_result(&result).await;
|
||||
let replay_msg_id = {
|
||||
let routes = self.routes.lock().await;
|
||||
routes
|
||||
.get(&goose_session_id)
|
||||
.and_then(|r| r.replay_message_id.clone())
|
||||
};
|
||||
|
||||
if let Some(message_id) = replay_msg_id {
|
||||
if let Some(title) = &update.fields.title {
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:tool_title",
|
||||
ToolTitlePayload {
|
||||
session_id: local_session_id.clone(),
|
||||
message_id: message_id.clone(),
|
||||
tool_call_id: update.tool_call_id.0.to_string(),
|
||||
title: title.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(content) = &update.fields.content {
|
||||
// During replay, always emit a tool_result so the
|
||||
// frontend can mark the tool request as completed.
|
||||
// Fall back to a generic summary when the content
|
||||
// type has no preview extractor.
|
||||
let result =
|
||||
extract_content_preview(content).unwrap_or_else(|| "Done".to_string());
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:tool_result",
|
||||
ToolResultPayload {
|
||||
session_id: local_session_id,
|
||||
message_id,
|
||||
content: result,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,25 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use acp_client::{MessageWriter, Store};
|
||||
use acp_client::MessageWriter;
|
||||
use agent_client_protocol::{
|
||||
Agent, CancelNotification, ClientSideConnection, ContentBlock as AcpContentBlock, ImageContent,
|
||||
LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind, SessionConfigOption,
|
||||
SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent,
|
||||
ListSessionsRequest, LoadSessionRequest, NewSessionRequest, PromptRequest, SessionConfigKind,
|
||||
SessionConfigOption, SessionConfigSelectOptions, SetSessionConfigOptionRequest, TextContent,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::dispatcher::SessionEventDispatcher;
|
||||
|
||||
/// Lightweight session metadata returned by `list_sessions`.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AcpSessionInfo {
|
||||
pub session_id: String,
|
||||
pub title: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct PreparedSession {
|
||||
goose_session_id: String,
|
||||
@@ -79,7 +88,6 @@ pub(super) struct PrepareSessionInput {
|
||||
pub(super) provider_id: String,
|
||||
pub(super) working_dir: PathBuf,
|
||||
pub(super) existing_agent_session_id: Option<String>,
|
||||
pub(super) store: Arc<dyn Store>,
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_session_inner(
|
||||
@@ -94,7 +102,6 @@ pub(super) async fn prepare_session_inner(
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
} = input;
|
||||
|
||||
let session_lock = {
|
||||
@@ -220,9 +227,6 @@ pub(super) async fn prepare_session_inner(
|
||||
.map_err(|error| format!("Failed to create Goose session: {error:?}"))?;
|
||||
|
||||
let new_id = response.session_id.to_string();
|
||||
store
|
||||
.set_agent_session_id(&local_session_id, &new_id)
|
||||
.map_err(|error| format!("Failed to save Goose session ID: {error}"))?;
|
||||
|
||||
dispatcher.bind_session(&new_id, &local_session_id).await;
|
||||
|
||||
@@ -282,7 +286,6 @@ pub(super) async fn send_prompt_inner(
|
||||
provider_id: String,
|
||||
working_dir: PathBuf,
|
||||
existing_agent_session_id: Option<String>,
|
||||
store: Arc<dyn Store>,
|
||||
writer: Arc<dyn MessageWriter>,
|
||||
prompt: String,
|
||||
images: Vec<(String, String)>,
|
||||
@@ -297,7 +300,6 @@ pub(super) async fn send_prompt_inner(
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -349,6 +351,76 @@ pub(super) async fn send_prompt_inner(
|
||||
result
|
||||
}
|
||||
|
||||
/// List all sessions known to the goose binary.
|
||||
pub(super) async fn list_sessions_inner(
|
||||
connection: &Arc<ClientSideConnection>,
|
||||
) -> Result<Vec<AcpSessionInfo>, String> {
|
||||
let response = connection
|
||||
.list_sessions(ListSessionsRequest::default())
|
||||
.await
|
||||
.map_err(|error| format!("Failed to list sessions via Goose ACP: {error:?}"))?;
|
||||
|
||||
Ok(response
|
||||
.sessions
|
||||
.into_iter()
|
||||
.map(|info| AcpSessionInfo {
|
||||
session_id: info.session_id.to_string(),
|
||||
title: info.title,
|
||||
updated_at: info.updated_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Load an existing session from the goose binary.
|
||||
///
|
||||
/// This binds the goose session ID to the local session ID in the dispatcher
|
||||
/// so that replayed `SessionNotification` events are routed to the correct
|
||||
/// frontend session. It also registers the session in the manager state so
|
||||
/// that subsequent `send_prompt` calls can reuse the goose session.
|
||||
pub(super) async fn load_session_inner(
|
||||
connection: &Arc<ClientSideConnection>,
|
||||
dispatcher: &Arc<SessionEventDispatcher>,
|
||||
state: &Arc<Mutex<ManagerState>>,
|
||||
local_session_id: &str,
|
||||
goose_session_id: &str,
|
||||
working_dir: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
dispatcher
|
||||
.bind_session(goose_session_id, local_session_id)
|
||||
.await;
|
||||
|
||||
let response = connection
|
||||
.load_session(LoadSessionRequest::new(
|
||||
goose_session_id.to_string(),
|
||||
working_dir.clone(),
|
||||
))
|
||||
.await
|
||||
.map_err(|error| format!("Failed to load Goose session: {error:?}"))?;
|
||||
|
||||
// Finalize any in-progress replay assistant message
|
||||
dispatcher.finalize_replay(goose_session_id).await;
|
||||
|
||||
if let Some(models) = &response.models {
|
||||
dispatcher.emit_model_state(local_session_id, models);
|
||||
}
|
||||
if let Some(options) = &response.config_options {
|
||||
dispatcher.emit_model_state_from_options(local_session_id, options);
|
||||
}
|
||||
|
||||
// Register the session so future prompts reuse this goose session
|
||||
let mut guard = state.lock().await;
|
||||
guard.sessions.insert(
|
||||
local_session_id.to_string(),
|
||||
PreparedSession {
|
||||
goose_session_id: goose_session_id.to_string(),
|
||||
provider_id: "goose".to_string(), // will be updated on next prepare
|
||||
working_dir,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn cancel_session_inner(
|
||||
connection: &Arc<ClientSideConnection>,
|
||||
dispatcher: &Arc<SessionEventDispatcher>,
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
mod catchup;
|
||||
mod goose_serve;
|
||||
mod manager;
|
||||
mod payloads;
|
||||
mod registry;
|
||||
mod store;
|
||||
mod writer;
|
||||
|
||||
pub use catchup::build_catchup_context;
|
||||
pub use manager::GooseAcpManager;
|
||||
pub use manager::{AcpSessionInfo, GooseAcpManager};
|
||||
pub use registry::{AcpRunningSession, AcpSessionRegistry};
|
||||
pub use store::TauriStore;
|
||||
pub use writer::TauriMessageWriter;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use acp_client::{MessageWriter, Store};
|
||||
use acp_client::MessageWriter;
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::services::sessions::SessionStore;
|
||||
use crate::types::messages::{
|
||||
MessageCompletionStatus, MessageContent, MessageMetadata, MessageRole, ToolCallStatus,
|
||||
};
|
||||
/// Build a composite registry key: `{session_id}__{persona_id}` when a
|
||||
/// persona is active, or plain `session_id` for backward compatibility.
|
||||
///
|
||||
@@ -53,14 +46,8 @@ impl AcpService {
|
||||
provider_id: String,
|
||||
working_dir: PathBuf,
|
||||
persona_id: Option<String>,
|
||||
session_store: Arc<SessionStore>,
|
||||
) -> Result<(), String> {
|
||||
session_store.ensure_session(&session_id, Some(provider_id.clone()));
|
||||
|
||||
let manager = GooseAcpManager::start(app_handle).await?;
|
||||
let tauri_store = TauriStore::new(session_store, session_id.clone(), persona_id.clone());
|
||||
let existing_agent_session_id = tauri_store.get_agent_session_id();
|
||||
let store: Arc<dyn Store> = Arc::new(tauri_store);
|
||||
|
||||
manager
|
||||
.prepare_session(
|
||||
@@ -68,8 +55,7 @@ impl AcpService {
|
||||
session_id,
|
||||
provider_id,
|
||||
working_dir,
|
||||
existing_agent_session_id,
|
||||
store,
|
||||
None, // no existing agent session ID — goose binary owns sessions
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -80,7 +66,6 @@ impl AcpService {
|
||||
pub async fn send_prompt(
|
||||
app_handle: tauri::AppHandle,
|
||||
registry: Arc<AcpSessionRegistry>,
|
||||
session_store: Arc<SessionStore>,
|
||||
session_id: String,
|
||||
provider_id: String,
|
||||
prompt: String,
|
||||
@@ -90,50 +75,12 @@ impl AcpService {
|
||||
persona_name: Option<String>,
|
||||
images: Vec<(String, String)>,
|
||||
) -> Result<(), String> {
|
||||
// Ensure the session exists in the SessionStore (create if needed)
|
||||
session_store.ensure_session(&session_id, Some(provider_id.clone()));
|
||||
|
||||
// Save the user message to SessionStore, with persona metadata when targeted
|
||||
let user_message_id = uuid::Uuid::new_v4().to_string();
|
||||
let user_message = crate::types::messages::Message {
|
||||
id: user_message_id.clone(),
|
||||
role: MessageRole::User,
|
||||
created: chrono::Utc::now().timestamp_millis(),
|
||||
content: vec![MessageContent::Text {
|
||||
text: prompt.clone(),
|
||||
}],
|
||||
metadata: if persona_id.is_some() {
|
||||
Some(crate::types::messages::MessageMetadata {
|
||||
target_persona_id: persona_id.clone(),
|
||||
target_persona_name: persona_name.clone(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
if let Err(e) = session_store.add_message(&session_id, user_message) {
|
||||
eprintln!(
|
||||
"Failed to save user message for session {}: {}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
|
||||
// Build catch-up context from intervening messages for this persona
|
||||
let catchup_context = if let Some(ref pid) = persona_id {
|
||||
let all_messages = session_store.get_messages(&session_id);
|
||||
build_catchup_context(&all_messages, pid, &user_message_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let registry_key = make_composite_key(&session_id, persona_id.as_deref());
|
||||
let cancel_token = registry.register(®istry_key, &provider_id);
|
||||
|
||||
let writer_impl = Arc::new(TauriMessageWriter::new(
|
||||
app_handle.clone(),
|
||||
session_id.clone(),
|
||||
Arc::clone(&session_store),
|
||||
persona_id.clone(),
|
||||
persona_name.clone(),
|
||||
));
|
||||
@@ -141,18 +88,13 @@ impl AcpService {
|
||||
®istry_key,
|
||||
writer_impl.assistant_message_id().to_string(),
|
||||
);
|
||||
let tauri_store =
|
||||
TauriStore::new(Arc::clone(&session_store), session_id.clone(), persona_id);
|
||||
let agent_session_id = tauri_store.get_agent_session_id();
|
||||
let store: Arc<dyn Store> = Arc::new(tauri_store);
|
||||
let writer: Arc<dyn MessageWriter> = writer_impl.clone();
|
||||
|
||||
// Build the effective prompt, including persona instructions and
|
||||
// catch-up context when available. When there is no extra context we
|
||||
// pass the raw prompt for backward compatibility.
|
||||
// Build the effective prompt, including persona instructions when
|
||||
// available. When there is no extra context we pass the raw prompt
|
||||
// for backward compatibility.
|
||||
let has_system = system_prompt.as_ref().is_some_and(|s| !s.is_empty());
|
||||
let has_catchup = catchup_context.is_some();
|
||||
let effective_prompt = if has_system || has_catchup {
|
||||
let effective_prompt = if has_system {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(ref sp) = system_prompt {
|
||||
if !sp.is_empty() {
|
||||
@@ -161,11 +103,6 @@ impl AcpService {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref ctx) = catchup_context {
|
||||
parts.push(format!(
|
||||
"<conversation-context>\n{ctx}\n</conversation-context>"
|
||||
));
|
||||
}
|
||||
parts.push(format!("<user-message>\n{prompt}\n</user-message>"));
|
||||
parts.join("\n\n")
|
||||
} else {
|
||||
@@ -179,8 +116,7 @@ impl AcpService {
|
||||
session_id.clone(),
|
||||
provider_id.clone(),
|
||||
working_dir,
|
||||
agent_session_id,
|
||||
store,
|
||||
None, // no existing agent session ID — goose binary owns sessions
|
||||
writer,
|
||||
effective_prompt,
|
||||
images,
|
||||
@@ -191,44 +127,15 @@ impl AcpService {
|
||||
drop(cancel_token);
|
||||
|
||||
if let Err(ref error) = result {
|
||||
let _ = session_store.update_message(
|
||||
&session_id,
|
||||
writer_impl.assistant_message_id(),
|
||||
|message| {
|
||||
for block in &mut message.content {
|
||||
if let MessageContent::ToolRequest { status, .. } = block {
|
||||
*status = ToolCallStatus::Error;
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = message
|
||||
.metadata
|
||||
.get_or_insert_with(MessageMetadata::default);
|
||||
metadata.completion_status = Some(MessageCompletionStatus::Error);
|
||||
},
|
||||
);
|
||||
|
||||
let error_message = crate::types::messages::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
role: MessageRole::System,
|
||||
created: chrono::Utc::now().timestamp_millis(),
|
||||
content: vec![MessageContent::SystemNotification {
|
||||
notification_type: "error".to_string(),
|
||||
text: error.clone(),
|
||||
}],
|
||||
metadata: Some(crate::types::messages::MessageMetadata {
|
||||
user_visible: Some(true),
|
||||
agent_visible: Some(false),
|
||||
..Default::default()
|
||||
// Emit an error event so the frontend can display it
|
||||
let _ = app_handle.emit(
|
||||
"acp:error",
|
||||
serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"messageId": writer_impl.assistant_message_id(),
|
||||
"error": error,
|
||||
}),
|
||||
};
|
||||
|
||||
if let Err(save_error) = session_store.add_message(&session_id, error_message) {
|
||||
eprintln!(
|
||||
"Failed to save ACP error message for session {}: {}",
|
||||
session_id, save_error
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
result
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::Serialize;
|
||||
/// Payload for the `acp:message_created` event.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct MessageCreatedPayload {
|
||||
pub(crate) struct MessageCreatedPayload {
|
||||
pub session_id: String,
|
||||
pub message_id: String,
|
||||
pub persona_id: Option<String>,
|
||||
@@ -13,7 +13,7 @@ pub(super) struct MessageCreatedPayload {
|
||||
/// Payload for the `acp:text` event.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct TextPayload {
|
||||
pub(crate) struct TextPayload {
|
||||
pub session_id: String,
|
||||
pub message_id: String,
|
||||
pub text: String,
|
||||
@@ -22,7 +22,7 @@ pub(super) struct TextPayload {
|
||||
/// Payload for the `acp:done` event.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct DonePayload {
|
||||
pub(crate) struct DonePayload {
|
||||
pub session_id: String,
|
||||
pub message_id: String,
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub(super) struct DonePayload {
|
||||
/// Payload for the `acp:tool_call` event.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ToolCallPayload {
|
||||
pub(crate) struct ToolCallPayload {
|
||||
pub session_id: String,
|
||||
pub message_id: String,
|
||||
pub tool_call_id: String,
|
||||
@@ -40,7 +40,7 @@ pub(super) struct ToolCallPayload {
|
||||
/// Payload for the `acp:tool_title` event.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ToolTitlePayload {
|
||||
pub(crate) struct ToolTitlePayload {
|
||||
pub session_id: String,
|
||||
pub message_id: String,
|
||||
pub tool_call_id: String,
|
||||
@@ -50,7 +50,7 @@ pub(super) struct ToolTitlePayload {
|
||||
/// Payload for the `acp:tool_result` event.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ToolResultPayload {
|
||||
pub(crate) struct ToolResultPayload {
|
||||
pub session_id: String,
|
||||
pub message_id: String,
|
||||
pub content: String,
|
||||
@@ -58,14 +58,14 @@ pub(super) struct ToolResultPayload {
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct SessionInfoPayload {
|
||||
pub(crate) struct SessionInfoPayload {
|
||||
pub session_id: String,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ModelStatePayload {
|
||||
pub(crate) struct ModelStatePayload {
|
||||
pub session_id: String,
|
||||
pub current_model_id: String,
|
||||
pub current_model_name: Option<String>,
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use acp_client::Store;
|
||||
|
||||
use super::make_composite_key;
|
||||
use crate::services::sessions::SessionStore;
|
||||
use crate::types::messages::{Message, MessageContent, MessageRole};
|
||||
|
||||
/// A [`Store`] implementation that persists ACP session mappings to disk
|
||||
/// under `~/.goose/acp_sessions/` and reads conversation history from the
|
||||
/// [`SessionStore`].
|
||||
///
|
||||
/// When `persona_id` is set, the store uses a composite key
|
||||
/// (`{sessionId}__{personaId}`) for both file-based session persistence and
|
||||
/// message filtering, so each persona maintains an independent ACP agent
|
||||
/// session and sees only its own conversation slice.
|
||||
pub struct TauriStore {
|
||||
sessions_dir: PathBuf,
|
||||
session_store: Arc<SessionStore>,
|
||||
/// The raw Goose session ID (without persona suffix).
|
||||
session_id: String,
|
||||
/// Optional persona scoping. When `Some`, the effective key used for
|
||||
/// on-disk persistence becomes `{session_id}__{persona_id}`.
|
||||
persona_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TauriStore {
|
||||
/// Create a new store, ensuring the backing directory exists.
|
||||
///
|
||||
/// * `session_store` – shared session store for reading messages.
|
||||
/// * `session_id` – the Goose chat session ID.
|
||||
/// * `persona_id` – when `Some`, enables per-persona composite keying.
|
||||
/// Pass `None` to preserve single-persona behaviour.
|
||||
pub fn new(
|
||||
session_store: Arc<SessionStore>,
|
||||
session_id: String,
|
||||
persona_id: Option<String>,
|
||||
) -> Self {
|
||||
let sessions_dir = dirs::home_dir()
|
||||
.expect("home dir")
|
||||
.join(".goose")
|
||||
.join("acp_sessions");
|
||||
let _ = std::fs::create_dir_all(&sessions_dir);
|
||||
Self {
|
||||
sessions_dir,
|
||||
session_store,
|
||||
session_id,
|
||||
persona_id,
|
||||
}
|
||||
}
|
||||
|
||||
// -- composite-key helpers ------------------------------------------------
|
||||
|
||||
/// Build the effective key used for on-disk session files.
|
||||
/// Returns `"{session_id}__{persona_id}"` when a persona is set,
|
||||
/// otherwise plain `"{session_id}"`.
|
||||
fn effective_key(&self) -> String {
|
||||
make_composite_key(&self.session_id, self.persona_id.as_deref())
|
||||
}
|
||||
|
||||
/// Look up a previously stored agent session ID, or `None` for new sessions.
|
||||
pub fn get_agent_session_id(&self) -> Option<String> {
|
||||
let key = self.effective_key();
|
||||
let path = self.sessions_dir.join(format!("{key}.json"));
|
||||
let json = std::fs::read_to_string(&path).ok()?;
|
||||
let mapping: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||
mapping["agent_session_id"].as_str().map(String::from)
|
||||
}
|
||||
|
||||
/// Remove session files that are older than the given duration.
|
||||
pub fn cleanup_stale_sessions(max_age: std::time::Duration) {
|
||||
let sessions_dir = dirs::home_dir()
|
||||
.expect("home dir")
|
||||
.join(".goose")
|
||||
.join("acp_sessions");
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
|
||||
let cutoff = std::time::SystemTime::now() - max_age;
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if modified < cutoff {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- persona-filtered message helpers -------------------------------------
|
||||
|
||||
/// Returns `true` if `msg` should be visible to the persona identified by
|
||||
/// `persona_id`.
|
||||
///
|
||||
/// Rules:
|
||||
/// * **User messages** are included when the message's
|
||||
/// `metadata.target_persona_id` equals `persona_id`, **or** when the
|
||||
/// message's `metadata.persona_id` equals `persona_id`.
|
||||
/// * **Assistant/System messages** are included when
|
||||
/// `metadata.persona_id` equals `persona_id`.
|
||||
/// * Messages with **no metadata** or no matching persona fields are
|
||||
/// excluded.
|
||||
fn message_matches_persona(msg: &Message, persona_id: &str) -> bool {
|
||||
let meta = match &msg.metadata {
|
||||
Some(m) => m,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
match msg.role {
|
||||
MessageRole::User => {
|
||||
let target_match = meta.target_persona_id.as_deref() == Some(persona_id);
|
||||
let persona_match = meta.persona_id.as_deref() == Some(persona_id);
|
||||
target_match || persona_match
|
||||
}
|
||||
MessageRole::Assistant | MessageRole::System => {
|
||||
meta.persona_id.as_deref() == Some(persona_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`Message`] into the `(role, text)` pair expected by the
|
||||
/// ACP `Store` trait.
|
||||
fn message_to_pair(msg: &Message) -> Option<(String, String)> {
|
||||
let role = match msg.role {
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::System => "system",
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let text_parts: Vec<String> = msg
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
MessageContent::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if text_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((role, text_parts.join("\n")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store for TauriStore {
|
||||
fn set_agent_session_id(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
agent_session_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let key = self.effective_key();
|
||||
let path = self.sessions_dir.join(format!("{key}.json"));
|
||||
let payload = serde_json::json!({
|
||||
"session_id": self.session_id,
|
||||
"persona_id": self.persona_id,
|
||||
"agent_session_id": agent_session_id,
|
||||
});
|
||||
let json = serde_json::to_string_pretty(&payload)
|
||||
.map_err(|e| format!("Failed to serialize agent session mapping: {e}"))?;
|
||||
std::fs::write(&path, json)
|
||||
.map_err(|e| format!("Failed to write agent session file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_session_messages(&self, _session_id: &str) -> Result<Vec<(String, String)>, String> {
|
||||
let messages = self.session_store.get_messages(&self.session_id);
|
||||
|
||||
let pairs: Vec<(String, String)> = match &self.persona_id {
|
||||
Some(pid) => {
|
||||
// Persona mode: only return messages relevant to this persona.
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| Self::message_matches_persona(m, pid))
|
||||
.filter_map(Self::message_to_pair)
|
||||
.collect()
|
||||
}
|
||||
None => {
|
||||
// Single-persona / backward-compat mode: return all messages.
|
||||
messages.iter().filter_map(Self::message_to_pair).collect()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(pairs)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tauri::Emitter;
|
||||
|
||||
use acp_client::{MessageWriter, SessionInfoUpdate, SessionModelState};
|
||||
|
||||
use crate::services::sessions::SessionStore;
|
||||
use crate::types::messages::{
|
||||
Message, MessageCompletionStatus, MessageContent, MessageMetadata, MessageRole, ToolCallStatus,
|
||||
};
|
||||
|
||||
use super::payloads::{
|
||||
DonePayload, MessageCreatedPayload, ModelStatePayload, SessionInfoPayload, TextPayload,
|
||||
ToolCallPayload, ToolResultPayload, ToolTitlePayload,
|
||||
};
|
||||
|
||||
/// A [`MessageWriter`] implementation that streams ACP output to the frontend
|
||||
/// via Tauri events, and saves the final assistant message to the
|
||||
/// [`SessionStore`] on finalization.
|
||||
struct PersistedMessageState {
|
||||
content: Vec<MessageContent>,
|
||||
has_unflushed_changes: bool,
|
||||
}
|
||||
|
||||
/// via Tauri events. No local persistence — the goose binary is the sole
|
||||
/// source of truth for messages.
|
||||
pub struct TauriMessageWriter {
|
||||
app_handle: tauri::AppHandle,
|
||||
session_id: String,
|
||||
session_store: Arc<SessionStore>,
|
||||
assistant_message_id: String,
|
||||
/// Accumulated structured content (text + tool calls/results) for persistence.
|
||||
state: std::sync::Mutex<PersistedMessageState>,
|
||||
}
|
||||
|
||||
impl TauriMessageWriter {
|
||||
@@ -37,32 +22,10 @@ impl TauriMessageWriter {
|
||||
pub fn new(
|
||||
app_handle: tauri::AppHandle,
|
||||
session_id: String,
|
||||
session_store: Arc<SessionStore>,
|
||||
persona_id: Option<String>,
|
||||
persona_name: Option<String>,
|
||||
) -> Self {
|
||||
let assistant_message_id = uuid::Uuid::new_v4().to_string();
|
||||
let message = Message {
|
||||
id: assistant_message_id.clone(),
|
||||
role: MessageRole::Assistant,
|
||||
created: chrono::Utc::now().timestamp_millis(),
|
||||
content: Vec::new(),
|
||||
metadata: Some(MessageMetadata {
|
||||
user_visible: Some(true),
|
||||
agent_visible: Some(true),
|
||||
persona_id: persona_id.clone(),
|
||||
persona_name: persona_name.clone(),
|
||||
completion_status: Some(MessageCompletionStatus::InProgress),
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
if let Err(error) = session_store.add_message(&session_id, message) {
|
||||
eprintln!(
|
||||
"Failed to create streaming assistant message for session {}: {}",
|
||||
session_id, error
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app_handle.emit(
|
||||
"acp:message_created",
|
||||
@@ -77,95 +40,13 @@ impl TauriMessageWriter {
|
||||
Self {
|
||||
app_handle,
|
||||
session_id,
|
||||
session_store,
|
||||
assistant_message_id,
|
||||
state: std::sync::Mutex::new(PersistedMessageState {
|
||||
content: Vec::new(),
|
||||
has_unflushed_changes: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assistant_message_id(&self) -> &str {
|
||||
&self.assistant_message_id
|
||||
}
|
||||
|
||||
fn persist_snapshot(&self, completion_status: Option<MessageCompletionStatus>) {
|
||||
let (should_persist_content, content) = {
|
||||
let mut state = self.state.lock().expect("state lock");
|
||||
let should_persist = state.has_unflushed_changes || completion_status.is_some();
|
||||
if should_persist {
|
||||
state.has_unflushed_changes = false;
|
||||
}
|
||||
(should_persist, state.content.clone())
|
||||
};
|
||||
|
||||
if !should_persist_content {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = self.session_store.update_message(
|
||||
&self.session_id,
|
||||
&self.assistant_message_id,
|
||||
|message| {
|
||||
message.content = content.clone();
|
||||
if let Some(status) = completion_status.clone() {
|
||||
let metadata = message
|
||||
.metadata
|
||||
.get_or_insert_with(MessageMetadata::default);
|
||||
let current = metadata.completion_status.clone();
|
||||
let should_replace = !matches!(
|
||||
current,
|
||||
Some(MessageCompletionStatus::Stopped)
|
||||
| Some(MessageCompletionStatus::Error)
|
||||
) || matches!(
|
||||
status,
|
||||
MessageCompletionStatus::Stopped | MessageCompletionStatus::Error
|
||||
);
|
||||
if should_replace {
|
||||
metadata.completion_status = Some(status);
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
eprintln!(
|
||||
"Failed to update assistant message {} for session {}: {}",
|
||||
self.assistant_message_id, self.session_id, error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_text_block(content: &mut Vec<MessageContent>, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match content.last_mut() {
|
||||
Some(MessageContent::Text { text: existing }) => existing.push_str(text),
|
||||
_ => content.push(MessageContent::Text {
|
||||
text: text.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_latest_unpaired_tool_request(
|
||||
content: &[MessageContent],
|
||||
) -> Option<(usize, String, String)> {
|
||||
for index in (0..content.len()).rev() {
|
||||
let MessageContent::ToolRequest { id, name, .. } = &content[index] else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let already_has_response = content.iter().any(
|
||||
|candidate| matches!(candidate, MessageContent::ToolResponse { id: response_id, .. } if response_id == id),
|
||||
);
|
||||
if !already_has_response {
|
||||
return Some((index, id.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -175,19 +56,6 @@ impl MessageWriter for TauriMessageWriter {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().expect("state lock");
|
||||
let starts_new_text_block =
|
||||
!matches!(state.content.last(), Some(MessageContent::Text { .. }));
|
||||
if starts_new_text_block {
|
||||
drop(state);
|
||||
self.persist_snapshot(None);
|
||||
state = self.state.lock().expect("state lock");
|
||||
}
|
||||
append_text_block(&mut state.content, text);
|
||||
state.has_unflushed_changes = true;
|
||||
}
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:text",
|
||||
TextPayload {
|
||||
@@ -199,8 +67,6 @@ impl MessageWriter for TauriMessageWriter {
|
||||
}
|
||||
|
||||
async fn finalize(&self) {
|
||||
self.persist_snapshot(Some(MessageCompletionStatus::Completed));
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:done",
|
||||
DonePayload {
|
||||
@@ -211,19 +77,6 @@ impl MessageWriter for TauriMessageWriter {
|
||||
}
|
||||
|
||||
async fn record_tool_call(&self, tool_call_id: &str, title: &str) {
|
||||
self.persist_snapshot(None);
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().expect("state lock");
|
||||
state.content.push(MessageContent::ToolRequest {
|
||||
id: tool_call_id.to_string(),
|
||||
name: title.to_string(),
|
||||
arguments: serde_json::json!({}),
|
||||
status: ToolCallStatus::Executing,
|
||||
});
|
||||
state.has_unflushed_changes = true;
|
||||
}
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:tool_call",
|
||||
ToolCallPayload {
|
||||
@@ -236,20 +89,6 @@ impl MessageWriter for TauriMessageWriter {
|
||||
}
|
||||
|
||||
async fn update_tool_call_title(&self, tool_call_id: &str, title: &str) {
|
||||
{
|
||||
let mut state = self.state.lock().expect("state lock");
|
||||
for block in state.content.iter_mut().rev() {
|
||||
if let MessageContent::ToolRequest { id, name, .. } = block {
|
||||
if id == tool_call_id {
|
||||
*name = title.to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.has_unflushed_changes = true;
|
||||
}
|
||||
self.persist_snapshot(None);
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:tool_title",
|
||||
ToolTitlePayload {
|
||||
@@ -262,37 +101,6 @@ impl MessageWriter for TauriMessageWriter {
|
||||
}
|
||||
|
||||
async fn record_tool_result(&self, content: &str) {
|
||||
self.persist_snapshot(None);
|
||||
|
||||
{
|
||||
let mut state = self.state.lock().expect("state lock");
|
||||
|
||||
let paired_request = find_latest_unpaired_tool_request(&state.content);
|
||||
|
||||
if let Some((request_index, tool_call_id, tool_name)) = paired_request {
|
||||
if let MessageContent::ToolRequest { status, .. } =
|
||||
&mut state.content[request_index]
|
||||
{
|
||||
*status = ToolCallStatus::Completed;
|
||||
}
|
||||
|
||||
state.content.push(MessageContent::ToolResponse {
|
||||
id: tool_call_id,
|
||||
name: tool_name,
|
||||
result: content.to_string(),
|
||||
is_error: false,
|
||||
});
|
||||
} else {
|
||||
state.content.push(MessageContent::ToolResponse {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: String::new(),
|
||||
result: content.to_string(),
|
||||
is_error: false,
|
||||
});
|
||||
}
|
||||
state.has_unflushed_changes = true;
|
||||
}
|
||||
|
||||
let _ = self.app_handle.emit(
|
||||
"acp:tool_result",
|
||||
ToolResultPayload {
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod acp;
|
||||
pub mod personas;
|
||||
pub mod sessions;
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
use crate::types::agents::{Session, SessionUpdate};
|
||||
use crate::types::messages::Message;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct SessionStore {
|
||||
/// Session metadata indexed by session ID. Messages are loaded lazily from disk.
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
sessions_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
fn message_preview(message: &Message) -> Option<String> {
|
||||
for content in &message.content {
|
||||
if let crate::types::messages::MessageContent::Text { text } = content {
|
||||
let cutoff = text.char_indices().nth(100).map(|(index, _)| index);
|
||||
return Some(match cutoff {
|
||||
Some(index) => format!("{}...", &text[..index]),
|
||||
None => text.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn refresh_session_message_metadata(session: &mut Session, messages: &[Message]) {
|
||||
session.message_count = messages.len() as u32;
|
||||
session.last_message_preview = messages.iter().rev().find_map(Self::message_preview);
|
||||
session.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
let sessions_dir = dirs::home_dir()
|
||||
.expect("home dir")
|
||||
.join(".goose")
|
||||
.join("sessions");
|
||||
let _ = std::fs::create_dir_all(&sessions_dir);
|
||||
|
||||
let metadata_file = sessions_dir.join("metadata.json");
|
||||
let sessions: HashMap<String, Session> = std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|json| serde_json::from_str(&json).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
sessions: Mutex::new(sessions),
|
||||
sessions_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the full metadata map to a single file.
|
||||
fn save_metadata(&self, sessions: &HashMap<String, Session>) {
|
||||
let metadata_file = self.sessions_dir.join("metadata.json");
|
||||
match serde_json::to_string_pretty(sessions) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&metadata_file, json) {
|
||||
eprintln!(
|
||||
"Failed to write session metadata to {}: {}",
|
||||
metadata_file.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to serialize session metadata: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist messages for a single session to its directory.
|
||||
fn save_messages(&self, session_id: &str, messages: &[Message]) {
|
||||
let dir = self.sessions_dir.join(session_id);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!(
|
||||
"Failed to create session directory {}: {}",
|
||||
dir.display(),
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string_pretty(messages) {
|
||||
Ok(json) => {
|
||||
let path = dir.join("messages.json");
|
||||
if let Err(e) = std::fs::write(&path, &json) {
|
||||
eprintln!("Failed to write messages for session {}: {}", session_id, e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Failed to serialize messages for session {}: {}",
|
||||
session_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages for a session from disk.
|
||||
fn load_messages(&self, session_id: &str) -> Vec<Message> {
|
||||
let path = self.sessions_dir.join(session_id).join("messages.json");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|json| serde_json::from_str(&json).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn create_session(&self, agent_id: Option<String>, project_id: Option<String>) -> Session {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let session = Session {
|
||||
id: id.clone(),
|
||||
title: "New Chat".to_string(),
|
||||
agent_id,
|
||||
project_id,
|
||||
provider_id: None,
|
||||
persona_id: None,
|
||||
model_name: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
message_count: 0,
|
||||
last_message_preview: None,
|
||||
archived_at: None,
|
||||
};
|
||||
|
||||
// Write empty messages file
|
||||
self.save_messages(&id, &[]);
|
||||
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
sessions.insert(id, session.clone());
|
||||
self.save_metadata(&sessions);
|
||||
session
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_session(&self, id: &str) -> Option<Session> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
sessions.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Ensure a session with the given ID exists, creating it if necessary.
|
||||
/// Returns the session (existing or newly created).
|
||||
pub fn ensure_session(&self, id: &str, agent_id: Option<String>) -> Session {
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
match sessions.entry(id.to_string()) {
|
||||
Entry::Occupied(entry) => entry.get().clone(),
|
||||
Entry::Vacant(entry) => {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let session = Session {
|
||||
id: id.to_string(),
|
||||
title: "New Chat".to_string(),
|
||||
agent_id,
|
||||
project_id: None,
|
||||
provider_id: None,
|
||||
persona_id: None,
|
||||
model_name: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
message_count: 0,
|
||||
last_message_preview: None,
|
||||
archived_at: None,
|
||||
};
|
||||
|
||||
self.save_messages(id, &[]);
|
||||
entry.insert(session.clone());
|
||||
self.save_metadata(&sessions);
|
||||
session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<Session> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let mut list: Vec<Session> = sessions
|
||||
.values()
|
||||
.filter(|s| s.archived_at.is_none())
|
||||
.cloned()
|
||||
.collect();
|
||||
// Sort by updated_at descending (most recent first)
|
||||
list.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
list
|
||||
}
|
||||
|
||||
pub fn list_archived_sessions(&self) -> Vec<Session> {
|
||||
let sessions = self.sessions.lock().unwrap();
|
||||
let mut list: Vec<Session> = sessions
|
||||
.values()
|
||||
.filter(|s| s.archived_at.is_some())
|
||||
.cloned()
|
||||
.collect();
|
||||
// Sort by archived_at descending (most recently archived first)
|
||||
list.sort_by(|a, b| b.archived_at.cmp(&a.archived_at));
|
||||
list
|
||||
}
|
||||
|
||||
pub fn archive_session(&self, id: &str) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("Session '{}' not found", id))?;
|
||||
|
||||
session.archived_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
self.save_metadata(&sessions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unarchive_session(&self, id: &str) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("Session '{}' not found", id))?;
|
||||
|
||||
session.archived_at = None;
|
||||
self.save_metadata(&sessions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_message(&self, session_id: &str, message: Message) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
|
||||
|
||||
let mut messages = self.load_messages(session_id);
|
||||
messages.push(message);
|
||||
Self::refresh_session_message_metadata(session, &messages);
|
||||
self.save_metadata(&sessions);
|
||||
self.save_messages(session_id, &messages);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_message<F>(
|
||||
&self,
|
||||
session_id: &str,
|
||||
message_id: &str,
|
||||
updater: F,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&mut Message),
|
||||
{
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(session_id)
|
||||
.ok_or_else(|| format!("Session '{}' not found", session_id))?;
|
||||
|
||||
let mut messages = self.load_messages(session_id);
|
||||
let message = messages
|
||||
.iter_mut()
|
||||
.find(|message| message.id == message_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Message '{}' not found in session '{}'",
|
||||
message_id, session_id
|
||||
)
|
||||
})?;
|
||||
updater(message);
|
||||
|
||||
Self::refresh_session_message_metadata(session, &messages);
|
||||
self.save_metadata(&sessions);
|
||||
self.save_messages(session_id, &messages);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_messages(&self, session_id: &str) -> Vec<Message> {
|
||||
// Acquire the mutex to avoid reading a partially-written messages file
|
||||
let _guard = self.sessions.lock().unwrap();
|
||||
self.load_messages(session_id)
|
||||
}
|
||||
|
||||
pub fn update_session(&self, id: &str, update: SessionUpdate) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
let session = sessions
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("Session '{}' not found", id))?;
|
||||
|
||||
let mut should_bump_timestamp = false;
|
||||
if let Some(title) = update.title {
|
||||
session.title = title;
|
||||
should_bump_timestamp = true;
|
||||
}
|
||||
if let Some(provider_id) = update.provider_id {
|
||||
session.provider_id = Some(provider_id);
|
||||
}
|
||||
if let Some(persona_id) = update.persona_id {
|
||||
session.persona_id = Some(persona_id);
|
||||
}
|
||||
if let Some(model_name) = update.model_name {
|
||||
session.model_name = Some(model_name);
|
||||
}
|
||||
if let Some(project_id) = update.project_id {
|
||||
session.project_id = project_id;
|
||||
}
|
||||
if should_bump_timestamp {
|
||||
session.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
}
|
||||
self.save_metadata(&sessions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, id: &str) -> Result<(), String> {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
sessions
|
||||
.remove(id)
|
||||
.ok_or_else(|| format!("Session '{}' not found", id))?;
|
||||
|
||||
self.save_metadata(&sessions);
|
||||
|
||||
// Remove messages directory from disk
|
||||
let dir = self.sessions_dir.join(id);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,6 @@ pub struct MessageAttachment {
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenState {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod agents;
|
||||
pub mod builtin_personas;
|
||||
#[allow(dead_code)]
|
||||
pub mod messages;
|
||||
|
||||
@@ -19,7 +19,6 @@ import { useAcpStream } from "@/features/chat/hooks/useAcpStream";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { findExistingDraft } from "@/features/chat/lib/newChat";
|
||||
import { getSessionMessages } from "@/shared/api/chat";
|
||||
import { useAppStartup } from "./hooks/useAppStartup";
|
||||
|
||||
export type AppView =
|
||||
@@ -59,35 +58,45 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||
|
||||
useAcpStream(true);
|
||||
|
||||
const loadingSessionsRef = useRef<Set<string>>(new Set());
|
||||
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// TODO: wire to ACP load_session replay instead of fetching from SessionStore
|
||||
const loadSessionMessages = useCallback(async (sessionId: string) => {
|
||||
const session = useChatSessionStore
|
||||
.getState()
|
||||
.sessions.find((s) => s.id === sessionId);
|
||||
if (session?.draft) return;
|
||||
const { messagesBySession, setMessages } = useChatStore.getState();
|
||||
const existing = messagesBySession[sessionId];
|
||||
if (
|
||||
(existing && existing.length > 0) ||
|
||||
loadingSessionsRef.current.has(sessionId)
|
||||
) {
|
||||
// Skip if we already have messages for this session (e.g. it was just created)
|
||||
const existing = useChatStore.getState().messagesBySession[sessionId];
|
||||
if (existing && existing.length > 0) {
|
||||
console.log(
|
||||
`[perf:load] ${sessionId.slice(0, 8)} skip — already has messages`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
loadingSessionsRef.current.add(sessionId);
|
||||
|
||||
const t0 = performance.now();
|
||||
console.log(`[perf:load] ${sessionId.slice(0, 8)} start`);
|
||||
const store = useChatStore.getState();
|
||||
store.setSessionLoading(sessionId, true);
|
||||
try {
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
const current = useChatStore.getState().messagesBySession[sessionId];
|
||||
if (!current || current.length === 0) {
|
||||
setMessages(sessionId, messages);
|
||||
}
|
||||
const t1 = performance.now();
|
||||
const { acpLoadSession } = await import("@/shared/api/acp");
|
||||
const t2 = performance.now();
|
||||
console.log(
|
||||
`[perf:load] ${sessionId.slice(0, 8)} import took ${(t2 - t1).toFixed(1)}ms`,
|
||||
);
|
||||
// For sessions loaded from ACP, the session ID is the goose session ID
|
||||
await acpLoadSession(sessionId, sessionId);
|
||||
const t3 = performance.now();
|
||||
console.log(
|
||||
`[perf:load] ${sessionId.slice(0, 8)} acpLoadSession resolved in ${(t3 - t2).toFixed(1)}ms (total ${(t3 - t0).toFixed(1)}ms)`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load messages for session ${sessionId}:`, err);
|
||||
console.error("Failed to load session messages:", err);
|
||||
} finally {
|
||||
loadingSessionsRef.current.delete(sessionId);
|
||||
useChatStore.getState().setSessionLoading(sessionId, false);
|
||||
console.log(
|
||||
`[perf:load] ${sessionId.slice(0, 8)} done, loading=false at +${(performance.now() - t0).toFixed(1)}ms`,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -33,9 +33,14 @@ export function useAppStartup() {
|
||||
};
|
||||
|
||||
const loadSessionState = async () => {
|
||||
const t0 = performance.now();
|
||||
console.log("[perf:startup] loadSessionState start");
|
||||
const { loadSessions, setActiveSession } =
|
||||
useChatSessionStore.getState();
|
||||
await loadSessions();
|
||||
console.log(
|
||||
`[perf:startup] loadSessions done in ${(performance.now() - t0).toFixed(1)}ms`,
|
||||
);
|
||||
setActiveSession(null);
|
||||
};
|
||||
|
||||
|
||||
@@ -155,15 +155,23 @@ export function useAcpStream(enabled: boolean): void {
|
||||
unlisteners.push(
|
||||
listen<AcpMessageCreatedPayload>("acp:message_created", (event) => {
|
||||
if (!active) return;
|
||||
console.log(
|
||||
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} message_created mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`,
|
||||
);
|
||||
const store = useChatStore.getState();
|
||||
if (
|
||||
!shouldTrackStreamingEvent(
|
||||
store,
|
||||
event.payload.sessionId,
|
||||
event.payload.messageId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
|
||||
// Accept if: session is loading (replay), or we're actively streaming/thinking
|
||||
const isLoading = store.loadingSessionIds.has(event.payload.sessionId);
|
||||
if (!isLoading) {
|
||||
if (
|
||||
!shouldTrackStreamingEvent(
|
||||
store,
|
||||
event.payload.sessionId,
|
||||
event.payload.messageId,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = store.messagesBySession[event.payload.sessionId]?.find(
|
||||
@@ -197,6 +205,9 @@ export function useAcpStream(enabled: boolean): void {
|
||||
unlisteners.push(
|
||||
listen<AcpTextPayload>("acp:text", (event) => {
|
||||
if (!active) return;
|
||||
console.log(
|
||||
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} text mid=${event.payload.messageId.slice(0, 8)} len=${event.payload.text.length} at ${performance.now().toFixed(1)}ms`,
|
||||
);
|
||||
const store = useChatStore.getState();
|
||||
if (
|
||||
!shouldTrackStreamingEvent(
|
||||
@@ -219,13 +230,33 @@ export function useAcpStream(enabled: boolean): void {
|
||||
unlisteners.push(
|
||||
listen<AcpDonePayload>("acp:done", (event) => {
|
||||
if (!active) return;
|
||||
console.log(
|
||||
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} done mid=${event.payload.messageId.slice(0, 8)} at ${performance.now().toFixed(1)}ms`,
|
||||
);
|
||||
const store = useChatStore.getState();
|
||||
const isLoading = store.loadingSessionIds.has(event.payload.sessionId);
|
||||
|
||||
store.updateMessage(
|
||||
event.payload.sessionId,
|
||||
event.payload.messageId,
|
||||
(message) => updateCompletionStatus(message, "completed"),
|
||||
(message) => {
|
||||
// Mark any tool requests still in "executing" as completed.
|
||||
// During replay, tool_result events may not fire for every
|
||||
// tool call (e.g. when the result content type has no preview),
|
||||
// so we finalize them here to stop running timers.
|
||||
const content = message.content.map((block) =>
|
||||
block.type === "toolRequest" && block.status === "executing"
|
||||
? { ...block, status: "completed" as const }
|
||||
: block,
|
||||
);
|
||||
return updateCompletionStatus({ ...message, content }, "completed");
|
||||
},
|
||||
);
|
||||
store.setStreamingMessageId(event.payload.sessionId, null);
|
||||
|
||||
// During replay, don't reset chat state or trigger side effects
|
||||
if (isLoading) return;
|
||||
|
||||
store.setChatState(event.payload.sessionId, "idle");
|
||||
if (
|
||||
useChatSessionStore.getState().activeSessionId !==
|
||||
@@ -357,6 +388,30 @@ export function useAcpStream(enabled: boolean): void {
|
||||
);
|
||||
|
||||
// acp:session_info — update session title from ACP provider
|
||||
// Replay: user messages from load_session history
|
||||
unlisteners.push(
|
||||
listen<{ sessionId: string; messageId: string; text: string }>(
|
||||
"acp:replay_user_message",
|
||||
(event) => {
|
||||
if (!active) return;
|
||||
console.log(
|
||||
`[perf:stream] ${event.payload.sessionId.slice(0, 8)} replay_user_message at ${performance.now().toFixed(1)}ms`,
|
||||
);
|
||||
const store = useChatStore.getState();
|
||||
store.addMessage(event.payload.sessionId, {
|
||||
id: event.payload.messageId,
|
||||
role: "user",
|
||||
created: Date.now(),
|
||||
content: [{ type: "text", text: event.payload.text }],
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
unlisteners.push(
|
||||
listen<AcpSessionInfoPayload>("acp:session_info", (event) => {
|
||||
if (!active) return;
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpSessionInfo } from "@/shared/api/acp";
|
||||
import { useChatSessionStore } from "../chatSessionStore";
|
||||
|
||||
vi.mock("@/shared/api/acp", () => ({
|
||||
acpListSessions: vi.fn(),
|
||||
}));
|
||||
|
||||
import { acpListSessions } from "@/shared/api/acp";
|
||||
|
||||
const mockedAcpListSessions = vi.mocked(acpListSessions);
|
||||
|
||||
const SESSION_CACHE_KEY = "goose:chat-sessions";
|
||||
|
||||
function resetStore() {
|
||||
useChatSessionStore.setState({
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
contextPanelOpenBySession: {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("chatSessionStore", () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
window.localStorage.removeItem(SESSION_CACHE_KEY);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem(SESSION_CACHE_KEY);
|
||||
});
|
||||
|
||||
describe("createDraftSession", () => {
|
||||
it("creates a draft session with default title", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
|
||||
expect(session.title).toBe("New Chat");
|
||||
expect(session.draft).toBe(true);
|
||||
expect(session.messageCount).toBe(0);
|
||||
expect(useChatSessionStore.getState().sessions).toContainEqual(session);
|
||||
});
|
||||
|
||||
it("creates a draft session with custom options", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession({
|
||||
title: "My Custom Chat",
|
||||
projectId: "proj-1",
|
||||
providerId: "openai",
|
||||
personaId: "persona-1",
|
||||
});
|
||||
|
||||
expect(session.title).toBe("My Custom Chat");
|
||||
expect(session.projectId).toBe("proj-1");
|
||||
expect(session.providerId).toBe("openai");
|
||||
expect(session.personaId).toBe("persona-1");
|
||||
expect(session.draft).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("promoteDraft", () => {
|
||||
it("removes draft flag from a draft session", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
expect(session.draft).toBe(true);
|
||||
|
||||
useChatSessionStore.getState().promoteDraft(session.id);
|
||||
|
||||
const updated = useChatSessionStore
|
||||
.getState()
|
||||
.sessions.find((s) => s.id === session.id);
|
||||
expect(updated?.draft).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does nothing for non-draft sessions", () => {
|
||||
useChatSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: "non-draft",
|
||||
title: "Regular Session",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messageCount: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useChatSessionStore.getState().promoteDraft("non-draft");
|
||||
|
||||
const session = useChatSessionStore
|
||||
.getState()
|
||||
.sessions.find((s) => s.id === "non-draft");
|
||||
expect(session?.draft).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeDraft", () => {
|
||||
it("removes a draft session", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
expect(useChatSessionStore.getState().sessions).toHaveLength(1);
|
||||
|
||||
useChatSessionStore.getState().removeDraft(session.id);
|
||||
|
||||
expect(useChatSessionStore.getState().sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clears activeSessionId if removing the active draft", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
useChatSessionStore.getState().setActiveSession(session.id);
|
||||
|
||||
useChatSessionStore.getState().removeDraft(session.id);
|
||||
|
||||
expect(useChatSessionStore.getState().activeSessionId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not remove non-draft sessions", () => {
|
||||
useChatSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: "non-draft",
|
||||
title: "Regular Session",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messageCount: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useChatSessionStore.getState().removeDraft("non-draft");
|
||||
|
||||
expect(useChatSessionStore.getState().sessions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadSessions", () => {
|
||||
it("loads sessions from ACP and maps them correctly", async () => {
|
||||
mockedAcpListSessions.mockResolvedValue([
|
||||
{ sessionId: "acp-1", title: "ACP Session 1", updatedAt: "2026-04-01" },
|
||||
{ sessionId: "acp-2", title: null, updatedAt: "2026-04-02" },
|
||||
]);
|
||||
|
||||
await useChatSessionStore.getState().loadSessions();
|
||||
|
||||
const sessions = useChatSessionStore.getState().sessions;
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0].id).toBe("acp-2"); // Most recent first
|
||||
expect(sessions[0].title).toBe("Untitled"); // null title becomes "Untitled"
|
||||
expect(sessions[1].id).toBe("acp-1");
|
||||
expect(sessions[1].title).toBe("ACP Session 1");
|
||||
});
|
||||
|
||||
it("preserves local drafts alongside ACP sessions", async () => {
|
||||
const draft = useChatSessionStore.getState().createDraftSession({
|
||||
title: "My Draft",
|
||||
});
|
||||
|
||||
mockedAcpListSessions.mockResolvedValue([
|
||||
{ sessionId: "acp-1", title: "ACP Session", updatedAt: "2026-04-01" },
|
||||
]);
|
||||
|
||||
await useChatSessionStore.getState().loadSessions();
|
||||
|
||||
const sessions = useChatSessionStore.getState().sessions;
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions.find((s) => s.id === "acp-1")).toBeDefined();
|
||||
expect(sessions.find((s) => s.id === draft.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets isLoading during fetch", async () => {
|
||||
let resolvePromise: (value: AcpSessionInfo[]) => void = () => {};
|
||||
mockedAcpListSessions.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const loadPromise = useChatSessionStore.getState().loadSessions();
|
||||
expect(useChatSessionStore.getState().isLoading).toBe(true);
|
||||
|
||||
resolvePromise([]);
|
||||
await loadPromise;
|
||||
|
||||
expect(useChatSessionStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to cached drafts on error", async () => {
|
||||
window.localStorage.setItem(
|
||||
SESSION_CACHE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "cached-draft",
|
||||
title: "Cached Draft",
|
||||
draft: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
mockedAcpListSessions.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await useChatSessionStore.getState().loadSessions();
|
||||
|
||||
const sessions = useChatSessionStore.getState().sessions;
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe("cached-draft");
|
||||
expect(sessions[0].draft).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSession", () => {
|
||||
it("updates session properties", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
|
||||
useChatSessionStore.getState().updateSession(session.id, {
|
||||
title: "Updated Title",
|
||||
projectId: "new-project",
|
||||
});
|
||||
|
||||
const updated = useChatSessionStore
|
||||
.getState()
|
||||
.sessions.find((s) => s.id === session.id);
|
||||
expect(updated?.title).toBe("Updated Title");
|
||||
expect(updated?.projectId).toBe("new-project");
|
||||
});
|
||||
|
||||
it("updates the updatedAt timestamp", () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
const originalUpdatedAt = session.updatedAt;
|
||||
|
||||
// Small delay to ensure timestamp changes
|
||||
vi.useFakeTimers();
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
useChatSessionStore.getState().updateSession(session.id, {
|
||||
title: "New Title",
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
const updated = useChatSessionStore
|
||||
.getState()
|
||||
.sessions.find((s) => s.id === session.id);
|
||||
expect(updated?.updatedAt).not.toBe(originalUpdatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("archiveSession", () => {
|
||||
it("removes the session from the list", async () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
|
||||
await useChatSessionStore.getState().archiveSession(session.id);
|
||||
|
||||
expect(useChatSessionStore.getState().sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clears activeSessionId if archiving the active session", async () => {
|
||||
const session = useChatSessionStore.getState().createDraftSession();
|
||||
useChatSessionStore.getState().setActiveSession(session.id);
|
||||
|
||||
await useChatSessionStore.getState().archiveSession(session.id);
|
||||
|
||||
expect(useChatSessionStore.getState().activeSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,3 +220,60 @@ describe("chatStore draft localStorage persistence", () => {
|
||||
expect(stored).toEqual({ s2: "world" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("chatStore session loading state", () => {
|
||||
beforeEach(() => {
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
queuedMessageBySession: {},
|
||||
draftsBySession: {},
|
||||
activeSessionId: null,
|
||||
isConnected: false,
|
||||
loadingSessionIds: new Set<string>(),
|
||||
});
|
||||
});
|
||||
|
||||
it("starts with empty loadingSessionIds", () => {
|
||||
expect(useChatStore.getState().loadingSessionIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it("adds session to loadingSessionIds when setSessionLoading(true)", () => {
|
||||
useChatStore.getState().setSessionLoading("s1", true);
|
||||
|
||||
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes session from loadingSessionIds when setSessionLoading(false)", () => {
|
||||
useChatStore.getState().setSessionLoading("s1", true);
|
||||
useChatStore.getState().setSessionLoading("s1", false);
|
||||
|
||||
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks multiple sessions independently", () => {
|
||||
useChatStore.getState().setSessionLoading("s1", true);
|
||||
useChatStore.getState().setSessionLoading("s2", true);
|
||||
|
||||
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(true);
|
||||
expect(useChatStore.getState().loadingSessionIds.has("s2")).toBe(true);
|
||||
|
||||
useChatStore.getState().setSessionLoading("s1", false);
|
||||
|
||||
expect(useChatStore.getState().loadingSessionIds.has("s1")).toBe(false);
|
||||
expect(useChatStore.getState().loadingSessionIds.has("s2")).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent for adding the same session", () => {
|
||||
useChatStore.getState().setSessionLoading("s1", true);
|
||||
useChatStore.getState().setSessionLoading("s1", true);
|
||||
|
||||
expect(useChatStore.getState().loadingSessionIds.size).toBe(1);
|
||||
});
|
||||
|
||||
it("is idempotent for removing a non-existent session", () => {
|
||||
useChatStore.getState().setSessionLoading("s1", false);
|
||||
|
||||
expect(useChatStore.getState().loadingSessionIds.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
createSession as apiCreateSession,
|
||||
listSessions as apiListSessions,
|
||||
archiveSession as apiArchiveSession,
|
||||
unarchiveSession as apiUnarchiveSession,
|
||||
updateSession as apiUpdateSession,
|
||||
} from "@/shared/api/chat";
|
||||
import type { Session } from "@/shared/types/chat";
|
||||
|
||||
const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions";
|
||||
import { acpListSessions } from "@/shared/api/acp";
|
||||
|
||||
// Extended session metadata used by the frontend session list
|
||||
export interface ChatSession {
|
||||
@@ -68,6 +60,8 @@ interface ChatSessionStoreActions {
|
||||
|
||||
export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions;
|
||||
|
||||
const SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions";
|
||||
|
||||
function loadCachedSessions(): ChatSession[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
@@ -110,7 +104,8 @@ function persistSessions(sessions: ChatSession[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionToChatSession(session: Session): ChatSession {
|
||||
/** Map a backend Session to the frontend ChatSession shape. */
|
||||
export function sessionToChatSession(session: Session): ChatSession {
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
@@ -129,43 +124,19 @@ function sessionToChatSession(session: Session): ChatSession {
|
||||
|
||||
export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
// State
|
||||
sessions: loadCachedSessions(),
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
contextPanelOpenBySession: {},
|
||||
|
||||
// Session lifecycle
|
||||
createSession: async (opts) => {
|
||||
const backendSession = await apiCreateSession(
|
||||
opts?.agentId,
|
||||
opts?.projectId,
|
||||
// Session lifecycle — local-only for now.
|
||||
// The goose binary creates the real ACP session on first prompt
|
||||
// (via prepare_session / send_prompt). These just manage the
|
||||
// frontend's in-memory session list.
|
||||
createSession: async (_opts) => {
|
||||
throw new Error(
|
||||
"createSession not yet wired to ACP — use createDraftSession",
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const chatSession: ChatSession = {
|
||||
id: backendSession.id,
|
||||
title: opts?.title ?? backendSession.title,
|
||||
projectId: opts?.projectId,
|
||||
agentId: opts?.agentId ?? backendSession.agentId,
|
||||
providerId: opts?.providerId,
|
||||
personaId: opts?.personaId,
|
||||
createdAt: backendSession.createdAt ?? now,
|
||||
updatedAt: backendSession.updatedAt ?? now,
|
||||
messageCount: backendSession.messageCount ?? 0,
|
||||
};
|
||||
const initialUpdate: Record<string, string> = {};
|
||||
if (opts?.title) initialUpdate.title = opts.title;
|
||||
if (opts?.providerId) initialUpdate.providerId = opts.providerId;
|
||||
if (opts?.personaId) initialUpdate.personaId = opts.personaId;
|
||||
if (Object.keys(initialUpdate).length > 0) {
|
||||
apiUpdateSession(backendSession.id, initialUpdate).catch((err) => {
|
||||
console.error("Failed to persist initial session metadata:", err);
|
||||
});
|
||||
}
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, chatSession],
|
||||
}));
|
||||
persistSessions([...get().sessions]);
|
||||
return chatSession;
|
||||
},
|
||||
|
||||
createDraftSession: (opts) => {
|
||||
@@ -214,11 +185,28 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
loadSessions: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const backendSessions = await apiListSessions();
|
||||
const chatSessions = backendSessions.map(sessionToChatSession);
|
||||
const acpSessions = await acpListSessions();
|
||||
const sessions: ChatSession[] = acpSessions.map((s) => ({
|
||||
id: s.sessionId,
|
||||
title: s.title ?? "Untitled",
|
||||
createdAt: s.updatedAt ?? new Date().toISOString(),
|
||||
updatedAt: s.updatedAt ?? new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
}));
|
||||
// Sort by updatedAt descending (most recent first)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
);
|
||||
// Preserve local drafts
|
||||
const drafts = get().sessions.filter((s) => s.draft);
|
||||
set({ sessions: [...chatSessions, ...drafts] });
|
||||
persistSessions(chatSessions);
|
||||
set({ sessions: [...sessions, ...drafts] });
|
||||
} catch (err) {
|
||||
console.error("Failed to load sessions from ACP:", err);
|
||||
// On error, at least load cached drafts
|
||||
const cached = loadCachedSessions();
|
||||
const drafts = cached.filter((s) => s.draft);
|
||||
set({ sessions: drafts });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
@@ -227,78 +215,28 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
|
||||
updateSession: (id, patch, opts) => {
|
||||
set((state) => ({
|
||||
sessions: state.sessions.map((s) =>
|
||||
s.id === id ? { ...s, ...patch } : s,
|
||||
s.id === id
|
||||
? { ...s, ...patch, updatedAt: new Date().toISOString() }
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
persistSessions(get().sessions);
|
||||
if (opts?.localOnly) return;
|
||||
const session = get().sessions.find((s) => s.id === id);
|
||||
if (session?.draft) return; // skip backend for drafts
|
||||
const backendPatch: {
|
||||
title?: string;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
modelName?: string;
|
||||
projectId?: string | null;
|
||||
userSetName?: boolean;
|
||||
} = {};
|
||||
if (patch.title) backendPatch.title = patch.title;
|
||||
if (patch.providerId) backendPatch.providerId = patch.providerId;
|
||||
if (patch.personaId) backendPatch.personaId = patch.personaId;
|
||||
if (patch.modelName) backendPatch.modelName = patch.modelName;
|
||||
if ("projectId" in patch) {
|
||||
backendPatch.projectId = patch.projectId ?? null;
|
||||
}
|
||||
if ("userSetName" in patch) {
|
||||
backendPatch.userSetName = patch.userSetName;
|
||||
}
|
||||
if (Object.keys(backendPatch).length > 0) {
|
||||
apiUpdateSession(id, backendPatch).catch((err) => {
|
||||
console.error("Failed to persist session update:", err);
|
||||
});
|
||||
}
|
||||
if (session?.draft) return;
|
||||
// TODO: wire non-draft updates to ACP when supported
|
||||
},
|
||||
|
||||
archiveSession: async (id) => {
|
||||
const previousActiveSessionId = get().activeSessionId;
|
||||
set((state) => {
|
||||
return {
|
||||
activeSessionId:
|
||||
state.activeSessionId === id ? null : state.activeSessionId,
|
||||
};
|
||||
});
|
||||
|
||||
// Archive on backend — update local state on success
|
||||
try {
|
||||
await apiArchiveSession(id);
|
||||
const archivedAt = new Date().toISOString();
|
||||
const nextSessions = get()
|
||||
.sessions.map((s) => (s.id === id ? { ...s, archivedAt } : s))
|
||||
.filter((s) => !s.archivedAt);
|
||||
const { [id]: _, ...remainingPanelState } =
|
||||
get().contextPanelOpenBySession;
|
||||
set({
|
||||
sessions: nextSessions,
|
||||
contextPanelOpenBySession: remainingPanelState,
|
||||
});
|
||||
persistSessions(nextSessions);
|
||||
} catch (err) {
|
||||
set({
|
||||
activeSessionId: previousActiveSessionId,
|
||||
});
|
||||
console.error("Failed to archive session:", err);
|
||||
throw err;
|
||||
}
|
||||
set((state) => ({
|
||||
sessions: state.sessions.filter((s) => s.id !== id),
|
||||
activeSessionId:
|
||||
state.activeSessionId === id ? null : state.activeSessionId,
|
||||
}));
|
||||
},
|
||||
|
||||
unarchiveSession: async (id) => {
|
||||
try {
|
||||
await apiUnarchiveSession(id);
|
||||
await get().loadSessions();
|
||||
} catch (err) {
|
||||
console.error("Failed to unarchive session:", err);
|
||||
throw err;
|
||||
}
|
||||
unarchiveSession: async (_id) => {
|
||||
// No-op — no archive persistence yet
|
||||
},
|
||||
|
||||
setActiveSession: (sessionId) => {
|
||||
|
||||
@@ -71,6 +71,9 @@ interface ChatStoreState {
|
||||
|
||||
// Connection
|
||||
isConnected: boolean;
|
||||
|
||||
// Sessions currently being loaded from history (replay in progress)
|
||||
loadingSessionIds: Set<string>;
|
||||
}
|
||||
|
||||
interface ChatStoreActions {
|
||||
@@ -119,6 +122,9 @@ interface ChatStoreActions {
|
||||
setDraft: (sessionId: string, text: string) => void;
|
||||
clearDraft: (sessionId: string) => void;
|
||||
|
||||
// Session loading (replay)
|
||||
setSessionLoading: (sessionId: string, loading: boolean) => void;
|
||||
|
||||
// Cleanup
|
||||
cleanupSession: (sessionId: string) => void;
|
||||
}
|
||||
@@ -133,6 +139,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
draftsBySession: loadCachedDrafts(),
|
||||
activeSessionId: null,
|
||||
isConnected: false,
|
||||
loadingSessionIds: new Set<string>(),
|
||||
|
||||
// Session management
|
||||
setActiveSession: (sessionId) =>
|
||||
@@ -421,6 +428,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
persistDrafts(get().draftsBySession);
|
||||
},
|
||||
|
||||
// Session loading (replay)
|
||||
setSessionLoading: (sessionId, loading) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.loadingSessionIds);
|
||||
if (loading) {
|
||||
next.add(sessionId);
|
||||
} else {
|
||||
next.delete(sessionId);
|
||||
}
|
||||
return { loadingSessionIds: next };
|
||||
}),
|
||||
|
||||
// Cleanup
|
||||
cleanupSession: (sessionId) => {
|
||||
set((state) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Placeholder skeleton shown while session history is being replayed
|
||||
* from the backend. Mimics the visual rhythm of a real conversation
|
||||
* with alternating user/assistant message shapes.
|
||||
*/
|
||||
export function ChatLoadingSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 items-start overflow-hidden"
|
||||
role="status"
|
||||
aria-label="Loading conversation"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-3xl py-4 space-y-6 px-4">
|
||||
{/* Date separator skeleton */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<Skeleton className="h-3 w-16 rounded-full" />
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<div className="space-y-2 max-w-[70%]">
|
||||
<Skeleton className="h-4 w-64 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assistant message — multi-line */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-6 rounded-full" />
|
||||
<Skeleton className="h-3 w-14 rounded-full" />
|
||||
</div>
|
||||
<div className="pl-8 space-y-2">
|
||||
<Skeleton className="h-4 w-full rounded-lg" />
|
||||
<Skeleton className="h-4 w-[85%] rounded-lg" />
|
||||
<Skeleton className="h-4 w-[60%] rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<div className="space-y-2 max-w-[70%]">
|
||||
<Skeleton className="h-4 w-48 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assistant message — shorter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-6 rounded-full" />
|
||||
<Skeleton className="h-3 w-14 rounded-full" />
|
||||
</div>
|
||||
<div className="pl-8 space-y-2">
|
||||
<Skeleton className="h-4 w-full rounded-lg" />
|
||||
<Skeleton className="h-4 w-[70%] rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool call block */}
|
||||
<div className="pl-8">
|
||||
<Skeleton className="h-10 w-[50%] rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Assistant continuation */}
|
||||
<div className="pl-8 space-y-2">
|
||||
<Skeleton className="h-4 w-full rounded-lg" />
|
||||
<Skeleton className="h-4 w-[45%] rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import { MessageTimeline } from "./MessageTimeline";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import type { PastedImage } from "@/shared/types/messages";
|
||||
import { LoadingGoose } from "./LoadingGoose";
|
||||
import { ChatLoadingSkeleton } from "./ChatLoadingSkeleton";
|
||||
import { ContextPanel } from "./ContextPanel";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useMessageQueue } from "../hooks/useMessageQueue";
|
||||
import { useSessionAutoTitle } from "@/features/sessions/hooks/useSessionAutoTitle";
|
||||
import { useChatStore } from "../stores/chatStore";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection";
|
||||
@@ -30,7 +30,7 @@ import { Button } from "@/shared/ui/button";
|
||||
import { ArtifactPolicyProvider } from "../hooks/ArtifactPolicyContext";
|
||||
|
||||
interface ChatViewProps {
|
||||
sessionId?: string;
|
||||
sessionId: string;
|
||||
agentName?: string;
|
||||
agentAvatarUrl?: string;
|
||||
initialProvider?: string;
|
||||
@@ -61,7 +61,7 @@ export function ChatView({
|
||||
onInitialMessageConsumed,
|
||||
onCreateProject,
|
||||
}: ChatViewProps) {
|
||||
const [activeSessionId] = useState(() => sessionId ?? crypto.randomUUID());
|
||||
const activeSessionId = sessionId;
|
||||
const isContextPanelOpen = useChatSessionStore(
|
||||
(s) => s.contextPanelOpenBySession[activeSessionId] ?? false,
|
||||
);
|
||||
@@ -76,7 +76,6 @@ export function ChatView({
|
||||
selectedProvider: globalSelectedProvider,
|
||||
setSelectedProvider: setGlobalSelectedProvider,
|
||||
} = useProviderSelection();
|
||||
|
||||
const personas = useAgentStore((s) => s.personas);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
|
||||
initialPersonaId ?? null,
|
||||
@@ -143,7 +142,6 @@ export function ChatView({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!session?.projectId || storedProject) {
|
||||
setFallbackProject(null);
|
||||
return;
|
||||
@@ -187,7 +185,6 @@ export function ChatView({
|
||||
if (providerId === selectedProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGlobalSelectedProvider(providerId);
|
||||
useChatSessionStore
|
||||
.getState()
|
||||
@@ -259,7 +256,6 @@ export function ChatView({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
acpPrepareSession(activeSessionId, selectedProvider, {
|
||||
workingDir: effectiveWorkingDir,
|
||||
personaId: selectedPersonaId ?? undefined,
|
||||
@@ -289,8 +285,11 @@ export function ChatView({
|
||||
personaInfo,
|
||||
effectiveWorkingDir,
|
||||
);
|
||||
|
||||
useSessionAutoTitle(activeSessionId);
|
||||
const isLoadingHistory = useChatStore(
|
||||
(s) =>
|
||||
s.loadingSessionIds.has(activeSessionId) &&
|
||||
(s.messagesBySession[activeSessionId]?.length ?? 0) === 0,
|
||||
);
|
||||
|
||||
const deferredSend = useRef<{ text: string; images?: PastedImage[] } | null>(
|
||||
null,
|
||||
@@ -385,14 +384,18 @@ export function ChatView({
|
||||
>
|
||||
<div className="relative flex h-full min-w-0">
|
||||
<div className="flex min-w-0 flex-1 flex-col pr-1">
|
||||
<MessageTimeline
|
||||
messages={messages}
|
||||
streamingMessageId={streamingMessageId}
|
||||
agentName={displayAgentName}
|
||||
agentAvatarUrl={personaAvatarSrc ?? agentAvatarUrl}
|
||||
/>
|
||||
{isLoadingHistory ? (
|
||||
<ChatLoadingSkeleton />
|
||||
) : (
|
||||
<MessageTimeline
|
||||
messages={messages}
|
||||
streamingMessageId={streamingMessageId}
|
||||
agentName={displayAgentName}
|
||||
agentAvatarUrl={personaAvatarSrc ?? agentAvatarUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showIndicator && (
|
||||
{showIndicator && !isLoadingHistory && (
|
||||
<LoadingGoose
|
||||
agentName={displayAgentName}
|
||||
chatState={
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChatLoadingSkeleton } from "../ChatLoadingSkeleton";
|
||||
|
||||
describe("ChatLoadingSkeleton", () => {
|
||||
it("renders with loading status role", () => {
|
||||
render(<ChatLoadingSkeleton />);
|
||||
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has accessible label for screen readers", () => {
|
||||
render(<ChatLoadingSkeleton />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("status", { name: /loading conversation/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders skeleton elements", () => {
|
||||
const { container } = render(<ChatLoadingSkeleton />);
|
||||
|
||||
const skeletons = container.querySelectorAll('[class*="animate-pulse"]');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
|
||||
// Mock the API module — include updateSession since chatSessionStore imports it
|
||||
vi.mock("@/shared/api/chat", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/shared/api/chat")>();
|
||||
return {
|
||||
...actual,
|
||||
generateSessionTitle: vi.fn(),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import { generateSessionTitle } from "@/shared/api/chat";
|
||||
import { useSessionAutoTitle } from "../useSessionAutoTitle";
|
||||
|
||||
const mockGenerateTitle = vi.mocked(generateSessionTitle);
|
||||
|
||||
function seedSession(
|
||||
id: string,
|
||||
overrides: Partial<{
|
||||
title: string;
|
||||
userSetName: boolean;
|
||||
draft: boolean;
|
||||
messageCount: number;
|
||||
}> = {},
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
useChatSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id,
|
||||
title: overrides.title ?? "New Chat",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
messageCount: overrides.messageCount ?? 0,
|
||||
draft: overrides.draft,
|
||||
userSetName: overrides.userSetName,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("useSessionAutoTitle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useChatStore.setState({
|
||||
messagesBySession: {},
|
||||
sessionStateById: {},
|
||||
draftsBySession: {},
|
||||
activeSessionId: null,
|
||||
isConnected: false,
|
||||
});
|
||||
useChatSessionStore.setState({
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
isLoading: false,
|
||||
contextPanelOpenBySession: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trigger when session has userSetName", () => {
|
||||
seedSession("s1", { userSetName: true, messageCount: 2 });
|
||||
useChatStore.getState().setChatState("s1", "idle");
|
||||
|
||||
renderHook(() => useSessionAutoTitle("s1"));
|
||||
|
||||
expect(mockGenerateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger when messageCount is not 2", () => {
|
||||
seedSession("s1", { messageCount: 0 });
|
||||
useChatStore.getState().setChatState("s1", "idle");
|
||||
|
||||
renderHook(() => useSessionAutoTitle("s1"));
|
||||
|
||||
expect(mockGenerateTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("generates title when first exchange completes", async () => {
|
||||
seedSession("s1", { messageCount: 2 });
|
||||
mockGenerateTitle.mockResolvedValue("Fix sidebar resize bug");
|
||||
|
||||
// Start in streaming state
|
||||
useChatStore.getState().setChatState("s1", "streaming");
|
||||
|
||||
const { rerender } = renderHook(() => useSessionAutoTitle("s1"));
|
||||
|
||||
// Transition to idle (stream completed)
|
||||
useChatStore.getState().setChatState("s1", "idle");
|
||||
rerender();
|
||||
|
||||
// Wait for the async call
|
||||
await vi.waitFor(() => {
|
||||
expect(mockGenerateTitle).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses fallback title on API failure", async () => {
|
||||
seedSession("s1", { messageCount: 2 });
|
||||
mockGenerateTitle.mockRejectedValue(new Error("LLM unavailable"));
|
||||
|
||||
// Add a user message so fallback can extract text
|
||||
useChatStore.getState().addMessage("s1", {
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
created: Date.now(),
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me fix the authentication middleware in the login flow",
|
||||
},
|
||||
],
|
||||
metadata: { userVisible: true },
|
||||
});
|
||||
|
||||
useChatStore.getState().setChatState("s1", "streaming");
|
||||
|
||||
const { rerender } = renderHook(() => useSessionAutoTitle("s1"));
|
||||
|
||||
useChatStore.getState().setChatState("s1", "idle");
|
||||
rerender();
|
||||
|
||||
// Wait for the rejection to be handled and fallback title to be set
|
||||
await vi.waitFor(() => {
|
||||
expect(mockGenerateTitle).toHaveBeenCalledWith("s1");
|
||||
// Should have updated session with fallback (first 50 chars of user message)
|
||||
const session = useChatSessionStore.getState().getSession("s1");
|
||||
expect(session?.title).toBe(
|
||||
"Help me fix the authentication middleware in the l",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { generateSessionTitle } from "@/shared/api/chat";
|
||||
|
||||
/**
|
||||
* Automatically generates a session title after the first exchange completes.
|
||||
* Watches for the transition from streaming → idle when messageCount === 2.
|
||||
* Skips sessions where the user has manually set a title.
|
||||
*/
|
||||
export function useSessionAutoTitle(sessionId: string) {
|
||||
const firedRef = useRef(false);
|
||||
const prevChatStateRef = useRef<string | null>(null);
|
||||
|
||||
const chatState = useChatStore(
|
||||
(s) => s.getSessionRuntime(sessionId).chatState,
|
||||
);
|
||||
const session = useChatSessionStore((s) =>
|
||||
s.sessions.find((candidate) => candidate.id === sessionId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (firedRef.current) return;
|
||||
if (!session || session.userSetName) return;
|
||||
if (session.messageCount !== 2) {
|
||||
prevChatStateRef.current = chatState;
|
||||
return;
|
||||
}
|
||||
|
||||
const wasStreaming = prevChatStateRef.current === "streaming";
|
||||
prevChatStateRef.current = chatState;
|
||||
|
||||
if (chatState !== "idle" || !wasStreaming) return;
|
||||
|
||||
firedRef.current = true;
|
||||
|
||||
generateSessionTitle(sessionId)
|
||||
.then((title) => {
|
||||
useChatSessionStore
|
||||
.getState()
|
||||
.updateSession(sessionId, { title, userSetName: false });
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback: use first 50 chars of first user message
|
||||
const messages =
|
||||
useChatStore.getState().messagesBySession[sessionId] ?? [];
|
||||
const firstUserMessage = messages.find((m) => m.role === "user");
|
||||
const textBlock = firstUserMessage?.content.find(
|
||||
(c) => c.type === "text",
|
||||
);
|
||||
if (textBlock && "text" in textBlock) {
|
||||
const fallback = textBlock.text.trim().slice(0, 50);
|
||||
useChatSessionStore
|
||||
.getState()
|
||||
.updateSession(sessionId, { title: fallback, userSetName: false });
|
||||
}
|
||||
});
|
||||
}, [sessionId, chatState, session]);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { filterSessions } from "../lib/filterSessions";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { listArchivedSessions } from "@/shared/api/chat";
|
||||
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
|
||||
|
||||
interface SessionHistoryViewProps {
|
||||
@@ -32,33 +31,9 @@ export function SessionHistoryView({
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const loadArchived = useCallback(() => {
|
||||
let cancelled = false;
|
||||
listArchivedSessions()
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setArchivedSessions(
|
||||
result.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
projectId: s.projectId,
|
||||
agentId: s.agentId,
|
||||
providerId: s.providerId,
|
||||
personaId: s.personaId,
|
||||
modelName: s.modelName,
|
||||
createdAt: s.createdAt,
|
||||
updatedAt: s.updatedAt,
|
||||
archivedAt: s.archivedAt,
|
||||
messageCount: s.messageCount,
|
||||
userSetName: s.userSetName,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setArchivedSessions([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// TODO: Wire to ACP when archived sessions are supported
|
||||
// For now, archived sessions aren't persisted to the backend
|
||||
setArchivedSessions([]);
|
||||
}, []);
|
||||
|
||||
useEffect(loadArchived, [loadArchived]);
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@/features/projects/api/projects";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { listArchivedSessions } from "@/shared/api/chat";
|
||||
|
||||
import type { Session } from "@/shared/types/chat";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -74,12 +74,10 @@ export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
.finally(() => setLoadingArchived(false));
|
||||
}, []);
|
||||
|
||||
// Load archived chats on mount
|
||||
// TODO: wire to ACP for archived session listing
|
||||
useEffect(() => {
|
||||
listArchivedSessions()
|
||||
.then(setArchivedChats)
|
||||
.catch(() => setArchivedChats([]))
|
||||
.finally(() => setLoadingArchivedChats(false));
|
||||
setArchivedChats([]);
|
||||
setLoadingArchivedChats(false);
|
||||
}, []);
|
||||
|
||||
const handleRestoreProject = async (id: string) => {
|
||||
|
||||
@@ -59,6 +59,36 @@ export async function acpPrepareSession(
|
||||
});
|
||||
}
|
||||
|
||||
/** Session info returned by the goose binary's list_sessions. */
|
||||
export interface AcpSessionInfo {
|
||||
sessionId: string;
|
||||
title: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
/** List all sessions known to the goose binary. */
|
||||
export async function acpListSessions(): Promise<AcpSessionInfo[]> {
|
||||
return invoke("acp_list_sessions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an existing session from the goose binary.
|
||||
*
|
||||
* This triggers message replay via SessionNotification events that the
|
||||
* frontend's useAcpStream hook picks up automatically.
|
||||
*/
|
||||
export async function acpLoadSession(
|
||||
sessionId: string,
|
||||
gooseSessionId: string,
|
||||
workingDir?: string,
|
||||
): Promise<void> {
|
||||
return invoke("acp_load_session", {
|
||||
sessionId,
|
||||
gooseSessionId,
|
||||
workingDir: workingDir ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cancel an in-progress ACP session so the backend stops streaming. */
|
||||
export async function acpCancelSession(
|
||||
sessionId: string,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Message } from "@/shared/types/messages";
|
||||
import type { Session } from "@/shared/types/chat";
|
||||
|
||||
export async function createSession(
|
||||
agentId?: string,
|
||||
projectId?: string,
|
||||
): Promise<Session> {
|
||||
return invoke("create_session", { agentId, projectId });
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<Session[]> {
|
||||
return invoke("list_sessions");
|
||||
}
|
||||
|
||||
export async function listArchivedSessions(): Promise<Session[]> {
|
||||
return invoke("list_archived_sessions");
|
||||
}
|
||||
|
||||
export async function getSessionMessages(
|
||||
sessionId: string,
|
||||
): Promise<Message[]> {
|
||||
return invoke("get_session_messages", { sessionId });
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
sessionId: string,
|
||||
message: Message,
|
||||
): Promise<Message> {
|
||||
return invoke("chat_send_message", { sessionId, message });
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
sessionId: string,
|
||||
update: {
|
||||
title?: string;
|
||||
providerId?: string;
|
||||
personaId?: string;
|
||||
modelName?: string;
|
||||
projectId?: string | null;
|
||||
userSetName?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
return invoke("update_session", { sessionId, update });
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
return invoke("delete_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function archiveSession(sessionId: string): Promise<void> {
|
||||
return invoke("archive_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function unarchiveSession(sessionId: string): Promise<void> {
|
||||
return invoke("unarchive_session", { sessionId });
|
||||
}
|
||||
|
||||
export async function generateSessionTitle(sessionId: string): Promise<string> {
|
||||
return invoke("generate_session_title", { sessionId });
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./chat";
|
||||
export * from "./agents";
|
||||
export * from "./acp";
|
||||
export * from "./git";
|
||||
|
||||
@@ -153,7 +153,10 @@ describe("createUserMessage", () => {
|
||||
expect(msg.created).toBeGreaterThan(0);
|
||||
expect(msg.content).toHaveLength(1);
|
||||
expect(msg.content[0]).toEqual({ type: "text", text: "hi there" });
|
||||
expect(msg.metadata).toBeUndefined();
|
||||
expect(msg.metadata).toEqual({
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes attachments when provided", () => {
|
||||
@@ -162,6 +165,8 @@ describe("createUserMessage", () => {
|
||||
];
|
||||
const msg = createUserMessage("check this", attachments);
|
||||
expect(msg.metadata).toBeDefined();
|
||||
expect(msg.metadata?.userVisible).toBe(true);
|
||||
expect(msg.metadata?.agentVisible).toBe(true);
|
||||
expect(msg.metadata?.attachments).toEqual(attachments);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,11 @@ export function createUserMessage(
|
||||
role: "user",
|
||||
created: Date.now(),
|
||||
content: [{ type: "text", text }],
|
||||
metadata: attachments ? { attachments } : undefined,
|
||||
metadata: {
|
||||
userVisible: true,
|
||||
agentVisible: true,
|
||||
...(attachments ? { attachments } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user