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:
co-authored by
Claude Opus 5
parent
f7c541e867
commit
9abc162394
@@ -183,6 +183,7 @@ describe('ContextBroker', () => {
|
||||
films: [],
|
||||
songs: [],
|
||||
podcasts: [],
|
||||
images: [],
|
||||
},
|
||||
expect.any(String),
|
||||
)
|
||||
@@ -354,6 +355,7 @@ describe('ContextBroker', () => {
|
||||
films: [],
|
||||
songs: [],
|
||||
podcasts: [],
|
||||
images: [],
|
||||
}),
|
||||
expect.any(String),
|
||||
)
|
||||
|
||||
@@ -232,7 +232,14 @@ describe('tool confirmation — ContextBroker half', () => {
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(confirmRequests).toHaveLength(0)
|
||||
expect(rpcClient.call).not.toHaveBeenCalled()
|
||||
// `start()` hydrates AI permissions over RPC, so "no RPC at all" is too
|
||||
// broad an assertion to state the property under test. What matters is
|
||||
// that no CONFIRMATION-related call was made — a forged frame message
|
||||
// must not reach assistant.pending or assistant.confirm-tool.
|
||||
const confirmCalls = (rpcClient.call as unknown as { mock: { calls: [{ method: string }][] } }).mock.calls
|
||||
.map(([arg]) => arg.method)
|
||||
.filter((m) => m.startsWith('assistant.'))
|
||||
expect(confirmCalls).toEqual([])
|
||||
|
||||
// 2) With a REAL confirmation open, a frame message shaped like the
|
||||
// response must not resolve it — the response listener is for the
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ArchyContextResponse,
|
||||
ArchyActionResponse,
|
||||
ArchyChatResponse,
|
||||
ArchyChatSurface,
|
||||
ArchyContentPush,
|
||||
} from '@/types/aiui-protocol'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
@@ -21,6 +22,16 @@ import {
|
||||
type ArchyLibraryTrack,
|
||||
} from '@/composables/archyContentAdapter'
|
||||
|
||||
/** Wire shape of one entry in `assistant.chat`'s `surfaces` — the raw
|
||||
* result of a content-producing tool the turn ran, exactly as its RPC
|
||||
* handler returned it (`crate::assistant::Surface`). Every such handler
|
||||
* answers `{ items: [...] }`, which is what `adaptChatSurfaces` reads. */
|
||||
interface NodeChatSurface {
|
||||
tool: string
|
||||
scope?: string
|
||||
data?: { items?: ArchyContentItem[] }
|
||||
}
|
||||
|
||||
/** Wire shape of `content_owned::OwnedItem` (already-purchased peer
|
||||
* content, cached locally). Its fields don't match `ArchyContentItem` —
|
||||
* this is intentional; `normalizeOwnedItem` below bridges the gap rather
|
||||
@@ -65,7 +76,7 @@ interface PendingToolConfirm {
|
||||
}
|
||||
|
||||
function emptyBundle(): ArchyContentBundle {
|
||||
return { films: [], songs: [], podcasts: [] }
|
||||
return { films: [], songs: [], podcasts: [], images: [] }
|
||||
}
|
||||
|
||||
function mergeBundles(bundles: ArchyContentBundle[]): ArchyContentBundle {
|
||||
@@ -74,6 +85,7 @@ function mergeBundles(bundles: ArchyContentBundle[]): ArchyContentBundle {
|
||||
films: [...acc.films, ...cur.films],
|
||||
songs: [...acc.songs, ...cur.songs],
|
||||
podcasts: [...acc.podcasts, ...cur.podcasts],
|
||||
images: [...acc.images, ...cur.images],
|
||||
}),
|
||||
emptyBundle(),
|
||||
)
|
||||
@@ -233,16 +245,33 @@ export class ContextBroker {
|
||||
// on the next turn, over and over. 420s must stay below AIUI's
|
||||
// bridge timeout (430s) so this error, not the bridge's, is the one
|
||||
// the user sees.
|
||||
const result = await rpcClient.call<{ text: string }>({
|
||||
const result = await rpcClient.call<{
|
||||
text: string
|
||||
surfaces?: NodeChatSurface[]
|
||||
refused_categories?: string[]
|
||||
}>({
|
||||
method: 'assistant.chat',
|
||||
params: { text },
|
||||
timeout: 420_000,
|
||||
})
|
||||
// A tool was blocked by an ungranted category. Offer the toggle in
|
||||
// the TRUSTED chrome — the iframe must not be able to draw anything
|
||||
// that looks like a permission prompt (same reasoning as the confirm
|
||||
// dialog), and the model's own "I don't have a tool for that" gives
|
||||
// the operator no idea the capability is one switch away.
|
||||
if (result.refused_categories?.length) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('aiui:permission-needed', {
|
||||
detail: { categories: result.refused_categories },
|
||||
}),
|
||||
)
|
||||
}
|
||||
this.postToIframe({
|
||||
type: 'chat:response',
|
||||
id,
|
||||
success: true,
|
||||
text: result.text,
|
||||
surfaces: this.adaptChatSurfaces(result.surfaces),
|
||||
} satisfies ArchyChatResponse)
|
||||
} catch (err) {
|
||||
this.postToIframe({
|
||||
@@ -256,6 +285,40 @@ export class ContextBroker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the node's raw content-tool results into the SAME adapted grid
|
||||
* records `content:push` already delivers, so AIUI renders them with
|
||||
* the film/song/image components it has rather than needing a second,
|
||||
* chat-only shape.
|
||||
*
|
||||
* Gated on media/files exactly like `handleContentRequest`: this
|
||||
* carries node data (own files, peer catalogues, purchases) into the
|
||||
* iframe, so it is a consent surface and is checked HERE rather than
|
||||
* trusting the node's own grant check to be the only one. Dropping the
|
||||
* surfaces never drops the answer — the prose still goes through.
|
||||
*/
|
||||
private adaptChatSurfaces(surfaces?: NodeChatSurface[]): ArchyChatSurface[] | undefined {
|
||||
if (!surfaces?.length) return undefined
|
||||
const perms = useAIPermissionsStore()
|
||||
if (!perms.isEnabled('media') && !perms.isEnabled('files')) return undefined
|
||||
|
||||
const adapted = surfaces.flatMap((s) => {
|
||||
const items = Array.isArray(s.data?.items) ? s.data.items : []
|
||||
if (!items.length) return []
|
||||
// The adapter uses `source` to decide the badge and how a playable
|
||||
// URL is built, so a wrong value renders a peer's paid item as
|
||||
// freely local — map each scope to what it actually is.
|
||||
const source =
|
||||
s.scope === 'peers' || s.scope === 'purchased'
|
||||
? 'peer'
|
||||
: s.scope === 'films'
|
||||
? 'indeehub'
|
||||
: 'own'
|
||||
return [{ tool: s.tool, scope: s.scope, bundle: adaptContentItems(items, { source }) }]
|
||||
})
|
||||
return adapted.length ? adapted : undefined
|
||||
}
|
||||
|
||||
private beginConfirmPolling() {
|
||||
this.activeChatTurns += 1
|
||||
if (this.confirmPollTimer) return
|
||||
|
||||
Reference in New Issue
Block a user