diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 822cd3e344..c8fc781ce7 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -222,6 +222,15 @@ pub struct UpdateSessionProjectRequest { pub project_id: Option, } +/// Rename a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/session/rename", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct RenameSessionRequest { + pub session_id: String, + pub title: String, +} + /// Archive a session (soft delete). #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/session/archive", response = EmptyResponse)] diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 81dfd429e9..252639c00a 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -110,6 +110,11 @@ "requestType": "UpdateSessionProjectRequest", "responseType": "EmptyResponse" }, + { + "method": "_goose/session/rename", + "requestType": "RenameSessionRequest", + "responseType": "EmptyResponse" + }, { "method": "_goose/session/archive", "requestType": "ArchiveSessionRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index a7ed35a715..1931938eab 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -743,6 +743,24 @@ "x-side": "agent", "x-method": "_goose/session/update_project" }, + "RenameSessionRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "sessionId", + "title" + ], + "description": "Rename a session.", + "x-side": "agent", + "x-method": "_goose/session/rename" + }, "ArchiveSessionRequest": { "type": "object", "properties": { @@ -1582,6 +1600,15 @@ "description": "Params for _goose/session/update_project", "title": "UpdateSessionProjectRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/RenameSessionRequest" + } + ], + "description": "Params for _goose/session/rename", + "title": "RenameSessionRequest" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 1011e21ad2..9a2ed15062 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -171,20 +171,51 @@ fn sid_short(id: &str) -> String { } fn thread_session_meta( - message_count: i64, - metadata: &crate::session::ThreadMetadata, + thread: &crate::session::Thread, ) -> serde_json::Map { let mut meta = serde_json::Map::new(); meta.insert( "messageCount".to_string(), - serde_json::Value::Number(message_count.into()), + serde_json::Value::Number(thread.message_count.into()), ); - if let Some(ref pid) = metadata.project_id { + meta.insert( + "createdAt".to_string(), + serde_json::Value::String(thread.created_at.to_rfc3339()), + ); + if let Some(ref archived_at) = thread.archived_at { + meta.insert( + "archivedAt".to_string(), + serde_json::Value::String(archived_at.to_rfc3339()), + ); + } + meta.insert( + "userSetName".to_string(), + serde_json::Value::Bool(thread.user_set_name), + ); + if let Some(ref pid) = thread.metadata.project_id { meta.insert( "projectId".to_string(), serde_json::Value::String(pid.clone()), ); } + if let Some(ref provider_id) = thread.metadata.provider_id { + meta.insert( + "providerId".to_string(), + serde_json::Value::String(provider_id.clone()), + ); + } + if let Some(ref model_id) = thread.metadata.model_id { + meta.insert( + "modelId".to_string(), + serde_json::Value::String(model_id.clone()), + ); + } + if let Some(ref persona_id) = thread.metadata.persona_id { + meta.insert( + "personaId".to_string(), + serde_json::Value::String(persona_id.clone()), + ); + } meta } @@ -1641,10 +1672,18 @@ impl GooseAcpAgent { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let persona_id = args + .meta + .as_ref() + .and_then(|m| m.get("personaId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + // Create the Thread — this IS the ACP session from the client's perspective. let thread_metadata = crate::session::ThreadMetadata { provider_id: requested_provider.clone(), project_id, + persona_id, mode: Some(self.goose_mode.to_string()), ..Default::default() }; @@ -2188,6 +2227,21 @@ impl GooseAcpAgent { let t_start = std::time::Instant::now(); debug!(target: "perf", sid = %sid, "perf: prompt start"); + // Update persona_id on the thread if the client sent one in _meta. + let prompt_persona_id = args + .meta + .as_ref() + .and_then(|m| m.get("personaId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + if let Some(ref pid) = prompt_persona_id { + let pid = pid.clone(); + self.update_thread_metadata(&thread_id, move |meta| { + meta.persona_id = Some(pid); + }) + .await?; + } + let cancel_token = CancellationToken::new(); let internal_session_id = self.internal_session_id(&thread_id).await?; @@ -2413,7 +2467,7 @@ impl GooseAcpAgent { let t_step = std::time::Instant::now(); let model_id_owned = model_id.to_string(); self.update_thread_metadata(thread_id, move |meta| { - meta.model_name = Some(model_id_owned); + meta.model_id = Some(model_id_owned); }) .await?; debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: set_model update_thread_metadata"); @@ -2635,6 +2689,7 @@ impl GooseAcpAgent { let provider_name_owned = provider_name.to_string(); self.update_thread_metadata(thread_id, move |meta| { meta.provider_id = Some(provider_name_owned); + meta.model_id = None; }) .await?; debug!(target: "perf", sid = %sid, ms = t_step.elapsed().as_millis() as u64, "perf: update_provider update_thread_metadata"); @@ -2701,7 +2756,7 @@ impl GooseAcpAgent { .as_deref() .map(std::path::PathBuf::from) .unwrap_or_default(); - let meta = thread_session_meta(t.message_count, &t.metadata); + let meta = thread_session_meta(&t); SessionInfo::new(SessionId::new(t.id), cwd) .title(t.name) .updated_at(t.updated_at.to_rfc3339()) @@ -2765,7 +2820,7 @@ impl GooseAcpAgent { }, ); - let meta = thread_session_meta(new_thread.message_count, &new_thread.metadata); + let meta = thread_session_meta(&new_thread); let mut response = ForkSessionResponse::new(SessionId::new(new_thread_id)) .modes(mode_state) @@ -3275,6 +3330,18 @@ impl GooseAcpAgent { Ok(EmptyResponse {}) } + #[custom_method(RenameSessionRequest)] + async fn on_rename_session( + &self, + req: RenameSessionRequest, + ) -> Result { + self.thread_manager + .update_thread(&req.session_id, Some(req.title), Some(true), None) + .await + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + Ok(EmptyResponse {}) + } + #[custom_method(ArchiveSessionRequest)] async fn on_archive_session( &self, diff --git a/crates/goose/src/session/thread_manager.rs b/crates/goose/src/session/thread_manager.rs index 3a96cc69ff..0863818871 100644 --- a/crates/goose/src/session/thread_manager.rs +++ b/crates/goose/src/session/thread_manager.rs @@ -30,8 +30,8 @@ pub struct ThreadMetadata { pub project_id: Option, #[serde(default)] pub provider_id: Option, - #[serde(default)] - pub model_name: Option, + #[serde(default, alias = "model_name")] + pub model_id: Option, #[serde(default)] pub mode: Option, #[serde(flatten)] diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index b2e6bcb15f..34272c69f6 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -57,12 +57,18 @@ pub async fn run_list_sessions() { let mut response = conn.list_sessions().await.unwrap(); for s in &mut response.sessions { s.updated_at = None; + // createdAt is a dynamic timestamp — verify it exists then remove for comparison. + if let Some(ref mut meta) = s.meta { + assert!(meta.get("createdAt").and_then(|v| v.as_str()).is_some()); + meta.remove("createdAt"); + } } let mut expected_meta = serde_json::Map::new(); expected_meta.insert( "messageCount".to_string(), serde_json::Value::Number(2.into()), ); + expected_meta.insert("userSetName".to_string(), serde_json::Value::Bool(false)); assert_eq!( response, ListSessionsResponse::new(vec![SessionInfo::new( diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 41f3c518c9..0b39464d4d 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -276,7 +276,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { perfLog( `[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`, ); - const agentId = agentStore.activeAgentId ?? undefined; const providerId = project?.preferredProvider ?? agentStore.selectedProvider ?? "goose"; const sessionModelPreference = @@ -312,7 +311,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const session = await sessionStore.createSession({ title, projectId: project?.id, - agentId, providerId: sessionModelPreference.providerId, workingDir, modelId: sessionModelPreference.modelId, @@ -327,7 +325,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return session; }, [ - agentStore.activeAgentId, agentStore.selectedProvider, chatStore, providerInventoryEntries, diff --git a/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts b/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts deleted file mode 100644 index 9a2005db03..0000000000 --- a/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { ModelOption } from "../types"; - -const ACP_SESSION_METADATA_STORAGE_KEY = "goose:acp-session-metadata"; -const LEGACY_SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; - -export interface SessionMetadataOverlayRecord { - sessionId: string; - userSetTitle?: string | null; - projectId?: string | null; - providerId?: string | null; - personaId?: string | null; - modelId?: string | null; - modelName?: string | null; - archivedAt?: string | null; - createdAt?: string | null; - agentId?: string | null; - lastKnownTitle?: string | null; - lastKnownUpdatedAt?: string | null; - lastKnownMessageCount?: number | null; - updatedAt: string; -} - -interface LegacySessionRecord { - id: string; - acpSessionId?: string; - title: string; - projectId?: string | null; - agentId?: string; - providerId?: string; - personaId?: string; - modelId?: string; - modelName?: string; - createdAt: string; - updatedAt: string; - archivedAt?: string; - messageCount: number; - draft?: boolean; - userSetName?: boolean; -} - -function parseStorageArray(storageKey: string): T[] { - if (typeof window === "undefined") return []; - try { - const stored = window.localStorage.getItem(storageKey); - if (!stored) return []; - const parsed = JSON.parse(stored); - return Array.isArray(parsed) ? (parsed as T[]) : []; - } catch { - return []; - } -} - -function persistStorageArray(storageKey: string, records: T[]): void { - if (typeof window === "undefined") return; - try { - if (records.length === 0) { - window.localStorage.removeItem(storageKey); - return; - } - window.localStorage.setItem(storageKey, JSON.stringify(records)); - } catch { - // localStorage may be unavailable - } -} - -function loadLegacySessions(): LegacySessionRecord[] { - return parseStorageArray( - LEGACY_SESSION_CACHE_STORAGE_KEY, - ); -} - -function recordFromLegacySession( - session: LegacySessionRecord, -): SessionMetadataOverlayRecord { - return { - sessionId: session.acpSessionId ?? session.id, - userSetTitle: session.userSetName ? session.title : null, - projectId: session.projectId ?? null, - providerId: session.providerId, - personaId: session.personaId, - modelId: session.modelId, - modelName: session.modelName, - archivedAt: session.archivedAt ?? null, - createdAt: session.createdAt, - agentId: session.agentId, - lastKnownTitle: session.title, - lastKnownUpdatedAt: session.updatedAt, - lastKnownMessageCount: session.messageCount, - updatedAt: session.updatedAt, - }; -} - -export function loadSessionMetadataOverlay(): Map< - string, - SessionMetadataOverlayRecord -> { - const records = parseStorageArray( - ACP_SESSION_METADATA_STORAGE_KEY, - ); - const overlays = new Map(records.map((record) => [record.sessionId, record])); - - for (const session of loadLegacySessions()) { - if (session.draft) continue; - const key = session.acpSessionId ?? session.id; - if (overlays.has(key)) continue; - overlays.set(key, recordFromLegacySession(session)); - } - - return overlays; -} - -export function persistSessionMetadataOverlay( - records: Iterable, -): void { - persistStorageArray( - ACP_SESSION_METADATA_STORAGE_KEY, - [...records].sort((a, b) => a.sessionId.localeCompare(b.sessionId)), - ); -} - -export function upsertSessionMetadataOverlayRecord( - record: SessionMetadataOverlayRecord, -): void { - const overlays = loadSessionMetadataOverlay(); - overlays.set(record.sessionId, record); - persistSessionMetadataOverlay(overlays.values()); -} - -export function removeSessionMetadataOverlayRecord(sessionId: string): void { - const overlays = loadSessionMetadataOverlay(); - if (!overlays.delete(sessionId)) return; - persistSessionMetadataOverlay(overlays.values()); -} - -export function modelIdsMatch( - cached: ModelOption[] | undefined, - next: ModelOption[], -): boolean { - return Boolean( - cached && - cached.length === next.length && - cached.every((model, index) => model.id === next[index]?.id), - ); -} diff --git a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 174313afc4..c5168d3fab 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AcpSessionInfo } from "@/shared/api/acp"; import { useChatSessionStore, type ChatSession } from "../chatSessionStore"; @@ -10,8 +10,12 @@ vi.mock("@/shared/api/acp", () => ({ acpListSessions: (...args: unknown[]) => mockAcpListSessions(...args), })); -const LEGACY_SESSION_CACHE_KEY = "goose:chat-sessions"; -const OVERLAY_CACHE_KEY = "goose:acp-session-metadata"; +vi.mock("@/shared/api/acpApi", () => ({ + archiveSession: vi.fn().mockResolvedValue(undefined), + unarchiveSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue(undefined), + updateSessionProject: vi.fn().mockResolvedValue(undefined), +})); function resetStore() { useChatSessionStore.setState({ @@ -45,16 +49,9 @@ function seedSession(overrides: Partial = {}): ChatSession { describe("chatSessionStore", () => { beforeEach(() => { resetStore(); - window.localStorage.removeItem(LEGACY_SESSION_CACHE_KEY); - window.localStorage.removeItem(OVERLAY_CACHE_KEY); vi.clearAllMocks(); }); - afterEach(() => { - window.localStorage.removeItem(LEGACY_SESSION_CACHE_KEY); - window.localStorage.removeItem(OVERLAY_CACHE_KEY); - }); - describe("createSession", () => { it("creates a real ACP-backed session", async () => { mockAcpCreateSession.mockResolvedValue({ sessionId: "acp-1" }); @@ -96,13 +93,23 @@ describe("chatSessionStore", () => { sessionId: "acp-1", title: "ACP Session 1", updatedAt: "2026-04-01", + createdAt: "2026-03-31", + archivedAt: null, + userSetName: false, messageCount: 4, + providerId: "openai", + modelId: "gpt-4.1", }, { sessionId: "acp-2", title: null, updatedAt: "2026-04-02", + createdAt: "2026-04-02", + archivedAt: null, + userSetName: false, messageCount: 7, + providerId: null, + modelId: null, }, ]); @@ -116,69 +123,39 @@ describe("chatSessionStore", () => { expect(sessions[1].id).toBe("acp-1"); expect(sessions[1].title).toBe("ACP Session 1"); expect(sessions[1].messageCount).toBe(4); + expect(sessions[1].providerId).toBe("openai"); + expect(sessions[1].modelId).toBe("gpt-4.1"); }); - it("rehydrates cached project metadata for ACP sessions", async () => { - window.localStorage.setItem( - LEGACY_SESSION_CACHE_KEY, - JSON.stringify([ - { - id: "acp-1", - title: "Renamed Project Chat", - projectId: "project-123", - providerId: "openai", - personaId: "persona-1", - createdAt: "2026-03-31", - updatedAt: "2026-04-01", - messageCount: 4, - userSetName: true, - }, - ]), - ); - + it("reads all metadata fields from backend response", async () => { mockAcpListSessions.mockResolvedValue([ { sessionId: "acp-1", - title: null, + title: "Renamed Chat", updatedAt: "2026-04-02", + createdAt: "2026-03-31", + archivedAt: null, + userSetName: true, messageCount: 7, projectId: "project-123", + providerId: "anthropic", + personaId: "persona-1", + modelId: "claude-sonnet-4", }, ]); await useChatSessionStore.getState().loadSessions(); const session = useChatSessionStore.getState().sessions[0]; - expect(session.title).toBe("Renamed Project Chat"); + expect(session.title).toBe("Renamed Chat"); expect(session.projectId).toBe("project-123"); - expect(session.providerId).toBe("openai"); + expect(session.providerId).toBe("anthropic"); expect(session.personaId).toBe("persona-1"); expect(session.createdAt).toBe("2026-03-31"); expect(session.updatedAt).toBe("2026-04-02"); expect(session.messageCount).toBe(7); expect(session.userSetName).toBe(true); - }); - - it("ignores legacy draft records while hydrating overlays", async () => { - window.localStorage.setItem( - LEGACY_SESSION_CACHE_KEY, - JSON.stringify([ - { - id: "cached-draft", - title: "Cached Draft", - draft: true, - createdAt: "2026-04-01", - updatedAt: "2026-04-01", - messageCount: 0, - }, - ]), - ); - - mockAcpListSessions.mockResolvedValue([]); - - await useChatSessionStore.getState().loadSessions(); - - expect(useChatSessionStore.getState().sessions).toEqual([]); + expect(session.modelId).toBe("claude-sonnet-4"); }); it("drops stale sessions that are no longer in ACP", async () => { @@ -194,7 +171,12 @@ describe("chatSessionStore", () => { sessionId: "acp-1", title: "ACP Session", updatedAt: "2026-04-02", + createdAt: "2026-04-02", + archivedAt: null, + userSetName: false, messageCount: 1, + providerId: null, + modelId: null, }, ]); @@ -225,39 +207,12 @@ describe("chatSessionStore", () => { expect(useChatSessionStore.getState().hasHydratedSessions).toBe(true); }); - it("falls back to cached sessions on error", async () => { - window.localStorage.setItem( - LEGACY_SESSION_CACHE_KEY, - JSON.stringify([ - { - id: "cached-session", - title: "Cached Session", - projectId: "project-123", - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - messageCount: 8, - }, - { - id: "cached-draft", - title: "Cached Draft", - draft: true, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - messageCount: 0, - }, - ]), - ); - + it("keeps empty sessions list on error", async () => { mockAcpListSessions.mockRejectedValue(new Error("Network error")); await useChatSessionStore.getState().loadSessions(); - const sessions = useChatSessionStore.getState().sessions; - expect(sessions).toHaveLength(1); - expect(sessions[0]).toMatchObject({ - id: "cached-session", - projectId: "project-123", - }); + expect(useChatSessionStore.getState().sessions).toEqual([]); expect(useChatSessionStore.getState().hasHydratedSessions).toBe(true); }); }); diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index d74a6a1687..de7d37d56e 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -7,19 +7,17 @@ import { import type { Session } from "@/shared/types/chat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { - loadSessionMetadataOverlay, - persistSessionMetadataOverlay, - upsertSessionMetadataOverlayRecord, - type SessionMetadataOverlayRecord, -} from "@/features/chat/lib/sessionMetadataOverlay"; -import { updateSessionProject } from "@/shared/api/acpApi"; + archiveSession as acpArchiveSession, + unarchiveSession as acpUnarchiveSession, + renameSession as acpRenameSession, + updateSessionProject, +} from "@/shared/api/acpApi"; export interface ChatSession { id: string; acpSessionId?: string; title: string; projectId?: string | null; - agentId?: string; providerId?: string; personaId?: string; modelId?: string; @@ -66,7 +64,6 @@ interface ChatSessionStoreState { interface CreateSessionOpts { title?: string; projectId?: string; - agentId?: string; providerId?: string; personaId?: string; workingDir?: string; @@ -74,18 +71,10 @@ interface CreateSessionOpts { modelName?: string; } -interface UpdateSessionOptions { - persistOverlay?: boolean; -} - interface ChatSessionStoreActions { createSession: (opts?: CreateSessionOpts) => Promise; loadSessions: () => Promise; - updateSession: ( - id: string, - patch: Partial, - opts?: UpdateSessionOptions, - ) => void; + updateSession: (id: string, patch: Partial) => void; addSession: (session: ChatSession) => void; archiveSession: (id: string) => Promise; unarchiveSession: (id: string) => Promise; @@ -103,101 +92,24 @@ interface ChatSessionStoreActions { export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions; -function overlayKeyForSession( - session: Pick, -) { - return session.acpSessionId ?? session.id; -} - -function buildOverlayRecord( - session: ChatSession, - existing?: SessionMetadataOverlayRecord, -): SessionMetadataOverlayRecord { - return { - sessionId: overlayKeyForSession(session), - userSetTitle: session.userSetName ? session.title : null, - projectId: session.projectId ?? null, - providerId: session.providerId ?? null, - personaId: session.personaId ?? null, - modelId: session.modelId ?? null, - modelName: session.modelName ?? null, - archivedAt: session.archivedAt ?? null, - createdAt: session.createdAt ?? existing?.createdAt ?? null, - agentId: session.agentId ?? existing?.agentId ?? null, - lastKnownTitle: session.title, - lastKnownUpdatedAt: session.updatedAt, - lastKnownMessageCount: session.messageCount, - updatedAt: new Date().toISOString(), - }; -} - -function overlayToFallbackSession( - overlay: SessionMetadataOverlayRecord, -): ChatSession { - const updatedAt = - overlay.lastKnownUpdatedAt ?? overlay.createdAt ?? overlay.updatedAt; - return { - id: overlay.sessionId, - acpSessionId: overlay.sessionId, - title: overlay.userSetTitle ?? overlay.lastKnownTitle ?? "Untitled", - projectId: overlay.projectId ?? undefined, - agentId: overlay.agentId ?? undefined, - providerId: overlay.providerId ?? undefined, - personaId: overlay.personaId ?? undefined, - modelId: overlay.modelId ?? undefined, - modelName: overlay.modelName ?? undefined, - createdAt: overlay.createdAt ?? updatedAt, - updatedAt, - archivedAt: overlay.archivedAt ?? undefined, - messageCount: overlay.lastKnownMessageCount ?? 0, - userSetName: Boolean(overlay.userSetTitle), - }; -} - -function mergeAcpSessionWithOverlay( - session: AcpSessionInfo, - overlay?: SessionMetadataOverlayRecord, -): ChatSession { - const updatedAt = session.updatedAt ?? overlay?.lastKnownUpdatedAt; +function acpSessionToChatSession(session: AcpSessionInfo): ChatSession { + const now = new Date().toISOString(); return { id: session.sessionId, acpSessionId: session.sessionId, - title: - overlay?.userSetTitle ?? - session.title ?? - overlay?.lastKnownTitle ?? - "Untitled", + title: session.title ?? "Untitled", projectId: session.projectId ?? undefined, - agentId: overlay?.agentId ?? undefined, - providerId: overlay?.providerId ?? undefined, - personaId: overlay?.personaId ?? undefined, - modelId: overlay?.modelId ?? undefined, - modelName: overlay?.modelName ?? undefined, - createdAt: overlay?.createdAt ?? updatedAt ?? new Date().toISOString(), - updatedAt: updatedAt ?? new Date().toISOString(), - archivedAt: overlay?.archivedAt ?? undefined, + providerId: session.providerId ?? undefined, + personaId: session.personaId ?? undefined, + modelId: session.modelId ?? undefined, + createdAt: session.createdAt ?? session.updatedAt ?? now, + updatedAt: session.updatedAt ?? now, + archivedAt: session.archivedAt ?? undefined, messageCount: session.messageCount, - userSetName: Boolean(overlay?.userSetTitle), + userSetName: session.userSetName, }; } -function syncOverlaySnapshots( - sessions: ChatSession[], - existingOverlays = loadSessionMetadataOverlay(), -): void { - const overlays = new Map(existingOverlays); - for (const session of sessions) { - overlays.set( - overlayKeyForSession(session), - buildOverlayRecord( - session, - existingOverlays.get(overlayKeyForSession(session)), - ), - ); - } - persistSessionMetadataOverlay(overlays.values()); -} - function sortByUpdatedAtDesc(sessions: ChatSession[]): ChatSession[] { return [...sessions].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), @@ -209,7 +121,6 @@ export function sessionToChatSession(session: Session): ChatSession { id: session.id, acpSessionId: session.id, title: session.title, - agentId: session.agentId, projectId: session.projectId, providerId: session.providerId, personaId: session.personaId, @@ -247,7 +158,6 @@ export const useChatSessionStore = create((set, get) => ({ acpSessionId: sessionId, title: opts.title ?? DEFAULT_CHAT_TITLE, projectId: opts.projectId, - agentId: opts.agentId, providerId, personaId: opts.personaId, modelId: opts.modelId, @@ -257,48 +167,32 @@ export const useChatSessionStore = create((set, get) => ({ messageCount: 0, }; set((state) => ({ sessions: [chatSession, ...state.sessions] })); - const existing = loadSessionMetadataOverlay().get( - overlayKeyForSession(chatSession), - ); - upsertSessionMetadataOverlayRecord( - buildOverlayRecord(chatSession, existing), - ); return chatSession; }, loadSessions: async () => { set({ isLoading: true }); try { - const overlays = loadSessionMetadataOverlay(); const acpSessions = await acpListSessions(); - const mergedAcpSessions = sortByUpdatedAtDesc( - acpSessions.map((session) => - mergeAcpSessionWithOverlay(session, overlays.get(session.sessionId)), - ), + const sessions = sortByUpdatedAtDesc( + acpSessions.map(acpSessionToChatSession), ); - const merged = mergedAcpSessions; const activeSessionId = get().activeSessionId; const activeSessionStillExists = activeSessionId == null || - merged.some((session) => session.id === activeSessionId); + sessions.some((session) => session.id === activeSessionId); set({ - sessions: merged, + sessions, activeSessionId: activeSessionStillExists ? activeSessionId : null, }); - syncOverlaySnapshots(mergedAcpSessions, overlays); } catch (error) { console.error("Failed to load sessions from ACP:", error); - const overlays = loadSessionMetadataOverlay(); - const fallbackSessions = sortByUpdatedAtDesc( - [...overlays.values()].map(overlayToFallbackSession), - ); - set({ sessions: fallbackSessions }); } finally { set({ isLoading: false, hasHydratedSessions: true }); } }, - updateSession: (id, patch, opts) => { + updateSession: (id, patch) => { set((state) => ({ sessions: state.sessions.map((session) => session.id === id @@ -312,17 +206,22 @@ export const useChatSessionStore = create((set, get) => ({ })); const updatedSession = get().sessions.find((session) => session.id === id); + const acpSessionId = updatedSession?.acpSessionId; - if (updatedSession && opts?.persistOverlay !== false) { - const key = overlayKeyForSession(updatedSession); - const existing = loadSessionMetadataOverlay().get(key); - upsertSessionMetadataOverlayRecord( - buildOverlayRecord(updatedSession, existing), + // Persist title rename to backend + if ( + "title" in patch && + "userSetName" in patch && + patch.userSetName && + acpSessionId && + patch.title + ) { + acpRenameSession(acpSessionId, patch.title).catch((err: unknown) => + console.error("Failed to rename session in backend:", err), ); } - // Persist projectId change to ACP backend - const acpSessionId = updatedSession?.acpSessionId; + // Persist projectId change to backend if ("projectId" in patch && acpSessionId) { updateSessionProject(acpSessionId, patch.projectId ?? null).catch( (err: unknown) => @@ -347,12 +246,6 @@ export const useChatSessionStore = create((set, get) => ({ } return { sessions: [normalizedSession, ...state.sessions] }; }); - const existing = loadSessionMetadataOverlay().get( - overlayKeyForSession(normalizedSession), - ); - upsertSessionMetadataOverlayRecord( - buildOverlayRecord(normalizedSession, existing), - ); }, archiveSession: async (id) => { @@ -366,11 +259,10 @@ export const useChatSessionStore = create((set, get) => ({ state.activeSessionId === id ? null : state.activeSessionId, })); const session = get().sessions.find((candidate) => candidate.id === id); - if (session) { - const existing = loadSessionMetadataOverlay().get( - overlayKeyForSession(session), + if (session?.acpSessionId) { + acpArchiveSession(session.acpSessionId).catch((err: unknown) => + console.error("Failed to archive session in backend:", err), ); - upsertSessionMetadataOverlayRecord(buildOverlayRecord(session, existing)); } }, @@ -381,11 +273,10 @@ export const useChatSessionStore = create((set, get) => ({ ), })); const session = get().sessions.find((candidate) => candidate.id === id); - if (session) { - const existing = loadSessionMetadataOverlay().get( - overlayKeyForSession(session), + if (session?.acpSessionId) { + acpUnarchiveSession(session.acpSessionId).catch((err: unknown) => + console.error("Failed to unarchive session in backend:", err), ); - upsertSessionMetadataOverlayRecord(buildOverlayRecord(session, existing)); } }, @@ -433,15 +324,6 @@ export const useChatSessionStore = create((set, get) => ({ : session, ), })); - const session = get().sessions.find( - (candidate) => candidate.id === sessionId, - ); - if (session) { - const existing = loadSessionMetadataOverlay().get( - overlayKeyForSession(session), - ); - upsertSessionMetadataOverlayRecord(buildOverlayRecord(session, existing)); - } }, getSession: (id) => get().sessions.find((session) => session.id === id), diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index e303ec2143..04900340da 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -95,7 +95,13 @@ export async function acpSendMessage( `[perf:send] ${sid} acpSendMessage → prompt(len=${prompt.length}, imgs=${images?.length ?? 0})`, ); const tPrompt = performance.now(); - await directAcp.prompt(gooseSessionId, content); + const meta: Record = {}; + if (personaId) meta.personaId = personaId; + await directAcp.prompt( + gooseSessionId, + content, + Object.keys(meta).length > 0 ? meta : undefined, + ); const tDone = performance.now(); perfLog( `[perf:send] ${sid} prompt() resolved in ${(tDone - tPrompt).toFixed(1)}ms (total acpSendMessage ${(tDone - tStart).toFixed(1)}ms)`, diff --git a/ui/goose2/src/shared/api/acpApi.ts b/ui/goose2/src/shared/api/acpApi.ts index 6fc8bb83ad..48af192308 100644 --- a/ui/goose2/src/shared/api/acpApi.ts +++ b/ui/goose2/src/shared/api/acpApi.ts @@ -17,8 +17,14 @@ export interface AcpSessionInfo { sessionId: string; title: string | null; updatedAt: string | null; + createdAt: string | null; + archivedAt: string | null; + userSetName: boolean; messageCount: number; projectId?: string | null; + providerId: string | null; + modelId: string | null; + personaId: string | null; } const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]); @@ -50,8 +56,14 @@ export async function listSessions(): Promise { sessionId: info.sessionId, title: info.title ?? null, updatedAt: info.updatedAt ?? null, + createdAt: (info._meta?.createdAt as string) ?? null, + archivedAt: (info._meta?.archivedAt as string) ?? null, + userSetName: info._meta?.userSetName === true, messageCount: (info._meta?.messageCount as number) ?? 0, projectId: (info._meta?.projectId as string) ?? null, + providerId: (info._meta?.providerId as string) ?? null, + modelId: (info._meta?.modelId as string) ?? null, + personaId: (info._meta?.personaId as string) ?? null, })); } @@ -78,7 +90,14 @@ export async function forkSession(sessionId: string): Promise { sessionId: response.sessionId, title: (response._meta?.title as string) ?? null, updatedAt: null, + createdAt: (response._meta?.createdAt as string) ?? null, + archivedAt: (response._meta?.archivedAt as string) ?? null, + userSetName: response._meta?.userSetName === true, messageCount: (response._meta?.messageCount as number) ?? 0, + projectId: (response._meta?.projectId as string) ?? null, + providerId: (response._meta?.providerId as string) ?? null, + modelId: (response._meta?.modelId as string) ?? null, + personaId: (response._meta?.personaId as string) ?? null, }; } @@ -137,6 +156,24 @@ export async function updateSessionProject( }); } +export async function archiveSession(sessionId: string): Promise { + const client = await getClient(); + await client.extMethod("_goose/session/archive", { sessionId }); +} + +export async function unarchiveSession(sessionId: string): Promise { + const client = await getClient(); + await client.extMethod("_goose/session/unarchive", { sessionId }); +} + +export async function renameSession( + sessionId: string, + title: string, +): Promise { + const client = await getClient(); + await client.extMethod("_goose/session/rename", { sessionId, title }); +} + export async function cancelSession(sessionId: string): Promise { const client = await getClient(); await client.cancel({ sessionId }); @@ -146,6 +183,7 @@ export async function newSession( workingDir: string, providerId?: string, projectId?: string, + personaId?: string, ): Promise { const tClient = performance.now(); const client = await getClient(); @@ -159,6 +197,7 @@ export async function newSession( const meta: Record = {}; if (providerId) meta.provider = providerId; if (projectId) meta.projectId = projectId; + if (personaId) meta.personaId = personaId; if (Object.keys(meta).length > 0) request.meta = meta; const tCall = performance.now(); @@ -192,7 +231,8 @@ export async function loadSession( export async function prompt( sessionId: string, content: ContentBlock[], + meta?: Record, ): Promise { const client = await getClient(); - return client.prompt({ sessionId, prompt: content }); + return client.prompt({ sessionId, prompt: content, _meta: meta }); } diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts index 2ec627d929..dd19ac0036 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts @@ -415,11 +415,7 @@ function handleShared(sessionId: string, update: SessionUpdate): void { if (session && !session.userSetName) { useChatSessionStore .getState() - .updateSession( - sessionId, - { title: info.title as string }, - { persistOverlay: false }, - ); + .updateSession(sessionId, { title: info.title as string }); } } break; @@ -456,11 +452,10 @@ function handleShared(sessionId: string, update: SessionUpdate): void { currentModelId; const sessionStore = useChatSessionStore.getState(); - sessionStore.updateSession( - sessionId, - { modelId: currentModelId, modelName: currentModelName }, - { persistOverlay: false }, - ); + sessionStore.updateSession(sessionId, { + modelId: currentModelId, + modelName: currentModelName, + }); } } break; diff --git a/ui/goose2/src/shared/api/acpSessionTracker.ts b/ui/goose2/src/shared/api/acpSessionTracker.ts index 1fe41e9170..dc0c3d493c 100644 --- a/ui/goose2/src/shared/api/acpSessionTracker.ts +++ b/ui/goose2/src/shared/api/acpSessionTracker.ts @@ -102,7 +102,12 @@ export async function prepareSession( if (!gooseSessionId) { const tNew = performance.now(); - const response = await acpApi.newSession(workingDir, providerId, projectId); + const response = await acpApi.newSession( + workingDir, + providerId, + projectId, + personaId, + ); gooseSessionId = response.sessionId; perfLog( `[perf:prepare] ${sid} tracker newSession done in ${(performance.now() - tNew).toFixed(1)}ms (goose_sid=${gooseSessionId.slice(0, 8)})`, diff --git a/ui/goose2/src/shared/types/chat.ts b/ui/goose2/src/shared/types/chat.ts index bf5d4b4b14..f595f94169 100644 --- a/ui/goose2/src/shared/types/chat.ts +++ b/ui/goose2/src/shared/types/chat.ts @@ -55,7 +55,6 @@ export const INITIAL_SESSION_CHAT_RUNTIME: SessionChatRuntime = { export interface Session { id: string; title: string; - agentId?: string; projectId?: string | null; providerId?: string; personaId?: string; diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 13c76ffd35..45ba4c2688 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -57,6 +57,7 @@ import type { RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, + RenameSessionRequest, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, @@ -222,6 +223,10 @@ export class GooseExtClient { await this.conn.extMethod("_goose/session/update_project", params); } + async GooseSessionRename(params: RenameSessionRequest): Promise { + await this.conn.extMethod("_goose/session/rename", params); + } + async GooseSessionArchive(params: ArchiveSessionRequest): Promise { await this.conn.extMethod("_goose/session/archive", params); } diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index df0cd080f7..8220602d83 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ProviderConfigKey, ProviderInventoryEntryDto, ProviderInventoryModelDto, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, CreateSourceRequest, CreateSourceResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, ProviderConfigKey, ProviderInventoryEntryDto, ProviderInventoryModelDto, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -113,6 +113,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "UpdateSessionProjectRequest", responseType: "EmptyResponse", }, + { + method: "_goose/session/rename", + requestType: "RenameSessionRequest", + responseType: "EmptyResponse", + }, { method: "_goose/session/archive", requestType: "ArchiveSessionRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index d69222cee4..222f30e9fc 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -388,6 +388,14 @@ export type UpdateSessionProjectRequest = { projectId?: string | null; }; +/** + * Rename a session. + */ +export type RenameSessionRequest = { + sessionId: string; + title: string; +}; + /** * Archive a session (soft delete). */ @@ -659,7 +667,7 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { [key: string]: unknown; } | null; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index ebb13c0ec1..3c7e1a34ce 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -328,6 +328,14 @@ export const zUpdateSessionProjectRequest = z.object({ ]).optional() }); +/** + * Rename a session. + */ +export const zRenameSessionRequest = z.object({ + sessionId: z.string(), + title: z.string() +}); + /** * Archive a session (soft delete). */ @@ -629,6 +637,7 @@ export const zExtRequest = z.object({ zExportSessionRequest, zImportSessionRequest, zUpdateSessionProjectRequest, + zRenameSessionRequest, zArchiveSessionRequest, zUnarchiveSessionRequest, zCreateSourceRequest,