diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 223a92acfd16..17ce9458f19e 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -181,6 +181,15 @@ pub struct RemoveSecretRequest { pub key: String, } +/// Update the project association for a session. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/session/update_project", response = EmptyResponse)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionProjectRequest { + pub session_id: String, + pub project_id: Option, +} + /// 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 769136c53e8a..99e048c1e908 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -90,6 +90,11 @@ "requestType": "ImportSessionRequest", "responseType": "ImportSessionResponse" }, + { + "method": "_goose/session/update_project", + "requestType": "UpdateSessionProjectRequest", + "responseType": "EmptyResponse" + }, { "method": "_goose/session/archive", "requestType": "ArchiveSessionRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index c44738c2232d..cb6d121998c5 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -669,6 +669,26 @@ "x-side": "agent", "x-method": "_goose/session/import" }, + "UpdateSessionProjectRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "projectId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "sessionId" + ], + "description": "Update the project association for a session.", + "x-side": "agent", + "x-method": "_goose/session/update_project" + }, "ArchiveSessionRequest": { "type": "object", "properties": { @@ -1487,6 +1507,15 @@ "description": "Params for _goose/session/import", "title": "ImportSessionRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/UpdateSessionProjectRequest" + } + ], + "description": "Params for _goose/session/update_project", + "title": "UpdateSessionProjectRequest" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 8d30708e5bf2..ac908d183c3a 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -153,6 +153,24 @@ fn sid_short(id: &str) -> String { id.chars().take(8).collect() } +fn thread_session_meta( + message_count: i64, + metadata: &crate::session::ThreadMetadata, +) -> serde_json::Map { + let mut meta = serde_json::Map::new(); + meta.insert( + "messageCount".to_string(), + serde_json::Value::Number(message_count.into()), + ); + if let Some(ref pid) = metadata.project_id { + meta.insert( + "projectId".to_string(), + serde_json::Value::String(pid.clone()), + ); + } + meta +} + fn extract_timeout_from_meta(meta: &Option) -> Option { meta.as_ref() .and_then(|m| m.get("timeout")) @@ -1539,9 +1557,17 @@ impl GooseAcpAgent { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let project_id = args + .meta + .as_ref() + .and_then(|m| m.get("projectId")) + .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, mode: Some(self.goose_mode.to_string()), ..Default::default() }; @@ -2544,11 +2570,7 @@ impl GooseAcpAgent { .as_deref() .map(std::path::PathBuf::from) .unwrap_or_default(); - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(t.message_count.into()), - ); + let meta = thread_session_meta(t.message_count, &t.metadata); SessionInfo::new(SessionId::new(t.id), cwd) .title(t.name) .updated_at(t.updated_at.to_rfc3339()) @@ -2613,11 +2635,7 @@ impl GooseAcpAgent { }, ); - let mut meta = serde_json::Map::new(); - meta.insert( - "messageCount".to_string(), - serde_json::Value::Number(new_thread.message_count.into()), - ); + let meta = thread_session_meta(new_thread.message_count, &new_thread.metadata); let mut response = ForkSessionResponse::new(SessionId::new(new_thread_id)) .modes(mode_state) @@ -3047,6 +3065,19 @@ impl GooseAcpAgent { }) } + #[custom_method(UpdateSessionProjectRequest)] + async fn on_update_session_project( + &self, + req: UpdateSessionProjectRequest, + ) -> Result { + let project_id = req.project_id; + self.update_thread_metadata(&req.session_id, move |meta| { + meta.project_id = project_id; + }) + .await?; + Ok(EmptyResponse {}) + } + #[custom_method(ArchiveSessionRequest)] async fn on_archive_session( &self, @@ -3189,7 +3220,7 @@ impl GooseAcpAgent { other => { return Err( sacp::Error::invalid_params().data(format!("Unsupported format: {other}")) - ) + ); } }; diff --git a/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts b/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts index befab7b96ab1..9a2005db03e9 100644 --- a/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts +++ b/ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts @@ -5,8 +5,8 @@ const LEGACY_SESSION_CACHE_STORAGE_KEY = "goose:chat-sessions"; export interface SessionMetadataOverlayRecord { sessionId: string; - projectId?: string | null; userSetTitle?: string | null; + projectId?: string | null; providerId?: string | null; personaId?: string | null; modelId?: string | null; @@ -74,8 +74,8 @@ function recordFromLegacySession( ): SessionMetadataOverlayRecord { return { sessionId: session.acpSessionId ?? session.id, - projectId: session.projectId, userSetTitle: session.userSetName ? session.title : null, + projectId: session.projectId ?? null, providerId: session.providerId, personaId: session.personaId, modelId: session.modelId, 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 61f5431fb033..174313afc441 100644 --- a/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/ui/goose2/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -142,6 +142,7 @@ describe("chatSessionStore", () => { title: null, updatedAt: "2026-04-02", messageCount: 7, + projectId: "project-123", }, ]); diff --git a/ui/goose2/src/features/chat/stores/chatSessionStore.ts b/ui/goose2/src/features/chat/stores/chatSessionStore.ts index 256154eade7d..d74a6a168722 100644 --- a/ui/goose2/src/features/chat/stores/chatSessionStore.ts +++ b/ui/goose2/src/features/chat/stores/chatSessionStore.ts @@ -12,6 +12,7 @@ import { upsertSessionMetadataOverlayRecord, type SessionMetadataOverlayRecord, } from "@/features/chat/lib/sessionMetadataOverlay"; +import { updateSessionProject } from "@/shared/api/acpApi"; export interface ChatSession { id: string; @@ -114,8 +115,8 @@ function buildOverlayRecord( ): SessionMetadataOverlayRecord { return { sessionId: overlayKeyForSession(session), - projectId: session.projectId ?? null, userSetTitle: session.userSetName ? session.title : null, + projectId: session.projectId ?? null, providerId: session.providerId ?? null, personaId: session.personaId ?? null, modelId: session.modelId ?? null, @@ -139,7 +140,7 @@ function overlayToFallbackSession( id: overlay.sessionId, acpSessionId: overlay.sessionId, title: overlay.userSetTitle ?? overlay.lastKnownTitle ?? "Untitled", - projectId: overlay.projectId, + projectId: overlay.projectId ?? undefined, agentId: overlay.agentId ?? undefined, providerId: overlay.providerId ?? undefined, personaId: overlay.personaId ?? undefined, @@ -166,7 +167,7 @@ function mergeAcpSessionWithOverlay( session.title ?? overlay?.lastKnownTitle ?? "Untitled", - projectId: overlay?.projectId, + projectId: session.projectId ?? undefined, agentId: overlay?.agentId ?? undefined, providerId: overlay?.providerId ?? undefined, personaId: overlay?.personaId ?? undefined, @@ -239,6 +240,7 @@ export const useChatSessionStore = create((set, get) => ({ const { sessionId } = await acpCreateSession(providerId, opts.workingDir, { personaId: opts.personaId, modelId: opts.modelId, + projectId: opts.projectId, }); const chatSession: ChatSession = { id: sessionId, @@ -318,7 +320,15 @@ export const useChatSessionStore = create((set, get) => ({ buildOverlayRecord(updatedSession, existing), ); } - // TODO: wire session updates to ACP when supported + + // Persist projectId change to ACP backend + const acpSessionId = updatedSession?.acpSessionId; + if ("projectId" in patch && acpSessionId) { + updateSessionProject(acpSessionId, patch.projectId ?? null).catch( + (err: unknown) => + console.error("Failed to update session project in backend:", err), + ); + } }, addSession: (session) => { diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index d786ae86573a..087964f10594 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -1,5 +1,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import * as directAcp from "./acpApi"; +import type { AcpSessionInfo } from "./acpApi"; import * as sessionTracker from "./acpSessionTracker"; import { getCatalogEntry, @@ -27,6 +28,7 @@ export interface AcpSendMessageOptions { export interface AcpPrepareSessionOptions { personaId?: string; + projectId?: string; } export interface AcpCreateSessionOptions extends AcpPrepareSessionOptions { @@ -116,6 +118,7 @@ export async function acpPrepareSession( providerId, workingDir, options.personaId, + options.projectId, ); perfLog( `[perf:prepare] ${sid} acpPrepareSession done in ${(performance.now() - t0).toFixed(1)}ms`, @@ -155,13 +158,7 @@ export async function acpSetModel( return directAcp.setModel(gooseSessionId ?? sessionId, modelId); } -/** Session info returned by the goose binary's list_sessions. */ -export interface AcpSessionInfo { - sessionId: string; - title: string | null; - updatedAt: string | null; - messageCount: number; -} +export type { AcpSessionInfo }; export interface AcpSessionSearchResult { sessionId: string; diff --git a/ui/goose2/src/shared/api/acpApi.ts b/ui/goose2/src/shared/api/acpApi.ts index bc029318ba95..6fc8bb83ad93 100644 --- a/ui/goose2/src/shared/api/acpApi.ts +++ b/ui/goose2/src/shared/api/acpApi.ts @@ -18,6 +18,7 @@ export interface AcpSessionInfo { title: string | null; updatedAt: string | null; messageCount: number; + projectId?: string | null; } const DEPRECATED_PROVIDER_IDS = new Set(["claude-code", "codex", "gemini-cli"]); @@ -50,6 +51,7 @@ export async function listSessions(): Promise { title: info.title ?? null, updatedAt: info.updatedAt ?? null, messageCount: (info._meta?.messageCount as number) ?? 0, + projectId: (info._meta?.projectId as string) ?? null, })); } @@ -124,6 +126,17 @@ export async function updateWorkingDir( await client.extMethod("goose/working_dir/update", { sessionId, workingDir }); } +export async function updateSessionProject( + sessionId: string, + projectId: string | null, +): Promise { + const client = await getClient(); + await client.extMethod("_goose/session/update_project", { + sessionId, + projectId, + }); +} + export async function cancelSession(sessionId: string): Promise { const client = await getClient(); await client.cancel({ sessionId }); @@ -132,6 +145,7 @@ export async function cancelSession(sessionId: string): Promise { export async function newSession( workingDir: string, providerId?: string, + projectId?: string, ): Promise { const tClient = performance.now(); const client = await getClient(); @@ -142,9 +156,10 @@ export async function newSession( mcpServers: [], }; - if (providerId) { - request.meta = { provider: providerId }; - } + const meta: Record = {}; + if (providerId) meta.provider = providerId; + if (projectId) meta.projectId = projectId; + if (Object.keys(meta).length > 0) request.meta = meta; const tCall = performance.now(); const response = await client.newSession(request); diff --git a/ui/goose2/src/shared/api/acpSessionTracker.ts b/ui/goose2/src/shared/api/acpSessionTracker.ts index 0465952cb4f2..1fe41e91703d 100644 --- a/ui/goose2/src/shared/api/acpSessionTracker.ts +++ b/ui/goose2/src/shared/api/acpSessionTracker.ts @@ -56,6 +56,7 @@ export async function prepareSession( providerId: string, workingDir: string, personaId?: string, + projectId?: string, ): Promise { const sid = sessionId.slice(0, 8); const key = makeKey(sessionId, personaId); @@ -101,7 +102,7 @@ export async function prepareSession( if (!gooseSessionId) { const tNew = performance.now(); - const response = await acpApi.newSession(workingDir, providerId); + const response = await acpApi.newSession(workingDir, providerId, projectId); 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/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index d482ca2b1be2..4d24696df803 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -56,6 +56,7 @@ import type { RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, + UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, @@ -194,6 +195,12 @@ export class GooseExtClient { return zImportSessionResponse.parse(raw) as ImportSessionResponse; } + async GooseSessionUpdateProject( + params: UpdateSessionProjectRequest, + ): Promise { + await this.conn.extMethod("_goose/session/update_project", 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 42ffa4b19270..801e58a1cda2 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 { 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, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { 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, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, SourceEntry, SourceType, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -93,6 +93,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "ImportSessionRequest", responseType: "ImportSessionResponse", }, + { + method: "_goose/session/update_project", + requestType: "UpdateSessionProjectRequest", + 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 4335c158920f..14800f9850e0 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -351,6 +351,14 @@ export type ImportSessionResponse = { messageCount: number; }; +/** + * Update the project association for a session. + */ +export type UpdateSessionProjectRequest = { + sessionId: string; + projectId?: string | null; +}; + /** * Archive a session (soft delete). */ @@ -618,7 +626,7 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | ListProvidersRequest | RefreshProviderInventoryRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | 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 | { [key: string]: unknown; } | null; }; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 6a1c364e1b34..3a6be88b13c4 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -293,6 +293,17 @@ export const zImportSessionResponse = z.object({ messageCount: z.number().int().gte(0) }); +/** + * Update the project association for a session. + */ +export const zUpdateSessionProjectRequest = z.object({ + sessionId: z.string(), + projectId: z.union([ + z.string(), + z.null() + ]).optional() +}); + /** * Archive a session (soft delete). */ @@ -594,6 +605,7 @@ export const zExtRequest = z.object({ zRemoveSecretRequest, zExportSessionRequest, zImportSessionRequest, + zUpdateSessionProjectRequest, zArchiveSessionRequest, zUnarchiveSessionRequest, zCreateSourceRequest,