feat(13-11): wire requestArchyLibrary + fire content/library fetch from a live init-time event (GAP-FOUND)

AIUI asks for the library the same way it asks for content, and the
fetch now actually fires without anyone typing a magic phrase:

- useArchy.ts: requestArchyLibrary(scope) sibling of requestArchyContent
  (13-06), same bridge call with kind: 'library', routed through the
  existing setArchyContent so the songs bucket fills exactly the way
  films already does.
- init() now calls both requestArchyContent('all','own') and
  requestArchyLibrary('own') once, fire-and-forget, immediately after
  archyBridge.init() — the GAP-FOUND fix. 13-06 built the whole
  content:request/content:push machinery and unit-tested it end to end,
  but nothing in the live UI ever called it (13-06-SUMMARY.md's Known
  Limitations); the fetch is now triggered by a real init-time UI event,
  not merely callable.
- useContentPanel.ts's setArchyContent now also opens the panel and
  populates availableTabs/activeTab/panelTitle when Archy supplied
  non-empty content — previously only the data refs were set while the
  tab bar and panelOpen stayed whatever the last regex-driven chat turn
  left them, so real content could sit fully populated and still never
  render. An empty bundle never force-opens the panel.

Deviation (Rule 2, mirrors 13-06's own archyBridge.ts precedent): kind:
'library' genuinely needs a different node-side RPC (music.list-tracks,
real tag-extracted metadata) than content.* (ContentItem has no artist/
album/duration field at all) — contextBroker.ts's handleContentRequest
gained one branch (fetchLibraryContent) to route it, and
aiui-protocol.ts's AIUIContentRequest.kind union gained the 'library'
literal, and archyBridge.ts's requestArchyContent kind param widened to
match. No second channel, no new message type, no new listener — the
existing content:request/content:push channel and its kind discriminator
carry this exactly as 13-06 designed it to. Full detail in the SUMMARY.

neode-ui: 926/926 tests green, vue-tsc -b clean. aiui: 341/344 (3
pre-existing, documented failures unrelated to this plan — 13-06/13-10
already recorded them), vue-tsc --noEmit clean.
This commit is contained in:
archipelago
2026-08-05 18:29:17 -04:00
parent abe77ebe7e
commit d25aea125f
8 changed files with 288 additions and 3 deletions
@@ -0,0 +1,73 @@
/**
* 13-11: requestArchyLibrary (sibling of 13-06's requestArchyContent) and
* the init-time auto-trigger that fires both over the real bridge — the
* GAP-FOUND fix. 13-06 built requestArchyContent/content:request/
* content:push and unit-tested all of it, but nothing in the live UI ever
* called it (see 13-06-SUMMARY.md's Known Limitations); this pins that the
* fetch now fires from a real init-time event, not merely from a direct
* unit-test call.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
type PostedMessage = { type: string; kind?: string; scope?: string; [key: string]: unknown }
describe('useArchy: requestArchyLibrary + init-time auto-trigger (13-11)', () => {
let originalParent: Window
beforeEach(() => {
vi.resetModules()
originalParent = window.parent
Object.defineProperty(window, 'parent', {
value: { postMessage: vi.fn() },
writable: true,
configurable: true,
})
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = true
})
afterEach(() => {
Object.defineProperty(window, 'parent', {
value: originalParent,
writable: true,
configurable: true,
})
delete (window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__
vi.restoreAllMocks()
})
it('requestArchyLibrary sends a content:request with kind "library"', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
archy.init()
archy.requestArchyLibrary('own').catch(() => {})
expect(window.parent.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'content:request', kind: 'library', scope: 'own' }),
window.location.origin,
)
archy.destroy()
})
it('init() fires both requestArchyContent and requestArchyLibrary as a live init-time event — not merely callable (GAP-FOUND)', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
archy.init()
const postMessage = window.parent.postMessage as unknown as ReturnType<typeof vi.fn>
const contentRequests = postMessage.mock.calls
.map(([msg]) => msg as PostedMessage)
.filter((msg) => msg.type === 'content:request')
expect(contentRequests.some((msg) => msg.kind === 'all')).toBe(true)
expect(contentRequests.some((msg) => msg.kind === 'library')).toBe(true)
archy.destroy()
})
it('exposes requestArchyLibrary on the composable\'s returned API', async () => {
const { useArchy } = await import('@/composables/useArchy')
const archy = useArchy()
expect(typeof archy.requestArchyLibrary).toBe('function')
})
})
@@ -77,6 +77,40 @@ describe('useContentPanel', () => {
expect(panel.selectedImage.value).toBeNull()
})
// 13-11 (GAP-FOUND 2026-08-03): setArchyContent must make real,
// non-empty node content actually visible — populating panelFilms/
// panelSongs/panelPodcasts alone left the tab bar and panelOpen
// untouched, so real data sat in memory and never rendered.
describe('setArchyContent makes non-empty content actually appear', () => {
it('opens the panel and adds a song tab when songs are non-empty', () => {
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.setArchyContent({ songs: [song] })
expect(panel.panelOpen.value).toBe(true)
expect(panel.availableTabs.value).toContain('song')
expect(panel.availableTabs.value).toContain('prompt')
expect(panel.activeTab.value).toBe('song')
expect(panel.panelTitle.value).toBe('Song')
})
it('adds a film tab and a song tab together when both are non-empty', () => {
const film = { id: 'f1', title: 'Film', year: 2025, genres: [], director: 'Dir' } as unknown as Film
const song = { id: 's1', title: 'Song', artist: 'Artist', album: 'Album', genres: [] } as Song
panel.setArchyContent({ films: [film], songs: [song] })
expect(panel.availableTabs.value).toEqual(['film', 'song', 'prompt'])
expect(panel.activeTab.value).toBe('film')
})
it('does not force the panel open when the bundle is entirely empty', () => {
panel.closePanel()
panel.setArchyContent({ films: [], songs: [], podcasts: [] })
expect(panel.panelOpen.value).toBe(false)
expect(panel.availableTabs.value).toHaveLength(0)
// archyContentActive still flips true — an explicit empty library
// is still a real answer from the node, distinct from "never asked".
expect(panel.archyContentActive.value).toBe(true)
})
})
it('openPlaceDetail and closePlaceDetail work', () => {
const place = { id: 'p1', name: 'Test Place', address: '123 St', lat: 0, lng: 0 } as Place
panel.openPlaceDetail(place)
@@ -126,6 +126,22 @@ export function useArchy() {
// Request theme on init
archyBridge.requestTheme()
// 13-11 (GAP-FOUND 2026-08-03): fire the content/library fetch from a
// real init-time event instead of leaving requestArchyContent /
// requestArchyLibrary merely callable with nothing in the live UI ever
// calling them — 13-06 built the whole content:request/content:push
// machinery and unit-tested it end to end, but nothing in ChatPage.vue's
// render tree (or anywhere else) ever invoked it, so real node content
// never appeared no matter how green the tests were (13-06's own Known
// Limitations). `init()` runs once per embedded session (guarded by
// `isInitialized` above) and is itself triggered by App.vue mounting —
// a real UI event, not a user-typed phrase. Fire-and-forget: both
// functions already resolve to a silent, logged no-op when the user
// hasn't granted Media/File access (`permitted: false`), so this never
// throws into `init()`.
void requestArchyContent('all', 'own')
void requestArchyLibrary('own')
}
/** Fetch context for all permitted categories */
@@ -233,6 +249,38 @@ export function useArchy() {
}
}
/**
* Request the node's real music library (13-11 — the D-13 wave) and hand
* it to the same `setArchyContent` sink `requestArchyContent` uses, so
* `SongGrid`'s `songs` bucket fills exactly the way the films bucket
* already does. Sibling of `requestArchyContent`, same bridge call, same
* permitted/not-permitted shape — the `'library'` kind is what routes
* this node-side to `music.list-tracks` (real tag-extracted metadata)
* instead of `content.*` (see `archyBridge.ts`'s `requestArchyContent`
* doc comment). `films`/`podcasts` are never touched by a library
* request — only `songs` is meaningful for `kind: 'library'`, so this
* merges into whatever films/podcasts `setArchyContent` last held rather
* than clobbering them with empty arrays.
*/
async function requestArchyLibrary(scope: 'own' | 'peers' | 'owned' = 'own') {
if (!isInitialized.value) return
try {
const res = await archyBridge.requestArchyContent('library', scope)
if (!res.permitted) {
console.warn('[AIUI Archy] library: not permitted — user should enable Media/File access in Archy Settings')
return
}
const panel = useContentPanel()
panel.setArchyContent({
films: panel.panelFilms.value,
songs: res.songs as Song[],
podcasts: panel.panelPodcasts.value,
})
} catch (err) {
console.warn('[AIUI Archy] library fetch failed:', (err as Error)?.message ?? err)
}
}
/** Apply accent color as CSS custom property */
function applyAccentColor(color: string) {
document.documentElement.style.setProperty('--color-accent', color)
@@ -333,6 +381,7 @@ export function useArchy() {
readFile,
tailLogs,
requestArchyContent,
requestArchyLibrary,
buildArchyContext,
}
}
@@ -297,6 +297,31 @@ export function useContentPanel() {
panelSongs.value = bundle.songs ?? []
panelPodcasts.value = bundle.podcasts ?? []
archyContentActive.value = true
// 13-11 (GAP-FOUND 2026-08-03): `availableTabs`/`activeTab`/`panelOpen`
// were previously untouched here — only `updatePanelFromText`'s regex
// path ever set them, so real Archy-sourced content could sit fully
// populated in these three refs while the tab bar and grid stayed
// whatever the last (or no) chat turn left them: closed, or showing
// only 'prompt' (13-06's own Known Limitations, this plan's must_haves
// GAP-FOUND). Only touches these three refs when Archy actually
// supplied non-empty content — an empty/never-granted library must not
// force the panel open on every mount.
const archyTabs: ContentTab[] = []
if (panelFilms.value.length > 0) archyTabs.push('film')
if (panelSongs.value.length > 0) archyTabs.push('song')
if (panelPodcasts.value.length > 0) archyTabs.push('podcast')
if (archyTabs.length > 0) {
availableTabs.value = [...archyTabs, 'prompt']
if (!archyTabs.includes(activeTab.value)) activeTab.value = archyTabs[0]!
if (panelFilms.value.length === 1) panelTitle.value = panelFilms.value[0]!.title
else if (panelFilms.value.length > 1) panelTitle.value = `${panelFilms.value.length} Films`
else if (panelSongs.value.length === 1) panelTitle.value = panelSongs.value[0]!.title
else if (panelSongs.value.length > 1) panelTitle.value = `${panelSongs.value.length} Songs`
else if (panelPodcasts.value.length === 1) panelTitle.value = panelPodcasts.value[0]!.title
else if (panelPodcasts.value.length > 1) panelTitle.value = `${panelPodcasts.value.length} Podcasts`
panelOpen.value = true
}
}
function setActiveTab(tab: ContentTab) {
@@ -299,9 +299,15 @@ export const archyBridge = {
* false` and empty arrays if the media/files permission categories
* aren't granted, rather than rejecting (mirrors `requestContext`'s
* shape so `useArchy.ts` can check `.permitted` the same way).
*
* `'library'` (13-11) is `useArchy.ts`'s `requestArchyLibrary`'s kind —
* resolves node-side to `music.list-tracks` instead of `content.*`
* (`neode-ui`'s `aiui-protocol.ts`/`contextBroker.ts`), so the returned
* `songs` carry real tag-extracted metadata rather than filename-derived
* guesses.
*/
requestArchyContent(
kind: 'films' | 'songs' | 'podcasts' | 'all',
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library',
scope?: 'own' | 'peers' | 'owned',
): Promise<ContentResponse> {
const id = generateId()