Skip to content
Merged
17 changes: 17 additions & 0 deletions command-center-ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions command-center-ai/docs/conversation-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion command-center-ai/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
5 changes: 3 additions & 2 deletions command-center-ai/src/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
74 changes: 56 additions & 18 deletions command-center-ai/src/backend/command-center-agent-icons-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +37,11 @@ export interface CommandCenterAgentIcon {
poses?: Readonly<Record<string, string>>;
/** The pose a declared application's tools show while they run, keyed by application. */
toolPoses?: Readonly<Record<string, string>>;
/**
* 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<string, CommandCenterAgentIcon>;
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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<string, CommandCenterAgentIcon> {
const icons = new Map<string, CommandCenterAgentIcon>();
if (!Array.isArray(payload)) {
Expand All @@ -69,25 +120,12 @@ export function normalizeCommandCenterAgentIcons(payload: unknown): Map<string,
}
const record = entry as Record<string, unknown>;
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<string, unknown>;
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;
}
Expand Down
3 changes: 2 additions & 1 deletion command-center-ai/src/engine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 80 additions & 0 deletions command-center-ai/src/engine/agent-icon-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -161,6 +162,85 @@ describe("agent icon cache", () => {
});
});

describe("agent icon cache seeded inline", () => {
const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>();
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<ArrayBuffer>) =>
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"]);
Expand Down
Loading
Loading