fix(aiui): the content surface renders what the assistant found

Four defects, one visible symptom: a correct prose answer beside an
empty grid.

1. The assistant's curated RPC bridge had an arm only for
   `content.list-mine`. `tools.rs` mapped the `peers`, `purchased` and
   `films` scopes onto three real, dispatcher-registered handlers that
   `assistant_dispatch_tool` had never heard of, so every non-"own"
   scope died on its catch-all. Downstream that read as "the peers have
   no content" — it was a missing match arm, and the tool never ran.
   Regression test added: every scope the schema advertises must reach a
   real handler.

2. `content.browse-all-peers` wrapped its whole fan-out in one
   `timeout(..).unwrap_or_default()`, which DISCARDED every completed
   batch the moment the budget expired. One slow peer turned a
   partly-successful browse into "0 reached, 16 unreachable". Observed
   live on archi-dev-box: back-to-back calls returned real peer items,
   then nothing. Now accumulates per batch and checks a deadline between
   them, so partial results always survive. Budget 20s -> 45s: two
   batches of eight at a 10s per-peer timeout had no headroom at all.

3. `assistant.chat` returned only `{ text }`. The structured results of
   any content tool the turn ran were dropped inside the loop, so the
   surface had nothing to render. The turn now carries them through
   (captured raw, before the untrusted wrap, since they go to a renderer
   that treats every field as inert data, never back into the prompt).

4. The adapter classified images as 'excluded' and dropped them. A node
   sharing mostly photos rendered as an empty grid while AIUI's image
   grid sat unused. Images now have a bucket, with the paid-lock and
   extension-fallback handling audio and video already had.

Also: the panel says "Loading…" while a turn is in flight and "Nothing
found" when it comes back empty, instead of leaving the previous
query's heading standing as though it answered this one; the system
prompt tells the model to call the content tool and summarise rather
than re-list what the cards already show; and a refused tool now names
its permission category so the trusted chrome can offer the settings
screen instead of leaving "I don't have a tool for that" as the only
clue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 05:00:54 -04:00
co-authored by Claude Opus 5
parent f7c541e867
commit 9abc162394
18 changed files with 650 additions and 56 deletions
@@ -30,11 +30,18 @@ describe('classifyByMime', () => {
expect(classifyByMime(item({ mime_type: 'audio/mpeg', filename: 'song.mp3' }))).toBe('audio')
})
it('excludes image and document mimes rather than mis-typing them', () => {
expect(classifyByMime(item({ mime_type: 'image/jpeg', filename: 'photo.jpg' }))).toBe('excluded')
it('classifies images as image, and still excludes documents', () => {
expect(classifyByMime(item({ mime_type: 'image/jpeg', filename: 'photo.jpg' }))).toBe('image')
expect(classifyByMime(item({ mime_type: 'application/pdf', filename: 'doc.pdf' }))).toBe('excluded')
})
it('classifies images by extension when the mime is generic', () => {
// A node whose catalog is mostly photos shared with an unidentified
// mime must not present as empty.
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.webp' }))).toBe('image')
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'p.heic' }))).toBe('image')
})
it('classifies m4a, aac, opus and wma as audio via extension fallback (ShareModal blind spot)', () => {
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.m4a' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.aac' }))).toBe('audio')
@@ -74,7 +81,7 @@ describe('adaptContentItems', () => {
expect(bundle.films).toHaveLength(0)
})
it('excludes image and document mimes from all three buckets', () => {
it('routes images to the images bucket and still excludes documents', () => {
const bundle = adaptContentItems(
[
item({ id: 'img-1', filename: 'photo.jpg', mime_type: 'image/jpeg' }),
@@ -85,6 +92,20 @@ describe('adaptContentItems', () => {
expect(bundle.films).toHaveLength(0)
expect(bundle.songs).toHaveLength(0)
expect(bundle.podcasts).toHaveLength(0)
// The photo renders; the PDF has no grid, so it stays out of every bucket.
expect(bundle.images).toHaveLength(1)
expect(bundle.images[0]!.id).toBe('img-1')
expect(bundle.images[0]!.url).toBe('/content/img-1')
})
it('locks a paid image: price carried, no URL to fetch bytes the user has not bought', () => {
const bundle = adaptContentItems(
[item({ id: 'p-1', filename: 'photo.jpg', mime_type: 'image/jpeg', access: { paid: { price_sats: 100 } } })],
{ source: 'own' },
)
expect(bundle.images[0]!.locked).toBe(true)
expect(bundle.images[0]!.priceSats).toBe(100)
expect(bundle.images[0]!.url).toBe('')
})
it('maps an access:Paid item with a price and a locked flag, and no playable source URL', () => {
@@ -139,12 +160,12 @@ describe('adaptContentItems', () => {
it('an empty input array produces empty films/songs/podcasts arrays, not undefined or an error', () => {
const bundle = adaptContentItems([], { source: 'own' })
expect(bundle).toEqual({ films: [], songs: [], podcasts: [] })
expect(bundle).toEqual({ films: [], songs: [], podcasts: [], images: [] })
})
it('handles null/undefined input the same as an empty array', () => {
expect(adaptContentItems(null, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [] })
expect(adaptContentItems(undefined, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [] })
expect(adaptContentItems(null, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
expect(adaptContentItems(undefined, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [], images: [] })
})
it('maps a null/absent description to an empty string, never the literal "null"', () => {
@@ -106,6 +106,23 @@ export interface Podcast {
priceSats?: number
}
/** Structurally matches `aiui/packages/core/src/types/content.ts`'s
* `ImageItem` — declared locally for the same D-19 reason as the shapes
* above (neode-ui does not depend on `@aiui/core`). */
export interface ImageItem {
id: string
url: string
title?: string
description?: string
alt?: string
width?: number
height?: number
source?: string
attribution?: string
locked?: boolean
priceSats?: number
}
// ─── Source shape: content_server.rs's ContentItem, as seen over RPC ───
/** `AccessControl` (`core/archipelago/src/content_server.rs`) serializes via
@@ -138,6 +155,12 @@ export interface ArchyContentBundle {
films: Film[]
songs: Song[]
podcasts: Podcast[]
/** Shared photos. Images were previously classified 'excluded' and
* dropped on the floor, so a node sharing mostly photos rendered as an
* empty grid — the single biggest gap between what the assistant could
* DESCRIBE and what the surface could SHOW. AIUI has had an image grid
* (`panelImages`/`ImageGrid`) the whole time; nothing fed it. */
images: ImageItem[]
}
export interface AdaptContentOptions {
@@ -153,7 +176,7 @@ export interface AdaptContentOptions {
// ─── Classification ───────────────────────────────────────────────────────
type ContentBucket = 'video' | 'audio' | 'excluded'
type ContentBucket = 'video' | 'audio' | 'image' | 'excluded'
// `m4a`, `aac`, `opus` and `wma` classify as audio via this extension
// fallback — `ShareModal.vue`'s mime map omits exactly these four today, so
@@ -175,6 +198,11 @@ const AUDIO_EXT_FALLBACK = new Set([
const VIDEO_EXT_FALLBACK = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v'])
// Same reasoning as the audio fallback above: a photo shared with a mime
// this node could not identify still has an unambiguous extension, and a
// node whose catalog is mostly photos should not present as empty.
const IMAGE_EXT_FALLBACK = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'bmp'])
function extensionOf(filename: string): string {
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
const idx = base.lastIndexOf('.')
@@ -204,10 +232,12 @@ export function classifyByMime(item: Pick<ArchyContentItem, 'mime_type' | 'filen
const mime = (item.mime_type || '').toLowerCase().trim()
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('image/')) return 'image'
const ext = extensionOf(item.filename || '')
if (AUDIO_EXT_FALLBACK.has(ext)) return 'audio'
if (VIDEO_EXT_FALLBACK.has(ext)) return 'video'
if (IMAGE_EXT_FALLBACK.has(ext)) return 'image'
return 'excluded'
}
@@ -310,6 +340,30 @@ export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions):
}
}
/**
* Map a shared photo onto AIUI's `ImageItem`. A locked (paid, not yet
* bought) image gets an EMPTY `url` for the same reason a locked film
* does: the card should render its price and lock, not silently fetch
* bytes the operator has not paid for.
*/
export function adaptToImage(item: ArchyContentItem, opts: AdaptContentOptions): ImageItem {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
const title = stripExtension(item.filename || '')
return {
id: item.id,
url: locked ? '' : buildMediaUrl(item, opts),
title,
description: item.description ?? '',
// `alt` falls back to the title rather than being left empty — a photo
// grid with no alt text is unreadable to a screen reader.
alt: title,
source: SOURCE_LABEL[opts.source],
locked,
...(priceSats !== null ? { priceSats } : {}),
}
}
export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): Song {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
@@ -396,15 +450,18 @@ export function adaptContentItems(
const films: Film[] = []
const songs: Song[] = []
const podcasts: Podcast[] = []
const images: ImageItem[] = []
for (const item of sorted) {
const bucket = classifyByMime(item)
if (bucket === 'video') films.push(adaptToFilm(item, opts))
else if (bucket === 'audio') songs.push(adaptToSong(item, opts))
// 'excluded' (image/document/other) — not mistyped into any bucket.
else if (bucket === 'image') images.push(adaptToImage(item, opts))
// 'excluded' (documents/archives/other) — no grid renders these, so
// they stay out of every bucket rather than being mistyped into one.
}
return { films, songs, podcasts }
return { films, songs, podcasts, images }
}
// ─── Library mapping (13-11) ────────────────────────────────────────────