Skip to content
4 changes: 4 additions & 0 deletions src/agent/composition/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ async function runAgentAsStreamingTool(
await context.publishDataEvent(buildInvokeAgentStreamDataEvent({
toolCallId: context.toolCallId,
agentId: agent.id,
...(agent.config.name ? { agentName: agent.config.name } : {}),
...(agent.config.avatarUrl ?? agent.config.avatar_url
? { avatarUrl: agent.config.avatarUrl ?? agent.config.avatar_url }
: {}),
event,
}));
}
Expand Down
32 changes: 32 additions & 0 deletions src/chat/invoke-agent-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,38 @@ export const INVOKE_AGENT_STREAM_EVENT_NAME = "veryfront.invoke_agent.stream";
export interface InvokeAgentStreamValue {
toolCallId: string;
agentId: string;
/** Child agent display name, for the card header (falls back to the id). */
agentName?: string;
/** Child agent avatar URL, so the card header matches the main chat header. */
avatarUrl?: string;
event: Record<string, unknown> & { type: string };
}

/** Durable, compact representation of all visible events from one child run. */
export interface InvokeAgentStreamSnapshot {
toolCallId: string;
agentId: string;
agentName?: string;
avatarUrl?: string;
events: Array<Record<string, unknown> & { type: string }>;
}

/** The child agent's identity carried alongside its event stream. */
export interface InvokeAgentStreamIdentity {
agentName?: string;
avatarUrl?: string;
}

/** Read the child agent's display identity from a live value or a snapshot. */
export function getInvokeAgentStreamIdentity(value: unknown): InvokeAgentStreamIdentity {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const record = value as Record<string, unknown>;
return {
agentName: typeof record.agentName === "string" ? record.agentName : undefined,
avatarUrl: typeof record.avatarUrl === "string" ? record.avatarUrl : undefined,
};
}

/** Build the generic tool data event consumed by the chat renderer. */
export function buildInvokeAgentStreamDataEvent(value: InvokeAgentStreamValue): {
type: typeof INVOKE_AGENT_STREAM_EVENT_NAME;
Expand Down Expand Up @@ -47,6 +69,8 @@ export function parseInvokeAgentStreamValue(value: unknown): InvokeAgentStreamVa
return {
toolCallId: record.toolCallId,
agentId: record.agentId,
...(typeof record.agentName === "string" ? { agentName: record.agentName } : {}),
...(typeof record.avatarUrl === "string" ? { avatarUrl: record.avatarUrl } : {}),
event: event as InvokeAgentStreamValue["event"],
};
}
Expand Down Expand Up @@ -108,9 +132,17 @@ export function appendInvokeAgentStreamSnapshot(
) {
return null;
}
const identity: InvokeAgentStreamIdentity = {
agentName: nextValue.agentName ??
(typeof existingRecord.agentName === "string" ? existingRecord.agentName : undefined),
avatarUrl: nextValue.avatarUrl ??
(typeof existingRecord.avatarUrl === "string" ? existingRecord.avatarUrl : undefined),
};
return {
toolCallId: nextValue.toolCallId,
agentId: nextValue.agentId,
...(identity.agentName ? { agentName: identity.agentName } : {}),
...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}),
events: mergeConsecutiveDeltas([...existingEvents, nextValue.event]),
};
}
4 changes: 3 additions & 1 deletion src/react/components/chat/chat/components/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ export function ConversationScrollButton({
onClick={onClick}
aria-label="Scroll to bottom"
className={cn(
"absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full border border-[var(--outline-border)] bg-[var(--secondary)] p-2 shadow-sm transition-colors hover:bg-[var(--tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--edge-medium)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
// A clean floating circle lifted off the transcript (bg-background +
// soft edge + md shadow), nudged clear of the composer boundary.
"absolute bottom-6 left-1/2 flex size-8 -translate-x-1/2 items-center justify-center rounded-full border border-[var(--edge-medium)] bg-[var(--background)] text-[var(--foreground)] shadow-md transition-colors hover:bg-[var(--tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--edge-medium)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]",
className,
)}
>
Expand Down
2 changes: 1 addition & 1 deletion src/react/components/chat/chat/components/reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function ReasoningRoot(

return (
<ReasoningContext.Provider value={context}>
<div ref={ref} className={cn("not-prose mb-3", className)}>
<div ref={ref} className={cn("not-prose", className)}>
{children ?? (
<>
<ReasoningTrigger icon={icon} labels={labels} />
Expand Down
28 changes: 22 additions & 6 deletions src/react/components/chat/chat/components/skill-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const skillToolRow = cva(
"flex min-w-0 items-center gap-2 truncate text-sm text-[var(--foreground)]",
);

/**
* Synthetic tool-call state a parent card assigns to a still-running child tool
* once the turn is interrupted, so the row freezes (no shimmer) instead of
* claiming to be running forever. Not a wire state — presentation only.
*/
export const CHILD_TOOL_STOPPED_STATE: string = "stopped";

/** Props accepted by {@link SkillTool}. */
export interface SkillToolProps {
/** The skill being loaded (id or filename). Renders in the row label. */
Expand All @@ -32,9 +39,11 @@ export interface SkillToolProps {
* Tool-call state.
* - `loading` — Sparkles pulses, label shimmers.
* - `loaded` — Check appears, label is solid.
* - `stopped` — the run was interrupted mid-load: static Sparkles, solid
* label, no animation (so a stopped turn never keeps shimmering).
* @default "loaded"
*/
state?: "loading" | "loaded";
state?: "loading" | "loaded" | "stopped";
className?: string;
}

Expand All @@ -45,13 +54,16 @@ export function SkillTool({
className,
}: SkillToolProps): React.JSX.Element {
const isLoading = state === "loading";
const label = isLoading ? `Loading skill: ${skill}` : `Loaded skill: ${skill}`;
const isLoaded = state === "loaded";
const label = isLoaded ? `Loaded skill: ${skill}` : `Loading skill: ${skill}`;

return (
<p className={cn(skillToolRow(), className)}>
{isLoading
? <SparklesIcon className="size-3.5! shrink-0 animate-pulse" />
: <CheckIcon className="size-3.5! shrink-0" />}
{isLoaded ? <CheckIcon className="size-3.5! shrink-0" /> : (
<SparklesIcon
className={cn("size-3.5! shrink-0", isLoading && "animate-pulse")}
/>
)}
{isLoading
? (
<Shimmer as="span" duration={1} className="min-w-0 truncate">
Expand All @@ -76,6 +88,10 @@ export function getSkillToolProps(
"unknown") as string;
return {
skill,
state: tool.state === "output-available" ? "loaded" : "loading",
state: tool.state === "output-available"
? "loaded"
: (tool.state as string) === CHILD_TOOL_STOPPED_STATE
? "stopped"
: "loading",
};
}
68 changes: 57 additions & 11 deletions src/react/components/chat/chat/components/tool-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ describe("ToolCall", () => {
flushSync(() =>
root.render(
<Message.Root
isStreaming
message={{
id: "assistant-message",
role: "assistant",
Expand All @@ -123,21 +124,23 @@ describe("ToolCall", () => {
)
);
};
const detailsFor = (label: string) =>
Array.from(host.querySelectorAll("details")).find((details) =>
details.querySelector("summary")?.textContent === label
);
// The child sections reuse the main-chat `Reasoning` disclosure (a button +
// conditionally-rendered content), so "open" is detected by whether the
// section's body text is in the DOM rather than a `<details open>` flag.
const shows = (text: string) => host.textContent?.includes(text) ?? false;

try {
renderWithEvents([]);
assertEquals(detailsFor("Instructions")?.open, true);
// Instructions phase: its body is visible.
assertEquals(shows("Fetch the newest cases."), true);

renderWithEvents([
{ type: "reasoning-start", id: "child-reasoning" },
{ type: "reasoning-delta", id: "child-reasoning", delta: "I should query first." },
]);
assertEquals(detailsFor("Instructions")?.open, false);
assertEquals(detailsFor("Thought process")?.open, true);
// Reasoning began: instructions collapse, the thought process is visible.
assertEquals(shows("Fetch the newest cases."), false);
assertEquals(shows("I should query first."), true);

renderWithEvents([
{ type: "reasoning-start", id: "child-reasoning" },
Expand All @@ -150,14 +153,56 @@ describe("ToolCall", () => {
input: { status: "open" },
},
]);
assertEquals(detailsFor("Instructions")?.open, false);
assertEquals(detailsFor("Thought process")?.open, false);
// Work phase: instructions stay collapsed and the child tool renders.
assertEquals(shows("Fetch the newest cases."), false);
assertEquals(shows("salesforce__list_cases"), true);
} finally {
await unmountReactRoot(root);
restore();
}
});

it("freezes a running child as Stopped once the turn stops streaming", () => {
const skillStreamPart = {
type: "data-veryfront.invoke_agent.stream" as const,
data: {
toolCallId: invokeAgentTool.toolCallId,
agentId: "case-ingest",
event: {
type: "tool-input-available",
toolCallId: "child-load-skill",
toolName: "load_skill",
input: { skillId: "case-normalise-redact" },
},
},
};
const message = {
id: "assistant-message",
role: "assistant" as const,
metadata: {},
parts: [invokeAgentTool, skillStreamPart],
};

// While the turn streams, the running child reads as Running.
const streamingHtml = renderToString(
<Message.Root message={message} isStreaming>
<ToolCall tool={invokeAgentTool} defaultExpanded />
</Message.Root>,
);
assertStringIncludes(streamingHtml, "Running");

// Once the turn stops streaming with no terminal output, the card and its
// still-running child freeze to Stopped instead of a forever-Running card.
const stoppedHtml = renderToString(
<Message.Root message={message} isStreaming={false}>
<ToolCall tool={invokeAgentTool} defaultExpanded />
</Message.Root>,
);
assertStringIncludes(stoppedHtml, "Stopped");
assertEquals(stoppedHtml.includes("Running"), false);
assertStringIncludes(stoppedHtml, "Loading skill: case-normalise-redact");
});

it("renders the child prompt in an Instructions disclosure", () => {
const tool: ChatDynamicToolPart = {
...invokeAgentTool,
Expand All @@ -172,7 +217,6 @@ describe("ToolCall", () => {

assertStringIncludes(html, "Instructions");
assertStringIncludes(html, "Fetch the five newest open cases and redact PII.");
assertStringIncludes(html, "text-[var(--soft)]");
assertEquals(html.includes("text-black"), false);
assertEquals(html.includes("text-white"), false);
});
Expand Down Expand Up @@ -230,7 +274,9 @@ describe("ToolCall", () => {
</Message.Root>,
);

assertStringIncludes(html, "Thought process");
// Reasoning is still streaming (no reasoning-end), so the shared Reasoning
// disclosure shows its "Thinking..." label and stays open.
assertStringIncludes(html, "Thinking...");
assertStringIncludes(html, "I should query Salesforce first.");
assertStringIncludes(html, "Fetching the newest cases now.");
assertStringIncludes(html, "salesforce__list_cases");
Expand Down
Loading
Loading