Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

## Unreleased

Compatibility axes: additive npm API on `AgentIcon` (`activity`), on `CommandCenterAgentIcon`
(optional `poses` and `toolPoses`), on `ChatRunStatusValue` (optional `currentTool`), and new
exports `resolveAgentIconPose`, `AGENT_ICON_RUN_STATUS_POSES` and the `AgentIconActivity` and
`AgentIconToolSignal` types; the read of the platform's additive `commandCenterIcon.poses` and
`toolPoses` and of its pose route; and the content of the packaged skill
`design-agent-conversation-capabilities`. No stylesheet class, browser storage key, skill name or
path, installer behavior, or SDK peer range changes. An agent without poses renders as before.

- **The working agent shows what it is doing.** When the platform declares poses for an agent
(the platform's ADR-033, amended 2026-10-16), the thread avatar of the agent that is working and
the rail header show the pose for the application whose tool runs, otherwise its `working`,
`thinking` or `error` pose, and the idle icon when a pose is missing. The thread reads the
latest tool call from the message; the rail reads the engine's new `currentTool`. Poses load
ahead where icons animate, and reduced motion shows the chosen pose still.

## 0.0.14

Compatibility axes: additive npm API on `ChatRailProps` (`resizable`, `width`, `defaultWidth`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ answers so the text and the tool calls tell the story on their own.
theme-following mask or a colour image, with a robot as the fallback. A colour icon may be an
animated pixel-art sprite: it moves only in the rail header, the connecting stage and while its
Agent works, shows its first frame everywhere else and under reduced motion, and keeps sharp
pixels when the platform marks it pixelated. During a live turn the avatar
pixels when the platform marks it pixelated. While the Agent works, those places show the pose
the Agent declares for the application whose tool runs, or its `working`, `thinking` or `error`
pose. During a live turn the avatar
is the robot until the next reload; that is reported to the package's owner.

## The Session Around The Answers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- Amended: 2026-09-16 for the unified Agent shortcut
- Amended: 2026-10-16 for animated and pixel-art icons (section 6), following the platform's
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
- 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 @@ -127,6 +129,28 @@ frame 0 is its still, and may mark a colour icon `imageRendering: "pixelated"`.
- **Pixel art stays sharp.** A colour icon marked `pixelated` is drawn with CSS
`image-rendering: pixelated`. Mask icons are always smooth.

### 7. Poses that follow the run and its tools

Amended 2026-10-16. The projection may carry, per agent, `poses` (pose name to its delivery URL)
and `toolPoses` (a declared application to a pose); the platform delivers a pose at
`/api/v1/command-center/agents/<agent_uid>/icon/<pose>/`. Three names also follow the run:
`thinking`, `working` and `error`.

- **Where.** Only the places that animate follow the run: the thread avatar of the agent that is
working and the rail header. `AgentIcon` takes `activity` (`thinking`, the latest tool and its
phase, a failed run); every other place keeps the idle icon's frame 0.
- **Which pose** (`resolveAgentIconPose`, `src/engine/agent-icon-pose.ts`): while a tool runs, the
rule of its application (`<application>__call_tool` or `__list_tools`), otherwise `working`;
after a failed tool or run, `error`; while the agent works, `thinking`; otherwise, or when the
pose is not declared, the idle icon.
- **Signals.** The thread reads the latest tool call of the active message from its parts (as the
working preview already does). The rail header reads `useChatRunStatus().currentTool`, which
the engine follows from the stream's `tool-call-start` and `tool-result` chunks; it is null
outside a run and optional on the public type.
- **Loading.** A place that animates loads the agent's poses ahead of use through the same byte
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.

## Non-goals

- Uploading or editing icons from the Command Center; the repository workflow owns that.
Expand All @@ -147,6 +171,10 @@ frame 0 is its still, and may mark a colour icon `imageRendering: "pixelated"`.
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.
- `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
application tool shows its pose.
- `ChatEngineProvider` mounts the provider and exposes `activeAgentUid`; the package's
`ChatThread.tsx` (`ActorAvatar`, the connecting stage prop) and `AgentConnectingState.tsx` use
the component, and so do the application's own agent surfaces in section 4.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,29 @@ describe("command center agent icons api", () => {
rendering: "mask",
imageRendering: "auto",
url: "https://api.test/icon/a1/",
poses: {},
toolPoses: {},
});
expect(icons.get("a5")?.rendering).toBe("color");
expect(normalizeCommandCenterAgentIcons({ not: "an array" }).size).toBe(0);
});

it("reads pose urls and keeps only tool rules whose pose exists", () => {
const icon = normalizeCommandCenterAgentIcons([
{
agentUid: "a1",
commandCenterIcon: {
rendering: "color",
url: "https://api.test/icon/",
poses: { reading: "https://api.test/icon/reading/", broken: 7 },
toolPoses: { tutorial_api: "reading", orders: "missing" },
},
},
]).get("a1");
expect(icon?.poses).toEqual({ reading: "https://api.test/icon/reading/" });
expect(icon?.toolPoses).toEqual({ tutorial_api: "reading" });
});

it("keeps pixelated scaling only for colour icons", () => {
const icons = normalizeCommandCenterAgentIcons([
{ agentUid: "pixel", commandCenterIcon: { rendering: "color", imageRendering: "pixelated", url: "https://api.test/p/" } },
Expand Down
23 changes: 22 additions & 1 deletion command-center-ai/src/backend/command-center-agent-icons-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface CommandCenterAgentIcon {
/** `pixelated` only for color icons; absent or `auto` scales smoothly. */
imageRendering?: CommandCenterAgentIconImageRendering;
url: string;
/** Delivery URL of each declared pose sprite, keyed by pose name. */
poses?: Readonly<Record<string, string>>;
/** The pose a declared application's tools show while they run, keyed by application. */
toolPoses?: Readonly<Record<string, string>>;
}

export type CommandCenterAgentIconMap = ReadonlyMap<string, CommandCenterAgentIcon>;
Expand All @@ -41,6 +45,19 @@ function readString(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}

function readStringMap(value: unknown) {
const entries: Record<string, string> = {};
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const text = readString(entry);
if (key && text) {
entries[key] = text;
}
}
}
return entries;
}

export function normalizeCommandCenterAgentIcons(payload: unknown): Map<string, CommandCenterAgentIcon> {
const icons = new Map<string, CommandCenterAgentIcon>();
if (!Array.isArray(payload)) {
Expand All @@ -66,7 +83,11 @@ export function normalizeCommandCenterAgentIcons(payload: unknown): Map<string,
rendering === "color" && readString(iconRecord.imageRendering ?? iconRecord.image_rendering) === "pixelated"
? "pixelated"
: "auto";
icons.set(agentUid, { agentUid, rendering, imageRendering, url });
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
28 changes: 27 additions & 1 deletion command-center-ai/src/engine/ChatEngineProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { cancelChatSession } from "../backend/session-cancel-api.js";
import { fetchSessionHistory } from "../backend/session-history-api.js";
import {
describeStreamedToolCallStatus,
describeToolActivity,
followStreamedToolCall,
type StreamedToolCall,
} from "../backend/tool-activity.js";
Expand All @@ -73,6 +74,7 @@ import {
type AgentSessionDetailSnapshot,
} from "../session-detail/model.js";
import { useAgentSessionDetail } from "../session-detail/useAgentSessionDetail.js";
import type { AgentIconToolSignal } from "./agent-icon-pose.js";
import { AgentIconsProvider } from "./agent-icons-context.js";
import { ChatEngineContext, ChatRunStatusContext } from "./engine-context.js";
import { LocalChatEngineProvider, type LocalChatEngineProviderProps } from "./LocalChatEngineProvider.js";
Expand Down Expand Up @@ -280,6 +282,11 @@ interface SessionRuntimeAccessUiMeta {
// useChatRunStatus().
export interface ChatRunStatusValue {
hasVisibleAssistantOutput: boolean;
/**
* The tool the running turn uses, from the stream: the declared application whose tool runs,
* if any, and its phase. Null outside a run; absent from engines that do not track it.
*/
currentTool?: AgentIconToolSignal | null;
runStatus: ChatRunStatus;
runStatusDetail: string | null;
thinkingSummary: string | null;
Expand Down Expand Up @@ -729,6 +736,7 @@ function PlatformChatEngineProvider({
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [runStatus, setRunStatus] = useState<ChatRunStatus>("idle");
const [runStatusDetail, setRunStatusDetail] = useState<string | null>(null);
const [currentTool, setCurrentTool] = useState<AgentIconToolSignal | null>(null);
const [thinkingSummary, setThinkingSummary] = useState<string | null>(null);
const [availableModels, setAvailableModels] = useState<AvailableChatModelOption[]>([]);
const [availableModelsError, setAvailableModelsError] = useState<string | null>(null);
Expand Down Expand Up @@ -3646,6 +3654,7 @@ function PlatformChatEngineProvider({
onRequestStart: async () => {
const queuedSend = queueSendingRef.current;
setRunStatus("queued");
setCurrentTool(null);
setRunStatusDetail(
queuedSend
? `Sending queued message ${queuedSend.position} of ${queuedSend.total}`
Expand Down Expand Up @@ -3754,13 +3763,26 @@ function PlatformChatEngineProvider({
return;
}
const status = describeStreamedToolCallStatus(call);
const application = describeToolActivity(call.toolName, undefined, null).application?.name ?? null;
setCurrentTool((previous) =>
previous?.phase === "running" && previous.application === application
? previous
: { application, phase: "running" },
);
setRunStatus("thinking");
setRunStatusDetail(status);
setThinkingSummary(status);
setHasVisibleAssistantOutput(true);
return;
}
if (type === "tool-result") {
const details =
data.result && typeof data.result === "object" && "details" in data.result
? (data.result as { details?: { is_error?: unknown; failure?: unknown } }).details
: null;
const failed =
data.isError === true || details?.is_error === true || details?.failure === "timeout";
setCurrentTool((previous) => (previous ? { ...previous, phase: failed ? "failed" : "done" } : previous));
setRunStatus("thinking");
setRunStatusDetail("Thinking...");
setThinkingSummary(null);
Expand Down Expand Up @@ -4444,12 +4466,16 @@ function PlatformChatEngineProvider({
);
const runStatusValue = useMemo<ChatRunStatusValue>(
() => ({
currentTool:
runStatus === "queued" || runStatus === "thinking" || runStatus === "responding"
? currentTool
: null,
hasVisibleAssistantOutput,
runStatus,
runStatusDetail,
thinkingSummary,
}),
[hasVisibleAssistantOutput, runStatus, runStatusDetail, thinkingSummary],
[currentTool, hasVisibleAssistantOutput, runStatus, runStatusDetail, thinkingSummary],
);

return (
Expand Down
61 changes: 61 additions & 0 deletions command-center-ai/src/engine/agent-icon-pose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";

import type { CommandCenterAgentIcon } from "../backend/command-center-agent-icons-api.js";
import { decodeToolSignal, encodeToolSignal, resolveAgentIconPose } from "./agent-icon-pose.js";

const icon: CommandCenterAgentIcon = {
agentUid: "a1",
rendering: "color",
imageRendering: "pixelated",
url: "https://api.test/icon/",
poses: {
thinking: "https://api.test/icon/thinking/",
working: "https://api.test/icon/working/",
error: "https://api.test/icon/error/",
reading: "https://api.test/icon/reading/",
},
toolPoses: { tutorial_api: "reading" },
};

describe("resolveAgentIconPose", () => {
it("shows the application's pose while its tool runs, and working for other tools", () => {
expect(resolveAgentIconPose(icon, { thinking: true, tool: { application: "tutorial_api", phase: "running" } })).toBe(
"reading",
);
expect(resolveAgentIconPose(icon, { thinking: true, tool: { application: "orders", phase: "running" } })).toBe(
"working",
);
expect(resolveAgentIconPose(icon, { thinking: true, tool: { application: null, phase: "running" } })).toBe(
"working",
);
});

it("follows the run: error after a failed tool or run, thinking otherwise, idle when not working", () => {
expect(resolveAgentIconPose(icon, { thinking: true, tool: { application: "tutorial_api", phase: "failed" } })).toBe(
"error",
);
expect(resolveAgentIconPose(icon, { thinking: false, tool: null, failed: true })).toBe("error");
expect(resolveAgentIconPose(icon, { thinking: true, tool: { application: "tutorial_api", phase: "done" } })).toBe(
"thinking",
);
expect(resolveAgentIconPose(icon, null)).toBeNull();
expect(resolveAgentIconPose(icon, { thinking: false, tool: null })).toBeNull();
});

it("falls back to idle when a pose is not declared", () => {
const bare: CommandCenterAgentIcon = { agentUid: "a2", rendering: "color", url: "https://api.test/icon/" };
expect(resolveAgentIconPose(bare, { thinking: true, tool: { application: "tutorial_api", phase: "running" } })).toBeNull();
expect(resolveAgentIconPose(bare, { thinking: true, tool: null })).toBeNull();
});

it("round-trips tool signals through their string form", () => {
const signal = { application: "tutorial_api", phase: "running" } as const;
expect(decodeToolSignal(encodeToolSignal(signal))).toEqual(signal);
expect(decodeToolSignal(encodeToolSignal({ application: null, phase: "failed" }))).toEqual({
application: null,
phase: "failed",
});
expect(decodeToolSignal("")).toBeNull();
expect(decodeToolSignal("waiting:x")).toBeNull();
});
});
75 changes: 75 additions & 0 deletions command-center-ai/src/engine/agent-icon-pose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { CommandCenterAgentIcon } from "../backend/command-center-agent-icons-api.js";

/**
* Which pose an agent's icon shows while it works (ADR 090, amended 2026-10-16).
*
* The platform delivers, per agent, pose sprites by name and rules mapping a declared
* application to a pose. Three pose names also follow the run status.
*/
export const AGENT_ICON_RUN_STATUS_POSES = {
thinking: "thinking",
working: "working",
error: "error",
} as const;

/** The tool an agent is using, as the thread or the stream sees it. */
export interface AgentIconToolSignal {
/** The declared application whose tool runs (`<application>__call_tool`), or null. */
application: string | null;
phase: "running" | "done" | "failed";
}

/** What the agent is doing now; absent when the place does not follow the run. */
export interface AgentIconActivity {
/** The agent is working on a turn. */
thinking: boolean;
/** The latest tool call of that turn, or null. */
tool: AgentIconToolSignal | null;
/** The run failed. */
failed?: boolean;
}

function declared(icon: CommandCenterAgentIcon, pose: string | null | undefined) {
return pose && icon.poses?.[pose] ? pose : null;
}

/**
* The pose to draw, or null for the idle icon: the application's rule while its tool runs,
* otherwise `working` while any tool runs, `error` after a failed tool or run, `thinking` while
* the agent works, and idle when the pose is not declared.
*/
export function resolveAgentIconPose(
icon: CommandCenterAgentIcon,
activity: AgentIconActivity | null | undefined,
): string | null {
if (!activity) {
return null;
}
const { tool } = activity;
if (tool?.phase === "running") {
const rule = tool.application ? icon.toolPoses?.[tool.application] : null;
return declared(icon, rule) ?? declared(icon, AGENT_ICON_RUN_STATUS_POSES.working);
}
if (tool?.phase === "failed" || activity.failed) {
return declared(icon, AGENT_ICON_RUN_STATUS_POSES.error);
}
return activity.thinking ? declared(icon, AGENT_ICON_RUN_STATUS_POSES.thinking) : null;
}

/** A tool signal as a string, so selectors can return a stable value. */
export function encodeToolSignal(signal: AgentIconToolSignal | null): string {
return signal ? `${signal.phase}:${signal.application ?? ""}` : "";
}

export function decodeToolSignal(value: string): AgentIconToolSignal | null {
if (!value) {
return null;
}
const separator = value.indexOf(":");
const phase = value.slice(0, separator);
const application = value.slice(separator + 1);
if (phase !== "running" && phase !== "done" && phase !== "failed") {
return null;
}
return { application: application || null, phase };
}
6 changes: 6 additions & 0 deletions command-center-ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ export {
type AgentConnectionStep,
} from "./ui/AgentConnectingState.js";
export { AgentIcon } from "./ui/AgentIcon.js";
export {
AGENT_ICON_RUN_STATUS_POSES,
resolveAgentIconPose,
type AgentIconActivity,
type AgentIconToolSignal,
} from "./engine/agent-icon-pose.js";

// Model provider settings (ADR 096, step 5)
export {
Expand Down
Loading
Loading