Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/goose-sdk/src/custom_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// Archive a session (soft delete).
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/session/archive", response = EmptyResponse)]
Expand Down
53 changes: 42 additions & 11 deletions crates/goose/src/acp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, serde_json::Value> {
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<Meta>) -> Option<u64> {
meta.as_ref()
.and_then(|m| m.get("timeout"))
Expand Down Expand Up @@ -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()
};
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3047,6 +3065,19 @@ impl GooseAcpAgent {
})
}

#[custom_method(UpdateSessionProjectRequest)]
async fn on_update_session_project(
&self,
req: UpdateSessionProjectRequest,
) -> Result<EmptyResponse, sacp::Error> {
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,
Expand Down Expand Up @@ -3189,7 +3220,7 @@ impl GooseAcpAgent {
other => {
return Err(
sacp::Error::invalid_params().data(format!("Unsupported format: {other}"))
)
);
}
};

Expand Down
4 changes: 2 additions & 2 deletions ui/goose2/src/features/chat/lib/sessionMetadataOverlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ describe("chatSessionStore", () => {
title: null,
updatedAt: "2026-04-02",
messageCount: 7,
projectId: "project-123",
},
]);

Expand Down
18 changes: 14 additions & 4 deletions ui/goose2/src/features/chat/stores/chatSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
upsertSessionMetadataOverlayRecord,
type SessionMetadataOverlayRecord,
} from "@/features/chat/lib/sessionMetadataOverlay";
import { updateSessionProject } from "@/shared/api/acpApi";

export interface ChatSession {
id: string;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -166,7 +167,7 @@ function mergeAcpSessionWithOverlay(
session.title ??
overlay?.lastKnownTitle ??
"Untitled",
projectId: overlay?.projectId,
projectId: session.projectId ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fallback to overlay projectId when ACP metadata is missing

When listSessions returns a session without _meta.projectId (which is common for older threads), mergeAcpSessionWithOverlay now forces projectId to session.projectId ?? undefined instead of reusing the cached overlay value. In that case the project association disappears in UI, and syncOverlaySnapshots immediately persists null, permanently erasing the previously stored projectId from local overlay data. This regresses migration behavior for existing sessions that only have project linkage in overlay storage.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is expected

agentId: overlay?.agentId ?? undefined,
providerId: overlay?.providerId ?? undefined,
personaId: overlay?.personaId ?? undefined,
Expand Down Expand Up @@ -239,6 +240,7 @@ export const useChatSessionStore = create<ChatSessionStore>((set, get) => ({
const { sessionId } = await acpCreateSession(providerId, opts.workingDir, {
personaId: opts.personaId,
modelId: opts.modelId,
projectId: opts.projectId,
});
const chatSession: ChatSession = {
id: sessionId,
Expand Down Expand Up @@ -318,7 +320,15 @@ export const useChatSessionStore = create<ChatSessionStore>((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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize project metadata writes with session prep updates

updateSession fires updateSessionProject as an untracked async side effect, so the move-to-project path can send this RPC concurrently with acpPrepareSession follow-up calls that trigger provider/model metadata writes. On the backend, both on_update_session_project and other metadata setters use update_thread_metadata (read-modify-write), so concurrent requests can clobber each other and persist stale metadata depending on completion order. This means a single project move combined with provider/model changes can silently revert projectId; queue or await project updates before issuing other metadata-mutating RPCs for the same session.

Useful? React with 👍 / 👎.

(err: unknown) =>
console.error("Failed to update session project in backend:", err),
);
Comment on lines +326 to +330

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize project-update calls to prevent stale writes

updateSession fires updateSessionProject as an untracked async side effect each time projectId is patched, so multiple rapid moves of the same session (for example A → B from the sidebar) issue concurrent RPCs with no ordering guarantee. If the earlier request resolves last, the backend thread metadata is reverted to the older project while the UI shows the newer one until a reload, causing persistent project mis-association. Queue or coalesce per-session project updates so only the latest value is committed.

Useful? React with 👍 / 👎.

}
},

addSession: (session) => {
Expand Down
11 changes: 4 additions & 7 deletions ui/goose2/src/shared/api/acp.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -27,6 +28,7 @@ export interface AcpSendMessageOptions {

export interface AcpPrepareSessionOptions {
personaId?: string;
projectId?: string;
}

export interface AcpCreateSessionOptions extends AcpPrepareSessionOptions {
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 19 additions & 4 deletions ui/goose2/src/shared/api/acpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -44,12 +45,13 @@ export async function listProviders(): Promise<AcpProvider[]> {

export async function listSessions(): Promise<AcpSessionInfo[]> {
const client = await getClient();
const response = await client.listSessions({});
const response = await client.unstable_listSessions({});
Comment thread
matt2e marked this conversation as resolved.
Outdated
return response.sessions.map((info: SessionInfo) => ({
sessionId: info.sessionId,
title: info.title ?? null,
updatedAt: info.updatedAt ?? null,
messageCount: (info._meta?.messageCount as number) ?? 0,
projectId: (info._meta?.projectId as string) ?? null,
}));
}

Expand Down Expand Up @@ -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<void> {
const client = await getClient();
await client.extMethod("_goose/session/update_project", {
sessionId,
projectId,
});
}

export async function cancelSession(sessionId: string): Promise<void> {
const client = await getClient();
await client.cancel({ sessionId });
Expand All @@ -132,6 +145,7 @@ export async function cancelSession(sessionId: string): Promise<void> {
export async function newSession(
workingDir: string,
providerId?: string,
projectId?: string,
): Promise<NewSessionResponse> {
const tClient = performance.now();
const client = await getClient();
Expand All @@ -142,9 +156,10 @@ export async function newSession(
mcpServers: [],
};

if (providerId) {
request.meta = { provider: providerId };
}
const meta: Record<string, string> = {};
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);
Expand Down
3 changes: 2 additions & 1 deletion ui/goose2/src/shared/api/acpSessionTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export async function prepareSession(
providerId: string,
workingDir: string,
personaId?: string,
projectId?: string,
): Promise<string> {
const sid = sessionId.slice(0, 8);
const key = makeKey(sessionId, personaId);
Expand Down Expand Up @@ -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)})`,
Expand Down
Loading