diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 96b790abd0..afbbe9e6ad 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -290,16 +290,7 @@ export class SessionManager { */ refreshSubagents(parentSessionId: SessionId): Promise { const existing = this.catalogInflight.get(parentSessionId) - if (existing !== undefined) { - // A refresh requested while a pull is in flight must not be silently - // coalesced into it: the in-flight response was requested before the - // triggering change (a membership frame or an opened menu), so it can - // never contain that change. Queue one trailing refresh that runs after - // the pull settles; without it the change stays invisible until an - // unrelated later trigger (reselection, menu reopen, reconnect). - this.catalogStale.add(parentSessionId) - return existing.promise - } + if (existing !== undefined) return existing.promise const previous = this.catalogs.get(parentSessionId) const expandableRows = new Set() const activityRows = new Map() @@ -774,11 +765,18 @@ export class SessionManager { for (const session of this.sessions.values()) void session.resync() } - /** Debounce membership refetches while one parent catalog is open. */ + /** Debounce membership refetches while one parent catalog is selected or open. */ private scheduleCatalogRefresh(parentSessionId: SessionId): void { if (this.catalogDebounce.has(parentSessionId)) return const timer = setTimeout(() => { this.catalogDebounce.delete(parentSessionId) + // The in-flight response predates the membership frame that scheduled + // this callback. Queue one post-settlement pull instead of treating an + // ordinary overlapping read as evidence that catalog membership changed. + if (this.catalogInflight.has(parentSessionId)) { + this.catalogStale.add(parentSessionId) + return + } void this.refreshSubagents(parentSessionId) }, 50) this.catalogDebounce.set(parentSessionId, timer) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 8ec3df98d8..f6d7baf318 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -530,6 +530,22 @@ describe('subagent catalogs', () => { ]) }) + it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + + const refresh = manager.refreshSubagents(root) + expect(manager.refreshSubagents(root)).toBe(refresh) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) + await refresh + + expect(api.callsOf('subagent.list')).toHaveLength(1) + }) + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { vi.useFakeTimers() try { @@ -538,8 +554,7 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) - manager.setSubagentCatalogOpen(root, true) + const manager = new SessionManager(api, root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced