diff --git a/neode-ui/src/services/__tests__/contextBroker.test.ts b/neode-ui/src/services/__tests__/contextBroker.test.ts index 47bfec62..f1edcbf4 100644 --- a/neode-ui/src/services/__tests__/contextBroker.test.ts +++ b/neode-ui/src/services/__tests__/contextBroker.test.ts @@ -253,6 +253,53 @@ describe('ContextBroker', () => { expect(freshCalls).toHaveLength(1) }) + it('delivers BOTH results when two different kinds are requested together', async () => { + // Regression: the guard used one counter for every kind, so requests for + // different kinds cancelled each other. useArchy.ts init fires + // content('all','own') then library('own') back to back and both sequence + // numbers are assigned synchronously before either awaits — so the first + // ALWAYS resolved stale and was dropped. Films, podcasts and own files + // never reached the grid; only music ever did. Different kinds populate + // different grids and cannot stale each other by definition. + const perms = useAIPermissionsStore() + perms.enableAll() + + vi.mocked(rpcClient.call).mockResolvedValue({ items: [], tracks: [] } as never) + + const films = callContentRequest('films-req', 'films', 'own') + const library = callContentRequest('library-req', 'library', 'own') + await Promise.all([films, library]) + + const pushed = mockPostMessage.mock.calls + .map(([msg]) => msg as { type?: string; id?: string }) + .filter((m) => m.type === 'content:push') + .map((m) => m.id) + + expect(pushed).toContain('films-req') + expect(pushed).toContain('library-req') + }) + + it('still discards a stale response within the same kind and scope', async () => { + // The guard's real purpose must survive being made per-key. + const perms = useAIPermissionsStore() + perms.enableAll() + vi.mocked(rpcClient.call).mockResolvedValue({ items: [] } as never) + + await callContentRequest('older', 'films', 'own') + await callContentRequest('newer', 'films', 'own') + + // Both completed in order here, so both post; the ordering guarantee is + // covered by the out-of-order test above. What this pins is that the key + // is kind+scope, so a DIFFERENT scope does not collide with this one. + const scoped = callContentRequest('peer-scope', 'films', 'peers') + await scoped + const pushed = mockPostMessage.mock.calls + .map(([msg]) => msg as { type?: string; id?: string }) + .filter((m) => m.type === 'content:push') + .map((m) => m.id) + expect(pushed).toContain('peer-scope') + }) + // 13-11: kind: 'library' is the one addition this wave makes to the // discriminator — it resolves to music.list-tracks, not content.*, // since a library track carries real tag-extracted metadata diff --git a/neode-ui/src/services/contextBroker.ts b/neode-ui/src/services/contextBroker.ts index 90b727eb..5cf1bda6 100644 --- a/neode-ui/src/services/contextBroker.ts +++ b/neode-ui/src/services/contextBroker.ts @@ -90,12 +90,21 @@ export class ContextBroker { private iframe: Ref private allowedOrigin: string private listener: ((e: MessageEvent) => void) | null = null - /** Monotonically-increasing content-request sequence — the AIUI-03 - * concurrency guard. Each `handleContentRequest` call captures its own - * sequence number before awaiting the RPC(s); if a newer content:request - * has started by the time it resolves, its result is discarded instead - * of posted, so a slow response can never overwrite fresher grid data. */ - private contentRequestSeq = 0 + /** Per-(kind, scope) content-request sequence — the AIUI-03 concurrency + * guard. Each `handleContentRequest` call captures its own sequence number + * before awaiting the RPC(s); if a newer request FOR THE SAME kind+scope + * has started by the time it resolves, its result is discarded, so a slow + * response can never overwrite fresher grid data. + * + * Keyed rather than global. A single shared counter made different kinds + * cancel each other: `useArchy.ts` init fires `content('all','own')` and + * `library('own')` back to back, both sequence numbers are assigned + * synchronously before either awaits, so the first request ALWAYS resolved + * with a stale number and was dropped — films, podcasts and own files never + * reached the grid and only music ever did. Different kinds populate + * different grids and cannot stale each other by definition; only a newer + * request for the same grid can. */ + private contentRequestSeq = new Map() /** 13-08: how often the broker asks the node for a pending destructive- * tool confirmation while a chat turn is in flight. The node's loop is @@ -368,9 +377,11 @@ export class ContextBroker { return } - const seq = ++this.contentRequestSeq const requestedScope: 'own' | 'peers' | 'owned' = scope === 'peers' || scope === 'owned' ? scope : 'own' + const seqKey = `${kind}:${requestedScope}` + const seq = (this.contentRequestSeq.get(seqKey) ?? 0) + 1 + this.contentRequestSeq.set(seqKey, seq) // 13-11: 'library' is the one kind value this wave adds — it resolves // to music.list-tracks (real tag-extracted metadata) instead of @@ -378,10 +389,10 @@ export class ContextBroker { const bundle = kind === 'library' ? await this.fetchLibraryContent() : await this.fetchAdaptedContent(requestedScope) - // Stale-response guard: a newer content:request has since started — - // discard this result instead of flipping the grids back to older - // data (AIUI-03 concurrency edge). - if (seq !== this.contentRequestSeq) return + // Stale-response guard: a newer content:request for this same kind+scope + // has since started — discard this result instead of flipping that grid + // back to older data (AIUI-03 concurrency edge). + if (seq !== this.contentRequestSeq.get(seqKey)) return this.postToIframe({ type: 'content:push',