mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
Revert "fix(inspector): clarify local action availability (#6696)"
This reverts commit2ad03320fe, reversing changes made tob3c3cb0d7b.
This commit is contained in:
@@ -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 | "-")[]>(
|
||||
() => [
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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")];
|
||||
|
||||
+15
@@ -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; }",
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+149
-19
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
@@ -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*\\|`));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user