diff --git a/command-center-ai/CHANGELOG.md b/command-center-ai/CHANGELOG.md index 3951be65..413c52fe 100644 --- a/command-center-ai/CHANGELOG.md +++ b/command-center-ai/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.0.17 + +Compatibility axes: additive npm API (`normalizeCommandCenterAgentIcon`, `primeAgentIcon`, the +`CommandCenterAgentIconImage` type, the optional `idle` on `CommandCenterAgentIcon`, and the +optional `icon` prop of `AgentIcon`), and the read of the platform's additive +`command_center_icon` on agent reads that ask for `include_icons`. No stylesheet class, browser +storage key or value, skill name or path, installer behavior, SDK peer range, or route the +package calls changes. An application that passes no `icon` renders as before. The platform's +`include_icons` must be deployed before an application asks for it; without it agent reads carry +no icon and `AgentIcon` falls back to the projection as before. + +- **Icons that come with an agent read** ([ADR 090](docs/adr/adr-090-agent-icons-in-command-center-surfaces.md), + section 8, following the platform's #1146). `AgentIcon` takes an `icon` the application holds and + draws its inline idle sprite without a request; `primeAgentIcon` seeds the byte cache from it so a + list draws its icons in its first frame. The cache compares a platform ETag without its quotes, + so a seeded icon and a later revalidation of the same bytes keep one object URL. + ## 0.0.16 Compatibility axes: sign-off error reporting, dialog Escape handling, and the content of the diff --git a/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md b/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md index 5f1961c7..92682e11 100644 --- a/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md +++ b/command-center-ai/docs/adr/adr-090-agent-icons-in-command-center-surfaces.md @@ -7,6 +7,8 @@ ADR-033 amendment of the same day - Amended: 2026-10-16 for poses that follow the agent's run and tools (section 7), following the platform's per-tool animations +- Amended: 2026-10-22 for icons that come with an agent read (section 8), following the + platform's ADR-033 amendment of the same day - Moved: 2026-09-23 into the chat package with its code (ADR 096, step 4); the code paths below are the package's - Related: @@ -151,6 +153,32 @@ and `toolPoses` (a declared application to a pose); the platform delivers a pose cache; until a pose's bytes are ready the idle icon stays. Each pose gets its frame 0 still, so reduced motion shows the chosen pose still. +### 8. Icons that come with an agent read + +Amended 2026-10-22 for the platform's [issue #1146](https://github.com/Main-Sequence-Server-Side/tdag-django/issues/1146). The platform's agent list, detail and semantic search accept +`include_icons`; each agent then carries `command_center_icon`, the projection's icon object plus +`idle`, the idle sprite inline (`{ mime, bytes }`, base64 of exactly what `url` delivers). A list +can then draw every icon with the list itself, without waiting for the projection or loading one +sprite per agent. + +- **Normalize.** `normalizeCommandCenterAgentIcon(agentUid, value)` reads either shape, the + projection's `commandCenterIcon` or an agent read's `command_center_icon`, and keeps a usable + `idle` (`image/png` or `image/svg+xml` with non-empty bytes). +- **Seed the byte cache.** `primeAgentIcon({ url, idle })` fills the cache entry of the icon's + delivery URL from the inline bytes, with the still of an animated PNG, and makes no request. The + entry is loading at once, so a load started meanwhile waits for it. A fresh ready entry is kept; + an older one is refreshed and its object URL replaced only when the bytes differ, as a + revalidation would. The platform's ETag is the quoted SHA-256 of the bytes, so the cache compares + it without its quotes and a seeded entry and a revalidation of the same bytes agree. +- **Draw.** `AgentIcon` takes an optional `icon`. When it is given, even as null, the provider's + projection is not consulted; its inline sprite is seeded before the idle image's own load, so the + icon draws without a request, also outside the engine. Without inline bytes (`idle` null: the + platform could not read storage) it loads `url` as before. An application that seeds the cache + while it reads the list, before rendering, draws the icons in the first frame. +- **Unchanged.** Poses are still loaded from their URLs where an icon animates. The projection + stays the source for surfaces that know only an agent uid (the thread, the rail, the session + explorer), and is still read once per Environment. + ## Non-goals - Uploading or editing icons from the Command Center; the repository workflow owns that. @@ -171,6 +199,10 @@ and `toolPoses` (a declared application to a pose); the platform delivers a pose scaling. - `src/engine/apng-still.ts`: frame 0 of an APNG as a static PNG; tested with the platform's own sanitizer output beside the cache tests. +- Since 2026-10-22, `normalizeCommandCenterAgentIcon`, `primeAgentIcon` and `AgentIcon`'s `icon`; + tested for the inline sprite's normalization, seeding without a request, the still, refresh, + agreement with an ETag revalidation, undecodable bytes, and drawing a held icon without a + provider, null, and without inline bytes. - `src/engine/agent-icon-pose.ts`: the pose resolver; `AgentIcon` (`activity`), `ChatThread` (the working agent), `ChatRail` (the header) and the engine's `currentTool`; tested for the resolver, the projection's poses, pose choice and loading, and a thread whose running diff --git a/command-center-ai/docs/conversation-contract.md b/command-center-ai/docs/conversation-contract.md index 90a999a5..401c1929 100644 --- a/command-center-ai/docs/conversation-contract.md +++ b/command-center-ai/docs/conversation-contract.md @@ -55,8 +55,9 @@ text and the tool calls should tell the answer on their own. thread draws the viewer's messages, other people's with their initials, and the Agent's with its icon, and labels the actors when more than one takes part. - Agent names are shown as the platform stores them. -- An Agent's icon comes from the platform's agent icon projection for the Environment, as a mask - that follows the theme or as a colour image, with a robot as the fallback +- An Agent's icon comes from the platform's agent icon projection for the Environment, or from an + agent read with `include_icons` that the application passes to `AgentIcon`, as a mask that + follows the theme or as a colour image, with a robot as the fallback ([ADR 090](./adr/adr-090-agent-icons-in-command-center-surfaces.md)). - Known issue: during a live turn the avatar is the robot until the next reload. diff --git a/command-center-ai/package.json b/command-center-ai/package.json index 548aceed..054de0dc 100644 --- a/command-center-ai/package.json +++ b/command-center-ai/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/command-center-ai", - "version": "0.0.16", + "version": "0.0.17", "private": false, "description": "A chat for Main Sequence Agents: the connection to the platform, a session engine, the chat UI, and model provider settings, built on the Command Center SDK.", "type": "module", diff --git a/command-center-ai/src/backend/README.md b/command-center-ai/src/backend/README.md index 233587b3..f9681363 100644 --- a/command-center-ai/src/backend/README.md +++ b/command-center-ai/src/backend/README.md @@ -87,8 +87,9 @@ proxy. Organization custom provider's OpenAI-compatible endpoint (`/chat/completions` or `/responses`) and parses the streamed reply. It is the only module here that does not talk to the platform or an Agent runtime: no Agent, AgentSession, runtime access, or platform JWT is involved. -- `command-center-agent-icons-api.ts` reads the agent icon projection and an icon's bytes - (ADR 090). +- `command-center-agent-icons-api.ts` reads the agent icon projection and an icon's bytes, and + normalizes one agent's icon, including the idle sprite an agent read carries inline with + `include_icons` (`normalizeCommandCenterAgentIcon`, ADR 090). - `tool-activity.ts`, `error-source.ts`, `http-error.ts`, and `user-scope.ts` are the shared helpers: tool activity labels, where an error came from, HTTP error text, and the user scope of a request. See [provider errors](../../docs/main-sequence-ai-provider-errors.md). diff --git a/command-center-ai/src/backend/command-center-agent-icons-api.test.ts b/command-center-ai/src/backend/command-center-agent-icons-api.test.ts index 3858c263..e358ac7f 100644 --- a/command-center-ai/src/backend/command-center-agent-icons-api.test.ts +++ b/command-center-ai/src/backend/command-center-agent-icons-api.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildCommandCenterAgentIconsUrl, fetchCommandCenterAgentIconBytes, + normalizeCommandCenterAgentIcon, normalizeCommandCenterAgentIcons, } from "./command-center-agent-icons-api.js"; import { createChatBackendConnection } from "./connection.js"; @@ -70,6 +71,44 @@ describe("command center agent icons api", () => { }); }); +describe("an icon an agent read carried", () => { + const icon = { + rendering: "color", + imageRendering: "pixelated", + url: "https://api.test/api/v1/command-center/agents/a1/icon/", + poses: { working: "https://api.test/api/v1/command-center/agents/a1/icon/working/" }, + toolPoses: { tutorial_api: "working" }, + }; + + it("keeps the inline idle sprite beside the projection's fields", () => { + expect( + normalizeCommandCenterAgentIcon("a1", { ...icon, idle: { mime: "image/png", bytes: "iVBORw0KGgo=" } }), + ).toEqual({ + agentUid: "a1", + rendering: "color", + imageRendering: "pixelated", + url: icon.url, + poses: icon.poses, + toolPoses: icon.toolPoses, + idle: { mime: "image/png", bytes: "iVBORw0KGgo=" }, + }); + }); + + it("drops an unusable idle sprite and loads from the url instead", () => { + for (const idle of [null, { mime: "image/gif", bytes: "R0lGOD" }, { mime: "image/png", bytes: " " }, "junk"]) { + const normalized = normalizeCommandCenterAgentIcon("a1", { ...icon, idle }); + expect(normalized?.url).toBe(icon.url); + expect(normalized).not.toHaveProperty("idle"); + } + }); + + it("is null without an agent or a usable icon", () => { + expect(normalizeCommandCenterAgentIcon("", icon)).toBeNull(); + expect(normalizeCommandCenterAgentIcon("a1", null)).toBeNull(); + expect(normalizeCommandCenterAgentIcon("a1", { ...icon, rendering: "sparkle" })).toBeNull(); + }); +}); + describe("command center agent icon bytes", () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/command-center-ai/src/backend/command-center-agent-icons-api.ts b/command-center-ai/src/backend/command-center-agent-icons-api.ts index e15db868..029d01c5 100644 --- a/command-center-ai/src/backend/command-center-agent-icons-api.ts +++ b/command-center-ai/src/backend/command-center-agent-icons-api.ts @@ -13,11 +13,20 @@ import { requestPlatform } from "./platform-request.js"; * `color` is drawn as an image and never tinted) and a delivery URL. Delivery * needs the normal bearer token and has no signed query, so the bytes are * fetched with the authenticated client and rendered from an object URL. + * + * An agent read with `include_icons=true` (the platform's agent list, detail and semantic search) + * carries the same icon per agent, plus the idle sprite inline, so a list draws without a request. */ export type CommandCenterAgentIconRendering = "mask" | "color"; /** How a color icon is scaled: smoothly, or with sharp pixels for pixel art. */ export type CommandCenterAgentIconImageRendering = "auto" | "pixelated"; +/** An idle sprite carried inline: its media type and its bytes, base64-encoded. */ +export interface CommandCenterAgentIconImage { + mime: "image/png" | "image/svg+xml"; + bytes: string; +} + export interface CommandCenterAgentIcon { agentUid: string; rendering: CommandCenterAgentIconRendering; @@ -28,6 +37,11 @@ export interface CommandCenterAgentIcon { poses?: Readonly>; /** The pose a declared application's tools show while they run, keyed by application. */ toolPoses?: Readonly>; + /** + * The idle sprite inline, the bytes `url` delivers, from an agent read with `include_icons`; + * the projection never carries it. Absent or null: the icon is loaded from `url`. + */ + idle?: CommandCenterAgentIconImage | null; } export type CommandCenterAgentIconMap = ReadonlyMap; @@ -58,6 +72,43 @@ function readStringMap(value: unknown) { return entries; } +function readIconImage(value: unknown): CommandCenterAgentIconImage | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const record = value as Record; + const mime = readString(record.mime); + const bytes = readString(record.bytes); + return (mime === "image/png" || mime === "image/svg+xml") && bytes ? { mime, bytes } : null; +} + +/** + * One agent's icon from the projection's `commandCenterIcon` or an agent read's + * `command_center_icon`; null when it is missing or unusable. + */ +export function normalizeCommandCenterAgentIcon(agentUid: string, value: unknown): CommandCenterAgentIcon | null { + if (!agentUid || !value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const iconRecord = value as Record; + const rendering = readString(iconRecord.rendering); + const url = readString(iconRecord.url); + if ((rendering !== "mask" && rendering !== "color") || !url) { + return null; + } + const imageRendering = + rendering === "color" && readString(iconRecord.imageRendering ?? iconRecord.image_rendering) === "pixelated" + ? "pixelated" + : "auto"; + const poses = readStringMap(iconRecord.poses); + const toolPoses = Object.fromEntries( + Object.entries(readStringMap(iconRecord.toolPoses ?? iconRecord.tool_poses)).filter(([, pose]) => pose in poses), + ); + const icon: CommandCenterAgentIcon = { agentUid, rendering, imageRendering, url, poses, toolPoses }; + const idle = readIconImage(iconRecord.idle); + return idle ? { ...icon, idle } : icon; +} + export function normalizeCommandCenterAgentIcons(payload: unknown): Map { const icons = new Map(); if (!Array.isArray(payload)) { @@ -69,25 +120,12 @@ export function normalizeCommandCenterAgentIcons(payload: unknown): Map; const agentUid = readString(record.agentUid ?? record.agent_uid); - const icon = record.commandCenterIcon ?? record.command_center_icon; - if (!agentUid || !icon || typeof icon !== "object" || Array.isArray(icon)) { - continue; - } - const iconRecord = icon as Record; - const rendering = readString(iconRecord.rendering); - const url = readString(iconRecord.url); - if ((rendering !== "mask" && rendering !== "color") || !url) { - continue; + const icon = agentUid + ? normalizeCommandCenterAgentIcon(agentUid, record.commandCenterIcon ?? record.command_center_icon) + : null; + if (agentUid && icon) { + icons.set(agentUid, icon); } - const imageRendering = - rendering === "color" && readString(iconRecord.imageRendering ?? iconRecord.image_rendering) === "pixelated" - ? "pixelated" - : "auto"; - const poses = readStringMap(iconRecord.poses); - const toolPoses = Object.fromEntries( - Object.entries(readStringMap(iconRecord.toolPoses ?? iconRecord.tool_poses)).filter(([, pose]) => pose in poses), - ); - icons.set(agentUid, { agentUid, rendering, imageRendering, url, poses, toolPoses }); } return icons; } diff --git a/command-center-ai/src/engine/README.md b/command-center-ai/src/engine/README.md index 62b80093..cf430459 100644 --- a/command-center-ai/src/engine/README.md +++ b/command-center-ai/src/engine/README.md @@ -120,7 +120,8 @@ An application may layer its own fields over the engine's value, as Command Cent names. - `agent-icons-context.tsx` and `agent-icon-cache.ts`: the agent icon projection, one per Environment and trusted for five minutes, and the icon bytes cache, both cleared when no one is - signed in (ADR 090). + signed in (ADR 090). `primeAgentIcon` seeds the cache from the idle sprite an agent read carried + inline, without a request (ADR 090, section 8). ## The Model Catalog Store diff --git a/command-center-ai/src/engine/agent-icon-cache.test.ts b/command-center-ai/src/engine/agent-icon-cache.test.ts index 8f2e5bba..b66a97a7 100644 --- a/command-center-ai/src/engine/agent-icon-cache.test.ts +++ b/command-center-ai/src/engine/agent-icon-cache.test.ts @@ -7,6 +7,7 @@ import { ensureAgentIcon, getAgentIconCacheEntry, isAgentIconRequestInFlight, + primeAgentIcon, } from "./agent-icon-cache.js"; import { apngStill } from "./apng-still.js"; import { createChatBackendConnection } from "../backend/connection.js"; @@ -161,6 +162,85 @@ describe("agent icon cache", () => { }); }); +describe("agent icon cache seeded inline", () => { + const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(); + const createObjectURL = vi.fn<(blob: Blob) => string>(); + const revokeObjectURL = vi.fn<(objectUrl: string) => void>(); + let objectUrlCounter = 0; + const base64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); + const sha256 = async (bytes: Uint8Array) => + Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); + + beforeEach(() => { + objectUrlCounter = 0; + fetchMock.mockReset(); + createObjectURL.mockReset(); + createObjectURL.mockImplementation(() => `blob:test/${(objectUrlCounter += 1)}`); + revokeObjectURL.mockReset(); + vi.stubGlobal("fetch", fetchMock); + Object.assign(URL, { createObjectURL, revokeObjectURL }); + clearAgentIconCache(); + }); + afterEach(() => { + clearAgentIconCache(); + vi.unstubAllGlobals(); + }); + + it("makes a ready entry, with the still of an animated PNG, without a request", async () => { + const seeding = primeAgentIcon({ url, idle: { mime: "image/png", bytes: base64(APNG) } }); + // Loading at once, so a request started now waits for the seed instead of fetching. + expect(getAgentIconCacheEntry(url)?.status).toBe("loading"); + ensureAgentIcon({ connection, url, token: "jwt" }); + await expect(seeding).resolves.toBe(true); + expect(getAgentIconCacheEntry(url)).toMatchObject({ + status: "ready", + objectUrl: "blob:test/1", + stillObjectUrl: "blob:test/2", + }); + const animation = createObjectURL.mock.calls[0]?.[0]; + expect(animation?.type).toBe("image/png"); + expect(Array.from(new Uint8Array(await animation!.arrayBuffer()))).toEqual(Array.from(APNG)); + ensureAgentIcon({ connection, url, token: "jwt" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("keeps a fresh entry and replaces an older one only when the bytes differ", async () => { + await primeAgentIcon({ url, idle: { mime: "image/png", bytes: base64(STATIC_PNG) } }); + await expect(primeAgentIcon({ url, idle: { mime: "image/png", bytes: base64(APNG) } })).resolves.toBe(true); + expect(getAgentIconCacheEntry(url)?.objectUrl).toBe("blob:test/1"); + const later = Date.now() + AGENT_ICON_REVALIDATE_MS + 1; + await primeAgentIcon({ url, idle: { mime: "image/png", bytes: base64(STATIC_PNG) }, now: later }); + expect(getAgentIconCacheEntry(url)?.objectUrl).toBe("blob:test/1"); + expect(revokeObjectURL).not.toHaveBeenCalled(); + await primeAgentIcon({ url, idle: { mime: "image/png", bytes: base64(APNG) }, now: later }); + expect(getAgentIconCacheEntry(url)?.objectUrl).toBe("blob:test/2"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test/1"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("agrees with a revalidation whose ETag is the platform's SHA-256 of the same bytes", async () => { + await primeAgentIcon({ url, idle: { mime: "image/png", bytes: base64(STATIC_PNG) } }); + fetchMock.mockResolvedValueOnce( + new Response(STATIC_PNG, { + status: 200, + headers: { ETag: `"${await sha256(STATIC_PNG)}"`, "Content-Type": "image/png" }, + }), + ); + ensureAgentIcon({ connection, url, token: "jwt", now: Date.now() + AGENT_ICON_REVALIDATE_MS + 1 }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(isAgentIconRequestInFlight(url)).toBe(false)); + expect(getAgentIconCacheEntry(url)?.objectUrl).toBe("blob:test/1"); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it("seeds nothing from bytes that do not decode", async () => { + await expect(primeAgentIcon({ url, idle: { mime: "image/png", bytes: "%%%" } })).resolves.toBe(false); + expect(getAgentIconCacheEntry(url)).toBeUndefined(); + }); +}); + describe("apngStill", () => { it("drops the animation chunks and keeps frame 0 byte for byte", () => { expect(pngChunkTypes(APNG)).toEqual(["IHDR", "acTL", "fcTL", "IDAT", "fcTL", "fdAT", "IEND"]); diff --git a/command-center-ai/src/engine/agent-icon-cache.ts b/command-center-ai/src/engine/agent-icon-cache.ts index fa5c9549..90125961 100644 --- a/command-center-ai/src/engine/agent-icon-cache.ts +++ b/command-center-ai/src/engine/agent-icon-cache.ts @@ -1,5 +1,8 @@ -import { fetchCommandCenterAgentIconBytes } from "../backend/command-center-agent-icons-api.js"; +import { + fetchCommandCenterAgentIconBytes, + type CommandCenterAgentIconImage, +} from "../backend/command-center-agent-icons-api.js"; import type { ChatBackendConnection } from "../backend/connection.js"; import { apngStill } from "./apng-still.js"; @@ -15,6 +18,9 @@ import { apngStill } from "./apng-still.js"; * * An animated PNG also gets a still: a static PNG of its frame 0, made once from the same bytes, * for the places that should not move (ADR 090, amended 2026-10-16). + * + * Bytes an agent read carried inline seed the entry of their delivery URL through + * `primeAgentIcon`, without a request (ADR 090, section 8). */ export const AGENT_ICON_REVALIDATE_MS = 5 * 60_000; export const AGENT_ICON_RETRY_MS = 60_000; @@ -70,13 +76,24 @@ function revokeObjectUrl(objectUrl: string) { } } +function createStillObjectUrlFromBytes(bytes: Uint8Array, type: string) { + if (type && type !== "image/png") { + return null; + } + try { + const still = apngStill(bytes); + return still ? createObjectUrl(new Blob([still], { type: "image/png" })) : null; + } catch { + return null; + } +} + async function createStillObjectUrl(blob: Blob) { if (blob.type && blob.type !== "image/png") { return null; } try { - const still = apngStill(new Uint8Array(await blob.arrayBuffer())); - return still ? createObjectUrl(new Blob([still], { type: "image/png" })) : null; + return createStillObjectUrlFromBytes(new Uint8Array(await blob.arrayBuffer()), blob.type); } catch { return null; } @@ -91,20 +108,26 @@ function revokeEntryUrls(entry: AgentIconCacheEntry) { } } -async function fingerprintBlob(blob: Blob, etag: string | null) { - if (etag) { - return etag; - } +async function sha256Hex(bytes: BufferSource) { try { const subtle = globalThis.crypto?.subtle; if (subtle) { - const digest = await subtle.digest("SHA-256", await blob.arrayBuffer()); + const digest = await subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } } catch { - // Fall through to the weak fingerprint. + // The caller falls back to the weak fingerprint. } - return `${blob.size}:${blob.type}`; + return null; +} + +// The platform's ETag is the quoted SHA-256 of the bytes, so without its quotes it equals the +// fingerprint of the same bytes seeded inline. +async function fingerprintBlob(blob: Blob, etag: string | null) { + if (etag) { + return etag.replace(/^W\//, "").replace(/"/g, ""); + } + return (await sha256Hex(await blob.arrayBuffer())) ?? `${blob.size}:${blob.type}`; } export function ensureAgentIcon({ @@ -176,6 +199,80 @@ export function ensureAgentIcon({ inFlight.set(url, request); } +function decodeBase64(text: string) { + return Uint8Array.from(atob(text), (character) => character.charCodeAt(0)); +} + +/** + * Seeds the entry of an icon's delivery URL from the idle sprite an agent read carried inline + * (the platform's `include_icons`), so the icon draws without a request. A fresh ready entry is + * kept as it is; an older one is refreshed, and its object URL replaced only when the bytes + * differ. Resolves whether the entry is ready afterwards; when it is not (the bytes do not decode + * or no object URL can be made), the caller loads the URL as usual. + */ +export function primeAgentIcon({ + idle, + now = Date.now(), + url, +}: { + idle: CommandCenterAgentIconImage; + now?: number; + url: string; +}): Promise { + const isReady = () => entries.get(url)?.status === "ready"; + const running = inFlight.get(url); + if (running) { + return running.then(isReady); + } + const existing = entries.get(url); + if (existing?.status === "ready" && now - existing.checkedAt < AGENT_ICON_REVALIDATE_MS) { + return Promise.resolve(true); + } + let bytes: Uint8Array; + try { + bytes = decodeBase64(idle.bytes); + } catch { + return Promise.resolve(isReady()); + } + if (!existing) { + entries.set(url, { status: "loading", objectUrl: null, stillObjectUrl: null, fingerprint: null, checkedAt: now }); + emit(); + } + const blob = new Blob([bytes], { type: idle.mime }); + const seeding = sha256Hex(bytes) + .then((digest) => { + const fingerprint = digest ?? `${blob.size}:${blob.type}`; + const current = entries.get(url); + if (current?.objectUrl && current.fingerprint === fingerprint) { + entries.set(url, { ...current, status: "ready", checkedAt: Date.now() }); + return; + } + const objectUrl = createObjectUrl(blob); + if (!objectUrl) { + if (!current?.objectUrl) { + entries.delete(url); + } + return; + } + if (current) { + revokeEntryUrls(current); + } + entries.set(url, { + status: "ready", + objectUrl, + stillObjectUrl: createStillObjectUrlFromBytes(bytes, idle.mime), + fingerprint, + checkedAt: Date.now(), + }); + }) + .finally(() => { + inFlight.delete(url); + emit(); + }); + inFlight.set(url, seeding); + return seeding.then(isReady); +} + export function clearAgentIconCache() { entries.forEach(revokeEntryUrls); entries.clear(); diff --git a/command-center-ai/src/index.ts b/command-center-ai/src/index.ts index 1ba8affd..d07320aa 100644 --- a/command-center-ai/src/index.ts +++ b/command-center-ai/src/index.ts @@ -137,8 +137,10 @@ export { export { fetchCommandCenterAgentIconBytes, fetchCommandCenterAgentIcons, + normalizeCommandCenterAgentIcon, type CommandCenterAgentIcon, type CommandCenterAgentIconBytes, + type CommandCenterAgentIconImage, } from "./backend/command-center-agent-icons-api.js"; // Tool activity and errors @@ -226,7 +228,7 @@ export { type AgentSessionExplorerAgent, type AgentSessionGroup, } from "./engine/agent-session-groups.js"; -export { clearAgentIconCache } from "./engine/agent-icon-cache.js"; +export { clearAgentIconCache, primeAgentIcon } from "./engine/agent-icon-cache.js"; export { AgentIconAuthContext, AgentIconsContext, diff --git a/command-center-ai/src/ui/AgentIcon.client.test.tsx b/command-center-ai/src/ui/AgentIcon.client.test.tsx index 3daff996..1a4ed5cc 100644 --- a/command-center-ai/src/ui/AgentIcon.client.test.tsx +++ b/command-center-ai/src/ui/AgentIcon.client.test.tsx @@ -184,6 +184,51 @@ describe("AgentIcon", () => { expect(image.style.imageRendering).toBe(""); }); + describe("an icon the caller holds", () => { + const inline: CommandCenterAgentIcon = { + agentUid: "inline", + rendering: "color", + url: "https://api.test/agents/inline/icon/", + idle: { mime: "image/png", bytes: btoa(String.fromCharCode(...APNG)) }, + }; + + function renderHeld(icon: CommandCenterAgentIcon | null, withProvider = true) { + const element = ( + } /> + ); + act(() => { + root.render( + withProvider ? ( + + {element} + + ) : ( + element + ), + ); + }); + } + + it("draws its inline idle sprite without a request, also outside the engine", async () => { + renderHeld(inline, false); + await vi.waitFor(() => expect(container.querySelector('img[data-agent-icon="color"]')).not.toBeNull()); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("takes precedence over the projection, even as null", async () => { + renderHeld(null); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(container.querySelector("[data-fallback]")).not.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("loads its url when it carries no inline sprite", async () => { + renderHeld({ ...inline, idle: null }); + await vi.waitFor(() => expect(container.querySelector('img[data-agent-icon="color"]')).not.toBeNull()); + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([inline.url]); + }); + }); + describe("poses", () => { const requested = () => fetchMock.mock.calls.map(([input]) => String(input)); diff --git a/command-center-ai/src/ui/AgentIcon.tsx b/command-center-ai/src/ui/AgentIcon.tsx index 2816fc3c..120ebf8e 100644 --- a/command-center-ai/src/ui/AgentIcon.tsx +++ b/command-center-ai/src/ui/AgentIcon.tsx @@ -1,7 +1,8 @@ import { useEffect, type ReactNode } from "react"; import { useCommandCenterViewport } from "@dev-mainsequence/command-center-sdk/layout"; -import { ensureAgentIcon } from "../engine/agent-icon-cache.js"; +import { type CommandCenterAgentIcon } from "../backend/command-center-agent-icons-api.js"; +import { ensureAgentIcon, primeAgentIcon } from "../engine/agent-icon-cache.js"; import { resolveAgentIconPose, type AgentIconActivity } from "../engine/agent-icon-pose.js"; import { useAgentIconAuth, @@ -28,23 +29,44 @@ import { cx } from "./class-names.js"; * `activity` says what the agent is doing; with it the icon shows the matching pose sprite (the * application's tool rule, `working`, `thinking`, `error`) and falls back to the idle icon. A * place that animates also loads the agent's poses ahead of use. + * + * `icon` is an icon the caller already holds, such as the `command_center_icon` an agent read + * returned with `include_icons`, normalized by `normalizeCommandCenterAgentIcon`. When it is given, + * even as null, the provider's projection is not consulted, and its inline idle sprite is drawn + * without a request, also outside the engine (ADR 090, section 8). */ export function AgentIcon({ agentUid, + icon: givenIcon, className, fallback, motion = "still", activity = null, }: { agentUid?: string | null; + icon?: CommandCenterAgentIcon | null; className?: string; fallback: ReactNode; motion?: "still" | "animate"; activity?: AgentIconActivity | null; }) { const lookup = useAgentIconLookup(); - const icon = lookup(agentUid); + const icon = givenIcon === undefined ? lookup(agentUid) : givenIcon; const auth = useAgentIconAuth(); + // Declared before the idle image's own load, so inline bytes are seeded before any request. + const idleUrl = icon?.url ?? null; + const inlineMime = icon?.idle?.mime ?? null; + const inlineBytes = icon?.idle?.bytes ?? null; + useEffect(() => { + if (!idleUrl || !inlineMime || !inlineBytes) { + return; + } + void primeAgentIcon({ url: idleUrl, idle: { mime: inlineMime, bytes: inlineBytes } }).then((ready) => { + if (!ready && auth?.connection) { + ensureAgentIcon({ connection: auth.connection, url: idleUrl, token: auth.token, tokenType: auth.tokenType }); + } + }); + }, [auth, idleUrl, inlineBytes, inlineMime]); const pose = icon ? resolveAgentIconPose(icon, activity) : null; const poseUrl = pose ? icon?.poses?.[pose] ?? null : null; const idle = useAgentIconImage(icon?.url ?? null, auth); diff --git a/command-center-ai/src/ui/README.md b/command-center-ai/src/ui/README.md index bec79328..358f364b 100644 --- a/command-center-ai/src/ui/README.md +++ b/command-center-ai/src/ui/README.md @@ -37,7 +37,9 @@ The thread: the fallback. An animated icon shows its first frame unless `motion="animate"` is passed, and always under reduced motion; a pixel-art icon is drawn with sharp pixels. With `activity` it shows the agent's pose for what it is doing: its application tool's pose, `working`, `thinking` or - `error`. + `error`. With `icon`, an icon the application already holds (an agent read's + `command_center_icon`, normalized by `normalizeCommandCenterAgentIcon`), it skips the projection + and draws the inline idle sprite without a request, also outside the engine. The frame Command Center draws around the thread, so every application looks like Command Center ([the rail guide](../../docs/rail-and-expanded-rail.md)): diff --git a/docs/packages/compatibility.md b/docs/packages/compatibility.md index 324ebff2..0d761845 100644 --- a/docs/packages/compatibility.md +++ b/docs/packages/compatibility.md @@ -8,7 +8,7 @@ | Contract manifest | `command-center-contract-manifest@v1` | Breaking wire changes require a new contract ID and schema. | | Static-site iframe | `mainsequence.*`, version `1` | Hosts and children reject malformed or unsupported messages. | | Themes | SDK semantic version | Published theme IDs and token names are compatibility-sensitive. | -| Chat | `0.0.16` | Public entrypoints follow semantic versioning. | +| Chat | `0.0.17` | Public entrypoints follow semantic versioning. | | Chat's SDK peer range | A caret range on the first SDK release that publishes `--warning-tint` (`^0.5.3` in the manifest) | A new SDK minor widens the range and releases the chat in the same change. | | Chat's platform routes | The routes listed in the chat's documentation | Each chat release names the routes it calls and the platform version it expects. | diff --git a/package-lock.json b/package-lock.json index 5a36a080..dd9c4698 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "command-center-ai": { "name": "@dev-mainsequence/command-center-ai", - "version": "0.0.16", + "version": "0.0.17", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": {