feat: migrate session metadata storage from frontend overlay to backend (#8769)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey 2026-04-24 15:28:48 +12:00 committed by GitHub
parent 97671f30b0
commit 86afdeab6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 287 additions and 411 deletions

View file

@ -222,6 +222,15 @@ pub struct UpdateSessionProjectRequest {
pub project_id: Option<String>,
}
/// 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)]

View file

@ -110,6 +110,11 @@
"requestType": "UpdateSessionProjectRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/session/rename",
"requestType": "RenameSessionRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/session/archive",
"requestType": "ArchiveSessionRequest",

View file

@ -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": [
{

View file

@ -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<String, serde_json::Value> {
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<EmptyResponse, sacp::Error> {
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,

View file

@ -30,8 +30,8 @@ pub struct ThreadMetadata {
pub project_id: Option<String>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub model_name: Option<String>,
#[serde(default, alias = "model_name")]
pub model_id: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(flatten)]

View file

@ -57,12 +57,18 @@ pub async fn run_list_sessions<C: Connection>() {
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(

View file

@ -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,

View file

@ -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<T>(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<T>(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<LegacySessionRecord>(
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<SessionMetadataOverlayRecord>(
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<SessionMetadataOverlayRecord>,
): 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),
);
}

View file

@ -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> = {}): 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);
});
});

View file

@ -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<ChatSession>;
loadSessions: () => Promise<void>;
updateSession: (
id: string,
patch: Partial<ChatSession>,
opts?: UpdateSessionOptions,
) => void;
updateSession: (id: string, patch: Partial<ChatSession>) => void;
addSession: (session: ChatSession) => void;
archiveSession: (id: string) => Promise<void>;
unarchiveSession: (id: string) => Promise<void>;
@ -103,101 +92,24 @@ interface ChatSessionStoreActions {
export type ChatSessionStore = ChatSessionStoreState & ChatSessionStoreActions;
function overlayKeyForSession(
session: Pick<ChatSession, "id" | "acpSessionId">,
) {
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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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<ChatSessionStore>((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),

View file

@ -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<string, unknown> = {};
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)`,

View file

@ -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<AcpSessionInfo[]> {
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<AcpSessionInfo> {
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<void> {
const client = await getClient();
await client.extMethod("_goose/session/archive", { sessionId });
}
export async function unarchiveSession(sessionId: string): Promise<void> {
const client = await getClient();
await client.extMethod("_goose/session/unarchive", { sessionId });
}
export async function renameSession(
sessionId: string,
title: string,
): Promise<void> {
const client = await getClient();
await client.extMethod("_goose/session/rename", { sessionId, title });
}
export async function cancelSession(sessionId: string): Promise<void> {
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<NewSessionResponse> {
const tClient = performance.now();
const client = await getClient();
@ -159,6 +197,7 @@ export async function newSession(
const meta: Record<string, string> = {};
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<string, unknown>,
): Promise<PromptResponse> {
const client = await getClient();
return client.prompt({ sessionId, prompt: content });
return client.prompt({ sessionId, prompt: content, _meta: meta });
}

View file

@ -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;

View file

@ -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)})`,

View file

@ -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;

View file

@ -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<void> {
await this.conn.extMethod("_goose/session/rename", params);
}
async GooseSessionArchive(params: ArchiveSessionRequest): Promise<void> {
await this.conn.extMethod("_goose/session/archive", params);
}

View file

@ -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",

View file

@ -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;
};

View file

@ -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,