diff --git a/apps/examples/desktop-app/sidecar/cloud-sessions.ts b/apps/examples/desktop-app/sidecar/cloud-sessions.ts index 7da70bc951..b32113463a 100644 --- a/apps/examples/desktop-app/sidecar/cloud-sessions.ts +++ b/apps/examples/desktop-app/sidecar/cloud-sessions.ts @@ -65,7 +65,7 @@ export class CloudSessionManager extends CloudSessionController { }); } override async listForDiscovery( - options: { timeoutMs?: number } = {}, + options: Parameters[0] = {}, ): Promise[]> { return (await super.listForDiscovery(options)).map((record) => ({ ...record, diff --git a/apps/examples/desktop-app/sidecar/commands.ts b/apps/examples/desktop-app/sidecar/commands.ts index 448c9f526a..fc7aa8ba2b 100644 --- a/apps/examples/desktop-app/sidecar/commands.ts +++ b/apps/examples/desktop-app/sidecar/commands.ts @@ -388,6 +388,7 @@ const OPENABLE_URL_PROTOCOLS = new Set(["https:", "http:", "mailto:", "tel:"]); // Fall back to cached rows rather than stall the sidebar on the cloud API. const CLOUD_DISCOVERY_BUDGET_MS = 2_000; +const CLOUD_DISCOVERY_MAX_AGE_MS = 30_000; function openUrlInDefaultBrowser(url: string): Promise { const platform = process.platform; @@ -2360,7 +2361,10 @@ export async function handleCommand( const local = discoverChatSessions(ctx, limit); if (!isCloudAgentsEnabled()) return local; const cloud = await getCloudSessionManager(ctx) - .listForDiscovery({ timeoutMs: CLOUD_DISCOVERY_BUDGET_MS }) + .listForDiscovery({ + timeoutMs: CLOUD_DISCOVERY_BUDGET_MS, + maxAgeMs: CLOUD_DISCOVERY_MAX_AGE_MS, + }) .catch((error) => { // A persistent listing failure silently empties the sidebar's // cloud rows; keep a diagnostic trail. @@ -2387,7 +2391,10 @@ export async function handleCommand( )) as JsonRecord[]; if (!isCloudAgentsEnabled()) return local; const cloud = await getCloudSessionManager(ctx) - .listForDiscovery({ timeoutMs: CLOUD_DISCOVERY_BUDGET_MS }) + .listForDiscovery({ + timeoutMs: CLOUD_DISCOVERY_BUDGET_MS, + maxAgeMs: CLOUD_DISCOVERY_MAX_AGE_MS, + }) .catch((error) => { ctx.logger?.error?.("Cloud session discovery failed", { error }); return []; diff --git a/sdk/DOC.md b/sdk/DOC.md index 693abeec7e..94a91e4565 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -135,6 +135,13 @@ the outer session is deleted). `dispose` preserves an injected map; without one, the controller owns and clears its pending state. Restoring options alone never authorizes recreation of a missing established task. +`CloudSessionController.listForDiscovery({ maxAgeMs })` can reuse the last +successful list for the specified duration. Omitting `maxAgeMs` starts or joins +a refresh. Pending refreshes retain their existing `timeoutMs` budget behavior. +Cached discovery still projects current live state and locally created sessions. +Desktop uses a 30-second window for combined history lists; local discovery, +cloud search, and session-open reads keep their existing refresh behavior. + ## Experimental cloud handoff `loadCloudModels` and `CloudSessionController.listModels()` provide eligible cloud diff --git a/sdk/packages/core/src/cloud/controller.test.ts b/sdk/packages/core/src/cloud/controller.test.ts index e0df22b006..03fec22473 100644 --- a/sdk/packages/core/src/cloud/controller.test.ts +++ b/sdk/packages/core/src/cloud/controller.test.ts @@ -210,6 +210,109 @@ function resumableFixture(status = "ready") { } describe("CloudSessionController neutral host contract", () => { + it.each([ + { rows: [] }, + { rows: [record] }, + ])("reuses a successful discovery list within maxAgeMs: %j", async ({ + rows, + }) => { + const f = fixture(); + f.api.list.mockResolvedValue(rows); + const now = vi.spyOn(Date, "now").mockReturnValue(0); + try { + const initial = await f.controller.listForDiscovery({ maxAgeMs: 30_000 }); + now.mockReturnValue(29_999); + expect(await f.controller.listForDiscovery({ maxAgeMs: 30_000 })).toEqual( + initial, + ); + expect(f.api.list).toHaveBeenCalledTimes(1); + now.mockReturnValue(30_000); + await f.controller.listForDiscovery({ maxAgeMs: 30_000 }); + expect(f.api.list).toHaveBeenCalledTimes(2); + await f.controller.listForDiscovery(); + expect(f.api.list).toHaveBeenCalledTimes(3); + await f.controller.dispose(); + await expect( + f.controller.listForDiscovery({ maxAgeMs: 30_000 }), + ).rejects.toThrow("disposed"); + } finally { + now.mockRestore(); + await f.controller.dispose(); + } + }); + + it("does not renew discovery freshness after a failed refresh", async () => { + const f = fixture(); + const now = vi.spyOn(Date, "now").mockReturnValue(0); + try { + const initial = await f.controller.listForDiscovery({ maxAgeMs: 30_000 }); + now.mockReturnValue(30_000); + f.api.list.mockRejectedValueOnce(new Error("Network unavailable")); + expect( + await f.controller.listForDiscovery({ maxAgeMs: 30_000, timeoutMs: 1 }), + ).toEqual(initial); + await f.controller.listForDiscovery({ maxAgeMs: 30_000 }); + expect(f.api.list).toHaveBeenCalledTimes(3); + } finally { + now.mockRestore(); + await f.controller.dispose(); + } + }); + + it("joins a pending discovery refresh even while the previous cache is fresh", async () => { + const f = fixture(); + await f.controller.listForDiscovery(); + let complete!: (rows: CloudSessionRecord[]) => void; + f.api.list.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const refresh = f.controller.listForDiscovery(); + const cached = f.controller.listForDiscovery({ maxAgeMs: 30_000 }); + await vi.waitFor(() => expect(f.api.list).toHaveBeenCalledTimes(2)); + complete([{ ...record, title: "Updated remotely" }]); + await refresh; + expect((await cached)[0]?.metadata).toMatchObject({ + title: "Updated remotely", + }); + await f.controller.dispose(); + }); + + it.each([ + false, + true, + ])("keeps cached discovery consistent after deletion (fails: %s)", async (fails) => { + const f = fixture(); + await f.controller.listForDiscovery(); + if (fails) { + f.api.delete.mockRejectedValueOnce(new Error("Network unavailable")); + await expect(f.controller.delete(record.id)).rejects.toThrow( + "Network unavailable", + ); + } else { + await f.controller.delete(record.id); + } + expect( + await f.controller.listForDiscovery({ maxAgeMs: 30_000 }), + ).toHaveLength(fails ? 1 : 0); + expect(f.api.list).toHaveBeenCalledTimes(1); + await f.controller.dispose(); + }); + + it("keeps cached discovery titles current after a readiness refresh and rename", async () => { + const f = fixture(); + await f.controller.listForDiscovery(); + await f.controller.waitUntilReady(record.id); + await f.controller.updateTitle(record.id, "New title"); + expect( + (await f.controller.listForDiscovery({ maxAgeMs: 30_000 }))[0]?.metadata, + ).toMatchObject({ title: "New title" }); + expect(f.api.list).toHaveBeenCalledTimes(2); + await f.controller.dispose(); + }); + it("refreshes an attached session title after an external rename", async () => { const f = await attached(); await f.controller.updateTitle(record.id, "Desktop title"); diff --git a/sdk/packages/core/src/cloud/controller.ts b/sdk/packages/core/src/cloud/controller.ts index 3fc9acfe74..b15acba5cb 100644 --- a/sdk/packages/core/src/cloud/controller.ts +++ b/sdk/packages/core/src/cloud/controller.ts @@ -438,6 +438,7 @@ export class CloudSessionController { private readonly unlistedSessions = new Map(); private readonly pendingInitialTasks: Map; private lastListedSessions: CloudSessionRecord[] = []; + private lastListedAt?: number; private discoveryRefresh?: Promise; private readonly unconfirmedInnerCreates = new Set(); private readonly createRequests = new Map< @@ -802,6 +803,7 @@ export class CloudSessionController { } } this.lastListedSessions = scoped; + this.lastListedAt = Date.now(); for (const record of scoped) if (this.sessions.has(record.id)) this.publishSnapshot(record.id); return immutableCopy(scoped); @@ -907,21 +909,29 @@ export class CloudSessionController { } async listForDiscovery( - options: { timeoutMs?: number } = {}, + options: { timeoutMs?: number; maxAgeMs?: number } = {}, ): Promise { + if (this.disposed) throw new Error("Cloud session controller was disposed"); + const maxAgeMs = options.maxAgeMs ?? 0; + const cacheIsFresh = + maxAgeMs > 0 && + this.lastListedAt !== undefined && + Date.now() - this.lastListedAt < maxAgeMs; const refresh = this.discoveryRefresh ?? - this.list().finally(() => { - if (this.discoveryRefresh === refresh) { - this.discoveryRefresh = undefined; - } - }); + (cacheIsFresh + ? undefined + : this.list().finally(() => { + if (this.discoveryRefresh === refresh) { + this.discoveryRefresh = undefined; + } + })); this.discoveryRefresh = refresh; let records = this.lastListedSessions; - if (options.timeoutMs === undefined) { + if (refresh && options.timeoutMs === undefined) { records = await refresh; - } else { + } else if (refresh) { let timeout: ReturnType | undefined; const result = await Promise.race([ refresh.then( @@ -2007,6 +2017,12 @@ export class CloudSessionController { if (record) { record.title = title; } + const listed = this.lastListedSessions.find( + (session) => session.id === outerSessionId, + ); + if (listed) { + listed.title = title; + } const live = this.sessions.get(outerSessionId); if (live) { live.title = title; @@ -2044,6 +2060,9 @@ export class CloudSessionController { throw error; } } + this.lastListedSessions = this.lastListedSessions.filter( + (record) => record.id !== outerSessionId, + ); this.knownSessions.delete(outerSessionId); this.unlistedSessions.delete(outerSessionId); this.pendingInitialTasks.delete(outerSessionId);