Skip to content
18 changes: 9 additions & 9 deletions docs/api-reference/veryfront/agent.md

Large diffs are not rendered by default.

32 changes: 16 additions & 16 deletions docs/api-reference/veryfront/chat.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions docs/api-reference/veryfront/ui.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions src/agent/composition/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Agent, AgentResponse, AgentStreamResult } from "../types.ts";
// Side-effect import: registers the globalThis bridges
import { agentAsTool, agentRegistry, registerAgent } from "./composition.ts";
import { createInvokeAgentTool } from "../runtime/agent-delegation.ts";
import { parseInvokeAgentStreamValue } from "#veryfront/chat/invoke-agent-stream.ts";

const BRIDGE_KEYS = ["__vfGetAgent", "__vfRegisterAgent", "__vfGetAllAgentIds"] as const;

Expand Down Expand Up @@ -280,6 +281,86 @@ describe("agentAsTool", () => {
]);
});

it("attaches the child agent's name and avatar to every published event", async () => {
const childResponse: AgentResponse = {
text: "streamed child result",
messages: [],
toolCalls: [],
status: "completed",
};
const childAgent = createMinimalAgent("case-ingest");
childAgent.config.name = "Intake Bot";
childAgent.config.avatarUrl = "https://cdn.example.com/agents/case-ingest.png";
childAgent.stream = (input) => {
input.onFinish?.(childResponse);
return Promise.resolve({
toDataStreamResponse() {
return new Response(
[
'data: {"type":"message-start","messageId":"child-message"}',
'data: {"type":"text-delta","id":"child-text","delta":"Fetching cases"}',
"",
].join("\n\n"),
{ headers: { "Content-Type": "text/event-stream" } },
);
},
});
};
const published: unknown[] = [];
const tool = createInvokeAgentTool({ resolveAgent: () => childAgent });

await tool.execute(
{ agent_id: "case-ingest", description: "Run case ingest", prompt: "Fetch", context: {} },
{
toolCallId: "parent-tool-call",
publishDataEvent: (event) => {
published.push(event.value);
},
},
);

// The card header must show the child's identity while it runs, so the
// identity rides along with the first event, not just the last.
assertEquals(published.length, 2);
for (const value of published) {
const parsed = parseInvokeAgentStreamValue(value);
assertEquals(parsed?.agentName, "Intake Bot");
assertEquals(parsed?.avatarUrl, "https://cdn.example.com/agents/case-ingest.png");
}
});

it("falls back to the deprecated snake_case avatar field", async () => {
const childAgent = createMinimalAgent("case-ingest");
childAgent.config.avatar_url = "https://cdn.example.com/legacy.png";
childAgent.stream = (input) => {
input.onFinish?.({ text: "ok", messages: [], toolCalls: [], status: "completed" });
return Promise.resolve({
toDataStreamResponse() {
return new Response('data: {"type":"message-start","messageId":"child-message"}\n\n', {
headers: { "Content-Type": "text/event-stream" },
});
},
});
};
const published: unknown[] = [];
const tool = createInvokeAgentTool({ resolveAgent: () => childAgent });

await tool.execute(
{ agent_id: "case-ingest", description: "Run case ingest", prompt: "Fetch", context: {} },
{
toolCallId: "parent-tool-call",
publishDataEvent: (event) => {
published.push(event.value);
},
},
);

assertEquals(
parseInvokeAgentStreamValue(published.at(0))?.avatarUrl,
"https://cdn.example.com/legacy.png",
);
});

it("does not publish child events for fixed agent wrappers", async () => {
const childAgent = createMinimalAgent("case-ingest");
const events: unknown[] = [];
Expand Down
14 changes: 13 additions & 1 deletion src/agent/composition/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import { getRuntimeSourceIntegrationPolicyFromContext } from "../runtime/runtime
import { runWithExactSourceIntegrationPolicy } from "#veryfront/integrations/source-policy-context.ts";
import type { SourceIntegrationPolicyManifest } from "#veryfront/integrations/source-policy.ts";
import { streamDataStreamEvents } from "../streaming/data-stream.ts";
import { buildInvokeAgentStreamDataEvent } from "#veryfront/chat/invoke-agent-stream.ts";
import {
buildInvokeAgentStreamDataEvent,
type InvokeAgentStreamIdentity,
} from "#veryfront/chat/invoke-agent-stream.ts";

/** Agent as tool helper. */
async function runAgentAsStreamingTool(
Expand All @@ -29,6 +32,14 @@ async function runAgentAsStreamingTool(
context?: ToolExecutionContext,
publishChildStream = false,
): Promise<AgentResponse> {
// Resolved once: the identity rides along with every published event, so
// recomputing it per chunk would repeat the work on the stream's hot path.
const childIdentity: InvokeAgentStreamIdentity = {
...(agent.config.name ? { agentName: agent.config.name } : {}),
...(agent.config.avatarUrl ?? agent.config.avatar_url
? { avatarUrl: agent.config.avatarUrl ?? agent.config.avatar_url }
: {}),
};
const execute = async (): Promise<AgentResponse> => {
let finalResponse: AgentResponse | undefined;
const stream = await agent.stream({
Expand All @@ -46,6 +57,7 @@ async function runAgentAsStreamingTool(
await context.publishDataEvent(buildInvokeAgentStreamDataEvent({
toolCallId: context.toolCallId,
agentId: agent.id,
...childIdentity,
event,
}));
}
Expand Down
136 changes: 136 additions & 0 deletions src/chat/invoke-agent-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* The child-agent stream contract: the identity a card header renders from, and
* how live events fold into the one durable snapshot part.
*/
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import {
appendInvokeAgentStreamSnapshot,
buildInvokeAgentStreamDataEvent,
getInvokeAgentStreamEvents,
getInvokeAgentStreamIdentity,
INVOKE_AGENT_STREAM_EVENT_NAME,
parseInvokeAgentStreamValue,
} from "./invoke-agent-stream.ts";

const identity = {
agentName: "Intake Bot",
avatarUrl: "https://cdn.example.com/agents/case-ingest.png",
};

function liveValue(
event: Record<string, unknown> & { type: string },
overrides: Record<string, unknown> = {},
) {
return {
toolCallId: "tool-invoke-agent",
agentId: "case-ingest",
...identity,
...overrides,
event,
};
}

describe("invoke-agent-stream identity", () => {
it("reads the identity from a live value", () => {
const value = liveValue({ type: "reasoning-delta", delta: "Thinking." });
assertEquals(getInvokeAgentStreamIdentity(value), identity);
});

it("reads the identity from a compact snapshot", () => {
const snapshot = { ...identity, toolCallId: "t", agentId: "a", events: [] };
assertEquals(getInvokeAgentStreamIdentity(snapshot), identity);
});

it("returns an empty identity for non-records and for missing or non-string fields", () => {
const empty = { agentName: undefined, avatarUrl: undefined };
assertEquals(getInvokeAgentStreamIdentity(null), {});
assertEquals(getInvokeAgentStreamIdentity([1, 2]), {});
assertEquals(getInvokeAgentStreamIdentity("nope"), {});
assertEquals(getInvokeAgentStreamIdentity({ toolCallId: "t" }), empty);
assertEquals(getInvokeAgentStreamIdentity({ agentName: 7, avatarUrl: {} }), empty);
});

it("carries the identity through build and parse", () => {
const event = { type: "text-delta", delta: "Working." };
const built = buildInvokeAgentStreamDataEvent(liveValue(event));
assertEquals(built.type, INVOKE_AGENT_STREAM_EVENT_NAME);
assertEquals(built.name, INVOKE_AGENT_STREAM_EVENT_NAME);

const parsed = parseInvokeAgentStreamValue(built.value);
assertEquals(parsed?.agentName, identity.agentName);
assertEquals(parsed?.avatarUrl, identity.avatarUrl);
assertEquals(parsed?.event, event);
});

it("omits identity fields that are absent or the wrong type", () => {
const parsed = parseInvokeAgentStreamValue({
toolCallId: "t",
agentId: "a",
agentName: 42,
event: { type: "text-delta", delta: "hi" },
});
assertEquals(Object.hasOwn(parsed ?? {}, "agentName"), false);
assertEquals(Object.hasOwn(parsed ?? {}, "avatarUrl"), false);
});
});

describe("appendInvokeAgentStreamSnapshot", () => {
it("keeps the identity once the runtime stops repeating it", () => {
const first = liveValue({ type: "reasoning-delta", id: "r", delta: "A" });
const second = liveValue({ type: "text-delta", delta: "B" }, {
agentName: undefined,
avatarUrl: undefined,
});

const snapshot = appendInvokeAgentStreamSnapshot(
{ ...first, events: [first.event] },
second,
);

assertEquals(snapshot?.agentName, identity.agentName);
assertEquals(snapshot?.avatarUrl, identity.avatarUrl);
assertEquals(snapshot?.events.length, 2);
});

it("lets a later value update the identity", () => {
const first = liveValue({ type: "text-delta", delta: "A" }, { agentName: undefined });
const second = liveValue({ type: "text-delta", delta: "B" }, { agentName: "Renamed" });

const snapshot = appendInvokeAgentStreamSnapshot(
{ ...first, events: [first.event] },
second,
);

assertEquals(snapshot?.agentName, "Renamed");
});

it("merges consecutive deltas of the same stream", () => {
const first = liveValue({ type: "text-delta", delta: "Hello " });
const second = liveValue({ type: "text-delta", delta: "world" });

const snapshot = appendInvokeAgentStreamSnapshot(
{ ...first, events: [first.event] },
second,
);

assertEquals(snapshot?.events, [{ type: "text-delta", delta: "Hello world" }]);
});

it("refuses to fold an event from another tool call", () => {
const first = liveValue({ type: "text-delta", delta: "A" });
const other = liveValue({ type: "text-delta", delta: "B" }, { toolCallId: "other" });

assertEquals(
appendInvokeAgentStreamSnapshot({ ...first, events: [first.event] }, other),
null,
);
});

it("reads events from both a live value and a snapshot", () => {
const event = { type: "text-delta", delta: "A" };
assertEquals(getInvokeAgentStreamEvents(liveValue(event)), [event]);
assertEquals(getInvokeAgentStreamEvents({ events: [event, { nope: true }] }), [event]);
assertEquals(getInvokeAgentStreamEvents(null), []);
});
});
38 changes: 38 additions & 0 deletions src/chat/invoke-agent-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,44 @@ 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.
*
* `avatarUrl` (not the message-metadata spelling `agentAvatarUrl`) matches the
* `invoke_agent` tool output field the card already falls back to, so both
* sources of the child avatar read the same key.
*/
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 +75,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 +138,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]),
};
}
6 changes: 5 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,11 @@ 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. Sized
// by padding rather than a fixed `size-8`, so the default `size-4`
// glyph lands on 32px while a larger custom `icon` still fits.
"absolute bottom-6 left-1/2 flex -translate-x-1/2 items-center justify-center rounded-full border border-[var(--edge-medium)] bg-[var(--background)] p-2 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
6 changes: 5 additions & 1 deletion src/react/components/chat/chat/components/reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ export interface ReasoningProps {
/**
* `Reasoning.Root` — context provider + wrapper. No children renders the
* default anatomy (`Trigger` + `Content`); pass children to recompose.
*
* Spacing belongs to the parent. The wrapper carries no bottom margin (it used
* to add `mb-3`, which doubled up with the flex gap of every layout that hosts
* it). Pass `className` to space it inside a container that has no gap.
*/
function ReasoningRoot(
{
Expand Down Expand Up @@ -141,7 +145,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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ describe("SkillTool", () => {
assertStringIncludes(html, "animate-pulse");
});

it("renders a terminal label with no animation once stopped", () => {
const html = renderToString(<SkillTool skill="review" state="stopped" />);
assertStringIncludes(html, "Stopped loading skill: review");
// A frozen row must not keep shimmering, and must not claim it loaded.
assertEquals(html.includes("animate-pulse"), false);
assertEquals(html.includes("Loaded skill"), false);
});

it("merges className onto the row", () => {
const html = renderToString(<SkillTool skill="review" className="vf-custom-row" />);
assertStringIncludes(html, "vf-custom-row");
Expand Down
Loading