Revert "fix(inspector): clarify local action availability (#6696)"

This reverts commit 2ad03320fe, reversing
changes made to b3c3cb0d7b.
This commit is contained in:
Alem Tuzlak
2026-08-28 14:27:20 +02:00
parent 1cb76928ad
commit a12f055fcb
64 changed files with 5025 additions and 190 deletions
+84
View File
@@ -114,6 +114,31 @@ export default function Home() {
);
}
function DemoToolCard({
colors,
title,
body,
}: {
colors: (typeof themeColors)[Theme];
title: string;
body: string;
}) {
return (
<div
style={{
padding: "12px",
backgroundColor: colors.muted,
borderRadius: "8px",
border: `1px solid ${colors.border}`,
color: colors.text,
}}
>
<strong>{title}</strong>
<pre style={{ marginTop: "8px", fontSize: "12px" }}>{body}</pre>
</div>
);
}
function Chat({
theme,
onToggleTheme,
@@ -137,6 +162,17 @@ function Chat({
available: "always",
});
useConfigureSuggestions({
suggestions: [
{
title: "Call 3 tools",
message:
"In this turn, call all three tools: sayHello with name Alem, getTime, and addNumbers with a=2 and b=3. Call each tool. Do not skip any.",
},
],
available: "always",
});
useAgentContext({
description: "The current Thread ID is:",
value: selectedThreadId ?? "stateless",
@@ -161,6 +197,7 @@ function Chat({
useFrontendTool({
name: "sayHello",
description: "Greet a person by name.",
parameters: z.object({
name: z.string(),
}),
@@ -168,6 +205,53 @@ function Chat({
alert(`Hello ${name}`);
return `Hello ${name}`;
},
render: ({ args, status }) => (
<DemoToolCard
colors={colors}
title="sayHello"
body={`Status: ${status}${args.name ? `\nName: ${args.name}` : ""}`}
/>
),
});
useFrontendTool({
name: "getTime",
description: "Return the current local time.",
parameters: z.object({
label: z.string().optional().describe("Optional label for this reading"),
}),
handler: async ({ label }) => {
const time = new Date().toLocaleString();
return label ? `${label}: ${time}` : time;
},
render: ({ args, status, result }) => (
<DemoToolCard
colors={colors}
title="getTime"
body={`Status: ${status}${
args.label ? `\nLabel: ${args.label}` : ""
}${result ? `\n${String(result)}` : ""}`}
/>
),
});
useFrontendTool({
name: "addNumbers",
description: "Add two numbers and return the sum.",
parameters: z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
}),
handler: async ({ a, b }) => a + b,
render: ({ args, status, result }) => (
<DemoToolCard
colors={colors}
title="addNumbers"
body={`Status: ${status}\n${args.a ?? "?"} + ${args.b ?? "?"} = ${
result ?? "..."
}`}
/>
),
});
const toolsMenu = useMemo<(ToolsMenuItem | "-")[]>(
() => [
+3
View File
@@ -29,6 +29,8 @@ Import these symbols from `@copilotkit/angular`.
- `AgentStore`
- `AngularActivityContentParseResult`
- `AngularActivityContentSchema`
- `AngularInspectorOpenRequest`
- `AngularInspectorSaveRequest`
- `AngularToolCall`
- `AssistantMessage`
- `AssistantMessageCopyButtonContext`
@@ -123,6 +125,7 @@ Import these symbols from `@copilotkit/angular`.
- `CopilotChatViewScrollToBottomButton`
- `CopilotChatViewScrollView`
- `CopilotDefaultToolRenderer`
- `CopilotInspector`
- `CopilotKit`
- `CopilotKitAgentContext`
- `CopilotKitConfig`
+6
View File
@@ -11,6 +11,9 @@ export interface CopilotChatLabels {
assistantMessageToolbarCopyCodeLabel: string;
assistantMessageToolbarCopyCodeCopiedLabel: string;
assistantMessageToolbarCopyMessageLabel: string;
assistantMessageToolbarInspectorLabel: string;
assistantMessageToolbarInspectorLocalOnlyLabel: string;
assistantMessageToolbarSaveSnippetLabel: string;
assistantMessageToolbarThumbsUpLabel: string;
assistantMessageToolbarThumbsDownLabel: string;
assistantMessageToolbarReadAloudLabel: string;
@@ -32,6 +35,9 @@ export const COPILOT_CHAT_DEFAULT_LABELS: CopilotChatLabels = {
assistantMessageToolbarCopyCodeLabel: "Copy",
assistantMessageToolbarCopyCodeCopiedLabel: "Copied",
assistantMessageToolbarCopyMessageLabel: "Copy",
assistantMessageToolbarInspectorLabel: "View in Inspector",
assistantMessageToolbarInspectorLocalOnlyLabel: "Development Only",
assistantMessageToolbarSaveSnippetLabel: "Save as snippet",
assistantMessageToolbarThumbsUpLabel: "Good response",
assistantMessageToolbarThumbsDownLabel: "Bad response",
assistantMessageToolbarReadAloudLabel: "Read aloud",
@@ -9,6 +9,7 @@ import {
ViewEncapsulation,
Optional,
Inject,
inject,
input,
output,
} from "@angular/core";
@@ -35,7 +36,12 @@ import {
CopilotChatAssistantMessageCopyButton,
CopilotChatAssistantMessageThumbsUpButton,
CopilotChatAssistantMessageThumbsDownButton,
CopilotChatAssistantMessageToolbarButton,
} from "./copilot-chat-assistant-message-buttons";
import { Bookmark, CopilotIcon } from "../icons/copilot-icon";
import { CopilotInspector } from "../../inspector";
import { CopilotChatConfiguration } from "../../chat-configuration";
import { injectChatLabels } from "../../chat-config";
import { CopilotChatAssistantMessageToolbar } from "./copilot-chat-assistant-message-toolbar";
import { cn } from "../../utils";
import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
@@ -49,7 +55,9 @@ import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
CopilotChatAssistantMessageRenderer,
CopilotChatAssistantMessageCopyButton,
CopilotChatAssistantMessageToolbar,
CopilotChatAssistantMessageToolbarButton,
CopilotChatToolCallsView,
CopilotIcon,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
@@ -95,8 +103,8 @@ import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
</copilot-chat-tool-calls-view>
}
<!-- Toolbar: show only when there is assistant text content -->
@if (toolbarVisible() && hasMessageContent()) {
<!-- Toolbar: show for text, or for Inspector actions on tool-only messages -->
@if (toolbarVisible() && (hasMessageContent() || inspectorEnabled())) {
@if (toolbarTemplate || toolbarComponent()) {
<copilot-slot
[slot]="toolbarTemplate || toolbarComponent()"
@@ -125,6 +133,29 @@ import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
</copilot-chat-assistant-message-copy-button>
}
@if (inspectorEnabled()) {
<button
type="button"
copilotChatAssistantMessageToolbarButton
data-testid="copilot-inspector-button"
[title]="inspectorTitle()"
(click)="handleViewInspector()"
>
I
</button>
@if (hasMessageContent()) {
<button
type="button"
copilotChatAssistantMessageToolbarButton
data-testid="copilot-save-snippet-button"
[title]="saveSnippetTitle()"
(click)="handleSaveSnippet()"
>
<copilot-icon [img]="bookmarkIcon" [size]="18" />
</button>
}
}
<!-- Thumbs up button - show if custom slot provided OR if handler available at top level -->
@if (
thumbsUpButtonComponent() ||
@@ -412,6 +443,11 @@ export class CopilotChatAssistantMessage {
// DI service exposes handler availability scoped to CopilotChatView
// Make it optional with a default fallback for testing
handlers: CopilotChatViewHandlers;
private readonly inspector = inject(CopilotInspector, { optional: true });
private readonly chatConfig = inject(CopilotChatConfiguration, {
optional: true,
});
private readonly labels = injectChatLabels();
constructor(
@Optional()
@@ -469,11 +505,48 @@ export class CopilotChatAssistantMessage {
children: null, // Will be populated by the toolbar content
}));
protected readonly bookmarkIcon = Bookmark;
// Return true if assistant message has non-empty text content
hasMessageContent(): boolean {
return (this.message()?.content ?? "").trim().length > 0;
}
inspectorEnabled(): boolean {
return this.inspector?.isInspectorEnabled === true;
}
inspectorTitle(): string {
return `${this.labels.assistantMessageToolbarInspectorLabel} (${this.labels.assistantMessageToolbarInspectorLocalOnlyLabel})`;
}
saveSnippetTitle(): string {
return `${this.labels.assistantMessageToolbarSaveSnippetLabel} (${this.labels.assistantMessageToolbarInspectorLocalOnlyLabel})`;
}
handleViewInspector(): void {
const message = this.message();
this.inspector?.openInspector({
messageId: message.id,
threadId: this.chatConfig?.threadId(),
agentId: this.chatConfig?.agentId(),
});
}
handleSaveSnippet(): void {
const message = this.message();
if (!this.hasMessageContent()) {
return;
}
void this.inspector?.saveEventSnippet({
kind: "text",
messageId: message.id,
content: message.content ?? "",
threadId: this.chatConfig?.threadId(),
agentId: this.chatConfig?.agentId(),
});
}
toolCallsViewContext = computed(() => ({
message: this.message(),
messages: this.messages(),
@@ -20,6 +20,12 @@ import { CopilotChatReasoningMessage } from "./copilot-chat-reasoning-message";
import { cn } from "../../utils";
import { CopilotKit } from "../../copilotkit";
import type { RenderActivityMessageConfig } from "../../activity-renderer";
import { CopilotInspector } from "../../inspector";
import { CopilotChatConfiguration } from "../../chat-configuration";
import { injectChatLabels } from "../../chat-config";
import { Bookmark, CopilotIcon } from "../icons/copilot-icon";
import { CopilotChatAssistantMessageToolbarButton } from "./copilot-chat-assistant-message-buttons";
import { CopilotSaveSnippetBeside } from "./copilot-save-snippet-beside";
/**
* CopilotChatMessageView component - Angular port of the React component.
@@ -37,6 +43,9 @@ import type { RenderActivityMessageConfig } from "../../activity-renderer";
CopilotChatUserMessage,
CopilotChatReasoningMessage,
CopilotChatMessageViewCursor,
CopilotIcon,
CopilotChatAssistantMessageToolbarButton,
CopilotSaveSnippetBeside,
],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
@@ -109,10 +118,24 @@ import type { RenderActivityMessageConfig } from "../../activity-renderer";
} @else if (message && message.role === "activity") {
@let activityRender = resolveActivityRender(message);
@if (activityRender) {
<ng-container
[ngComponentOutlet]="activityRender.component"
[ngComponentOutletInputs]="activityRender.inputs"
/>
<copilot-save-snippet-beside
[enabled]="canSaveActivity(asActivityMessage(message))"
>
<ng-container
[ngComponentOutlet]="activityRender.component"
[ngComponentOutletInputs]="activityRender.inputs"
/>
<button
saveSnippet
type="button"
copilotChatAssistantMessageToolbarButton
data-testid="copilot-activity-save-snippet-button"
[title]="saveSnippetTitle()"
(click)="saveActivitySnippet(asActivityMessage(message))"
>
<copilot-icon [img]="bookmarkIcon" [size]="18" />
</button>
</copilot-save-snippet-beside>
}
}
}
@@ -196,6 +219,12 @@ export class CopilotChatMessageView {
protected readonly defaultReasoningComponent = CopilotChatReasoningMessage;
protected readonly defaultCursorComponent = CopilotChatMessageViewCursor;
protected readonly copilotKit = inject(CopilotKit);
private readonly inspector = inject(CopilotInspector, { optional: true });
private readonly chatConfig = inject(CopilotChatConfiguration, {
optional: true,
});
protected readonly labels = injectChatLabels();
protected readonly bookmarkIcon = Bookmark;
// Derived values from inputs
protected messagesValue = computed(() => this.messages());
@@ -279,6 +308,33 @@ export class CopilotChatMessageView {
return message as ReasoningMessage;
}
asActivityMessage(message: Message): ActivityMessage {
return message as ActivityMessage;
}
canSaveActivity(message: ActivityMessage): boolean {
return (
this.inspector?.isInspectorEnabled === true &&
(message.activityType === "a2ui-surface" ||
message.activityType === "open-generative-ui")
);
}
saveSnippetTitle(): string {
return `${this.labels.assistantMessageToolbarSaveSnippetLabel} (${this.labels.assistantMessageToolbarInspectorLocalOnlyLabel})`;
}
saveActivitySnippet(message: ActivityMessage): void {
void this.inspector?.saveEventSnippet({
kind: "activity",
messageId: message.id,
activityType: message.activityType,
content: message.content,
threadId: this.chatConfig?.threadId(),
agentId: this.chatConfig?.agentId(),
});
}
// TrackBy function for performance optimization
trackByMessageId(index: number, message: Message): string {
return message?.id || `index-${index}`;
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
linkedSignal,
signal,
@@ -11,10 +12,19 @@ import type { Message, ReasoningMessage } from "@ag-ui/core";
import { cn } from "../../utils";
import { CopilotChatAssistantMessageRenderer } from "./copilot-chat-assistant-message-renderer";
import { formatReasoningDuration } from "./copilot-chat-reasoning-message-utils";
import { CopilotInspector } from "../../inspector";
import { CopilotChatConfiguration } from "../../chat-configuration";
import { injectChatLabels } from "../../chat-config";
import { Bookmark, CopilotIcon } from "../icons/copilot-icon";
import { CopilotChatAssistantMessageToolbarButton } from "./copilot-chat-assistant-message-buttons";
@Component({
selector: "copilot-chat-reasoning-message",
imports: [CopilotChatAssistantMessageRenderer],
imports: [
CopilotChatAssistantMessageRenderer,
CopilotIcon,
CopilotChatAssistantMessageToolbarButton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
@@ -23,37 +33,50 @@ import { formatReasoningDuration } from "./copilot-chat-reasoning-message-utils"
data-testid="copilot-chat-reasoning-message"
data-copilot-reasoning-message
>
<button
type="button"
data-testid="reasoning-block"
[class]="headerClass()"
[attr.aria-expanded]="hasContent() ? open() : null"
(click)="toggle()"
>
<span class="cpk:font-medium">{{ label() }}</span>
@if (isStreaming() && !hasContent()) {
<span class="cpk:inline-flex cpk:items-center cpk:ml-1">
<span
class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
></span>
</span>
}
@if (hasContent()) {
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
[class]="chevronClass()"
<div class="cpk:flex cpk:items-center cpk:gap-1">
<button
type="button"
data-testid="reasoning-block"
[class]="headerClass()"
[attr.aria-expanded]="hasContent() ? open() : null"
(click)="toggle()"
>
<span class="cpk:font-medium">{{ label() }}</span>
@if (isStreaming() && !hasContent()) {
<span class="cpk:inline-flex cpk:items-center cpk:ml-1">
<span
class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
></span>
</span>
}
@if (hasContent()) {
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
[class]="chevronClass()"
>
<path d="m9 18 6-6-6-6"></path>
</svg>
}
</button>
@if (canSaveReasoning()) {
<button
type="button"
copilotChatAssistantMessageToolbarButton
data-testid="copilot-reasoning-save-snippet-button"
[title]="saveSnippetTitle()"
(click)="saveReasoningSnippet()"
>
<path d="m9 18 6-6-6-6"></path>
</svg>
<copilot-icon [img]="bookmarkIcon" [size]="18" />
</button>
}
</button>
</div>
@if (hasContent() || isStreaming()) {
<div
@@ -90,6 +113,13 @@ export class CopilotChatReasoningMessage {
readonly isRunning = input<boolean>(false);
readonly inputClass = input<string | undefined>();
private readonly inspector = inject(CopilotInspector, { optional: true });
private readonly chatConfig = inject(CopilotChatConfiguration, {
optional: true,
});
protected readonly labels = injectChatLabels();
protected readonly bookmarkIcon = Bookmark;
private readonly userToggled = signal(false);
protected readonly isLatest = computed(() => {
@@ -163,4 +193,22 @@ export class CopilotChatReasoningMessage {
this.userToggled.set(true);
this.open.update((value) => !value);
}
protected canSaveReasoning(): boolean {
return this.inspector?.isInspectorEnabled === true && this.hasContent();
}
protected saveSnippetTitle(): string {
return `${this.labels.assistantMessageToolbarSaveSnippetLabel} (${this.labels.assistantMessageToolbarInspectorLocalOnlyLabel})`;
}
protected saveReasoningSnippet(): void {
void this.inspector?.saveEventSnippet({
kind: "reasoning",
messageId: this.message().id,
content: this.message().content ?? "",
threadId: this.chatConfig?.threadId(),
agentId: this.chatConfig?.agentId(),
});
}
}
@@ -1,20 +1,52 @@
import { Component, ChangeDetectionStrategy, input } from "@angular/core";
import {
Component,
ChangeDetectionStrategy,
inject,
input,
} from "@angular/core";
import type { AssistantMessage, Message } from "@ag-ui/core";
import { RenderToolCalls } from "../../render-tool-calls";
import { CopilotInspector } from "../../inspector";
import { CopilotChatConfiguration } from "../../chat-configuration";
import { injectChatLabels } from "../../chat-config";
import { Bookmark, CopilotIcon } from "../icons/copilot-icon";
import { CopilotChatAssistantMessageToolbarButton } from "./copilot-chat-assistant-message-buttons";
import { CopilotSaveSnippetBeside } from "./copilot-save-snippet-beside";
type AssistantToolCall = NonNullable<AssistantMessage["toolCalls"]>[number];
@Component({
selector: "copilot-chat-tool-calls-view",
imports: [RenderToolCalls],
imports: [
RenderToolCalls,
CopilotIcon,
CopilotChatAssistantMessageToolbarButton,
CopilotSaveSnippetBeside,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<copilot-render-tool-calls
[message]="message()"
[messages]="messages()"
[agentId]="agentId()"
[isLoading]="isLoading()"
>
</copilot-render-tool-calls>
@for (toolCall of message().toolCalls ?? []; track toolCall.id) {
<copilot-save-snippet-beside [enabled]="inspectorEnabled(toolCall)">
<copilot-render-tool-calls
[message]="singleToolMessage(toolCall)"
[messages]="messages()"
[agentId]="agentId()"
[isLoading]="isLoading()"
>
</copilot-render-tool-calls>
<button
saveSnippet
type="button"
copilotChatAssistantMessageToolbarButton
data-testid="copilot-tool-save-snippet-button"
[title]="saveSnippetTitle()"
(click)="saveToolSnippet(toolCall)"
>
<copilot-icon [img]="bookmarkIcon" [size]="18" />
</button>
</copilot-save-snippet-beside>
}
`,
})
export class CopilotChatToolCallsView {
@@ -22,4 +54,56 @@ export class CopilotChatToolCallsView {
readonly messages = input.required<Message[]>();
readonly agentId = input<string | undefined>();
readonly isLoading = input<boolean>(false);
private readonly inspector = inject(CopilotInspector, { optional: true });
private readonly chatConfig = inject(CopilotChatConfiguration, {
optional: true,
});
protected readonly labels = injectChatLabels();
protected readonly bookmarkIcon = Bookmark;
protected inspectorEnabled(toolCall: AssistantToolCall): boolean {
return (
this.inspector?.isInspectorEnabled === true &&
hasCompleteArgs(toolCall.function.arguments)
);
}
protected saveSnippetTitle(): string {
return `${this.labels.assistantMessageToolbarSaveSnippetLabel} (${this.labels.assistantMessageToolbarInspectorLocalOnlyLabel})`;
}
protected singleToolMessage(toolCall: AssistantToolCall): AssistantMessage {
return {
...this.message(),
toolCalls: [toolCall],
};
}
protected saveToolSnippet(toolCall: AssistantToolCall): void {
void this.inspector?.saveEventSnippet({
kind: "tool-call",
messageId: this.message().id,
toolCallId: toolCall.id,
toolName: toolCall.function.name,
argsJson: toolCall.function.arguments || "{}",
threadId: this.chatConfig?.threadId(),
agentId: this.chatConfig?.agentId(),
});
}
}
// A streaming tool call has truncated arguments. Do not offer to capture it
// until the JSON is complete, or the snippet holds a broken partial payload.
function hasCompleteArgs(args: string | undefined): boolean {
const trimmed = (args ?? "").trim();
if (!trimmed) {
return true;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,100 @@
import { NgStyle } from "@angular/common";
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
inject,
input,
signal,
viewChild,
} from "@angular/core";
import {
findOverflowAncestor,
measureSaveSnippetSide,
SAVE_SNIPPET_BESIDE_BODY_CLASS,
SAVE_SNIPPET_BESIDE_SAVE_CLASS,
SAVE_SNIPPET_BESIDE_WRAP_CLASS,
saveSnippetBesideStyle,
} from "./save-snippet-beside";
@Component({
selector: "copilot-save-snippet-beside",
imports: [NgStyle],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { style: "display: contents" },
template: `
<div
#wrap
[class]="enabled() ? wrapClass : null"
[attr.data-save-snippet-side]="enabled() ? side() : null"
>
<div #body [class]="enabled() ? bodyClass : null">
<ng-content />
</div>
<div
[class]="enabled() ? saveClass : null"
[hidden]="!enabled()"
[ngStyle]="enabled() ? saveStyle() : null"
>
<ng-content select="[saveSnippet]" />
</div>
</div>
`,
})
export class CopilotSaveSnippetBeside {
readonly enabled = input(false);
protected readonly wrapClass = SAVE_SNIPPET_BESIDE_WRAP_CLASS;
protected readonly bodyClass = SAVE_SNIPPET_BESIDE_BODY_CLASS;
protected readonly saveClass = SAVE_SNIPPET_BESIDE_SAVE_CLASS;
protected readonly side = signal<"left" | "right">("right");
protected readonly saveStyle = () => saveSnippetBesideStyle(this.side());
private readonly wrap = viewChild<ElementRef<HTMLElement>>("wrap");
private readonly body = viewChild<ElementRef<HTMLElement>>("body");
private readonly destroyRef = inject(DestroyRef);
private observer: ResizeObserver | undefined;
constructor() {
const onResize = () => this.measure();
afterNextRender(() => {
this.bindObserver();
window.addEventListener("resize", onResize);
});
this.destroyRef.onDestroy(() => {
this.observer?.disconnect();
window.removeEventListener("resize", onResize);
});
}
private bindObserver(): void {
this.observer?.disconnect();
const wrap = this.wrap()?.nativeElement;
const body = this.body()?.nativeElement;
if (!this.enabled() || !wrap || !body) {
return;
}
this.measure();
if (typeof ResizeObserver === "undefined") {
return;
}
this.observer = new ResizeObserver(() => this.measure());
this.observer.observe(wrap);
this.observer.observe(body);
const clip = findOverflowAncestor(wrap);
if (clip !== wrap) {
this.observer.observe(clip);
}
}
private measure(): void {
const wrap = this.wrap()?.nativeElement;
const body = this.body()?.nativeElement;
if (!wrap || !body) {
return;
}
this.side.set(measureSaveSnippetSide(wrap, body));
}
}
@@ -0,0 +1,84 @@
export const SAVE_SNIPPET_ICON_SLOT_PX = 36;
export const SAVE_SNIPPET_BESIDE_WRAP_CLASS =
"cpk:relative cpk:w-full cpk:overflow-visible";
export const SAVE_SNIPPET_BESIDE_BODY_CLASS = "cpk:w-full";
export const SAVE_SNIPPET_BESIDE_SAVE_CLASS = "cpk:absolute cpk:top-0 cpk:z-10";
export function saveSnippetBesideStyle(side: "left" | "right") {
if (side === "left") {
return {
left: "auto",
right: "100%",
marginLeft: "0px",
marginRight: "4px",
};
}
return {
left: "100%",
right: "auto",
marginLeft: "4px",
marginRight: "0px",
};
}
export function pickSaveSnippetSide(
roomRight: number,
roomLeft: number,
slotPx = SAVE_SNIPPET_ICON_SLOT_PX,
): "left" | "right" {
if (roomRight >= slotPx) {
return "right";
}
if (roomLeft >= slotPx) {
return "left";
}
return "right";
}
export function findOverflowAncestor(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const overflowX = style.overflowX;
const overflow = style.overflow;
if (
overflowX === "hidden" ||
overflowX === "auto" ||
overflowX === "scroll" ||
overflow === "hidden" ||
overflow === "auto" ||
overflow === "scroll"
) {
return node;
}
node = node.parentElement;
}
return document.documentElement;
}
export function measurableBox(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node && getComputedStyle(node).display === "contents") {
node = node.firstElementChild as HTMLElement | null;
}
return node ?? el;
}
export function measureSaveSnippetSide(
wrap: HTMLElement,
body: HTMLElement,
): "left" | "right" {
const clip = findOverflowAncestor(wrap);
const clipRect = clip.getBoundingClientRect();
const box = measurableBox(
(body.firstElementChild as HTMLElement | null) ?? body,
);
const bodyRect = box.getBoundingClientRect();
return pickSaveSnippetSide(
clipRect.right - bodyRect.right,
bodyRect.left - clipRect.left,
);
}
@@ -74,6 +74,9 @@ export const ArrowUp: CopilotIconData = [
path("m5 12 7-7 7 7"),
path("M12 19V5"),
];
export const Bookmark: CopilotIconData = [
path("m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"),
];
export const Check: CopilotIconData = [path("M20 6 9 17l-5-5")];
export const ChevronDown: CopilotIconData = [path("m6 9 6 6 6-6")];
export const ChevronLeft: CopilotIconData = [path("m15 18-6-6 6-6")];
@@ -392,6 +392,21 @@ describe("CopilotOpenGenerativeUIRenderer", () => {
expect(measureCallCount()).toBe(1);
});
it("measures height when a completed snapshot arrives before the sandbox is ready", async () => {
setContent(fixture, {
html: ["<body><p>Tall chart</p></body>"],
htmlComplete: true,
generating: false,
initialHeight: 80,
});
await flushSandboxImport(fixture);
expect(
mockRun.mock.calls.some(
([code]) => typeof code === "string" && code.includes("__ck_resize"),
),
).toBe(true);
});
it("tears down a completed sandbox immediately when a fresh generation starts", async () => {
setContent(fixture, {
css: ".dashboard { color: blue; }",
@@ -183,6 +183,7 @@ export class CopilotOpenGenerativeUIRenderer implements OnChanges {
private sandbox: WebsandboxInstance | undefined;
private previewSandbox: WebsandboxInstance | undefined;
private sandboxReady = false;
private readonly sandboxIsReady = signal(false);
private previewReady = false;
private executedExpressionIndex = 0;
private pendingQueue: string[] = [];
@@ -293,6 +294,7 @@ export class CopilotOpenGenerativeUIRenderer implements OnChanges {
this.containerRef();
this.reconcileSandboxState(state);
this.sandboxIsReady();
if (!state.generatingDone || this.heightMeasured || !this.sandbox) {
return;
}
@@ -484,6 +486,7 @@ export class CopilotOpenGenerativeUIRenderer implements OnChanges {
sandbox.promise.then(() => {
if (isCancelled() || this.sandbox !== sandbox) return;
this.sandboxReady = true;
this.sandboxIsReady.set(true);
void sandbox.run(`
var s = document.createElement('style');
s.textContent = 'html, body { overflow: hidden !important; }';
@@ -504,6 +507,7 @@ export class CopilotOpenGenerativeUIRenderer implements OnChanges {
this.heightMeasured = false;
this.pendingQueue = [];
this.sandboxReady = false;
this.sandboxIsReady.set(false);
}
private injectJsFunctions(jsFunctions: string | undefined): void {
@@ -597,6 +601,7 @@ export class CopilotOpenGenerativeUIRenderer implements OnChanges {
this.sandbox = undefined;
}
this.sandboxReady = false;
this.sandboxIsReady.set(false);
this.heightMeasured = false;
}
}
+2 -2
View File
@@ -41,6 +41,7 @@ import {
anyActivityContentSchema,
} from "./activity-renderer";
import { injectCopilotKitConfig } from "./config";
import { CopilotInspector } from "./inspector";
import { HumanInTheLoop } from "./human-in-the-loop";
import { ensureLicenseWatermark } from "./license-watermark";
import { CopilotA2UIActivityRenderer } from "./components/a2ui/a2ui-activity-renderer";
@@ -61,7 +62,6 @@ import {
import { CopilotOpenGenerativeUIActivityRenderer } from "./components/open-generative-ui/open-generative-ui-activity-renderer";
import { CopilotOpenGenerativeUIToolRenderer } from "./components/open-generative-ui/open-generative-ui-tool-renderer";
import { standardSchemaZodToJsonSchema } from "./standard-schema-zod";
import { CopilotInspector } from "./inspector";
/**
* Advertise a client-provided A2UI catalog to the runtime without mutating the
@@ -81,12 +81,12 @@ function withA2UICatalogCapability(
@Injectable({ providedIn: "root" })
export class CopilotKit {
readonly #config = injectCopilotKitConfig();
readonly #inspector = inject(CopilotInspector);
readonly #extensionActivityMessageRenderers = inject(
ɵCOPILOTKIT_BUILT_IN_ACTIVITY_RENDERERS,
);
readonly #hitl = inject(HumanInTheLoop);
readonly #rootInjector = inject(Injector);
readonly #inspector = inject(CopilotInspector);
/** Whether unknown tools may use the built-in text-only fallback renderer. */
readonly defaultToolRenderingEnabled =
this.#config.defaultToolRendering === true;
+65
View File
@@ -30,8 +30,39 @@ export type AngularInspectorOpenRequest = {
messageId: string;
threadId?: string;
agentId?: string;
menu?: "event-snippets";
snippetId?: string;
};
export type AngularInspectorSaveRequest = {
threadId?: string;
agentId?: string;
} & (
| {
kind: "text";
messageId: string;
content: string;
}
| {
kind: "reasoning";
messageId: string;
content: string;
}
| {
kind: "tool-call";
messageId: string;
toolCallId: string;
toolName: string;
argsJson: string | Record<string, unknown>;
}
| {
kind: "activity";
messageId: string;
activityType: string;
content: unknown;
}
);
@Injectable({ providedIn: "root" })
export class CopilotInspector {
private readonly config = inject<CopilotKitConfig | null>(
@@ -74,6 +105,40 @@ export class CopilotInspector {
this.element?.openInspector?.("message_toolbar", request);
}
async saveEventSnippet(request: AngularInspectorSaveRequest): Promise<void> {
try {
const mod = await import("@copilotkit/web-inspector");
const threadId = request.threadId ?? "inspector-snippet";
const runId = `inspector-snippet-${Date.now()}`;
const compiled = mod.compileChatSnippet({
...request,
threadId,
runId,
});
const now = new Date().toISOString();
const snippet = {
id: crypto.randomUUID(),
name: compiled.name,
recipe: compiled.recipe,
events: compiled.events,
createdAt: now,
updatedAt: now,
};
mod.upsertEventSnippet(snippet);
this.openInspector({
messageId: request.messageId,
threadId: request.threadId,
agentId: request.agentId,
menu: "event-snippets",
snippetId: snippet.id,
});
} catch (error) {
// Compile can throw on bad args, and storage can throw QuotaExceededError.
// Callers fire this as `void saveEventSnippet(...)`, so report it here.
console.error("[CopilotKit] Could not save the event snippet.", error);
}
}
private async mount(): Promise<void> {
try {
const mod = await import("@copilotkit/web-inspector");
@@ -62,7 +62,7 @@ test.each([
expect(new Set(documentedExports).size).toBe(documentedExports.length);
expect(documentedExports).toEqual(readEntryPointExports(entryPoint));
},
15_000,
60_000,
);
test("ships the README and exhaustive API contract in the package", () => {
+1
View File
@@ -1,5 +1,6 @@
export * from "./lib/config";
export * from "./lib/copilotkit";
export * from "./lib/inspector";
export * from "./lib/tools";
export * from "./lib/render-tool-calls";
export * from "./lib/activity-renderer";
@@ -0,0 +1,240 @@
import { AbstractAgent, EventType } from "@ag-ui/client";
import type { AssistantMessage, BaseEvent, RunAgentInput } from "@ag-ui/client";
import type { Observable } from "rxjs";
import { EMPTY } from "rxjs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CopilotKitCore } from "../core";
import {
ɵinjectInspectorEvents,
ɵresetInspectorInject,
} from "../core/inspect-inject";
class ReplayAgent extends AbstractAgent {
run(_input: RunAgentInput): Observable<BaseEvent> {
return EMPTY;
}
}
const TEXT_EVENTS: BaseEvent[] = [
{
type: EventType.RUN_STARTED,
threadId: "thread-1",
runId: "run-1",
},
{
type: EventType.TEXT_MESSAGE_START,
messageId: "msg-1",
role: "assistant",
},
{
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "msg-1",
delta: "Hello from a snippet",
},
{
type: EventType.TEXT_MESSAGE_END,
messageId: "msg-1",
},
{
type: EventType.RUN_FINISHED,
threadId: "thread-1",
runId: "run-1",
},
];
const TOOL_EVENTS: BaseEvent[] = [
{
type: EventType.RUN_STARTED,
threadId: "thread-1",
runId: "run-tool",
},
{
type: EventType.TOOL_CALL_START,
toolCallId: "call-1",
toolCallName: "sayHello",
parentMessageId: "asst-1",
},
{
type: EventType.TOOL_CALL_ARGS,
toolCallId: "call-1",
delta: '{"name":"Alem"}',
},
{
type: EventType.TOOL_CALL_END,
toolCallId: "call-1",
},
{
type: EventType.RUN_FINISHED,
threadId: "thread-1",
runId: "run-tool",
},
];
const ACTIVITY_EVENTS: BaseEvent[] = [
{
type: EventType.RUN_STARTED,
threadId: "thread-1",
runId: "run-ui",
},
{
type: EventType.ACTIVITY_SNAPSHOT,
messageId: "act-ui",
activityType: "open-generative-ui",
content: {
html: ["<div>Hello sandbox</div>"],
htmlComplete: true,
generating: false,
},
replace: true,
},
{
type: EventType.RUN_FINISHED,
threadId: "thread-1",
runId: "run-ui",
},
];
function createInjectHarness() {
const agent = new ReplayAgent({ threadId: "thread-1" });
agent.agentId = "default";
const core = new CopilotKitCore({
agents__unsafe_dev_only: { default: agent },
});
return { agent, core };
}
describe("ɵinjectInspectorEvents", () => {
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("applies snippet events through runAgent and records new message ids", async () => {
const { agent, core } = createInjectHarness();
const result = await ɵinjectInspectorEvents({
core,
agent,
events: TEXT_EVENTS,
});
expect(result.messageIds.length).toBeGreaterThan(0);
const assistant = agent.messages.find((message) =>
result.messageIds.includes(message.id),
);
expect(assistant?.content).toContain("Hello from a snippet");
expect(assistant?.id).not.toBe("msg-1");
});
it("adds a new turn when the same snippet is injected again", async () => {
const { agent, core } = createInjectHarness();
const first = await ɵinjectInspectorEvents({
core,
agent,
events: TEXT_EVENTS,
});
const second = await ɵinjectInspectorEvents({
core,
agent,
events: TEXT_EVENTS,
});
expect(second.messageIds.length).toBeGreaterThan(0);
expect(second.messageIds[0]).not.toBe(first.messageIds[0]);
const hellos = agent.messages.filter(
(message) =>
message.role === "assistant" &&
String(message.content).includes("Hello from a snippet"),
);
expect(hellos).toHaveLength(2);
});
it("adds a new tool call when the same tool snippet is injected again", async () => {
const { agent, core } = createInjectHarness();
await ɵinjectInspectorEvents({
core,
agent,
events: TOOL_EVENTS,
});
await ɵinjectInspectorEvents({
core,
agent,
events: TOOL_EVENTS,
});
const assistants = agent.messages.filter(
(message): message is AssistantMessage =>
message.role === "assistant" && (message.toolCalls?.length ?? 0) > 0,
);
expect(assistants).toHaveLength(2);
const firstCall = assistants[0]?.toolCalls?.[0];
const secondCall = assistants[1]?.toolCalls?.[0];
expect(firstCall?.function.name).toBe("sayHello");
expect(secondCall?.function.name).toBe("sayHello");
expect(firstCall?.id).not.toBe(secondCall?.id);
});
it("restores agent.run after inject", async () => {
const { agent, core } = createInjectHarness();
const originalRun = agent.run;
await ɵinjectInspectorEvents({
core,
agent,
events: TEXT_EVENTS,
});
expect(agent.run).toBe(originalRun);
});
it("does not inject while the agent is running", async () => {
const { agent, core } = createInjectHarness();
agent.isRunning = true;
await expect(
ɵinjectInspectorEvents({
core,
agent,
events: TEXT_EVENTS,
}),
).rejects.toThrow("The agent is running");
expect(agent.messages).toEqual([]);
});
it("resets only the injected messages", async () => {
const { agent, core } = createInjectHarness();
agent.setMessages([{ id: "keep", role: "user", content: "stay" }]);
const result = await ɵinjectInspectorEvents({
core,
agent,
events: TEXT_EVENTS,
});
ɵresetInspectorInject({ agent, messageIds: result.messageIds });
expect(agent.messages.map((message) => message.id)).toEqual(["keep"]);
});
it("applies an open-generative-ui activity snapshot as an activity message", async () => {
const { agent, core } = createInjectHarness();
const result = await ɵinjectInspectorEvents({
core,
agent,
events: ACTIVITY_EVENTS,
});
expect(result.messageIds.length).toBeGreaterThan(0);
const activity = agent.messages.find((message) =>
result.messageIds.includes(message.id),
);
expect(activity?.role).toBe("activity");
expect(activity?.id).not.toBe("act-ui");
});
});
+5
View File
@@ -4,3 +4,8 @@ export * from "./context-store";
export * from "./suggestion-engine";
export * from "./run-handler";
export * from "./state-manager";
export {
ɵinjectInspectorEvents,
ɵresetInspectorInject,
} from "./inspect-inject";
export type { InspectorInjectResult as ɵInspectorInjectResult } from "./inspect-inject";
+109
View File
@@ -0,0 +1,109 @@
import type {
AbstractAgent,
BaseEvent,
Message,
RunAgentInput,
} from "@ag-ui/client";
import { from } from "rxjs";
import type { CopilotKitCore } from "./core";
export type InspectorInjectResult = {
messageIds: string[];
};
const REMINT_ID_KEYS = [
"messageId",
"parentMessageId",
"toolCallId",
"runId",
] as const;
function newInspectorId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `cpk-inject-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function remintInspectorEventIds(
events: ReadonlyArray<{ type: string; [key: string]: unknown }>,
): Array<{ type: string; [key: string]: unknown }> {
const mapped = new Map<string, string>();
const idFor = (value: string) => {
const existing = mapped.get(value);
if (existing) {
return existing;
}
const next = newInspectorId();
mapped.set(value, next);
return next;
};
return events.map((event) => {
const copy: { type: string; [key: string]: unknown } = { ...event };
for (const key of REMINT_ID_KEYS) {
const value = copy[key];
if (typeof value === "string" && value.length > 0) {
copy[key] = idFor(value);
}
}
return copy;
});
}
/**
* Inspector-only helper. Apply AG-UI events to a live agent through the
* same runAgent path a real agent uses, so chat and frontend-tool handlers
* update. Not a documented product API.
*/
export async function ɵinjectInspectorEvents(params: {
core: CopilotKitCore;
agent: AbstractAgent;
events: ReadonlyArray<{ type: string }>;
}): Promise<InspectorInjectResult> {
const { core, agent, events } = params;
if (agent.isRunning) {
throw new Error("The agent is running. Wait for the current run to end.");
}
if (events.length === 0) {
throw new Error("Snippet JSON must be an array of events.");
}
const before = new Set(agent.messages.map((message) => message.id));
const originalRun = agent.run;
// AG-UI applies events by `type` string. Snippet JSON is that payload.
// Mint new message, tool-call, and run ids so a second Run is a new
// turn. Replaying the saved ids is a no-op: the agent already has them.
const runEvents = remintInspectorEventIds(events) as BaseEvent[];
agent.run = (_input: RunAgentInput) => from(runEvents);
try {
await core.runAgent({ agent });
} finally {
agent.run = originalRun;
}
return {
messageIds: agent.messages
.filter((message) => !before.has(message.id))
.map((message) => message.id),
};
}
/**
* Remove messages created by the last Inspector inject. Does not undo
* frontend-tool handler side effects.
*/
export function ɵresetInspectorInject(params: {
agent: AbstractAgent;
messageIds: readonly string[];
}): void {
const drop = new Set(params.messageIds);
if (drop.size === 0) {
return;
}
const next: Message[] = params.agent.messages.filter(
(message) => !drop.has(message.id),
);
params.agent.setMessages(next);
}
@@ -664,7 +664,11 @@ export async function collectModuleGraph({
const inputs = Object.keys(result.metafile.inputs);
// A graph that does not even contain its own entry means we measured nothing.
const entryKey = path.relative(pkgRoot, path.resolve(pkgRoot, entryFile));
// esbuild metafile keys use POSIX separators even on Windows.
const entryKey = path
.relative(pkgRoot, path.resolve(pkgRoot, entryFile))
.split(path.sep)
.join("/");
if (!inputs.includes(entryKey) && !inputs.includes(entryFile)) {
throw new Error(
`the module graph of ${path.basename(entryFile)} does not contain the entry ` +
@@ -2,6 +2,10 @@ import { describe, it, expect, vi } from "vitest";
import { render, act, waitFor } from "@testing-library/react";
import React, { useState } from "react";
import type { Theme } from "@copilotkit/a2ui-renderer";
import {
createA2UIMessageRenderer,
runA2UIAction,
} from "../a2ui/A2UIMessageRenderer";
vi.mock("../providers", () => ({
useCopilotKit: vi.fn(() => ({
@@ -15,8 +19,6 @@ vi.mock("../providers", () => ({
describe("A2UIMessageRenderer rendering integration", () => {
it("should render A2UI surface content via React renderer", async () => {
const { createA2UIMessageRenderer } =
await import("../a2ui/A2UIMessageRenderer.js");
const renderer = createA2UIMessageRenderer({
theme: {} as Theme,
});
@@ -62,8 +64,6 @@ describe("A2UIMessageRenderer rendering integration", () => {
});
it("should update surface when operations change", async () => {
const { createA2UIMessageRenderer } =
await import("../a2ui/A2UIMessageRenderer.js");
const renderer = createA2UIMessageRenderer({
theme: {} as Theme,
});
@@ -146,8 +146,6 @@ describe("A2UIMessageRenderer rendering integration", () => {
});
it("should return null when no operations are provided", async () => {
const { createA2UIMessageRenderer } =
await import("../a2ui/A2UIMessageRenderer.js");
const renderer = createA2UIMessageRenderer({
theme: {} as Theme,
});
@@ -163,8 +161,6 @@ describe("A2UIMessageRenderer rendering integration", () => {
});
it("should render multiple surfaces independently", async () => {
const { createA2UIMessageRenderer } =
await import("../a2ui/A2UIMessageRenderer.js");
const renderer = createA2UIMessageRenderer({
theme: {} as Theme,
});
@@ -248,7 +244,6 @@ describe("runA2UIAction onAction interceptor", () => {
};
it("does NOT run the agent when onAction returns null", async () => {
const { runA2UIAction } = await import("../a2ui/A2UIMessageRenderer.js");
const copilotkit = makeCopilotkit();
const onAction = vi.fn().mockReturnValue(null);
@@ -261,7 +256,6 @@ describe("runA2UIAction onAction interceptor", () => {
});
it("forwards the modified action when onAction returns one", async () => {
const { runA2UIAction } = await import("../a2ui/A2UIMessageRenderer.js");
const copilotkit = makeCopilotkit();
const modified = { ...message.userAction, name: "navigate_handled" };
const onAction = vi.fn().mockReturnValue(modified);
@@ -277,7 +271,6 @@ describe("runA2UIAction onAction interceptor", () => {
});
it("forwards the original message unchanged when no onAction is supplied", async () => {
const { runA2UIAction } = await import("../a2ui/A2UIMessageRenderer.js");
const copilotkit = makeCopilotkit();
await runA2UIAction({ message, agent: "my-agent", copilotkit });
@@ -288,7 +281,6 @@ describe("runA2UIAction onAction interceptor", () => {
});
it("forwards unchanged when onAction returns undefined", async () => {
const { runA2UIAction } = await import("../a2ui/A2UIMessageRenderer.js");
const copilotkit = makeCopilotkit();
const onAction = vi.fn().mockReturnValue(undefined);
@@ -6,17 +6,50 @@ export type CopilotKitInspectorOpenRequest = {
messageId: string;
threadId?: string;
agentId?: string;
menu?: "event-snippets";
snippetId?: string;
};
export type CopilotKitInspectorSaveRequest = {
threadId?: string;
agentId?: string;
} & (
| {
kind: "text";
messageId: string;
content: string;
}
| {
kind: "reasoning";
messageId: string;
content: string;
}
| {
kind: "tool-call";
messageId: string;
toolCallId: string;
toolName: string;
argsJson: string | Record<string, unknown>;
}
| {
kind: "activity";
messageId: string;
activityType: string;
content: unknown;
}
);
type CopilotKitInspectorContextValue = {
isInspectorEnabled: boolean;
openInspector: (request: CopilotKitInspectorOpenRequest) => void;
saveEventSnippet: (request: CopilotKitInspectorSaveRequest) => Promise<void>;
};
const CopilotKitInspectorContext =
React.createContext<CopilotKitInspectorContextValue>({
isInspectorEnabled: false,
openInspector: () => undefined,
saveEventSnippet: async () => undefined,
});
export const CopilotKitInspectorContextProvider =
@@ -172,6 +172,7 @@ const OpenGenerativeUIActivityRendererInner = React.memo(
function OpenGenerativeUIActivityRendererInner({ content }: InnerProps) {
const initialHeight = content.initialHeight ?? 200;
const [autoHeight, setAutoHeight] = useState<number | null>(null);
const [sandboxReady, setSandboxReady] = useState(false);
const sandboxFunctions = useSandboxFunctions();
const localApi = useMemo(() => {
@@ -352,6 +353,7 @@ const OpenGenerativeUIActivityRendererInner = React.memo(
sandbox.promise.then(() => {
if (cancelled) return;
sandboxReadyRef.current = true;
setSandboxReady(true);
// Prevent scrollbars — the container auto-sizes to fit content
sandbox.run(`
@@ -388,6 +390,7 @@ const OpenGenerativeUIActivityRendererInner = React.memo(
sandboxRef.current = null;
}
sandboxReadyRef.current = false;
setSandboxReady(false);
setAutoHeight(null);
};
}, [fullHtml, css, localApi]);
@@ -434,7 +437,7 @@ const OpenGenerativeUIActivityRendererInner = React.memo(
const generationDone = content.generating === false;
useEffect(() => {
const sandbox = sandboxRef.current;
if (!generationDone || !sandbox) return;
if (!generationDone || !sandboxReady || !sandbox) return;
let handled = false;
const onMessage = (e: MessageEvent) => {
@@ -473,7 +476,7 @@ const OpenGenerativeUIActivityRendererInner = React.memo(
return () => {
window.removeEventListener("message", onMessage);
};
}, [generationDone]);
}, [generationDone, sandboxReady]);
const height = autoHeight ?? initialHeight;
@@ -261,6 +261,43 @@ describe("OpenGenerativeUIActivityRenderer", () => {
expect(mockRun).toHaveBeenCalledWith("foo()");
});
it("measures height after a complete snapshot arrives before the sandbox is ready", async () => {
const { container } = renderRenderer({
html: ["<head></head><body><div>Tall sandbox</div></body>"],
htmlComplete: true,
generating: false,
initialHeight: 80,
});
const box = container.firstElementChild as HTMLElement;
expect(box.style.height).toBe("80px");
await flushImport();
await act(async () => {
mockPromiseResolve();
await mockPromise;
});
await flushImport();
expect(
mockRun.mock.calls.some(
(call) =>
typeof call[0] === "string" && call[0].includes("__ck_resize"),
),
).toBe(true);
await act(() => {
const event = new MessageEvent("message", {
data: { type: "__ck_resize", height: 420 },
});
Object.defineProperty(event, "source", {
value: mockIframe.contentWindow,
});
window.dispatchEvent(event);
});
expect(box.style.height).toBe("420px");
});
it("recreates sandbox when html changes", async () => {
const { rerender } = render(
<OpenGenerativeUIActivityRenderer
@@ -26,6 +26,7 @@ import { Streamdown } from "streamdown";
import { copyToClipboard } from "@copilotkit/shared";
import CopilotChatToolCallsView from "./CopilotChatToolCallsView";
import { useCopilotKitInspector } from "../CopilotKitInspectorContext";
import { SaveSnippetIconButton } from "./SaveSnippetIconButton";
export type CopilotChatFeedbackMessage = AssistantMessage & {
rawEvent?: unknown;
@@ -37,6 +38,7 @@ export type CopilotChatAssistantMessageProps = WithSlots<
toolbar: typeof CopilotChatAssistantMessage.Toolbar;
copyButton: typeof CopilotChatAssistantMessage.CopyButton;
inspectorButton: typeof CopilotChatAssistantMessage.InspectorButton;
saveSnippetButton: typeof CopilotChatAssistantMessage.SaveSnippetButton;
thumbsUpButton: typeof CopilotChatAssistantMessage.ThumbsUpButton;
thumbsDownButton: typeof CopilotChatAssistantMessage.ThumbsDownButton;
readAloudButton: typeof CopilotChatAssistantMessage.ReadAloudButton;
@@ -70,6 +72,7 @@ export function CopilotChatAssistantMessage({
toolbar,
copyButton,
inspectorButton,
saveSnippetButton,
thumbsUpButton,
thumbsDownButton,
readAloudButton,
@@ -80,7 +83,8 @@ export function CopilotChatAssistantMessage({
...props
}: CopilotChatAssistantMessageProps) {
useKatexStyles();
const { isInspectorEnabled, openInspector } = useCopilotKitInspector();
const { isInspectorEnabled, openInspector, saveEventSnippet } =
useCopilotKitInspector();
const chatConfiguration = useCopilotChatConfiguration();
const boundMarkdownRenderer = renderSlot(
@@ -125,6 +129,27 @@ export function CopilotChatAssistantMessage({
},
);
const hasContent = !!(message.content && message.content.trim().length > 0);
const boundSaveSnippetButton = renderSlot(
saveSnippetButton,
CopilotChatAssistantMessage.SaveSnippetButton,
{
onClick: () => {
if (!hasContent) {
return;
}
void saveEventSnippet({
kind: "text",
messageId: message.id,
content: message.content ?? "",
threadId: chatConfiguration?.threadId,
agentId: chatConfiguration?.agentId,
});
},
},
);
const boundThumbsDownButton = renderSlot(
thumbsDownButton,
CopilotChatAssistantMessage.ThumbsDownButton,
@@ -157,6 +182,7 @@ export function CopilotChatAssistantMessage({
<div className="cpk:flex cpk:items-center cpk:gap-1">
{boundCopyButton}
{isInspectorEnabled && boundInspectorButton}
{isInspectorEnabled && hasContent && boundSaveSnippetButton}
{(onThumbsUp || thumbsUpButton) && boundThumbsUpButton}
{(onThumbsDown || thumbsDownButton) && boundThumbsDownButton}
{(onReadAloud || readAloudButton) && boundReadAloudButton}
@@ -176,8 +202,6 @@ export function CopilotChatAssistantMessage({
},
);
// Don't show toolbar if message has no content (only tool calls)
const hasContent = !!(message.content && message.content.trim().length > 0);
const isLatestAssistantMessage =
message.role === "assistant" &&
messages?.[messages.length - 1]?.id === message.id;
@@ -195,6 +219,7 @@ export function CopilotChatAssistantMessage({
toolCallsView: boundToolCallsView,
copyButton: boundCopyButton,
inspectorButton: boundInspectorButton,
saveSnippetButton: boundSaveSnippetButton,
thumbsUpButton: boundThumbsUpButton,
thumbsDownButton: boundThumbsDownButton,
readAloudButton: boundReadAloudButton,
@@ -375,10 +400,9 @@ export namespace CopilotChatAssistantMessage {
React.ButtonHTMLAttributes<HTMLButtonElement> & {
title: string;
tooltip?: React.ReactNode;
tooltipClassName?: string;
children: React.ReactNode;
}
> = ({ title, tooltip, tooltipClassName, children, ...props }) => {
> = ({ title, tooltip, children, ...props }) => {
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -391,7 +415,7 @@ export namespace CopilotChatAssistantMessage {
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={tooltipClassName}>
<TooltipContent side="bottom">
{tooltip ?? <p>{title}</p>}
</TooltipContent>
</Tooltip>
@@ -453,29 +477,41 @@ export namespace CopilotChatAssistantMessage {
export const InspectorButton: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement>
> = ({ title, className, ...props }) => {
> = ({ title, ...props }) => {
const config = useCopilotChatConfiguration();
const labels = config?.labels ?? CopilotChatDefaultLabels;
const primaryLabel = title || labels.assistantMessageToolbarInspectorLabel;
const accessibleLabel = `${primaryLabel} (local only)`;
const localOnlyLabel =
labels.assistantMessageToolbarInspectorLocalOnlyLabel;
const accessibleLabel = `${primaryLabel} (${localOnlyLabel})`;
return (
<ToolbarButton
data-testid="copilot-inspector-button"
title={accessibleLabel}
className={twMerge("cpk:w-auto cpk:gap-1.5 cpk:px-2", className)}
tooltipClassName="cpk:max-w-64 cpk:text-left cpk:leading-4"
tooltip="View this message in the Inspector to get more information. This button and the inspector only display during local development (localhost, dev env)."
tooltip={
<div className="cpk:flex cpk:flex-col cpk:gap-0.5">
<span>{primaryLabel}</span>
<span className="cpk:text-[10px] cpk:opacity-65">
{localOnlyLabel}
</span>
</div>
}
{...props}
>
<CopilotKitColoredIcon />
<span className="cpk:font-medium">{primaryLabel}</span>
<span className="cpk:text-xs cpk:text-muted-foreground">
(local only)
</span>
</ToolbarButton>
);
};
export const SaveSnippetButton: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement>
> = (props) => (
<SaveSnippetIconButton
data-testid="copilot-save-snippet-button"
{...props}
/>
);
export const ThumbsUpButton: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement>
> = ({ title, ...props }) => {
@@ -549,6 +585,8 @@ CopilotChatAssistantMessage.CopyButton.displayName =
"CopilotChatAssistantMessage.CopyButton";
CopilotChatAssistantMessage.InspectorButton.displayName =
"CopilotChatAssistantMessage.InspectorButton";
CopilotChatAssistantMessage.SaveSnippetButton.displayName =
"CopilotChatAssistantMessage.SaveSnippetButton";
CopilotChatAssistantMessage.ThumbsUpButton.displayName =
"CopilotChatAssistantMessage.ThumbsUpButton";
CopilotChatAssistantMessage.ThumbsDownButton.displayName =
@@ -32,6 +32,12 @@ import {
} from "../intelligence-indicator";
import type { IntelligenceIndicatorView } from "../intelligence-indicator";
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
import { useCopilotKitInspector } from "../CopilotKitInspectorContext";
import { CopilotChatDefaultLabels } from "../../providers/CopilotChatConfigurationProvider";
import {
SaveSnippetBesideChrome,
SaveSnippetIconButton,
} from "./SaveSnippetIconButton";
/**
* Resolves a slot value into a { Component, slotProps } pair, handling the three
@@ -182,6 +188,49 @@ const MemoizedUserMessage = React.memo(
/**
* Memoized wrapper for activity messages to prevent re-renders when other messages change.
*/
function ActivitySnippetChrome({
message,
children,
}: {
message: ActivityMessage;
children: React.ReactNode;
}) {
const { isInspectorEnabled, saveEventSnippet } = useCopilotKitInspector();
const chatConfiguration = useCopilotChatConfiguration();
const canSave =
isInspectorEnabled &&
(message.activityType === "a2ui-surface" ||
message.activityType === "open-generative-ui");
if (!canSave) {
return children;
}
const labels = chatConfiguration?.labels ?? CopilotChatDefaultLabels;
const primaryLabel = labels.assistantMessageToolbarSaveSnippetLabel;
return (
<SaveSnippetBesideChrome
showSave
saveButton={
<SaveSnippetIconButton
data-testid="copilot-activity-save-snippet-button"
title={primaryLabel}
onClick={() =>
void saveEventSnippet({
kind: "activity",
messageId: message.id,
activityType: message.activityType,
content: message.content,
threadId: chatConfiguration?.threadId,
agentId: chatConfiguration?.agentId,
})
}
/>
}
>
{children}
</SaveSnippetBesideChrome>
);
}
const MemoizedActivityMessage = React.memo(
function MemoizedActivityMessage({
message,
@@ -192,7 +241,11 @@ const MemoizedActivityMessage = React.memo(
message: ActivityMessage,
) => React.ReactElement | null;
}) {
return renderActivityMessage(message);
return (
<ActivitySnippetChrome message={message}>
{renderActivityMessage(message)}
</ActivitySnippetChrome>
);
},
(prevProps, nextProps) => {
// Message ID changed = different message, must re-render
@@ -5,6 +5,9 @@ import { twMerge } from "tailwind-merge";
import { Streamdown } from "streamdown";
import type { WithSlots } from "../../lib/slots";
import { renderSlot } from "../../lib/slots";
import { useCopilotKitInspector } from "../CopilotKitInspectorContext";
import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage";
import { useCopilotChatConfiguration } from "../../providers/CopilotChatConfigurationProvider";
export type CopilotChatReasoningMessageProps = WithSlots<
{
@@ -45,6 +48,8 @@ export function CopilotChatReasoningMessage({
const isLatest = messages?.[messages.length - 1]?.id === message.id;
const isStreaming = !!(isRunning && isLatest);
const hasContent = !!(message.content && message.content.length > 0);
const { isInspectorEnabled, saveEventSnippet } = useCopilotKitInspector();
const chatConfiguration = useCopilotChatConfiguration();
// Track elapsed time while streaming
const startTimeRef = useRef<number | null>(null);
@@ -145,7 +150,22 @@ export function CopilotChatReasoningMessage({
data-message-id={message.id}
{...props}
>
{boundHeader}
<div className="cpk:flex cpk:items-center cpk:gap-1">
{boundHeader}
{isInspectorEnabled && hasContent && (
<CopilotChatAssistantMessage.SaveSnippetButton
onClick={() =>
void saveEventSnippet({
kind: "reasoning",
messageId: message.id,
content: message.content ?? "",
threadId: chatConfiguration?.threadId,
agentId: chatConfiguration?.agentId,
})
}
/>
)}
</div>
{boundToggle}
</div>
);
@@ -1,6 +1,15 @@
import { useRenderToolCall } from "../../hooks";
import type { AssistantMessage, Message, ToolMessage } from "@ag-ui/core";
import React from "react";
import React, { useLayoutEffect, useRef, useState } from "react";
import { useCopilotKitInspector } from "../CopilotKitInspectorContext";
import {
CopilotChatDefaultLabels,
useCopilotChatConfiguration,
} from "../../providers/CopilotChatConfigurationProvider";
import {
SaveSnippetBesideChrome,
SaveSnippetIconButton,
} from "./SaveSnippetIconButton";
export type CopilotChatToolCallsViewProps = {
message: AssistantMessage;
@@ -12,6 +21,9 @@ export function CopilotChatToolCallsView({
messages = [],
}: CopilotChatToolCallsViewProps) {
const renderToolCall = useRenderToolCall();
const { isInspectorEnabled, saveEventSnippet } = useCopilotKitInspector();
const chatConfiguration = useCopilotChatConfiguration();
const labels = chatConfiguration?.labels ?? CopilotChatDefaultLabels;
if (!message.toolCalls || message.toolCalls.length === 0) {
return null;
@@ -23,18 +35,92 @@ export function CopilotChatToolCallsView({
const toolMessage = messages.find(
(m) => m.role === "tool" && m.toolCallId === toolCall.id,
) as ToolMessage | undefined;
const rendered = renderToolCall({
toolCall,
toolMessage,
});
if (
!isInspectorEnabled ||
!hasCompleteArgs(toolCall.function.arguments)
) {
return <React.Fragment key={toolCall.id}>{rendered}</React.Fragment>;
}
const primaryLabel = labels.assistantMessageToolbarSaveSnippetLabel;
return (
<React.Fragment key={toolCall.id}>
{renderToolCall({
toolCall,
toolMessage,
})}
</React.Fragment>
<ToolCallSnippetChrome
key={toolCall.id}
label={primaryLabel}
onSave={() =>
void saveEventSnippet({
kind: "tool-call",
messageId: message.id,
toolCallId: toolCall.id,
toolName: toolCall.function.name,
argsJson: toolCall.function.arguments || "{}",
threadId: chatConfiguration?.threadId,
agentId: chatConfiguration?.agentId,
})
}
>
{rendered}
</ToolCallSnippetChrome>
);
})}
</>
);
}
// A streaming tool call has truncated arguments. Do not offer to capture it
// until the JSON is complete, or the snippet holds a broken partial payload.
function hasCompleteArgs(args: string | undefined): boolean {
const trimmed = (args ?? "").trim();
if (!trimmed) {
return true;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
function ToolCallSnippetChrome({
children,
label,
onSave,
}: {
children: React.ReactNode;
label: string;
onSave: () => void;
}) {
const bodyRef = useRef<HTMLDivElement>(null);
const [showSave, setShowSave] = useState(false);
useLayoutEffect(() => {
const el = bodyRef.current;
setShowSave(!!el && el.childElementCount > 0);
}, [children]);
return (
<SaveSnippetBesideChrome
showSave={showSave}
saveButton={
<SaveSnippetIconButton
data-testid="copilot-tool-save-snippet-button"
title={label}
onClick={onSave}
/>
}
>
<div ref={bodyRef} className="cpk:contents">
{children}
</div>
</SaveSnippetBesideChrome>
);
}
export default CopilotChatToolCallsView;
@@ -0,0 +1,126 @@
import { useLayoutEffect, useRef, useState } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { Bookmark } from "lucide-react";
import {
CopilotChatDefaultLabels,
useCopilotChatConfiguration,
} from "../../providers/CopilotChatConfigurationProvider";
import { Button } from "../../components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../../components/ui/tooltip";
import {
SAVE_SNIPPET_BESIDE_BODY_CLASS,
SAVE_SNIPPET_BESIDE_SAVE_CLASS,
SAVE_SNIPPET_BESIDE_WRAP_CLASS,
findOverflowAncestor,
measureSaveSnippetSide,
saveSnippetBesideStyle,
} from "./save-snippet-beside";
export function SaveSnippetIconButton({
title,
className,
...props
}: ButtonHTMLAttributes<HTMLButtonElement>) {
const config = useCopilotChatConfiguration();
const labels = config?.labels ?? CopilotChatDefaultLabels;
const primaryLabel = title || labels.assistantMessageToolbarSaveSnippetLabel;
const localOnlyLabel = labels.assistantMessageToolbarInspectorLocalOnlyLabel;
const accessibleLabel = `${primaryLabel} (${localOnlyLabel})`;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="assistantMessageToolbarButton"
aria-label={accessibleLabel}
title={accessibleLabel}
className={className}
{...props}
>
<Bookmark className="cpk:size-[18px]" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<div className="cpk:flex cpk:flex-col cpk:gap-0.5">
<span>{primaryLabel}</span>
<span className="cpk:text-[10px] cpk:opacity-65">
{localOnlyLabel}
</span>
</div>
</TooltipContent>
</Tooltip>
);
}
export function SaveSnippetBesideChrome({
children,
showSave,
saveButton,
}: {
children: ReactNode;
showSave: boolean;
saveButton: ReactNode;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const [side, setSide] = useState<"left" | "right">("right");
useLayoutEffect(() => {
if (!showSave) {
return;
}
const wrap = wrapRef.current;
const body = bodyRef.current;
if (!wrap || !body) {
return;
}
const measure = () => {
setSide(measureSaveSnippetSide(wrap, body));
};
measure();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(measure);
observer.observe(wrap);
observer.observe(body);
const clip = findOverflowAncestor(wrap);
if (clip !== wrap) {
observer.observe(clip);
}
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [showSave]);
if (!showSave) {
return children;
}
return (
<div
ref={wrapRef}
className={SAVE_SNIPPET_BESIDE_WRAP_CLASS}
data-save-snippet-side={side}
>
<div ref={bodyRef} className={SAVE_SNIPPET_BESIDE_BODY_CLASS}>
{children}
</div>
<div
className={SAVE_SNIPPET_BESIDE_SAVE_CLASS}
style={saveSnippetBesideStyle(side)}
>
{saveButton}
</div>
</div>
);
}
@@ -1,11 +1,19 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { z } from "zod";
import { CopilotChatAssistantMessage } from "../CopilotChatAssistantMessage";
import { CopilotChatConfigurationProvider } from "../../../providers/CopilotChatConfigurationProvider";
import { CopilotKitProvider } from "../../../providers/CopilotKitProvider";
import type { AssistantMessage } from "@ag-ui/core";
import { CopilotKitInspectorContextProvider } from "../../CopilotKitInspectorContext";
import { defineToolCallRenderer } from "../../../types/defineToolCallRenderer";
const sayHelloRenderer = defineToolCallRenderer({
name: "sayHello",
args: z.object({ name: z.string() }),
render: () => <div>Hello card</div>,
});
// No mocks needed - Vitest handles ES modules natively!
@@ -102,39 +110,28 @@ describe("CopilotChatAssistantMessage", () => {
).toBeNull();
});
it("renders the local Inspector button and opens it from the toolbar", async () => {
it("renders the local Inspector action and opens it from the toolbar", () => {
const openInspector = vi.fn();
const saveEventSnippet = vi.fn();
renderWithProvider(
<CopilotKitInspectorContextProvider
value={{ isInspectorEnabled: true, openInspector }}
value={{
isInspectorEnabled: true,
openInspector,
saveEventSnippet,
}}
>
<CopilotChatAssistantMessage message={basicMessage} />
</CopilotKitInspectorContextProvider>,
);
const inspectorButton = screen.getByRole("button", {
name: "View in Inspector (local only)",
name: "View in Inspector (Development Only)",
});
const inspectorIcon = screen.getByTestId("copilot-inspector-icon");
expect(inspectorIcon.querySelectorAll("linearGradient")).toHaveLength(4);
expect(inspectorButton.textContent).toContain("View in Inspector");
expect(inspectorButton.textContent).toContain("(local only)");
expect(screen.queryByRole("menu")).toBeNull();
expect(
screen.queryByRole("button", { name: /save as snippet/i }),
).toBeNull();
fireEvent.mouseEnter(inspectorButton);
await waitFor(() =>
expect(
screen.getByText(
"View this message in the Inspector to get more information. This button and the inspector only display during local development (localhost, dev env).",
),
).toBeDefined(),
);
fireEvent.click(inspectorButton);
expect(openInspector).toHaveBeenCalledWith({
@@ -142,6 +139,139 @@ describe("CopilotChatAssistantMessage", () => {
threadId: TEST_THREAD_ID,
agentId: "default",
});
fireEvent.click(
screen.getByRole("button", {
name: "Save as snippet (Development Only)",
}),
);
expect(saveEventSnippet).toHaveBeenCalledWith({
kind: "text",
messageId: basicMessage.id,
content: basicMessage.content ?? "",
threadId: TEST_THREAD_ID,
agentId: "default",
});
});
it("does not put Save as snippet on the toolbar for a tool-only message", () => {
const openInspector = vi.fn();
const saveEventSnippet = vi.fn();
const toolMessage: AssistantMessage = {
role: "assistant",
content: "",
id: "tool-message-1",
toolCalls: [
{
id: "call-1",
type: "function",
function: { name: "sayHello", arguments: '{"name":"Alem"}' },
},
],
};
renderWithProvider(
<CopilotKitInspectorContextProvider
value={{
isInspectorEnabled: true,
openInspector,
saveEventSnippet,
}}
>
<CopilotChatAssistantMessage message={toolMessage} />
</CopilotKitInspectorContextProvider>,
);
expect(screen.queryByTestId("copilot-save-snippet-button")).toBeNull();
expect(
screen.queryByTestId("copilot-tool-save-snippet-button"),
).toBeNull();
});
it("saves a rendered tool from the tool overlay, not the toolbar", () => {
const openInspector = vi.fn();
const saveEventSnippet = vi.fn();
const toolMessage: AssistantMessage = {
role: "assistant",
content: "",
id: "tool-message-2",
toolCalls: [
{
id: "call-2",
type: "function",
function: { name: "sayHello", arguments: '{"name":"Alem"}' },
},
],
};
render(
<CopilotKitProvider renderToolCalls={[sayHelloRenderer]}>
<CopilotChatConfigurationProvider threadId={TEST_THREAD_ID}>
<CopilotKitInspectorContextProvider
value={{
isInspectorEnabled: true,
openInspector,
saveEventSnippet,
}}
>
<CopilotChatAssistantMessage message={toolMessage} />
</CopilotKitInspectorContextProvider>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>,
);
expect(screen.queryByTestId("copilot-save-snippet-button")).toBeNull();
const overlay = screen.getByTestId("copilot-tool-save-snippet-button");
expect(
overlay
.closest("[data-save-snippet-side]")
?.getAttribute("data-save-snippet-side"),
).toBe("right");
fireEvent.click(overlay);
expect(saveEventSnippet).toHaveBeenCalledWith({
kind: "tool-call",
messageId: toolMessage.id,
toolCallId: "call-2",
toolName: "sayHello",
argsJson: '{"name":"Alem"}',
threadId: TEST_THREAD_ID,
agentId: "default",
});
});
it("hides the tool bookmark while the arguments are still streaming", () => {
const streamingMessage: AssistantMessage = {
role: "assistant",
content: "",
id: "tool-message-3",
toolCalls: [
{
id: "call-3",
type: "function",
function: { name: "sayHello", arguments: '{"name":"Al' },
},
],
};
render(
<CopilotKitProvider renderToolCalls={[sayHelloRenderer]}>
<CopilotChatConfigurationProvider threadId={TEST_THREAD_ID}>
<CopilotKitInspectorContextProvider
value={{
isInspectorEnabled: true,
openInspector: vi.fn(),
saveEventSnippet: vi.fn(),
}}
>
<CopilotChatAssistantMessage message={streamingMessage} />
</CopilotKitInspectorContextProvider>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>,
);
expect(
screen.queryByTestId("copilot-tool-save-snippet-button"),
).toBeNull();
});
it("renders all buttons when all callbacks provided", () => {
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
pickSaveSnippetSide,
saveSnippetBesideStyle,
} from "../save-snippet-beside";
describe("pickSaveSnippetSide", () => {
it("puts the icon on the right when there is room on the right", () => {
expect(pickSaveSnippetSide(40, 8)).toBe("right");
});
it("puts the icon on the left when the right is too tight", () => {
expect(pickSaveSnippetSide(8, 40)).toBe("left");
});
it("stays on the right when both sides are too tight", () => {
expect(pickSaveSnippetSide(8, 8)).toBe("right");
});
});
describe("saveSnippetBesideStyle", () => {
it("hangs the icon just outside the right edge", () => {
expect(saveSnippetBesideStyle("right")).toEqual({
left: "100%",
right: "auto",
marginLeft: 4,
marginRight: 0,
});
});
it("hangs the icon just outside the left edge", () => {
expect(saveSnippetBesideStyle("left")).toEqual({
left: "auto",
right: "100%",
marginLeft: 0,
marginRight: 4,
});
});
});
@@ -0,0 +1,84 @@
export const SAVE_SNIPPET_ICON_SLOT_PX = 36;
export const SAVE_SNIPPET_BESIDE_WRAP_CLASS =
"cpk:relative cpk:w-full cpk:overflow-visible";
export const SAVE_SNIPPET_BESIDE_BODY_CLASS = "cpk:w-full";
export const SAVE_SNIPPET_BESIDE_SAVE_CLASS = "cpk:absolute cpk:top-0 cpk:z-10";
export function saveSnippetBesideStyle(side: "left" | "right") {
if (side === "left") {
return {
left: "auto",
right: "100%",
marginLeft: 0,
marginRight: 4,
} as const;
}
return {
left: "100%",
right: "auto",
marginLeft: 4,
marginRight: 0,
} as const;
}
export function pickSaveSnippetSide(
roomRight: number,
roomLeft: number,
slotPx = SAVE_SNIPPET_ICON_SLOT_PX,
): "left" | "right" {
if (roomRight >= slotPx) {
return "right";
}
if (roomLeft >= slotPx) {
return "left";
}
return "right";
}
export function findOverflowAncestor(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const overflowX = style.overflowX;
const overflow = style.overflow;
if (
overflowX === "hidden" ||
overflowX === "auto" ||
overflowX === "scroll" ||
overflow === "hidden" ||
overflow === "auto" ||
overflow === "scroll"
) {
return node;
}
node = node.parentElement;
}
return document.documentElement;
}
export function measurableBox(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node && getComputedStyle(node).display === "contents") {
node = node.firstElementChild as HTMLElement | null;
}
return node ?? el;
}
export function measureSaveSnippetSide(
wrap: HTMLElement,
body: HTMLElement,
): "left" | "right" {
const clip = findOverflowAncestor(wrap);
const clipRect = clip.getBoundingClientRect();
const box = measurableBox(
(body.firstElementChild as HTMLElement | null) ?? body,
);
const bodyRect = box.getBoundingClientRect();
return pickSaveSnippetSide(
clipRect.right - bodyRect.right,
bodyRect.left - clipRect.left,
);
}
@@ -26,6 +26,7 @@ export const CopilotChatDefaultLabels = {
assistantMessageToolbarCopyMessageLabel: "Copy",
assistantMessageToolbarInspectorLabel: "View in Inspector",
assistantMessageToolbarInspectorLocalOnlyLabel: "Development Only",
assistantMessageToolbarSaveSnippetLabel: "Save as snippet",
assistantMessageToolbarThumbsUpLabel: "Good response",
assistantMessageToolbarThumbsDownLabel: "Bad response",
assistantMessageToolbarReadAloudLabel: "Read aloud",
@@ -21,7 +21,10 @@ export { CopilotKitContext, useLicenseContext } from "../context";
import { z } from "zod";
import { CopilotKitInspector } from "../components/CopilotKitInspector";
import { CopilotKitInspectorContextProvider } from "../components/CopilotKitInspectorContext";
import type { CopilotKitInspectorOpenRequest } from "../components/CopilotKitInspectorContext";
import type {
CopilotKitInspectorOpenRequest,
CopilotKitInspectorSaveRequest,
} from "../components/CopilotKitInspectorContext";
import { LicenseWarningBanner } from "../components/license-warning-banner";
import { createLicenseContextValue } from "@copilotkit/shared";
import type {
@@ -336,12 +339,50 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
[],
);
const saveEventSnippet = useCallback(
async (request: CopilotKitInspectorSaveRequest) => {
try {
const mod = await import("@copilotkit/web-inspector");
const threadId = request.threadId ?? "inspector-snippet";
const runId = `inspector-snippet-${Date.now()}`;
const compiled = mod.compileChatSnippet({
...request,
threadId,
runId,
});
const now = new Date().toISOString();
const snippet = {
id: crypto.randomUUID(),
name: compiled.name,
recipe: compiled.recipe,
events: compiled.events,
createdAt: now,
updatedAt: now,
};
mod.upsertEventSnippet(snippet);
requestInspectorOpen({
messageId: request.messageId,
threadId: request.threadId,
agentId: request.agentId,
menu: "event-snippets",
snippetId: snippet.id,
});
} catch (error) {
// Compile can throw on bad args, and storage can throw QuotaExceededError.
// Callers fire this as `void saveEventSnippet(...)`, so report it here.
console.error("[CopilotKit] Could not save the event snippet.", error);
}
},
[requestInspectorOpen],
);
const inspectorContextValue = useMemo(
() => ({
isInspectorEnabled: shouldRenderInspector,
openInspector: requestInspectorOpen,
saveEventSnippet,
}),
[shouldRenderInspector, requestInspectorOpen],
[shouldRenderInspector, requestInspectorOpen, saveEventSnippet],
);
// Normalize array props to stable references with clear dev warnings
@@ -117,6 +117,8 @@ export const OpenGenerativeUIRenderer = defineComponent({
const executedExpressionIndex = ref(0);
const jsFunctionsInjected = ref(false);
const pendingQueue = ref<string[]>([]);
const autoHeight = ref<number | null>(null);
const heightMeasured = ref(false);
const localApi = computed(() => {
const api: Record<string, unknown> = {};
@@ -185,7 +187,8 @@ export const OpenGenerativeUIRenderer = defineComponent({
() => !!fullHtml.value || hasPreview.value,
);
const resolvedHeight = computed(
() => `${throttledContent.value.initialHeight ?? 200}px`,
() =>
`${autoHeight.value ?? throttledContent.value.initialHeight ?? 200}px`,
);
const destroyPreview = () => {
@@ -205,6 +208,8 @@ export const OpenGenerativeUIRenderer = defineComponent({
pendingQueue.value = [];
executedExpressionIndex.value = 0;
jsFunctionsInjected.value = false;
autoHeight.value = null;
heightMeasured.value = false;
};
watch(
@@ -354,6 +359,42 @@ export const OpenGenerativeUIRenderer = defineComponent({
{ immediate: true },
);
const measureOnce = `
(function() {
var s = document.createElement('style');
s.textContent = 'body { height: auto !important; min-height: 0 !important; }';
document.head.appendChild(s);
var h = document.body.scrollHeight;
var cs = getComputedStyle(document.body);
h += parseFloat(cs.marginTop) || 0;
h += parseFloat(cs.marginBottom) || 0;
s.remove();
parent.postMessage({ type: "__ck_resize", height: Math.ceil(h) }, "*");
})();
`;
watch(
[sandboxReady, () => throttledContent.value.generating === false],
([ready, generationDone]) => {
const sandbox = sandboxRef.value;
if (!ready || !generationDone || heightMeasured.value || !sandbox) {
return;
}
heightMeasured.value = true;
const onMessage = (event: MessageEvent) => {
if (
event.source === sandbox.iframe.contentWindow &&
event.data?.type === "__ck_resize"
) {
autoHeight.value = event.data.height;
window.removeEventListener("message", onMessage);
}
};
window.addEventListener("message", onMessage);
void sandbox.run(measureOnce);
},
);
watch(
() => throttledContent.value.jsFunctions,
(functionsCode) => {
@@ -4,6 +4,7 @@ import {
defineComponent,
getCurrentInstance,
h,
inject,
onBeforeUnmount,
onMounted,
ref,
@@ -13,6 +14,7 @@ import { StreamMarkdown } from "streamdown-vue";
import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
import { CopilotChatDefaultLabels } from "../../providers/types";
import {
IconBookmark,
IconCheck,
IconCopy,
IconDownload,
@@ -34,6 +36,7 @@ import type {
CopilotChatAssistantMessageToolbarSlotProps,
} from "./types";
import { useKatexStyles } from "../../hooks/use-katex-styles";
import { InspectorKey } from "../../providers/keys";
useKatexStyles();
@@ -88,6 +91,10 @@ const emit = defineEmits<{
const config = useCopilotChatConfiguration();
const labels = computed(() => config.value?.labels ?? CopilotChatDefaultLabels);
const inspector = inject(InspectorKey, null);
const isInspectorEnabled = computed(
() => inspector?.isInspectorEnabled.value === true,
);
const instance = getCurrentInstance();
const copied = ref(false);
let copiedResetTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -661,10 +668,31 @@ const isLatestAssistantMessage = computed(
const shouldShowToolbar = computed(
() =>
props.toolbarVisible &&
hasContent.value &&
(hasContent.value || isInspectorEnabled.value) &&
!(props.isRunning && isLatestAssistantMessage.value),
);
function handleViewInspector() {
inspector?.openInspector({
messageId: props.message.id,
threadId: config.value?.threadId,
agentId: config.value?.agentId,
});
}
function handleSaveSnippet() {
if (!hasContent.value) {
return;
}
void inspector?.saveEventSnippet({
kind: "text",
messageId: props.message.id,
content: props.message.content ?? "",
threadId: config.value?.threadId,
agentId: config.value?.agentId,
});
}
function resetCopiedStateWithDelay() {
if (copiedResetTimeout) {
clearTimeout(copiedResetTimeout);
@@ -814,6 +842,29 @@ onBeforeUnmount(() => {
</button>
</slot>
<button
v-if="isInspectorEnabled"
data-testid="copilot-inspector-button"
type="button"
:class="toolbarButtonClass"
:aria-label="`${labels.assistantMessageToolbarInspectorLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
:title="`${labels.assistantMessageToolbarInspectorLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
@click="handleViewInspector"
>
I
</button>
<button
v-if="isInspectorEnabled && hasContent"
data-testid="copilot-save-snippet-button"
type="button"
:class="toolbarButtonClass"
:aria-label="`${labels.assistantMessageToolbarSaveSnippetLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
:title="`${labels.assistantMessageToolbarSaveSnippetLabel} (${labels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
@click="handleSaveSnippet"
>
<IconBookmark class="cpk:size-[18px]" />
</button>
<slot
v-if="hasThumbsUp"
name="thumbs-up-button"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, useSlots, watch } from "vue";
import { computed, inject, ref, useSlots, watch } from "vue";
import type { Component } from "vue";
import type {
ActivityMessage,
@@ -19,6 +19,9 @@ import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfi
import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage.vue";
import CopilotChatReasoningMessage from "./CopilotChatReasoningMessage.vue";
import CopilotChatUserMessage from "./CopilotChatUserMessage.vue";
import { InspectorKey } from "../../providers/keys";
import { IconBookmark } from "../icons";
import SaveSnippetBeside from "./SaveSnippetBeside.vue";
interface MessageMetaProps {
message: Message;
@@ -38,6 +41,23 @@ interface ActivitySlotProps {
agent: unknown;
}
const inspector = inject(InspectorKey, null);
const canSaveActivity = (message: ActivityMessage) =>
inspector?.isInspectorEnabled.value === true &&
(message.activityType === "a2ui-surface" ||
message.activityType === "open-generative-ui");
function saveActivitySnippet(message: ActivityMessage) {
void inspector?.saveEventSnippet({
kind: "activity",
messageId: message.id,
activityType: message.activityType,
content: message.content,
threadId: config.value?.threadId,
agentId: config.value?.agentId,
});
}
type InterruptSlotProps = InterruptRenderProps<unknown, unknown>;
type CustomMessagePosition = VueCustomMessageRendererProps["position"];
type ResolvedCustomMessageRenderer = {
@@ -414,28 +434,42 @@ function resolveToolMessage(
/>
</slot>
<slot
v-else-if="message.role === 'activity'"
:name="getActivitySlotName(message.activityType)"
:activity-type="message.activityType"
:content="message.content"
:message="message"
:agent="resolvedThreadAgent"
>
<slot
name="activity-message"
:activity-type="message.activityType"
:content="message.content"
:message="message"
:agent="resolvedThreadAgent"
>
<component
v-if="resolveActivityRenderer(message)"
:is="resolveActivityRenderer(message)!.renderer"
v-bind="resolveActivityRenderer(message)!.props"
/>
</slot>
</slot>
<div v-else-if="message.role === 'activity'">
<SaveSnippetBeside :enabled="canSaveActivity(message)">
<slot
:name="getActivitySlotName(message.activityType)"
:activity-type="message.activityType"
:content="message.content"
:message="message"
:agent="resolvedThreadAgent"
>
<slot
name="activity-message"
:activity-type="message.activityType"
:content="message.content"
:message="message"
:agent="resolvedThreadAgent"
>
<component
v-if="resolveActivityRenderer(message)"
:is="resolveActivityRenderer(message)!.renderer"
v-bind="resolveActivityRenderer(message)!.props"
/>
</slot>
</slot>
<template #save>
<button
type="button"
class="cpk:inline-flex cpk:h-8 cpk:w-8 cpk:items-center cpk:justify-center cpk:rounded-md cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8] cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]"
data-testid="copilot-activity-save-snippet-button"
:aria-label="`${config?.labels.assistantMessageToolbarSaveSnippetLabel ?? 'Save as snippet'} (${config?.labels.assistantMessageToolbarInspectorLocalOnlyLabel ?? 'Development Only'})`"
@click="saveActivitySnippet(message)"
>
<IconBookmark class="cpk:size-[18px]" />
</button>
</template>
</SaveSnippetBeside>
</div>
<slot
v-if="componentSlots['message-after']"
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, inject, onBeforeUnmount, ref, watch } from "vue";
import type { Message, ReasoningMessage } from "@ag-ui/core";
import { StreamMarkdown } from "streamdown-vue";
import { IconChevronRight } from "../icons";
import { IconBookmark, IconChevronRight } from "../icons";
import { InspectorKey } from "../../providers/keys";
import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
import { CopilotChatDefaultLabels } from "../../providers/types";
const props = withDefaults(
defineProps<{
@@ -139,6 +142,27 @@ function toggleOpen() {
userToggledDuringStreaming.value = true;
isOpen.value = !isOpen.value;
}
const inspector = inject(InspectorKey, null);
const chatConfiguration = useCopilotChatConfiguration();
const canSaveReasoning = computed(
() => inspector?.isInspectorEnabled.value === true && hasContent.value,
);
const saveLabel = computed(
() =>
chatConfiguration.value?.labels.assistantMessageToolbarSaveSnippetLabel ??
CopilotChatDefaultLabels.assistantMessageToolbarSaveSnippetLabel,
);
function saveReasoningSnippet() {
void inspector?.saveEventSnippet({
kind: "reasoning",
messageId: props.message.id,
content: normalizedContent.value,
threadId: chatConfiguration.value?.threadId,
agentId: chatConfiguration.value?.agentId,
});
}
</script>
<template>
@@ -169,41 +193,53 @@ function toggleOpen() {
},
}"
>
<slot
name="header"
:is-open="isOpen"
:label="label"
:has-content="hasContent"
:is-streaming="isStreaming"
:on-click="hasContent ? toggleOpen : undefined"
>
<button
type="button"
class="cpk:inline-flex cpk:items-center cpk:gap-1 cpk:py-1 cpk:text-sm cpk:text-muted-foreground cpk:transition-colors cpk:select-none"
:class="
hasContent
? 'cpk:hover:text-foreground cpk:cursor-pointer'
: 'cpk:cursor-default'
"
:aria-expanded="hasContent ? isOpen : undefined"
@click="hasContent ? toggleOpen() : undefined"
<div class="cpk:flex cpk:items-center cpk:gap-1">
<slot
name="header"
:is-open="isOpen"
:label="label"
:has-content="hasContent"
:is-streaming="isStreaming"
:on-click="hasContent ? toggleOpen : undefined"
>
<span class="cpk:font-medium">{{ label }}</span>
<span
v-if="isStreaming && !hasContent"
class="cpk:inline-flex cpk:items-center cpk:ml-1"
<button
type="button"
class="cpk:inline-flex cpk:items-center cpk:gap-1 cpk:py-1 cpk:text-sm cpk:text-muted-foreground cpk:transition-colors cpk:select-none"
:class="
hasContent
? 'cpk:hover:text-foreground cpk:cursor-pointer'
: 'cpk:cursor-default'
"
:aria-expanded="hasContent ? isOpen : undefined"
@click="hasContent ? toggleOpen() : undefined"
>
<span class="cpk:font-medium">{{ label }}</span>
<span
class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
v-if="isStreaming && !hasContent"
class="cpk:inline-flex cpk:items-center cpk:ml-1"
>
<span
class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
/>
</span>
<IconChevronRight
v-if="hasContent"
class="cpk:size-3.5 cpk:shrink-0 cpk:transition-transform cpk:duration-200"
:class="{ 'cpk:rotate-90': isOpen }"
/>
</span>
<IconChevronRight
v-if="hasContent"
class="cpk:size-3.5 cpk:shrink-0 cpk:transition-transform cpk:duration-200"
:class="{ 'cpk:rotate-90': isOpen }"
/>
</button>
</slot>
<button
v-if="canSaveReasoning"
type="button"
class="cpk:inline-flex cpk:h-8 cpk:w-8 cpk:items-center cpk:justify-center cpk:rounded-md cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8] cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]"
data-testid="copilot-save-snippet-button"
:aria-label="`${saveLabel} (${chatConfiguration?.labels.assistantMessageToolbarInspectorLocalOnlyLabel ?? CopilotChatDefaultLabels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
@click="saveReasoningSnippet"
>
<IconBookmark class="cpk:size-[18px]" />
</button>
</slot>
</div>
<slot
name="toggle"
@@ -1,10 +1,15 @@
<script setup lang="ts">
import { useSlots } from "vue";
import { inject, useSlots } from "vue";
import type { AssistantMessage, Message } from "@ag-ui/core";
import type { CopilotChatToolCallRenderSlotProps } from "./types";
import CopilotChatToolCallItem from "./CopilotChatToolCallItem.vue";
import SaveSnippetBeside from "./SaveSnippetBeside.vue";
import { InspectorKey } from "../../providers/keys";
import { useCopilotChatConfiguration } from "../../providers/useCopilotChatConfiguration";
import { CopilotChatDefaultLabels } from "../../providers/types";
import { IconBookmark } from "../icons";
withDefaults(
const props = withDefaults(
defineProps<{
message: AssistantMessage;
messages?: Message[];
@@ -29,21 +34,70 @@ const componentSlots = useSlots() as Record<
function getForwardedSlotNames(): ToolCallSlotName[] {
return Object.keys(componentSlots) as ToolCallSlotName[];
}
// A streaming tool call has truncated arguments. Do not offer to capture it
// until the JSON is complete, or the snippet holds a broken partial payload.
function hasCompleteArgs(args: string | undefined): boolean {
const trimmed = (args ?? "").trim();
if (!trimmed) {
return true;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
}
const inspector = inject(InspectorKey, null);
const chatConfiguration = useCopilotChatConfiguration();
const canSave = (toolCall: { function: { arguments: string } }) =>
inspector?.isInspectorEnabled.value === true &&
hasCompleteArgs(toolCall.function.arguments);
const saveLabel = () =>
chatConfiguration.value?.labels.assistantMessageToolbarSaveSnippetLabel ??
CopilotChatDefaultLabels.assistantMessageToolbarSaveSnippetLabel;
function saveToolCall(toolCall: {
id: string;
function: { name: string; arguments: string };
}) {
void inspector?.saveEventSnippet({
kind: "tool-call",
messageId: props.message.id,
toolCallId: toolCall.id,
toolName: toolCall.function.name,
argsJson: toolCall.function.arguments || "{}",
threadId: chatConfiguration.value?.threadId,
agentId: chatConfiguration.value?.agentId,
});
}
</script>
<template>
<CopilotChatToolCallItem
v-for="toolCall in message.toolCalls ?? []"
:key="toolCall.id"
:tool-call="toolCall"
:messages="messages"
>
<template
v-for="slotName in getForwardedSlotNames()"
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps ?? {}" />
</template>
</CopilotChatToolCallItem>
<div v-for="toolCall in message.toolCalls ?? []" :key="toolCall.id">
<SaveSnippetBeside :enabled="canSave(toolCall)">
<CopilotChatToolCallItem :tool-call="toolCall" :messages="messages">
<template
v-for="slotName in getForwardedSlotNames()"
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps ?? {}" />
</template>
</CopilotChatToolCallItem>
<template #save>
<button
type="button"
class="cpk:inline-flex cpk:h-8 cpk:w-8 cpk:items-center cpk:justify-center cpk:rounded-md cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8] cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]"
data-testid="copilot-tool-save-snippet-button"
:aria-label="`${saveLabel()} (${chatConfiguration?.labels.assistantMessageToolbarInspectorLocalOnlyLabel ?? CopilotChatDefaultLabels.assistantMessageToolbarInspectorLocalOnlyLabel})`"
@click="saveToolCall(toolCall)"
>
<IconBookmark class="cpk:size-[18px]" />
</button>
</template>
</SaveSnippetBeside>
</div>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
SAVE_SNIPPET_BESIDE_BODY_CLASS,
SAVE_SNIPPET_BESIDE_SAVE_CLASS,
SAVE_SNIPPET_BESIDE_WRAP_CLASS,
findOverflowAncestor,
measureSaveSnippetSide,
saveSnippetBesideStyle,
} from "./save-snippet-beside";
const props = defineProps<{
enabled: boolean;
}>();
const wrapRef = ref<HTMLElement | null>(null);
const bodyRef = ref<HTMLElement | null>(null);
const side = ref<"left" | "right">("right");
let observer: ResizeObserver | undefined;
function measure() {
const wrap = wrapRef.value;
const body = bodyRef.value;
if (!props.enabled || !wrap || !body) {
return;
}
side.value = measureSaveSnippetSide(wrap, body);
}
function bindObserver() {
observer?.disconnect();
const wrap = wrapRef.value;
const body = bodyRef.value;
if (!props.enabled || !wrap || !body) {
return;
}
measure();
if (typeof ResizeObserver === "undefined") {
return;
}
observer = new ResizeObserver(measure);
observer.observe(wrap);
observer.observe(body);
const clip = findOverflowAncestor(wrap);
if (clip !== wrap) {
observer.observe(clip);
}
}
onMounted(() => {
watch([wrapRef, bodyRef, () => props.enabled], () => bindObserver(), {
flush: "post",
immediate: true,
});
window.addEventListener("resize", measure);
});
onBeforeUnmount(() => {
observer?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<slot v-if="!enabled" />
<div
v-else
ref="wrapRef"
:class="SAVE_SNIPPET_BESIDE_WRAP_CLASS"
:data-save-snippet-side="side"
>
<div ref="bodyRef" :class="SAVE_SNIPPET_BESIDE_BODY_CLASS">
<slot />
</div>
<div
:class="SAVE_SNIPPET_BESIDE_SAVE_CLASS"
:style="saveSnippetBesideStyle(side)"
>
<slot name="save" />
</div>
</div>
</template>
@@ -0,0 +1,84 @@
export const SAVE_SNIPPET_ICON_SLOT_PX = 36;
export const SAVE_SNIPPET_BESIDE_WRAP_CLASS =
"cpk:relative cpk:w-full cpk:overflow-visible";
export const SAVE_SNIPPET_BESIDE_BODY_CLASS = "cpk:w-full";
export const SAVE_SNIPPET_BESIDE_SAVE_CLASS = "cpk:absolute cpk:top-0 cpk:z-10";
export function saveSnippetBesideStyle(side: "left" | "right") {
if (side === "left") {
return {
left: "auto",
right: "100%",
marginLeft: "0px",
marginRight: "4px",
} as const;
}
return {
left: "100%",
right: "auto",
marginLeft: "4px",
marginRight: "0px",
} as const;
}
export function pickSaveSnippetSide(
roomRight: number,
roomLeft: number,
slotPx = SAVE_SNIPPET_ICON_SLOT_PX,
): "left" | "right" {
if (roomRight >= slotPx) {
return "right";
}
if (roomLeft >= slotPx) {
return "left";
}
return "right";
}
export function findOverflowAncestor(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el.parentElement;
while (node) {
const style = getComputedStyle(node);
const overflowX = style.overflowX;
const overflow = style.overflow;
if (
overflowX === "hidden" ||
overflowX === "auto" ||
overflowX === "scroll" ||
overflow === "hidden" ||
overflow === "auto" ||
overflow === "scroll"
) {
return node;
}
node = node.parentElement;
}
return document.documentElement;
}
export function measurableBox(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node && getComputedStyle(node).display === "contents") {
node = node.firstElementChild as HTMLElement | null;
}
return node ?? el;
}
export function measureSaveSnippetSide(
wrap: HTMLElement,
body: HTMLElement,
): "left" | "right" {
const clip = findOverflowAncestor(wrap);
const clipRect = clip.getBoundingClientRect();
const box = measurableBox(
(body.firstElementChild as HTMLElement | null) ?? body,
);
const bodyRect = box.getBoundingClientRect();
return pickSaveSnippetSide(
clipRect.right - bodyRect.right,
bodyRect.left - clipRect.left,
);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import {
IconArrowUp,
IconBookmark,
IconCheck,
IconCheckCircle,
IconChevronDown,
@@ -31,6 +32,7 @@ describe("icons adapter", () => {
IconPlus,
IconMic,
IconArrowUp,
IconBookmark,
IconCheck,
IconSquare,
IconLoader2,
@@ -5,6 +5,7 @@ export {
Plus as IconPlus,
Mic as IconMic,
ArrowUp as IconArrowUp,
Bookmark as IconBookmark,
Check as IconCheck,
Square as IconSquare,
Loader2 as IconLoader2,
@@ -35,7 +35,7 @@ import {
MCPAppsActivityType,
} from "../components/MCPAppsActivityRenderer";
import { CopilotKitKey, InspectorKey, SandboxFunctionsKey } from "./keys";
import type { VueInspectorOpenRequest } from "./keys";
import type { VueInspectorOpenRequest, VueInspectorSaveRequest } from "./keys";
import {
LicenseContextKey,
createLicenseContextValue,
@@ -128,9 +128,44 @@ function openInspector(request: VueInspectorOpenRequest) {
inspectorOpenRequest.value = { ...request };
}
async function saveEventSnippet(request: VueInspectorSaveRequest) {
try {
const mod = await import("@copilotkit/web-inspector");
const threadId = request.threadId ?? "inspector-snippet";
const runId = `inspector-snippet-${Date.now()}`;
const compiled = mod.compileChatSnippet({
...request,
threadId,
runId,
});
const now = new Date().toISOString();
const snippet = {
id: crypto.randomUUID(),
name: compiled.name,
recipe: compiled.recipe,
events: compiled.events,
createdAt: now,
updatedAt: now,
};
mod.upsertEventSnippet(snippet);
openInspector({
messageId: request.messageId,
threadId: request.threadId,
agentId: request.agentId,
menu: "event-snippets",
snippetId: snippet.id,
});
} catch (error) {
// Compile can throw on bad args, and storage can throw QuotaExceededError.
// Callers fire this as `void saveEventSnippet(...)`, so report it here.
console.error("[CopilotKit] Could not save the event snippet.", error);
}
}
provide(InspectorKey, {
isInspectorEnabled,
openInspector,
saveEventSnippet,
});
const initialFrontendTools = props.frontendTools;
+32
View File
@@ -28,11 +28,43 @@ export type VueInspectorOpenRequest = {
messageId: string;
threadId?: string;
agentId?: string;
menu?: "event-snippets";
snippetId?: string;
};
export type VueInspectorSaveRequest = {
threadId?: string;
agentId?: string;
} & (
| {
kind: "text";
messageId: string;
content: string;
}
| {
kind: "reasoning";
messageId: string;
content: string;
}
| {
kind: "tool-call";
messageId: string;
toolCallId: string;
toolName: string;
argsJson: string | Record<string, unknown>;
}
| {
kind: "activity";
messageId: string;
activityType: string;
content: unknown;
}
);
export type VueInspectorContextValue = {
isInspectorEnabled: ComputedRef<boolean>;
openInspector: (request: VueInspectorOpenRequest) => void;
saveEventSnippet: (request: VueInspectorSaveRequest) => Promise<void>;
};
export const InspectorKey: InjectionKey<VueInspectorContextValue> = Symbol(
+3
View File
@@ -8,6 +8,9 @@ export const CopilotChatDefaultLabels = {
assistantMessageToolbarCopyCodeLabel: "Copy",
assistantMessageToolbarCopyCodeCopiedLabel: "Copied",
assistantMessageToolbarCopyMessageLabel: "Copy",
assistantMessageToolbarInspectorLabel: "View in Inspector",
assistantMessageToolbarInspectorLocalOnlyLabel: "Development Only",
assistantMessageToolbarSaveSnippetLabel: "Save as snippet",
assistantMessageToolbarThumbsUpLabel: "Good response",
assistantMessageToolbarThumbsDownLabel: "Bad response",
assistantMessageToolbarReadAloudLabel: "Read aloud",
@@ -537,7 +537,12 @@ test("metadata usage stays independent from Threads capability and debug navigat
expect(findControl(root, label), label).toBeDefined();
}
await context.selectTab("Agent");
for (const label of ["AG-UI Events", "Agent", "Context"]) {
for (const label of [
"AG-UI Events",
"Event Snippets",
"Agent",
"Context",
]) {
expect(findControl(root, label), label).toBeDefined();
}
} finally {
@@ -7,7 +7,11 @@ import type { InspectorMetadataV1 } from "@copilotkit/core";
import type { ɵThread } from "@copilotkit/core";
import { expect, test, vi } from "vitest";
import { WebInspectorElement } from "../index.js";
import {
WebInspectorElement,
compileChatSnippet,
upsertEventSnippet,
} from "../index.js";
type InspectorNavigationContext = {
core: CopilotKitCore;
@@ -399,6 +403,7 @@ test("first launch opens Home with live navigation and sidebar statuses", async
"memories",
"agents",
"ag-ui-events",
"event-snippets",
"agent-context",
]);
expect(navigation.textContent).toContain("Workbench");
@@ -1156,6 +1161,7 @@ test("Inspect shows flattened live leaves and hides optional sources", async ()
"memories",
"agents",
"ag-ui-events",
"event-snippets",
"frontend-tools",
"capabilities",
"agent-context",
@@ -1186,6 +1192,7 @@ test("Inspect shows flattened live leaves and hides optional sources", async ()
"memories",
"agents",
"ag-ui-events",
"event-snippets",
"agent-context",
]);
expectCurrentNavigation(root, "inspect", "agents");
@@ -1198,6 +1205,11 @@ test("Inspect shows flattened live leaves and hides optional sources", async ()
test("persisted leaves restore after Inspector has been opened, and first upgrade open is Home", async () => {
const validLeaves = [
{ leaf: "ag-ui-events", group: "inspect", marker: "No events yet" },
{
leaf: "event-snippets",
group: "inspect",
marker: "Event Snippets",
},
{ leaf: "agents", group: "inspect", marker: "No agent selected" },
{
leaf: "frontend-tools",
@@ -1584,3 +1596,303 @@ Read what shipped.
withoutAnnouncement.teardown();
}
});
test("opening a saved snippet fills the Event Snippets recipe form", async () => {
const context = await setup();
try {
const text = compileChatSnippet({
kind: "text",
messageId: "asst-1",
content: "Hello from the saved snippet",
threadId: "thread-1",
runId: "run-1",
});
const tool = compileChatSnippet({
kind: "tool-call",
messageId: "asst-2",
toolCallId: "call-1",
toolName: "sayHello",
argsJson: '{"name":"Alem"}',
threadId: "thread-1",
runId: "run-2",
});
const textSnippet = {
id: "snip-text-1",
name: text.name,
recipe: text.recipe,
events: text.events,
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
};
const toolSnippet = {
id: "snip-tool-1",
name: tool.name,
recipe: tool.recipe,
events: tool.events,
createdAt: "2026-08-21T00:00:01.000Z",
updatedAt: "2026-08-21T00:00:01.000Z",
};
upsertEventSnippet(textSnippet);
upsertEventSnippet(toolSnippet);
context.inspector.openInspector("message_toolbar", {
messageId: "asst-1",
menu: "event-snippets",
snippetId: textSnippet.id,
});
await context.inspector.updateComplete;
const root = requireElement(
context.inspector.shadowRoot,
"Web Inspector shadow root was not rendered",
);
const recipe = requireElement(
root.querySelector<HTMLSelectElement>(
'[data-testid="cpk-snippet-recipe"]',
),
"Recipe select was not rendered",
);
const textField = requireElement(
root.querySelector<HTMLTextAreaElement>(
'[data-testid="cpk-snippet-text"]',
),
"Assistant text field was not rendered",
);
expect(recipe.value).toBe("text");
expect(textField.value).toBe("Hello from the saved snippet");
const toolRow = Array.from(root.querySelectorAll("button")).find((button) =>
button.textContent?.includes(toolSnippet.name),
);
requireElement(toolRow, "Saved tool snippet was not listed").click();
await context.inspector.updateComplete;
const toolRecipe = requireElement(
root.querySelector<HTMLSelectElement>(
'[data-testid="cpk-snippet-recipe"]',
),
"Recipe select was not rendered after tool select",
);
const toolName = requireElement(
root.querySelector<HTMLInputElement>(
'[data-testid="cpk-snippet-tool-name"]',
),
"Tool name field was not rendered",
);
const toolArgs = requireElement(
root.querySelector<HTMLTextAreaElement>(
'[data-testid="cpk-snippet-tool-args"]',
),
"Tool args field was not rendered",
);
expect(toolRecipe.value).toBe("tool-call");
expect(toolName.value).toBe("sayHello");
expect(JSON.parse(toolArgs.value)).toEqual({ name: "Alem" });
} finally {
context.teardown();
}
});
test("Event Snippets groups by recipe and disables idle actions", async () => {
const context = await setup({ agentIds: ["default"] });
try {
const text = compileChatSnippet({
kind: "text",
messageId: "asst-1",
content: "Hello from the saved snippet",
threadId: "thread-1",
runId: "run-1",
});
const tool = compileChatSnippet({
kind: "tool-call",
messageId: "asst-2",
toolCallId: "call-1",
toolName: "sayHello",
argsJson: '{"name":"Alem"}',
threadId: "thread-1",
runId: "run-2",
});
upsertEventSnippet({
id: "snip-text-1",
name: text.name,
recipe: text.recipe,
events: text.events,
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
});
upsertEventSnippet({
id: "snip-tool-1",
name: tool.name,
recipe: tool.recipe,
events: tool.events,
createdAt: "2026-08-21T00:00:01.000Z",
updatedAt: "2026-08-21T00:00:01.000Z",
});
await context.open();
await context.selectLeaf("event-snippets");
const root = requireElement(
context.inspector.shadowRoot,
"Web Inspector shadow root was not rendered",
);
const categories = Array.from(
root.querySelectorAll("[data-testid='cpk-snippet-category']"),
).map((node) => node.getAttribute("data-recipe"));
expect(categories).toEqual(["tool-call", "text"]);
expect(
root.querySelector("[data-testid='cpk-snippet-list-resize']"),
).not.toBeNull();
expect(
root.querySelectorAll("[data-testid='cpk-snippet-item']").length,
).toBe(2);
expect(
root.querySelector("[data-testid='cpk-snippet-import']"),
).not.toBeNull();
const toolItem = requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-item'][data-recipe='tool-call']",
),
"Tool snippet was not listed",
);
expect(toolItem.classList.contains("inspector-nav-control")).toBe(true);
expect(
root
.querySelector("[data-testid='cpk-snippet-category']")
?.classList.contains("inspector-sidebar-label"),
).toBe(true);
const run = requireElement(
root.querySelector<HTMLButtonElement>("[data-testid='cpk-snippet-run']"),
"Run was not rendered",
);
const save = requireElement(
root.querySelector<HTMLButtonElement>("[data-testid='cpk-snippet-save']"),
"Save was not rendered",
);
const reset = requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-reset']",
),
"Reset was not rendered",
);
const del = requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-delete']",
),
"Delete was not rendered",
);
const exp = requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-export']",
),
"Export was not rendered",
);
expect(run.disabled).toBe(true);
expect(save.disabled).toBe(true);
expect(reset.disabled).toBe(true);
expect(del.disabled).toBe(true);
expect(exp.disabled).toBe(false);
expect(
run.parentElement?.querySelector("[data-testid='cpk-snippet-import']"),
).toBeNull();
toolItem.click();
await context.inspector.updateComplete;
expect(
requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-item'][data-recipe='tool-call']",
),
"Tool snippet was not listed after select",
).classList.contains("inspector-nav-control-active"),
).toBe(true);
const runAfter = requireElement(
root.querySelector<HTMLButtonElement>("[data-testid='cpk-snippet-run']"),
"Run was not rendered after select",
);
const saveAfter = requireElement(
root.querySelector<HTMLButtonElement>("[data-testid='cpk-snippet-save']"),
"Save was not rendered after select",
);
const delAfter = requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-delete']",
),
"Delete was not rendered after select",
);
expect(runAfter.disabled).toBe(false);
expect(saveAfter.disabled).toBe(false);
expect(delAfter.disabled).toBe(false);
} finally {
context.teardown();
}
});
test("Event Snippets enables Run and Save after typing into the raw recipe", async () => {
const context = await setup({ agentIds: ["default"] });
try {
await context.open();
await context.selectLeaf("event-snippets");
const root = requireElement(
context.inspector.shadowRoot,
"Web Inspector shadow root was not rendered",
);
const recipe = requireElement(
root.querySelector<HTMLSelectElement>(
"[data-testid='cpk-snippet-recipe']",
),
"Recipe selector was not rendered",
);
recipe.value = "raw";
recipe.dispatchEvent(new Event("change", { bubbles: true }));
await context.inspector.updateComplete;
const json = requireElement(
root.querySelector<HTMLTextAreaElement>(
"[data-testid='cpk-snippet-json']",
),
"Events JSON field was not rendered",
);
expect(
requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-run']",
),
"Run was not rendered",
).disabled,
).toBe(true);
json.value = JSON.stringify([
{ type: "RUN_STARTED", threadId: "thread-1", runId: "run-1" },
{ type: "TEXT_MESSAGE_START", messageId: "msg-1", role: "assistant" },
{ type: "TEXT_MESSAGE_CONTENT", messageId: "msg-1", delta: "hi" },
{ type: "TEXT_MESSAGE_END", messageId: "msg-1" },
{ type: "RUN_FINISHED", threadId: "thread-1", runId: "run-1" },
]);
json.dispatchEvent(new Event("input", { bubbles: true }));
await context.inspector.updateComplete;
expect(
requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-run']",
),
"Run was not rendered after typing",
).disabled,
).toBe(false);
expect(
requireElement(
root.querySelector<HTMLButtonElement>(
"[data-testid='cpk-snippet-save']",
),
"Save was not rendered after typing",
).disabled,
).toBe(false);
} finally {
context.teardown();
}
});
@@ -349,6 +349,7 @@ test("What's new remains directly below Home whether or not anything is unread",
"Learning",
"Agent",
"AG-UI Events",
"Event Snippets",
"Context",
]);
expect(navUnreadMarker(context.inspector)).not.toBeNull();
@@ -41,6 +41,7 @@ type InspectorLeafKey =
| "threads"
| "whats-new"
| "ag-ui-events"
| "event-snippets"
| "agents"
| "frontend-tools"
| "capabilities"
@@ -1135,7 +1136,7 @@ test("has_threads follows only real rows visible in the active Agent context", a
});
test("metadata telemetry uses every stable legacy leaf key", async () => {
const revisions = Array.from({ length: 8 }, (_, index) =>
const revisions = Array.from({ length: 9 }, (_, index) =>
planRevision(index),
);
const harness = await setup({
@@ -1154,6 +1155,7 @@ test("metadata telemetry uses every stable legacy leaf key", async () => {
{ group: "home", leaf: "whats-new" },
{ group: "workbench", leaf: "threads" },
{ group: "inspect", leaf: "ag-ui-events" },
{ group: "inspect", leaf: "event-snippets" },
{ group: "inspect", leaf: "agents" },
{ group: "inspect", leaf: "frontend-tools" },
{ group: "inspect", leaf: "capabilities" },
+838 -1
View File
@@ -26,6 +26,8 @@ import {
ɵselectMemoriesError,
ɵselectMemoriesAvailable,
ɵselectMemoriesRealtimeStatus,
ɵinjectInspectorEvents,
ɵresetInspectorInject,
} from "@copilotkit/core";
import type {
CopilotKitCoreSubscriber,
@@ -96,6 +98,33 @@ import {
} from "./lib/inspector-nav.js";
import type { InspectorNavGroupKey, MenuKey } from "./lib/inspector-nav.js";
import { selectVisibleRealThreadId } from "./lib/thread-selection.js";
import {
ACTIVITY_STARTERS,
compileActivityRecipe,
compileReasoningRecipe,
compileTextRecipe,
compileToolCallRecipe,
createSnippetId,
deleteEventSnippet,
expandSnippetEventsForRun,
exportEventSnippetsJson,
groupEventSnippets,
importEventSnippets,
loadEventSnippets,
parseSnippetEvents,
editorStateFromSnippet,
recipeIconName,
recipeIconWrapClass,
recipeLabel,
snippetContainsToolCall,
snippetJsonIsRunnable,
upsertEventSnippet,
} from "./lib/event-snippets.js";
import type {
EventSnippet,
LastInject,
SnippetRecipe,
} from "./lib/event-snippets.js";
import {
TELEMETRY_DOCS_URL,
ensureTelemetryDistinctId,
@@ -128,6 +157,8 @@ import {
trackWhatsNewClicked,
trackWhatsNewSignalViewed,
trackWhatsNewViewed,
trackEventSnippetsRun,
trackEventSnippetsSaved,
} from "./lib/telemetry.js";
import {
createOnboardingPrompt,
@@ -157,6 +188,16 @@ import type {
export type { Anchor } from "./lib/types.js";
export { buildCapabilityRows as ɵbuildCapabilityRows };
export type { CapabilityToolRow as ɵCapabilityToolRow };
export {
compileChatSnippet,
compileFromActivityMessage,
upsertEventSnippet,
} from "./lib/event-snippets.js";
export type {
ActivitySnippetMessage,
ChatSnippetCapture,
EventSnippet,
} from "./lib/event-snippets.js";
export type InspectorOpenOptions = {
/** Select the thread that contains the message. */
@@ -165,6 +206,10 @@ export type InspectorOpenOptions = {
agentId?: string;
/** Scroll the selected thread timeline to this message when available. */
messageId?: string;
/** Open this Inspect leaf instead of Threads. */
menu?: MenuKey;
/** Select this Event Snippet after open. */
snippetId?: string;
};
export const WEB_INSPECTOR_TAG = "cpk-web-inspector" as const;
@@ -6349,6 +6394,26 @@ export class WebInspectorElement extends LitElement {
inspect: "ag-ui-events",
};
private lastScrolledAgentNavigationLayout: string | null = null;
private eventSnippets: EventSnippet[] = [];
private selectedSnippetId: string | null = null;
private snippetRecipe: SnippetRecipe = "tool-call";
private snippetName = "";
private snippetJson = "[]";
private snippetToolName = "";
private snippetToolArgs = "{}";
private snippetReasoningText = "";
private snippetTextContent = "";
private snippetActivityType = "a2ui-surface";
private snippetActivityContent = "{}";
private snippetError: string | null = null;
private snippetBanner: string | null = null;
private snippetConfirmOpen = false;
private lastInject: LastInject | null = null;
private snippetListWidth = 200;
private snippetDividerResizing = false;
private snippetDividerPointerId = -1;
private snippetDividerStartX = 0;
private snippetDividerStartWidth = 0;
private selectedThreadId: string | null = null;
private inAppThreadId: string | null = null;
private inAppAgentId: string | null = null;
@@ -6671,6 +6736,11 @@ export class WebInspectorElement extends LitElement {
label: "AG-UI Events",
icon: "Zap" as LucideIconName,
},
{
key: "event-snippets",
label: "Event Snippets",
icon: "Code" as LucideIconName,
},
{ key: "agents", label: "Agent", icon: "Bot" as LucideIconName },
...(hasFrontendTools
? [
@@ -8096,6 +8166,27 @@ export class WebInspectorElement extends LitElement {
this.requestUpdate();
}
private focusEventSnippets(options: InspectorOpenOptions): void {
this.pendingPersistedMenu = null;
this.selectedMenu = "event-snippets";
this.settingsOpen = false;
this.lastSelectedMenuByGroup.inspect = "event-snippets";
this.contextMenuOpen = false;
this.layoutMenuOpen = false;
this.reloadEventSnippets();
if (options.snippetId) {
this.selectEventSnippet(options.snippetId);
}
if (
options.agentId &&
this.contextOptions.some((option) => option.key === options.agentId)
) {
this.selectedContext = options.agentId;
}
this.persistState();
this.requestUpdate();
}
private filterEvents(events: InspectorEvent[]): InspectorEvent[] {
const query = this.eventFilterText.trim().toLowerCase();
@@ -13917,7 +14008,9 @@ export class WebInspectorElement extends LitElement {
source: InspectorOpenSource,
options: InspectorOpenOptions = {},
): void {
if (options.threadId) {
if (options.snippetId || options.menu === "event-snippets") {
this.focusEventSnippets(options);
} else if (options.threadId) {
this.focusThread(options);
}
@@ -14866,6 +14959,709 @@ export class WebInspectorElement extends LitElement {
`;
}
private reloadEventSnippets(): void {
this.eventSnippets = loadEventSnippets();
}
private handleSnippetImportChange = async (event: Event): Promise<void> => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
try {
this.eventSnippets = importEventSnippets(await file.text());
this.snippetError = null;
} catch (error) {
this.snippetError =
error instanceof Error ? error.message : "Import failed.";
}
this.requestUpdate();
};
private renderSnippetImportControl() {
return html`
<label
class="inline-flex shrink-0 cursor-pointer items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-[11px] text-gray-700"
data-testid="cpk-snippet-import"
>
Import
<input
type="file"
accept="application/json"
class="hidden"
@change=${this.handleSnippetImportChange}
/>
</label>
`;
}
private selectEventSnippet(id: string): void {
const snippet = this.eventSnippets.find((item) => item.id === id);
this.selectedSnippetId = id;
if (!snippet) {
this.requestUpdate();
return;
}
const editor = editorStateFromSnippet(snippet);
this.snippetRecipe = editor.recipe;
this.snippetName = editor.name;
this.snippetJson = editor.json;
this.snippetToolName = editor.draft.toolName;
this.snippetToolArgs = editor.draft.toolArgs;
this.snippetReasoningText = editor.draft.reasoningText;
this.snippetTextContent = editor.draft.textContent;
this.snippetActivityType = editor.draft.activityType;
this.snippetActivityContent = editor.draft.activityContent;
this.snippetError = null;
this.requestUpdate();
}
private compileSnippetDraft(): ReturnType<typeof parseSnippetEvents> {
switch (this.snippetRecipe) {
case "tool-call":
return compileToolCallRecipe({
toolName: this.snippetToolName,
argsJson: this.snippetToolArgs,
threadId: this.getSnippetThreadId(),
runId: this.getSnippetRunId(),
});
case "reasoning":
return compileReasoningRecipe({
text: this.snippetReasoningText,
threadId: this.getSnippetThreadId(),
runId: this.getSnippetRunId(),
});
case "text":
return compileTextRecipe({
text: this.snippetTextContent,
threadId: this.getSnippetThreadId(),
runId: this.getSnippetRunId(),
});
case "activity":
return compileActivityRecipe({
activityType: this.snippetActivityType,
contentJson: this.snippetActivityContent,
threadId: this.getSnippetThreadId(),
runId: this.getSnippetRunId(),
});
case "raw":
return parseSnippetEvents(this.snippetJson);
}
}
private getSnippetThreadId(): string {
return this.selectedThreadId ?? "inspector-snippet";
}
private getSnippetRunId(): string {
return `inspector-snippet-${Date.now()}`;
}
private getSnippetTargetAgent(): AbstractAgent | null {
const core = this._core;
if (!core) {
return null;
}
const selected =
this.selectedContext !== "all-agents" ? this.selectedContext : null;
if (selected) {
return core.getAgent(selected) ?? null;
}
const first = this.contextOptions.find(
(option) => option.key !== "all-agents",
);
return first ? (core.getAgent(first.key) ?? null) : null;
}
private applyRecipeToEditor(): void {
try {
const events = this.compileSnippetDraft();
this.snippetJson = JSON.stringify(events, null, 2);
this.snippetError = null;
} catch (error) {
this.snippetError =
error instanceof Error ? error.message : "Could not compile recipe.";
}
this.requestUpdate();
}
private handleSnippetRecipeChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
if (
value === "tool-call" ||
value === "reasoning" ||
value === "text" ||
value === "activity" ||
value === "raw"
) {
this.snippetRecipe = value;
if (value !== "raw") {
this.applyRecipeToEditor();
} else {
this.requestUpdate();
}
}
}
private saveCurrentSnippet(source: "chat" | "pane"): void {
try {
const events = parseSnippetEvents(this.snippetJson);
const now = new Date().toISOString();
const snippet: EventSnippet = {
id: this.selectedSnippetId ?? createSnippetId(),
name: this.snippetName.trim() || recipeLabel(this.snippetRecipe),
recipe: this.snippetRecipe,
events,
createdAt:
this.eventSnippets.find((item) => item.id === this.selectedSnippetId)
?.createdAt ?? now,
updatedAt: now,
};
this.eventSnippets = upsertEventSnippet(snippet);
this.selectedSnippetId = snippet.id;
this.snippetBanner = "Snippet saved.";
this.snippetError = null;
if (!this.core?.telemetryDisabled) {
trackEventSnippetsSaved({
recipe: this.snippetRecipe,
source,
success: true,
});
}
} catch (error) {
this.snippetError =
error instanceof Error ? error.message : "Could not save snippet.";
if (!this.core?.telemetryDisabled) {
trackEventSnippetsSaved({
recipe: this.snippetRecipe,
source,
success: false,
});
}
}
this.requestUpdate();
}
private async runCurrentSnippet(): Promise<void> {
this.snippetError = null;
this.snippetBanner = null;
let events;
try {
events = expandSnippetEventsForRun(parseSnippetEvents(this.snippetJson));
} catch (error) {
this.snippetError =
error instanceof Error ? error.message : "Snippet JSON is invalid.";
this.requestUpdate();
return;
}
if (snippetContainsToolCall(events) && !this.snippetConfirmOpen) {
this.snippetConfirmOpen = true;
this.requestUpdate();
return;
}
this.snippetConfirmOpen = false;
const core = this._core;
const agent = this.getSnippetTargetAgent();
if (!core || !agent) {
this.snippetError = "No agent is available to inject into.";
this.requestUpdate();
return;
}
if (agent.isRunning) {
this.snippetError =
"The agent is running. Wait for the current run to end.";
this.requestUpdate();
return;
}
try {
const result = await ɵinjectInspectorEvents({
core,
agent,
events,
});
this.lastInject = {
snippetId: this.selectedSnippetId ?? "unsaved",
agentId: agent.agentId ?? "default",
runId: this.getSnippetRunId(),
messageIds: result.messageIds,
};
this.snippetBanner =
"Inspector injected these events into the live thread.";
if (!this.core?.telemetryDisabled) {
trackEventSnippetsRun({
recipe: this.snippetRecipe,
source: "pane",
success: true,
});
}
} catch (error) {
this.snippetError =
error instanceof Error ? error.message : "Could not run snippet.";
if (!this.core?.telemetryDisabled) {
trackEventSnippetsRun({
recipe: this.snippetRecipe,
source: "pane",
success: false,
});
}
}
this.requestUpdate();
}
private resetLastSnippetRun(): void {
const last = this.lastInject;
const core = this._core;
if (!last || !core) {
return;
}
const agent = core.getAgent(last.agentId);
if (!agent) {
this.snippetError = "The injected agent is no longer available.";
this.requestUpdate();
return;
}
ɵresetInspectorInject({ agent, messageIds: last.messageIds });
this.lastInject = null;
this.snippetBanner =
"Last inject was removed from the thread. App state from a tool handler was not undone.";
this.requestUpdate();
}
private renderEventSnippetsView() {
if (this.eventSnippets.length === 0 && this.selectedSnippetId === null) {
this.reloadEventSnippets();
}
const agent = this.getSnippetTargetAgent();
const runBlocked = agent?.isRunning === true;
const canRunJson = snippetJsonIsRunnable(this.snippetJson);
const canRun = canRunJson && !!agent && !runBlocked;
const canSave = canRunJson;
const canDelete = this.selectedSnippetId !== null;
const canExport = this.eventSnippets.length > 0;
const canReset = this.lastInject !== null;
const snippetGroups = groupEventSnippets(this.eventSnippets);
const tools = this._core?.tools ?? [];
return html`
<div class="flex h-full min-h-0 flex-col bg-white">
<div
class="flex items-center justify-between gap-4 border-b border-gray-200 px-4 py-3"
>
<div class="min-w-0">
<h2 class="text-sm font-semibold text-gray-900">Event Snippets</h2>
<p class="mt-1 text-[11px] text-gray-500">
Compile AG-UI events, run them on the live agent, and save them
for later. Tool-call runs use the real frontend-tool handler.
</p>
</div>
${this.renderSnippetImportControl()}
</div>
${
this.snippetBanner
? html`<div
class="border-b border-emerald-200 bg-emerald-50 px-4 py-2 text-[11px] text-emerald-800"
role="status"
>
${this.snippetBanner}
</div>`
: nothing
}
${
this.snippetError
? html`<div
class="border-b border-rose-200 bg-rose-50 px-4 py-2 text-[11px] text-rose-800"
role="alert"
>
${this.snippetError}
</div>`
: nothing
}
${
this.snippetConfirmOpen
? html`<div
class="border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11px] text-amber-900"
role="alertdialog"
aria-label="Confirm tool-call run"
>
<p>
This snippet contains a tool call. Run will execute the real
handler on the live thread.
</p>
<div class="mt-2 flex gap-2">
<button
type="button"
class="rounded-md bg-gray-900 px-2 py-1 text-[11px] text-white"
data-testid="cpk-snippet-run-handler"
@click=${() => {
this.snippetConfirmOpen = true;
void this.runCurrentSnippet();
}}
>
Run handler
</button>
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-[11px]"
@click=${() => {
this.snippetConfirmOpen = false;
this.requestUpdate();
}}
>
Cancel
</button>
</div>
</div>`
: nothing
}
<div class="flex min-h-0 flex-1">
<div
class="inspector-snippet-sidebar shrink-0"
style="width:${this.snippetListWidth}px"
data-testid="cpk-snippet-list"
>
${
snippetGroups.length === 0
? html`
<p class="px-2 py-2 text-[11px] text-gray-500">No saved snippets yet.</p>
`
: html`
<nav class="inspector-sidebar-nav" aria-label="Saved snippets">
${snippetGroups.map(
(group) => html`
<div class="inspector-sidebar-section">
<div
class="inspector-sidebar-label"
data-testid="cpk-snippet-category"
data-recipe=${group.recipe}
>
${group.label}
</div>
${group.snippets.map((snippet) => {
const isSelected =
snippet.id === this.selectedSnippetId;
return html`
<button
type="button"
class="inspector-nav-control inspector-sidebar-control ${
isSelected
? "inspector-nav-control-active"
: ""
}"
data-testid="cpk-snippet-item"
data-recipe=${snippet.recipe}
aria-current=${isSelected ? "page" : nothing}
aria-label="${recipeLabel(snippet.recipe)}: ${snippet.name}"
style=${INTERACTIVE_FOCUS_BASE_STYLE}
@click=${() =>
this.selectEventSnippet(snippet.id)}
>
<span
class="inspector-nav-icon"
aria-hidden="true"
>
<span
class="flex h-6 w-6 items-center justify-center rounded-md ${recipeIconWrapClass(
snippet.recipe,
)}"
>
${this.renderIcon(
recipeIconName(
snippet.recipe,
) as LucideIconName,
)}
</span>
</span>
<span class="inspector-nav-label"
>${snippet.name}</span
>
</button>
`;
})}
</div>
`,
)}
</nav>
`
}
</div>
<div
class="w-1.5 shrink-0 cursor-col-resize bg-gray-200 hover:bg-gray-400"
role="separator"
aria-orientation="vertical"
aria-label="Resize snippet list"
title="Drag to resize"
data-testid="cpk-snippet-list-resize"
style="touch-action:none"
@pointerdown=${this.handleSnippetListDividerPointerDown}
@pointermove=${this.handleSnippetListDividerPointerMove}
@pointerup=${this.handleSnippetListDividerPointerUp}
@pointercancel=${this.handleSnippetListDividerPointerUp}
></div>
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-auto p-4">
<label class="text-[11px] text-gray-600">
Name
<input
class="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-[11px]"
data-testid="cpk-snippet-name"
.value=${this.snippetName}
@input=${(event: Event) => {
this.snippetName = (event.target as HTMLInputElement).value;
}}
/>
</label>
<label class="text-[11px] text-gray-600">
Recipe
<select
class="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-[11px]"
data-testid="cpk-snippet-recipe"
.value=${this.snippetRecipe}
@change=${this.handleSnippetRecipeChange}
>
${["tool-call", "reasoning", "text", "activity", "raw"].map(
(recipe) => html`<option
value=${recipe}
?selected=${recipe === this.snippetRecipe}
>
${recipeLabel(recipe as SnippetRecipe)}
</option>`,
)}
</select>
</label>
${
this.snippetRecipe === "tool-call"
? html`
<label class="text-[11px] text-gray-600">
Tool
<input
list="cpk-snippet-tools"
class="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-[11px]"
data-testid="cpk-snippet-tool-name"
.value=${this.snippetToolName}
@input=${(event: Event) => {
this.snippetToolName = (
event.target as HTMLInputElement
).value;
this.applyRecipeToEditor();
}}
/>
<datalist id="cpk-snippet-tools">
${tools.map(
(tool) => html`<option value=${tool.name}></option>`,
)}
</datalist>
</label>
<label class="text-[11px] text-gray-600">
Args JSON
<textarea
class="mt-1 h-20 w-full rounded-md border border-gray-200 px-2 py-1 font-mono text-[11px]"
data-testid="cpk-snippet-tool-args"
.value=${this.snippetToolArgs}
@input=${(event: Event) => {
this.snippetToolArgs = (
event.target as HTMLTextAreaElement
).value;
this.applyRecipeToEditor();
}}
></textarea>
</label>
`
: nothing
}
${
this.snippetRecipe === "reasoning"
? html`<label class="text-[11px] text-gray-600">
Reasoning
<textarea
class="mt-1 h-20 w-full rounded-md border border-gray-200 px-2 py-1 text-[11px]"
data-testid="cpk-snippet-reasoning"
.value=${this.snippetReasoningText}
@input=${(event: Event) => {
this.snippetReasoningText = (
event.target as HTMLTextAreaElement
).value;
this.applyRecipeToEditor();
}}
></textarea>
</label>`
: nothing
}
${
this.snippetRecipe === "text"
? html`<label class="text-[11px] text-gray-600">
Assistant text
<textarea
class="mt-1 h-20 w-full rounded-md border border-gray-200 px-2 py-1 text-[11px]"
data-testid="cpk-snippet-text"
.value=${this.snippetTextContent}
@input=${(event: Event) => {
this.snippetTextContent = (
event.target as HTMLTextAreaElement
).value;
this.applyRecipeToEditor();
}}
></textarea>
</label>`
: nothing
}
${
this.snippetRecipe === "activity"
? html`
<label class="text-[11px] text-gray-600">
Activity type
<input
list="cpk-snippet-activity"
class="mt-1 w-full rounded-md border border-gray-200 px-2 py-1 text-[11px]"
data-testid="cpk-snippet-activity-type"
.value=${this.snippetActivityType}
@input=${(event: Event) => {
this.snippetActivityType = (
event.target as HTMLInputElement
).value;
this.applyRecipeToEditor();
}}
/>
<datalist id="cpk-snippet-activity">
${ACTIVITY_STARTERS.map(
(type) => html`<option value=${type}></option>`,
)}
</datalist>
</label>
<label class="text-[11px] text-gray-600">
Content JSON
<textarea
class="mt-1 h-24 w-full rounded-md border border-gray-200 px-2 py-1 font-mono text-[11px]"
data-testid="cpk-snippet-activity-content"
.value=${this.snippetActivityContent}
@input=${(event: Event) => {
this.snippetActivityContent = (
event.target as HTMLTextAreaElement
).value;
this.applyRecipeToEditor();
}}
></textarea>
</label>
`
: nothing
}
<label class="text-[11px] text-gray-600">
Events JSON
<textarea
class="mt-1 h-40 w-full rounded-md border border-gray-200 px-2 py-1 font-mono text-[11px]"
data-testid="cpk-snippet-json"
.value=${this.snippetJson}
@input=${(event: Event) => {
this.snippetJson = (
event.target as HTMLTextAreaElement
).value;
this.requestUpdate();
}}
></textarea>
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-md bg-gray-900 px-3 py-1.5 text-[11px] text-white disabled:opacity-50"
data-testid="cpk-snippet-run"
?disabled=${!canRun}
title=${
runBlocked
? "The agent is running. Wait for the current run to end."
: !agent
? "No agent is available to inject into."
: !canRunJson
? "Events JSON must be a non-empty event array."
: "Run snippet"
}
@click=${() => void this.runCurrentSnippet()}
>
${runBlocked ? "Agent running" : "Run"}
</button>
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-[11px] disabled:opacity-50"
data-testid="cpk-snippet-save"
?disabled=${!canSave}
title=${
canSave
? "Save snippet"
: "Events JSON must be a non-empty event array."
}
@click=${() => this.saveCurrentSnippet("pane")}
>
Save
</button>
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-[11px] disabled:opacity-50"
data-testid="cpk-snippet-reset"
?disabled=${!canReset}
title=${
canReset
? "Remove the last inject from the thread"
: "No Inspector inject to reset."
}
@click=${() => this.resetLastSnippetRun()}
>
Reset last run
</button>
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-[11px] disabled:opacity-50"
data-testid="cpk-snippet-delete"
?disabled=${!canDelete}
title=${
canDelete
? "Delete this snippet"
: "Select a snippet to delete."
}
@click=${() => {
if (!this.selectedSnippetId) return;
if (!window.confirm("Delete this snippet?")) {
return;
}
this.eventSnippets = deleteEventSnippet(
this.selectedSnippetId,
);
this.selectedSnippetId = null;
this.requestUpdate();
}}
>
Delete
</button>
<button
type="button"
class="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-[11px] disabled:opacity-50"
data-testid="cpk-snippet-export"
?disabled=${!canExport}
title=${
canExport
? "Export snippets"
: "Save a snippet before you export."
}
@click=${() => {
if (!canExport) return;
const blob = new Blob(
[exportEventSnippetsJson(this.eventSnippets)],
{ type: "application/json" },
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "event-snippets.json";
link.click();
URL.revokeObjectURL(url);
}}
>
Export
</button>
</div>
</div>
</div>
</div>
`;
}
private renderMainContent() {
if (this.settingsOpen) {
return this.renderSettingsPanel();
@@ -14894,6 +15690,10 @@ export class WebInspectorElement extends LitElement {
return this.renderPlaygroundView();
}
if (this.selectedMenu === "event-snippets") {
return this.renderEventSnippetsView();
}
if (this.selectedMenu === "agents") {
return this.renderAgentsView();
}
@@ -15711,6 +16511,39 @@ export class WebInspectorElement extends LitElement {
);
};
private handleSnippetListDividerPointerDown = (event: PointerEvent) => {
this.snippetDividerResizing = true;
this.snippetDividerPointerId = event.pointerId;
this.snippetDividerStartX = event.clientX;
this.snippetDividerStartWidth = this.snippetListWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
};
private handleSnippetListDividerPointerMove = (event: PointerEvent) => {
if (
!this.snippetDividerResizing ||
this.snippetDividerPointerId !== event.pointerId
) {
return;
}
const delta = event.clientX - this.snippetDividerStartX;
this.snippetListWidth = Math.max(
160,
Math.min(360, this.snippetDividerStartWidth + delta),
);
this.requestUpdate();
};
private handleSnippetListDividerPointerUp = (event: PointerEvent) => {
if (this.snippetDividerPointerId !== event.pointerId) return;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(this.snippetDividerPointerId)) {
target.releasePointerCapture(this.snippetDividerPointerId);
}
this.snippetDividerResizing = false;
};
private handleThreadDividerPointerDown = (event: PointerEvent) => {
this.threadDividerResizing = true;
this.threadDividerPointerId = event.pointerId;
@@ -18339,6 +19172,10 @@ export class WebInspectorElement extends LitElement {
this.homeViewedThisOpen = false;
}
if (key === "event-snippets") {
this.reloadEventSnippets();
}
if (key === "ag-ui-events" || key === "agents") {
const keepErrorLanding =
this.pendingScrollToEventId !== null ||
@@ -0,0 +1,508 @@
import { afterEach, describe, expect, it } from "vitest";
import {
EVENT_SNIPPETS_STORAGE_KEY,
compileActivityRecipe,
compileChatSnippet,
compileFromActivityMessage,
expandSnippetEventsForRun,
groupEventSnippets,
recipeIconName,
recipeIconWrapClass,
snippetJsonIsRunnable,
compileReasoningRecipe,
compileTextRecipe,
compileToolCallRecipe,
deleteEventSnippet,
ensureRunEnvelope,
exportEventSnippetsJson,
importEventSnippets,
loadEventSnippets,
parseSnippetEvents,
editorStateFromSnippet,
recipeDraftFromEvents,
snippetArgsJson,
snippetContainsToolCall,
upsertEventSnippet,
} from "../event-snippets.js";
import type { EventSnippet, SnippetEvent } from "../event-snippets.js";
const THREAD_ID = "thread-1";
const RUN_ID = "run-1";
function memoryStorage(initial: Record<string, string> = {}) {
const store = { ...initial };
return {
getItem(key: string) {
return store[key] ?? null;
},
setItem(key: string, value: string) {
store[key] = value;
},
dump() {
return store;
},
};
}
function typesOf(events: ReadonlyArray<SnippetEvent>) {
return events.map((event) => event.type);
}
describe("event snippet recipes", () => {
it("compiles a tool call without RESULT and wraps the run", () => {
const events = compileToolCallRecipe({
toolName: "get_weather",
argsJson: '{"city":"Berlin"}',
threadId: THREAD_ID,
runId: RUN_ID,
parentMessageId: "msg-1",
toolCallId: "call-1",
});
expect(typesOf(events)).toEqual([
"RUN_STARTED",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"RUN_FINISHED",
]);
expect(snippetContainsToolCall(events)).toBe(true);
});
it("rejects an empty tool name and invalid args JSON", () => {
expect(() =>
compileToolCallRecipe({
toolName: " ",
argsJson: "{}",
threadId: THREAD_ID,
runId: RUN_ID,
}),
).toThrow("Tool name is required.");
expect(() =>
compileToolCallRecipe({
toolName: "get_weather",
argsJson: "{",
threadId: THREAD_ID,
runId: RUN_ID,
}),
).toThrow("Tool args JSON is invalid.");
const emptyArgs = compileToolCallRecipe({
toolName: "sayHello",
argsJson: " ",
threadId: THREAD_ID,
runId: RUN_ID,
});
const argsEvent = emptyArgs.find(
(event) => event.type === "TOOL_CALL_ARGS",
);
expect(argsEvent?.delta).toBe("{}");
expect(snippetArgsJson({ name: "Alem" })).toBe('{"name":"Alem"}');
expect(snippetArgsJson('{"name":"Alem"}{"name":"Alem"}')).toBe(
'{"name":"Alem"}',
);
const fromObject = compileToolCallRecipe({
toolName: "sayHello",
argsJson: { name: "Alem" },
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(
fromObject.find((event) => event.type === "TOOL_CALL_ARGS")?.delta,
).toBe('{"name":"Alem"}');
});
it("rejects truncated tool args fast instead of scanning every prefix", () => {
// A streaming tool call. No prefix ever parses, so the old recovery loop
// walked the whole string once per character.
const truncated = `{"html":"${"a".repeat(200_000)}`;
const started = performance.now();
expect(() => snippetArgsJson(truncated)).toThrow(
"Tool args JSON is invalid.",
);
expect(performance.now() - started).toBeLessThan(1_000);
});
it("still recovers the first object when text follows it", () => {
expect(snippetArgsJson('{"a":{"b":"}"}} trailing junk')).toBe(
'{"a":{"b":"}"}}',
);
expect(snippetArgsJson('{"a":"\\""} tail')).toBe('{"a":"\\""}');
});
it("compiles reasoning, text, and activity recipes", () => {
expect(
typesOf(
compileReasoningRecipe({
text: "Checking the schema",
threadId: THREAD_ID,
runId: RUN_ID,
}),
),
).toEqual([
"RUN_STARTED",
"REASONING_START",
"REASONING_MESSAGE_START",
"REASONING_MESSAGE_CONTENT",
"REASONING_MESSAGE_END",
"REASONING_END",
"RUN_FINISHED",
]);
expect(
typesOf(
compileTextRecipe({
text: "Hello",
threadId: THREAD_ID,
runId: RUN_ID,
messageId: "msg-text",
}),
),
).toEqual([
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"RUN_FINISHED",
]);
const activity = compileActivityRecipe({
activityType: "a2ui-surface",
contentJson: '{"a2ui_operations":[]}',
threadId: THREAD_ID,
runId: RUN_ID,
messageId: "msg-a2ui",
});
expect(typesOf(activity)).toEqual([
"RUN_STARTED",
"ACTIVITY_SNAPSHOT",
"RUN_FINISHED",
]);
expect(snippetContainsToolCall(activity)).toBe(false);
});
it("does not add a second run envelope when one is already present", () => {
const events = ensureRunEnvelope(
[
{ type: "RUN_STARTED", threadId: THREAD_ID, runId: RUN_ID },
{ type: "TEXT_MESSAGE_START", messageId: "m1", role: "assistant" },
{ type: "RUN_FINISHED", threadId: THREAD_ID, runId: RUN_ID },
],
{ threadId: THREAD_ID, runId: RUN_ID },
);
expect(typesOf(events)).toEqual([
"RUN_STARTED",
"TEXT_MESSAGE_START",
"RUN_FINISHED",
]);
});
});
describe("event snippet capture", () => {
it("saves text, reasoning, and tool-call captures as their own recipes", () => {
const text = compileChatSnippet({
kind: "text",
messageId: "asst-1",
content: "Looking that up",
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(text.recipe).toBe("text");
expect(text.name).toContain("Looking that up");
expect(typesOf(text.events)).toEqual([
"RUN_STARTED",
"TEXT_MESSAGE_START",
"TEXT_MESSAGE_CONTENT",
"TEXT_MESSAGE_END",
"RUN_FINISHED",
]);
const reasoning = compileChatSnippet({
kind: "reasoning",
messageId: "think-1",
content: "I should check the weather",
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(reasoning.recipe).toBe("reasoning");
expect(typesOf(reasoning.events)).toContain("REASONING_MESSAGE_CONTENT");
const tool = compileChatSnippet({
kind: "tool-call",
messageId: "asst-1",
toolCallId: "call-1",
toolName: "get_weather",
argsJson: '{"city":"Oslo"}',
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(tool.recipe).toBe("tool-call");
expect(typesOf(tool.events)).toEqual([
"RUN_STARTED",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"RUN_FINISHED",
]);
expect(tool.events.some((event) => event.type === "TOOL_CALL_RESULT")).toBe(
false,
);
});
it("fills recipe fields from saved events", () => {
const tool = compileChatSnippet({
kind: "tool-call",
messageId: "asst-1",
toolCallId: "call-1",
toolName: "sayHello",
argsJson: '{"name":"Alem"}',
threadId: THREAD_ID,
runId: RUN_ID,
});
const draft = recipeDraftFromEvents(tool.events);
expect(draft.toolName).toBe("sayHello");
expect(JSON.parse(draft.toolArgs)).toEqual({ name: "Alem" });
const text = compileChatSnippet({
kind: "text",
messageId: "asst-2",
content: "Hello there",
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(recipeDraftFromEvents(text.events).textContent).toBe("Hello there");
const reasoning = compileChatSnippet({
kind: "reasoning",
messageId: "think-1",
content: "I should check the weather",
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(recipeDraftFromEvents(reasoning.events).reasoningText).toBe(
"I should check the weather",
);
const activity = compileChatSnippet({
kind: "activity",
messageId: "act-2",
activityType: "a2ui-surface",
content: { a2ui_operations: [] },
threadId: THREAD_ID,
runId: RUN_ID,
});
const activityDraft = recipeDraftFromEvents(activity.events);
expect(activityDraft.activityType).toBe("a2ui-surface");
expect(JSON.parse(activityDraft.activityContent)).toEqual({
a2ui_operations: [],
});
const editor = editorStateFromSnippet({
id: "snip-text",
name: text.name,
recipe: text.recipe,
events: text.events,
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
});
expect(editor.recipe).toBe("text");
expect(editor.draft.textContent).toBe("Hello there");
expect(editor.json).toContain("TEXT_MESSAGE_CONTENT");
});
it("saves an activity message as a snapshot", () => {
const compiled = compileFromActivityMessage({
message: {
id: "act-1",
activityType: "open-generative-ui",
content: { html: ["<div>Hi</div>"], htmlComplete: true },
},
threadId: THREAD_ID,
runId: RUN_ID,
});
expect(compiled.recipe).toBe("activity");
expect(compiled.name).toContain("open-generative-ui");
const snapshot = compiled.events.find(
(event) => event.type === "ACTIVITY_SNAPSHOT",
);
expect(snapshot?.activityType).toBe("open-generative-ui");
});
it("rejects empty text and tool captures", () => {
expect(() =>
compileChatSnippet({
kind: "text",
messageId: "empty",
content: " ",
threadId: THREAD_ID,
runId: RUN_ID,
}),
).toThrow("Assistant text is required.");
expect(() =>
compileChatSnippet({
kind: "tool-call",
messageId: "asst-1",
toolCallId: "call-1",
toolName: "",
argsJson: "{}",
threadId: THREAD_ID,
runId: RUN_ID,
}),
).toThrow("Tool name is required.");
});
});
describe("event snippet sidebar and run expansion", () => {
it("groups saved snippets by recipe and names an icon for each", () => {
const groups = groupEventSnippets([
{
id: "t",
name: "sayHello",
recipe: "tool-call",
events: [{ type: "TOOL_CALL_END" }],
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
},
{
id: "x",
name: "Hello",
recipe: "text",
events: [{ type: "TEXT_MESSAGE_END" }],
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
},
]);
expect(groups.map((group) => group.recipe)).toEqual(["tool-call", "text"]);
expect(recipeIconName("tool-call")).toBe("Hammer");
expect(recipeIconName("activity")).toBe("LayoutDashboard");
expect(recipeIconWrapClass("tool-call")).toContain("amber");
expect(recipeIconWrapClass("reasoning")).toContain("violet");
});
it("treats empty or invalid JSON as not runnable", () => {
expect(snippetJsonIsRunnable("[]")).toBe(false);
expect(snippetJsonIsRunnable("{")).toBe(false);
expect(snippetJsonIsRunnable('[{"type":"RUN_STARTED"}]')).toBe(true);
});
it("expands generateSandboxedUi tool args into an activity snapshot", () => {
const compiled = compileChatSnippet({
kind: "tool-call",
messageId: "asst-1",
toolCallId: "call-ui",
toolName: "generateSandboxedUi",
argsJson: '{"html":"<div>Hello sandbox</div>","css":"div{color:red}"}',
threadId: THREAD_ID,
runId: RUN_ID,
});
const expanded = expandSnippetEventsForRun(compiled.events);
const snapshot = expanded.find(
(event) => event.type === "ACTIVITY_SNAPSHOT",
);
expect(snapshot?.activityType).toBe("open-generative-ui");
expect(snapshot?.messageId).toBe("call-ui-activity");
expect(snapshot?.content).toMatchObject({
html: ["<div>Hello sandbox</div>"],
htmlComplete: true,
generating: false,
});
expect(typesOf(expanded)).toContain("TOOL_CALL_RESULT");
expect(
expanded.find((event) => event.type === "TOOL_CALL_RESULT")?.content,
).toBe("UI generated");
expect(typesOf(expanded).at(-1)).toBe("RUN_FINISHED");
});
it("does not add a tool result when generateSandboxedUi args have no UI", () => {
const events = compileToolCallRecipe({
toolName: "generateSandboxedUi",
argsJson: '{"placeholderMessages":["Working"]}',
threadId: THREAD_ID,
runId: RUN_ID,
parentMessageId: "asst-empty",
toolCallId: "call-empty",
});
const expanded = expandSnippetEventsForRun(events);
expect(typesOf(expanded)).not.toContain("ACTIVITY_SNAPSHOT");
expect(typesOf(expanded)).not.toContain("TOOL_CALL_RESULT");
});
it("does not add a second open-generative-ui snapshot when one exists", () => {
const events = [
{ type: "ACTIVITY_SNAPSHOT", activityType: "open-generative-ui" },
{
type: "TOOL_CALL_START",
toolCallId: "call-ui",
toolCallName: "generateSandboxedUi",
},
];
const expanded = expandSnippetEventsForRun(events);
expect(
expanded.filter((event) => event.type === "ACTIVITY_SNAPSHOT"),
).toHaveLength(1);
expect(
expanded.find((event) => event.type === "TOOL_CALL_RESULT")?.toolCallId,
).toBe("call-ui");
});
});
describe("event snippet JSON", () => {
it("parses a valid event array and rejects bad JSON", () => {
expect(parseSnippetEvents('[{"type":"RUN_STARTED"}]')).toEqual([
{ type: "RUN_STARTED" },
]);
expect(() => parseSnippetEvents("{")).toThrow("Snippet JSON is invalid.");
expect(() => parseSnippetEvents("{}")).toThrow(
"Snippet JSON must be an array of events.",
);
expect(() => parseSnippetEvents('[{"noType":true}]')).toThrow(
"Each event must have a string type.",
);
});
});
describe("event snippet storage", () => {
afterEach(() => {
if (typeof window !== "undefined") {
window.localStorage.removeItem(EVENT_SNIPPETS_STORAGE_KEY);
}
});
it("round-trips upsert, load, export, import, and delete", () => {
const storage = memoryStorage();
const snippet: EventSnippet = {
id: "snip-1",
name: "Weather",
recipe: "tool-call",
events: [{ type: "TOOL_CALL_END", toolCallId: "c1" }],
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
};
upsertEventSnippet(snippet, storage);
expect(loadEventSnippets(storage)).toEqual([snippet]);
const exported = exportEventSnippetsJson(loadEventSnippets(storage));
const other = memoryStorage();
const imported = importEventSnippets(exported, other);
expect(imported).toHaveLength(1);
expect(imported[0]?.id).not.toBe("snip-1");
expect(imported[0]?.name).toBe("Weather");
expect(deleteEventSnippet("snip-1", storage)).toEqual([]);
});
it("leaves existing snippets in place when import JSON is invalid", () => {
const storage = memoryStorage();
const snippet: EventSnippet = {
id: "keep-me",
name: "Keep",
recipe: "text",
events: [{ type: "TEXT_MESSAGE_END", messageId: "m1" }],
createdAt: "2026-08-21T00:00:00.000Z",
updatedAt: "2026-08-21T00:00:00.000Z",
};
upsertEventSnippet(snippet, storage);
expect(() => importEventSnippets("{", storage)).toThrow(
"Import JSON is invalid.",
);
expect(loadEventSnippets(storage)).toEqual([snippet]);
});
});
@@ -23,6 +23,8 @@ describe("inspector-nav", () => {
expect(getGroupForMenu("threads")).toBe("workbench");
expect(getGroupForMenu("memories")).toBe("workbench");
expect(getGroupForMenu("agents")).toBe("inspect");
expect(getGroupForMenu("event-snippets")).toBe("inspect");
expect(isInspectorMenuKey("event-snippets")).toBe(true);
});
it("uses an icon rail when docked left or under 720px", () => {
@@ -12,6 +12,7 @@ import {
getTelemetryDistinctIdForUrl,
maybeShowDisclosure,
track,
trackErrorSignalViewed,
trackInspectorOpened,
trackTalkToEngineerClicked,
trackThreadsEmptyEnabledViewed,
@@ -264,6 +265,80 @@ describe("typed helpers", () => {
});
});
it("trackErrorSignalViewed sends the failure class, the presentation and whether a pill was shown", async () => {
trackErrorSignalViewed({
source: "connection",
presentation: "animated",
label: "shown",
});
await Promise.resolve();
const [, init] = fetchMock.mock.calls[0]!;
const body = JSON.parse((init?.body as string) ?? "{}") as {
event: string;
properties: Record<string, unknown>;
};
expect(body.event).toBe("oss.inspector.error_signal_viewed");
expect(body.properties).toMatchObject({
source: "connection",
presentation: "animated",
label: "shown",
});
});
it("trackErrorSignalViewed refuses to forward anything but its three enums", async () => {
// The one place a later change could casually attach a free-text field.
// The helper rebuilds its payload, so extra keys cannot ride along.
trackErrorSignalViewed({
source: "threads",
presentation: "reduced_motion",
label: "suppressed",
// @ts-expect-error - deliberately passing a field the helper must drop
message: "ECONNREFUSED http://localhost:4000/api/copilotkit",
});
await Promise.resolve();
const raw = (fetchMock.mock.calls[0]?.[1]?.body as string) ?? "{}";
expect(raw).not.toContain("ECONNREFUSED");
expect(raw).not.toContain("localhost:4000");
expect(raw).not.toContain("message");
const properties = (
JSON.parse(raw) as { properties: Record<string, unknown> }
).properties;
expect(properties.source).toBe("threads");
expect(properties.presentation).toBe("reduced_motion");
expect(properties.label).toBe("suppressed");
});
it("trackErrorSignalViewed sends nothing when the user has opted out", async () => {
setTelemetryOptOut(true);
trackErrorSignalViewed({
source: "connection",
presentation: "animated",
label: "shown",
});
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
});
it("trackInspectorOpened carries the error signal and its class", async () => {
trackInspectorOpened({
open_source: "floating_button",
has_unseen_announcement: false,
has_error_signal: true,
error_signal_source: "threads",
});
await Promise.resolve();
const [, init] = fetchMock.mock.calls[0]!;
const properties = (
JSON.parse((init?.body as string) ?? "{}") as {
properties: Record<string, unknown>;
}
).properties;
expect(properties).toMatchObject({
has_error_signal: true,
error_signal_source: "threads",
});
});
it("opened and What's new events carry no message, state, or announcement content", async () => {
trackInspectorOpened({ open_source: "floating_button" });
trackWhatsNewViewed({
@@ -457,6 +532,7 @@ describe("event catalogue", () => {
expect(names.filter((name) => name.includes("banner"))).toEqual([]);
expect(names).toContain("oss.inspector.whats_new_viewed");
expect(names).toContain("oss.inspector.whats_new_signal_viewed");
expect(names).toContain("oss.inspector.error_signal_viewed");
expect(names).toContain("oss.inspector.whats_new_clicked");
expect(names.filter((name) => name.includes("dismissed"))).toEqual([
// The example tour keeps its own dismissal; the announcement's is gone.
@@ -464,10 +540,12 @@ describe("event catalogue", () => {
]);
});
it("holds twenty-six event names, all under the owned oss.inspector prefix", () => {
it("holds twenty-eight event names, all under the owned oss.inspector prefix", () => {
const names = Object.values(TELEMETRY_EVENTS) as string[];
expect(names).toHaveLength(26);
expect(names).toHaveLength(28);
expect(names).toContain("oss.inspector.event_snippets_run");
expect(names).toContain("oss.inspector.event_snippets_saved");
expect(names.filter((name) => !name.startsWith("oss.inspector."))).toEqual(
[],
);
@@ -0,0 +1,885 @@
export const EVENT_SNIPPETS_STORAGE_KEY = "cpk:inspector:event-snippets";
export const SNIPPET_RECIPES = [
"tool-call",
"reasoning",
"text",
"activity",
"raw",
] as const;
export type SnippetRecipe = (typeof SNIPPET_RECIPES)[number];
export type SnippetEvent = {
type: string;
[key: string]: unknown;
};
export type EventSnippet = {
id: string;
name: string;
recipe: SnippetRecipe;
events: SnippetEvent[];
createdAt: string;
updatedAt: string;
};
export type LastInject = {
snippetId: string;
agentId: string;
runId: string;
messageIds: string[];
};
export type AssistantSnippetMessage = {
id: string;
content?: string | null;
toolCalls?: ReadonlyArray<{
id: string;
function: { name: string; arguments: string };
}>;
};
export type ActivitySnippetMessage = {
id: string;
activityType: string;
content: unknown;
};
export const ACTIVITY_STARTERS = [
"a2ui-surface",
"open-generative-ui",
] as const;
export function isSnippetRecipe(value: unknown): value is SnippetRecipe {
return (
typeof value === "string" &&
SNIPPET_RECIPES.some((recipe) => recipe === value)
);
}
export function createSnippetId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `cpk-snippet-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function autoSnippetName(recipe: SnippetRecipe, label: string): string {
const time = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const base = label.trim() || recipeLabel(recipe);
return `${base} - ${time}`;
}
export function recipeLabel(recipe: SnippetRecipe): string {
switch (recipe) {
case "tool-call":
return "Tool call";
case "reasoning":
return "Reasoning";
case "text":
return "Assistant text";
case "activity":
return "Activity";
case "raw":
return "Raw events";
}
}
export const GENERATE_SANDBOXED_UI_TOOL_NAME = "generateSandboxedUi";
export const OPEN_GENERATIVE_UI_ACTIVITY_TYPE = "open-generative-ui";
export function recipeIconName(recipe: SnippetRecipe): string {
switch (recipe) {
case "tool-call":
return "Hammer";
case "reasoning":
return "Brain";
case "text":
return "MessageSquare";
case "activity":
return "LayoutDashboard";
case "raw":
return "Code";
}
}
export function recipeIconWrapClass(recipe: SnippetRecipe): string {
switch (recipe) {
case "tool-call":
return "bg-amber-100 text-amber-700";
case "reasoning":
return "bg-violet-100 text-violet-700";
case "text":
return "bg-sky-100 text-sky-700";
case "activity":
return "bg-emerald-100 text-emerald-700";
case "raw":
return "bg-gray-200 text-gray-700";
}
}
export function groupEventSnippets(
snippets: ReadonlyArray<EventSnippet>,
): Array<{
recipe: SnippetRecipe;
label: string;
snippets: EventSnippet[];
}> {
return SNIPPET_RECIPES.flatMap((recipe) => {
const items = snippets.filter((snippet) => snippet.recipe === recipe);
if (items.length === 0) {
return [];
}
return [{ recipe, label: recipeLabel(recipe), snippets: items }];
});
}
export function snippetJsonIsRunnable(raw: string): boolean {
try {
return parseSnippetEvents(raw).length > 0;
} catch {
return false;
}
}
export function expandSnippetEventsForRun(
events: ReadonlyArray<SnippetEvent>,
): SnippetEvent[] {
const next = [...events];
const hasOpenGenUi = next.some(
(event) =>
event.type === "ACTIVITY_SNAPSHOT" &&
event.activityType === OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
);
const toolCallIds: string[] = [];
for (const event of next) {
if (
event.type === "TOOL_CALL_START" &&
event.toolCallName === GENERATE_SANDBOXED_UI_TOOL_NAME &&
typeof event.toolCallId === "string"
) {
toolCallIds.push(event.toolCallId);
}
}
if (toolCallIds.length === 0) {
return next;
}
const argParts = new Map<string, string[]>();
for (const event of next) {
if (
event.type === "TOOL_CALL_ARGS" &&
typeof event.toolCallId === "string" &&
typeof event.delta === "string"
) {
const parts = argParts.get(event.toolCallId) ?? [];
parts.push(event.delta);
argParts.set(event.toolCallId, parts);
}
}
const extras: SnippetEvent[] = [];
for (const toolCallId of toolCallIds) {
let addedSnapshot = false;
if (!hasOpenGenUi) {
const snapshot = activitySnapshotFromSandboxedUiArgs(
toolCallId,
(argParts.get(toolCallId) ?? []).join(""),
);
if (snapshot) {
extras.push(snapshot);
addedSnapshot = true;
}
}
if (!hasOpenGenUi && !addedSnapshot) {
continue;
}
const hasResult = next.some(
(event) =>
event.type === "TOOL_CALL_RESULT" && event.toolCallId === toolCallId,
);
if (!hasResult) {
extras.push({
type: "TOOL_CALL_RESULT",
toolCallId,
messageId: `${toolCallId}-result`,
role: "tool",
content: "UI generated",
});
}
}
if (extras.length === 0) {
return next;
}
const finishIndex = next.findIndex((event) => event.type === "RUN_FINISHED");
if (finishIndex === -1) {
return [...next, ...extras];
}
return [...next.slice(0, finishIndex), ...extras, ...next.slice(finishIndex)];
}
function activitySnapshotFromSandboxedUiArgs(
toolCallId: string,
rawArgs: string,
): SnippetEvent | null {
let args: { [key: string]: unknown };
try {
const parsed: unknown = JSON.parse(snippetArgsJson(rawArgs));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
args = parsed as { [key: string]: unknown };
} catch {
return null;
}
const html = sandboxedUiHtmlChunks(args.html);
const css = typeof args.css === "string" ? args.css : undefined;
const jsFunctions =
typeof args.jsFunctions === "string" ? args.jsFunctions : undefined;
const jsExpressions = Array.isArray(args.jsExpressions)
? args.jsExpressions.filter(
(item): item is string => typeof item === "string",
)
: undefined;
if (
html.length === 0 &&
css === undefined &&
jsFunctions === undefined &&
(jsExpressions === undefined || jsExpressions.length === 0)
) {
return null;
}
return {
type: "ACTIVITY_SNAPSHOT",
messageId: `${toolCallId}-activity`,
activityType: OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
content: {
initialHeight:
typeof args.initialHeight === "number" ? args.initialHeight : undefined,
generating: false,
css,
cssComplete: true,
html,
htmlComplete: true,
jsFunctions,
jsFunctionsComplete: true,
jsExpressions,
jsExpressionsComplete: true,
},
replace: true,
};
}
function sandboxedUiHtmlChunks(html: unknown): string[] {
if (typeof html === "string" && html.length > 0) {
return [html];
}
if (Array.isArray(html)) {
return html.filter((item): item is string => typeof item === "string");
}
return [];
}
export function snippetContainsToolCall(
events: ReadonlyArray<SnippetEvent>,
): boolean {
return events.some(
(event) =>
typeof event.type === "string" && event.type.startsWith("TOOL_CALL"),
);
}
export function ensureRunEnvelope(
events: ReadonlyArray<SnippetEvent>,
ids: { threadId: string; runId: string },
): SnippetEvent[] {
const next = [...events];
const hasStart = next.some((event) => event.type === "RUN_STARTED");
const hasFinish = next.some((event) => event.type === "RUN_FINISHED");
if (!hasStart) {
next.unshift({
type: "RUN_STARTED",
threadId: ids.threadId,
runId: ids.runId,
});
}
if (!hasFinish) {
next.push({
type: "RUN_FINISHED",
threadId: ids.threadId,
runId: ids.runId,
});
}
return next;
}
export function snippetArgsJson(value: unknown): string {
if (value && typeof value === "object") {
return JSON.stringify(value);
}
if (typeof value !== "string") {
return "{}";
}
const trimmed = value.trim() || "{}";
try {
return JSON.stringify(JSON.parse(trimmed));
} catch {
const recovered = firstJsonValue(trimmed);
if (recovered !== undefined) {
return JSON.stringify(recovered);
}
throw new Error("Tool args JSON is invalid.");
}
}
// ponytail: single depth scan instead of parse-every-prefix. Recovers the first
// complete object from text with trailing junk, and bails at once on truncated
// args (a streaming tool call) rather than walking the whole string.
function firstJsonValue(raw: string): unknown {
const start = raw.indexOf("{");
if (start === -1) {
return undefined;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < raw.length; index += 1) {
const char = raw[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
try {
return JSON.parse(raw.slice(start, index + 1));
} catch {
return undefined;
}
}
}
}
return undefined;
}
export function compileToolCallRecipe(input: {
toolName: string;
argsJson: string | Record<string, unknown>;
threadId: string;
runId: string;
parentMessageId?: string;
toolCallId?: string;
}): SnippetEvent[] {
const toolName = input.toolName.trim();
if (!toolName) {
throw new Error("Tool name is required.");
}
const argsJson = snippetArgsJson(input.argsJson);
parseObjectJson(argsJson, "Tool args");
const parentMessageId = input.parentMessageId ?? createSnippetId();
const toolCallId = input.toolCallId ?? createSnippetId();
return ensureRunEnvelope(
[
{
type: "TOOL_CALL_START",
toolCallId,
toolCallName: toolName,
parentMessageId,
},
{
type: "TOOL_CALL_ARGS",
toolCallId,
delta: argsJson,
},
{
type: "TOOL_CALL_END",
toolCallId,
},
],
input,
);
}
export function compileReasoningRecipe(input: {
text: string;
threadId: string;
runId: string;
}): SnippetEvent[] {
const text = input.text.trim();
if (!text) {
throw new Error("Reasoning text is required.");
}
const messageId = createSnippetId();
return ensureRunEnvelope(
[
{ type: "REASONING_START", messageId },
{ type: "REASONING_MESSAGE_START", messageId, role: "reasoning" },
{ type: "REASONING_MESSAGE_CONTENT", messageId, delta: text },
{ type: "REASONING_MESSAGE_END", messageId },
{ type: "REASONING_END", messageId },
],
input,
);
}
export function compileTextRecipe(input: {
text: string;
threadId: string;
runId: string;
messageId?: string;
}): SnippetEvent[] {
const text = input.text.trim();
if (!text) {
throw new Error("Assistant text is required.");
}
const messageId = input.messageId ?? createSnippetId();
return ensureRunEnvelope(
[
{ type: "TEXT_MESSAGE_START", messageId, role: "assistant" },
{ type: "TEXT_MESSAGE_CONTENT", messageId, delta: text },
{ type: "TEXT_MESSAGE_END", messageId },
],
input,
);
}
export function compileActivityRecipe(input: {
activityType: string;
contentJson: string;
threadId: string;
runId: string;
messageId?: string;
}): SnippetEvent[] {
const activityType = input.activityType.trim();
if (!activityType) {
throw new Error("Activity type is required.");
}
const content = parseObjectJson(input.contentJson, "Activity content");
const messageId = input.messageId ?? createSnippetId();
return ensureRunEnvelope(
[
{
type: "ACTIVITY_SNAPSHOT",
messageId,
activityType,
content,
replace: true,
},
],
input,
);
}
export type ChatSnippetCapture =
| {
kind: "text";
messageId: string;
content: string;
}
| {
kind: "reasoning";
messageId: string;
content: string;
}
| {
kind: "tool-call";
messageId: string;
toolCallId: string;
toolName: string;
argsJson: string | Record<string, unknown>;
}
| {
kind: "activity";
messageId: string;
activityType: string;
content: unknown;
};
export type RecipeDraft = {
toolName: string;
toolArgs: string;
reasoningText: string;
textContent: string;
activityType: string;
activityContent: string;
};
export function emptyRecipeDraft(): RecipeDraft {
return {
toolName: "",
toolArgs: "{}",
reasoningText: "",
textContent: "",
activityType: "a2ui-surface",
activityContent: "{}",
};
}
export function compileChatSnippet(
input: ChatSnippetCapture & { threadId: string; runId: string },
): { recipe: SnippetRecipe; events: SnippetEvent[]; name: string } {
switch (input.kind) {
case "text":
return {
recipe: "text",
events: compileTextRecipe({
text: input.content,
threadId: input.threadId,
runId: input.runId,
messageId: input.messageId,
}),
name: autoSnippetName("text", snippetLabelFromText(input.content)),
};
case "reasoning":
return {
recipe: "reasoning",
events: compileReasoningRecipe({
text: input.content,
threadId: input.threadId,
runId: input.runId,
}),
name: autoSnippetName("reasoning", snippetLabelFromText(input.content)),
};
case "tool-call":
return {
recipe: "tool-call",
events: compileToolCallRecipe({
toolName: input.toolName,
argsJson: snippetArgsJson(input.argsJson),
threadId: input.threadId,
runId: input.runId,
parentMessageId: input.messageId,
toolCallId: input.toolCallId,
}),
name: autoSnippetName("tool-call", input.toolName),
};
case "activity":
return {
recipe: "activity",
events: compileActivityRecipe({
activityType: input.activityType,
contentJson: JSON.stringify(input.content ?? {}),
threadId: input.threadId,
runId: input.runId,
messageId: input.messageId,
}),
name: autoSnippetName("activity", input.activityType),
};
}
}
export function editorStateFromSnippet(snippet: EventSnippet): {
recipe: SnippetRecipe;
name: string;
json: string;
draft: RecipeDraft;
} {
return {
recipe: snippet.recipe,
name: snippet.name,
json: JSON.stringify(snippet.events, null, 2),
draft: recipeDraftFromEvents(snippet.events),
};
}
export function recipeDraftFromEvents(
events: ReadonlyArray<SnippetEvent>,
): RecipeDraft {
const draft = emptyRecipeDraft();
const textParts: string[] = [];
const reasoningParts: string[] = [];
const argParts: string[] = [];
for (const event of events) {
if (event.type === "TEXT_MESSAGE_CONTENT") {
const delta = readEventString(event, "delta");
if (delta) {
textParts.push(delta);
}
}
if (event.type === "REASONING_MESSAGE_CONTENT") {
const delta = readEventString(event, "delta");
if (delta) {
reasoningParts.push(delta);
}
}
if (event.type === "TOOL_CALL_START") {
const toolName = readEventString(event, "toolCallName");
if (toolName) {
draft.toolName = toolName;
}
}
if (event.type === "TOOL_CALL_ARGS") {
const delta = readEventString(event, "delta");
if (delta) {
argParts.push(delta);
}
}
if (event.type === "ACTIVITY_SNAPSHOT") {
const activityType = readEventString(event, "activityType");
if (activityType) {
draft.activityType = activityType;
}
draft.activityContent = prettyJson(event.content ?? {});
}
}
draft.textContent = textParts.join("");
draft.reasoningText = reasoningParts.join("");
if (argParts.length > 0) {
draft.toolArgs = prettyJson(argParts.join(""));
}
return draft;
}
export function compileFromActivityMessage(input: {
message: ActivitySnippetMessage;
threadId: string;
runId: string;
}): { recipe: SnippetRecipe; events: SnippetEvent[]; name: string } {
return compileChatSnippet({
kind: "activity",
messageId: input.message.id,
activityType: input.message.activityType,
content: input.message.content,
threadId: input.threadId,
runId: input.runId,
});
}
export function parseSnippetEvents(raw: string): SnippetEvent[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Snippet JSON is invalid.");
}
if (!Array.isArray(parsed)) {
throw new Error("Snippet JSON must be an array of events.");
}
const events: SnippetEvent[] = [];
for (const item of parsed) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new Error("Each event must be an object with a string type.");
}
const record = item as { type?: unknown };
if (typeof record.type !== "string" || record.type.length === 0) {
throw new Error("Each event must have a string type.");
}
events.push(item as SnippetEvent);
}
return events;
}
export function loadEventSnippets(
storage: Pick<Storage, "getItem"> | null = defaultStorage(),
): EventSnippet[] {
if (!storage) {
return [];
}
try {
const raw = storage.getItem(EVENT_SNIPPETS_STORAGE_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.flatMap((item) => {
const snippet = parseStoredSnippet(item);
return snippet ? [snippet] : [];
});
} catch {
return [];
}
}
export function saveEventSnippets(
snippets: ReadonlyArray<EventSnippet>,
storage: Pick<Storage, "setItem"> | null = defaultStorage(),
): void {
if (!storage) {
throw new Error("Snippet storage is not available.");
}
storage.setItem(EVENT_SNIPPETS_STORAGE_KEY, JSON.stringify(snippets));
}
export function upsertEventSnippet(
snippet: EventSnippet,
storage: Pick<Storage, "getItem" | "setItem"> | null = defaultStorage(),
): EventSnippet[] {
const current = loadEventSnippets(storage);
const index = current.findIndex((item) => item.id === snippet.id);
const next =
index === -1
? [...current, snippet]
: current.map((item, itemIndex) =>
itemIndex === index ? snippet : item,
);
saveEventSnippets(next, storage);
return next;
}
export function deleteEventSnippet(
id: string,
storage: Pick<Storage, "getItem" | "setItem"> | null = defaultStorage(),
): EventSnippet[] {
const next = loadEventSnippets(storage).filter((item) => item.id !== id);
saveEventSnippets(next, storage);
return next;
}
export function exportEventSnippetsJson(
snippets: ReadonlyArray<EventSnippet>,
): string {
return JSON.stringify(snippets, null, 2);
}
export function importEventSnippets(
raw: string,
storage: Pick<Storage, "getItem" | "setItem"> | null = defaultStorage(),
): EventSnippet[] {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Import JSON is invalid.");
}
const incoming = Array.isArray(parsed) ? parsed : [parsed];
const imported: EventSnippet[] = [];
for (const item of incoming) {
const snippet = parseStoredSnippet(item);
if (!snippet) {
throw new Error("Import JSON is not a valid snippet list.");
}
imported.push({
...snippet,
id: createSnippetId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
if (imported.length === 0) {
throw new Error("Import JSON is not a valid snippet list.");
}
const next = [...loadEventSnippets(storage), ...imported];
saveEventSnippets(next, storage);
return next;
}
function snippetLabelFromText(text: string): string {
const compact = text.trim().replace(/\s+/g, " ");
if (compact.length <= 40) {
return compact;
}
return `${compact.slice(0, 37)}...`;
}
function parseObjectJson(raw: string, label: string): unknown {
try {
return JSON.parse(raw);
} catch {
throw new Error(`${label} JSON is invalid.`);
}
}
function readEventString(event: SnippetEvent, key: string): string | null {
const value = event[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function prettyJson(value: unknown): string {
if (typeof value === "string") {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value ?? {}, null, 2);
} catch {
return "{}";
}
}
function parseStoredSnippet(value: unknown): EventSnippet | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const record = value as {
id?: unknown;
name?: unknown;
recipe?: unknown;
events?: unknown;
createdAt?: unknown;
updatedAt?: unknown;
};
if (typeof record.id !== "string" || record.id.length === 0) {
return null;
}
if (typeof record.name !== "string") {
return null;
}
if (!isSnippetRecipe(record.recipe)) {
return null;
}
if (!Array.isArray(record.events)) {
return null;
}
const events: SnippetEvent[] = [];
for (const event of record.events) {
if (!event || typeof event !== "object" || Array.isArray(event)) {
return null;
}
if (typeof (event as SnippetEvent).type !== "string") {
return null;
}
events.push(event as SnippetEvent);
}
return {
id: record.id,
name: record.name,
recipe: record.recipe,
events,
createdAt:
typeof record.createdAt === "string"
? record.createdAt
: new Date().toISOString(),
updatedAt:
typeof record.updatedAt === "string"
? record.updatedAt
: new Date().toISOString(),
};
}
function defaultStorage(): Pick<Storage, "getItem" | "setItem"> | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
}
@@ -8,6 +8,7 @@ export const INSPECTOR_GROUPS = {
inspect: [
"agents",
"ag-ui-events",
"event-snippets",
"frontend-tools",
"capabilities",
"agent-context",
@@ -65,6 +65,8 @@ export const TELEMETRY_EVENTS = {
homeStoryBeatSelected: "oss.inspector.home_story_beat_selected",
metadataModuleViewed: "oss.inspector.metadata_module_viewed",
metadataActionClicked: "oss.inspector.metadata_action_clicked",
eventSnippetsRun: "oss.inspector.event_snippets_run",
eventSnippetsSaved: "oss.inspector.event_snippets_saved",
} as const;
export type TelemetryEvent =
@@ -333,6 +335,7 @@ export type InspectorLeafKey =
| "playground"
| "threads"
| "ag-ui-events"
| "event-snippets"
| "agents"
| "frontend-tools"
| "capabilities"
@@ -736,6 +739,39 @@ export function trackMetadataActionClicked(
});
}
export type EventSnippetRecipeKind =
| "tool-call"
| "reasoning"
| "text"
| "activity"
| "raw";
export type EventSnippetSource = "chat" | "pane";
export function trackEventSnippetsRun(props: {
recipe: EventSnippetRecipeKind;
source: EventSnippetSource;
success: boolean;
}): void {
track(TELEMETRY_EVENTS.eventSnippetsRun, {
recipe: props.recipe,
source: props.source,
success: props.success,
});
}
export function trackEventSnippetsSaved(props: {
recipe: EventSnippetRecipeKind;
source: EventSnippetSource;
success: boolean;
}): void {
track(TELEMETRY_EVENTS.eventSnippetsSaved, {
recipe: props.recipe,
source: props.source,
success: props.success,
});
}
/**
* Returns the inspector's anonymous distinct-ID for cross-domain
* propagation onto outbound announcement-CTA links, or `null` when the user
+11 -1
View File
@@ -221,6 +221,15 @@
padding 220ms ease;
}
.inspector-snippet-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
background-color: #f7f6fd;
padding: 12px 10px;
}
.inspector-sidebar[data-icon-rail="true"] {
width: 52px;
min-width: 52px;
@@ -3606,7 +3615,8 @@ small.inspector-system-health-detail {
color: #f3f4f8 !important;
}
.inspector-window[data-color-scheme="dark"] .inspector-sidebar {
.inspector-window[data-color-scheme="dark"] .inspector-sidebar,
.inspector-window[data-color-scheme="dark"] .inspector-snippet-sidebar {
border-color: #3a3d49;
background-color: #15171e;
color: #f3f4f8;
@@ -6,10 +6,12 @@ snippet_cell: frontend-tools
---
import InspectorPaneFrontendTools from "@/snippets/shared/inspector/open-inspector-pane-frontend-tools.mdx";
import InspectorPaneEventSnippets from "@/snippets/shared/inspector/open-inspector-pane-event-snippets.mdx";
<InlineDemo demo="frontend-tools" />
<InspectorPaneFrontendTools components={props.components} />
<InspectorPaneEventSnippets components={props.components} />
## What is this?
@@ -4,6 +4,10 @@ icon: "lucide/FileJson"
description: "Render rich, declarative UI surfaces from your agent using the A2UI protocol."
---
import InspectorPaneEventSnippets from "@/snippets/shared/inspector/open-inspector-pane-event-snippets.mdx";
<InspectorPaneEventSnippets components={props.components} />
## What is this?
[A2UI](https://a2ui.org) (Agent-to-UI) is a declarative Generative UI
@@ -5,8 +5,12 @@ description: "Let agents generate fully interactive HTML/CSS/JS UIs that stream
snippet_cell: open-gen-ui
---
import InspectorPaneEventSnippets from "@/snippets/shared/inspector/open-inspector-pane-event-snippets.mdx";
<InlineDemo demo="open-gen-ui" />
<InspectorPaneEventSnippets components={props.components} />
## What is this?
Open Generative UI lets the agent generate complete, sandboxed UI on the fly
@@ -0,0 +1,11 @@
<Callout type="info" title="See this in Inspector">
Open Inspector on localhost. Go to **Inspect**, then **Event Snippets**.
You can compile a tool call, reasoning, text, or activity, run it on the live
agent, and save it. Saved snippets are grouped by recipe. On localhost chat,
**Save as snippet** uses the recipe for the thing you click and fills the form.
On a tool call, generative UI, or A2UI, the bookmark sits to the right of the
block (or to the left if there is no room on the right).
Run of a `generateSandboxedUi` tool call paints the sandbox UI in chat.
More detail: [Inspector](/inspector).
</Callout>
@@ -104,6 +104,15 @@ test("mapped feature pages import the matching Inspector Callout", () => {
expect(read("docs/frontend-tools.mdx")).toContain(
"open-inspector-pane-frontend-tools.mdx",
);
expect(read("docs/frontend-tools.mdx")).toContain(
"open-inspector-pane-event-snippets.mdx",
);
expect(read("docs/generative-ui/open-generative-ui.mdx")).toContain(
"open-inspector-pane-event-snippets.mdx",
);
expect(read("docs/generative-ui/a2ui/index.mdx")).toContain(
"open-inspector-pane-event-snippets.mdx",
);
expect(read("docs/shared-state.mdx")).toContain(
"open-inspector-pane-state.mdx",
);
@@ -144,6 +153,9 @@ test("Inspector Callout snippets name the shipped pane and skip unshipped work",
expect(
read("snippets/shared/inspector/open-inspector-pane-threads.mdx"),
).toContain("**Threads**");
expect(
read("snippets/shared/inspector/open-inspector-pane-event-snippets.mdx"),
).toContain("**Event Snippets**");
});
test("pane map lists each shipped pane with a Callout or no page yet", () => {
@@ -158,6 +170,7 @@ test("pane map lists each shipped pane with a Callout or no page yet", () => {
"Context",
"Learning",
"Capabilities",
"Event Snippets",
]) {
expect(paneMap).toMatch(new RegExp(`\\|\\s*${pane}\\s*\\|`));
}
+2 -1
View File
@@ -7,6 +7,7 @@ Update this file in the same change that adds or removes a pane.
| ----------------- | --------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------- |
| Agent | Default web quickstart step | `snippets/shared/inspector/open-inspector-step.mdx` | First check in the quickstart step |
| AG-UI Events | Default web quickstart step | `snippets/shared/inspector/open-inspector-step.mdx` | Second check, after a chat message |
| Event Snippets | Frontend tools, A2UI, Open Generative UI | `open-inspector-pane-event-snippets.mdx` | Inspect leaf for emit, save, and replay |
| Threads | Default web quickstart step, Threads overview | `open-inspector-step.mdx`, `open-inspector-pane-threads.mdx` | Unlocked or Enable Intelligence both count |
| Frontend Tools | Frontend tools, human-in-the-loop overview | `open-inspector-pane-frontend-tools.mdx` | HITL tools appear here when registered |
| State | Shared state | `open-inspector-pane-state.mdx` | Thread detail tab |
@@ -20,7 +21,7 @@ Update this file in the same change that adds or removes a pane.
## Unshipped (no Callout)
- Fork from here
- Emit events
- Emit events (shipped as Event Snippets)
- Pop-out window
## Surfaces that do not get the Open Inspector step