feat(inspector): add Learning view and workbench

This commit is contained in:
Martha Kelly Schumann
2026-09-04 17:11:59 -07:00
committed by Tyler Slaton
parent 05fc4e05a4
commit c70502b137
36 changed files with 5159 additions and 331 deletions
+4
View File
@@ -253,6 +253,10 @@ jobs:
if: matrix.node-version == '22.x'
run: pnpm --dir showcase/scripts exec playwright install chromium
- name: Run Web Inspector browser tests
if: ${{ matrix.node-version == '22.x' && matrix.react-version == '19' && contains(steps.select.outputs.projects, '@copilotkit/web-inspector') }}
run: pnpm nx run @copilotkit/web-inspector:test:browser
- name: Verify packed Angular consumer matrix
if: matrix.node-version == '22.x'
run: pnpm run verify:angular-package
+3 -3
View File
@@ -7,7 +7,7 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23023047' d='M4 4h16v16H4z'/%3E%3Cpath fill='%23fff' d='M8 8h8v2H8zm0 4h8v2H8zm0 4h5v2H8z'/%3E%3C/svg%3E"
/>
<title>Inspector Threads state lab</title>
<title>Inspector state workbench</title>
<script type="module" src="/main.ts"></script>
<style>
:root {
@@ -363,8 +363,8 @@
<body>
<header class="topbar">
<div class="title">
<h1>Inspector Threads state lab</h1>
<p>37 local fixtures · no Intelligence service</p>
<h1>Inspector state workbench</h1>
<p>54 local fixtures · no external Intelligence service</p>
</div>
<label>
<span class="field-label">Scenario</span>
@@ -0,0 +1,149 @@
import { expect, test } from "@playwright/test";
import type { Request } from "@playwright/test";
type Transport = "rest" | "single";
function learningPages(
request: Request,
transport: Transport,
): { skillsPage: string; insightsPage: string } | null {
if (transport === "rest") {
const url = new URL(request.url());
if (!url.pathname.endsWith("/inspector-learning")) return null;
return {
skillsPage: url.searchParams.get("skillsPage") ?? "",
insightsPage: url.searchParams.get("insightsPage") ?? "",
};
}
if (request.method() !== "POST") return null;
let body: {
method?: unknown;
params?: Record<string, unknown>;
};
try {
body = request.postDataJSON() as typeof body;
} catch {
return null;
}
if (body.method !== "inspector/learning") return null;
return {
skillsPage: String(body.params?.skillsPage ?? ""),
insightsPage: String(body.params?.insightsPage ?? ""),
};
}
for (const transport of ["rest", "single"] as const) {
test(`keeps Skills and Insights pages independent over ${transport}`, async ({
page,
}) => {
await page.goto(
`/learning-states.html?state=multiple-skills&fixture=pagination&transport=${transport}`,
);
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
const skills = page.getByRole("region", { name: "Skills in registry" });
const insights = page.getByRole("region", { name: "More Insights" });
const skillsPager = skills.getByRole("navigation", {
name: "skills pages",
});
const insightsPager = insights.getByRole("navigation", {
name: "insights pages",
});
const skillsPrevious = skillsPager.getByRole("button", {
name: "Previous",
});
const skillsNext = skillsPager.getByRole("button", { name: "Next" });
const insightsPrevious = insightsPager.getByRole("button", {
name: "Previous",
});
const insightsNext = insightsPager.getByRole("button", { name: "Next" });
await expect(skillsPager.getByText("Page 1 of 2")).toBeVisible();
await expect(insightsPager.getByText("Page 1 of 2")).toBeVisible();
await expect(skillsPrevious).toBeDisabled();
await expect(skillsNext).toBeEnabled();
await expect(insightsPrevious).toBeDisabled();
await expect(insightsNext).toBeEnabled();
await expect(
skills.getByRole("heading", { name: "verify-refund-request" }),
).toBeVisible();
await expect(
insights.getByRole("heading", {
name: /Verify the order before giving refund guidance/,
}),
).toBeVisible();
const observedPages: Array<{
skillsPage: string;
insightsPage: string;
}> = [];
page.on("request", (request) => {
const pages = learningPages(request, transport);
if (pages) observedPages.push(pages);
});
await skillsNext.click();
await expect(skillsPager.getByText("Page 2 of 2")).toBeVisible();
await expect(
skills.getByRole("heading", { name: "explain-payment-state" }),
).toBeVisible();
await expect(
skills.getByRole("heading", { name: "verify-refund-request" }),
).toHaveCount(0);
await expect(skillsPrevious).toBeEnabled();
await expect(skillsNext).toBeDisabled();
await expect(insightsPager.getByText("Page 1 of 2")).toBeVisible();
await expect(insightsPrevious).toBeDisabled();
await expect(insightsNext).toBeEnabled();
await insightsNext.click();
await expect(insightsPager.getByText("Page 2 of 2")).toBeVisible();
await expect(
insights.getByRole("heading", {
name: /Calculate the return deadline from the delivery date/,
}),
).toBeVisible();
await expect(
insights.getByRole("heading", {
name: /Verify the order before giving refund guidance/,
}),
).toHaveCount(0);
await expect(insightsPrevious).toBeEnabled();
await expect(insightsNext).toBeDisabled();
await expect(skillsPager.getByText("Page 2 of 2")).toBeVisible();
await expect(skillsPrevious).toBeEnabled();
await expect(skillsNext).toBeDisabled();
await skillsPrevious.click();
await expect(skillsPager.getByText("Page 1 of 2")).toBeVisible();
await expect(
skills.getByRole("heading", { name: "verify-refund-request" }),
).toBeVisible();
await expect(skillsPrevious).toBeDisabled();
await expect(skillsNext).toBeEnabled();
await expect(insightsPager.getByText("Page 2 of 2")).toBeVisible();
await insightsPrevious.click();
await expect(insightsPager.getByText("Page 1 of 2")).toBeVisible();
await expect(
insights.getByRole("heading", {
name: /Verify the order before giving refund guidance/,
}),
).toBeVisible();
await expect(insightsPrevious).toBeDisabled();
await expect(insightsNext).toBeEnabled();
await expect(skillsPager.getByText("Page 1 of 2")).toBeVisible();
await expect
.poll(() => observedPages)
.toEqual([
{ skillsPage: "2", insightsPage: "1" },
{ skillsPage: "2", insightsPage: "2" },
{ skillsPage: "1", insightsPage: "2" },
{ skillsPage: "1", insightsPage: "1" },
]);
});
}
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
for (const transport of ["rest", "single"] as const) {
test(`restores copied Learning setup over ${transport}`, async ({
context,
page,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: "http://127.0.0.1:5177",
});
await page.goto(
`/learning-states.html?state=landing&preserveSetup=true&transport=${transport}`,
{ waitUntil: "domcontentloaded" },
);
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
await page
.getByRole("button", { name: "Copy setup prompt for Threads" })
.click();
const setup = page.getByRole("region", { name: "Set up Learning" });
await expect(setup.getByText("1 of 3 steps")).toBeVisible();
await expect(
setup.getByRole("heading", { name: "Waiting for the first Thread" }),
).toBeVisible();
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
await expect(setup.getByText("1 of 3 steps")).toBeVisible();
await expect(
setup.getByRole("heading", { name: "Waiting for the first Thread" }),
).toBeVisible();
});
}
@@ -0,0 +1,260 @@
import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
import type { CopilotKitCore } from "@copilotkit/core";
import type { WebInspectorElement } from "@copilotkit/web-inspector";
import { LEARNING_LAB_BASE_PATH } from "./learning-state-fixtures.js";
import type {
LearningLabState,
LearningScreenshotState,
} from "./learning-state-fixtures.js";
const LEARNING_SETUP_STORAGE_KEY = "cpk:inspector:learning-setup:v1";
export function learningLabRuntimeUrl(
origin: string,
state: LearningLabState,
): string {
return `${origin}${LEARNING_LAB_BASE_PATH}/${state}`;
}
export function prepareLearningStateClient(
options: Readonly<{
state: LearningScreenshotState;
preserveSetup?: boolean;
embeddedWorkbench?: boolean;
viewportWidth?: number;
viewportHeight?: number;
}>,
): void {
if (!options.preserveSetup) {
window.localStorage.removeItem(LEARNING_SETUP_STORAGE_KEY);
}
const width = options.viewportWidth ?? window.innerWidth;
const height = options.viewportHeight ?? window.innerHeight;
const narrow = width <= 900;
const floatInWorkbench = options.embeddedWorkbench === true && !narrow;
const workbenchStage = floatInWorkbench
? document
.querySelector<HTMLElement>(".inspector-stage")
?.getBoundingClientRect()
: undefined;
const workbenchHeader = floatInWorkbench
? document.querySelector<HTMLElement>(".topbar")?.getBoundingClientRect()
: undefined;
const windowOffset = floatInWorkbench
? {
x: Math.round((workbenchStage?.left ?? 0) + 16),
y: Math.round((workbenchHeader?.bottom ?? 0) + 12),
}
: undefined;
const windowWidth = floatInWorkbench
? Math.max(320, Math.min(width - 32, (workbenchStage?.width ?? width) - 32))
: narrow
? width - 32
: width - 96;
const windowHeight = floatInWorkbench
? Math.max(400, height - (windowOffset?.y ?? 0) - 16)
: narrow
? height
: height - 48;
window.localStorage.setItem(
"cpk:inspector:state",
JSON.stringify({
isOpen: true,
hasOpenedInspector: true,
selectedMenu: "memories",
selectedContext: "Checkout Assistant",
dockMode: narrow ? "docked-left" : "floating",
sidebarCollapsed: false,
colorSchemePreference: "light",
window: {
...(windowOffset
? {
anchor: { horizontal: "left", vertical: "top" },
anchorOffset: windowOffset,
}
: {}),
size: {
width: windowWidth,
height: windowHeight,
},
hasCustomPosition: floatInWorkbench,
},
}),
);
if (options.state === "copy-error") {
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { writeText: () => Promise.reject(new Error("Clipboard denied")) },
});
}
}
function waitFor<T>(
read: () => T | null | undefined | false,
label: string,
timeoutMs = 10_000,
): Promise<T> {
return new Promise((resolve, reject) => {
const started = performance.now();
const tick = () => {
const value = read();
if (value) {
resolve(value);
return;
}
if (performance.now() - started >= timeoutMs) {
reject(new Error(`Timed out waiting for ${label}.`));
return;
}
window.setTimeout(tick, 20);
};
tick();
});
}
export async function waitForLearningConnection(
core: CopilotKitCore,
): Promise<void> {
if (
core.runtimeConnectionStatus ===
CopilotKitCoreRuntimeConnectionStatus.Connected ||
core.runtimeConnectionStatus === CopilotKitCoreRuntimeConnectionStatus.Error
) {
return;
}
await new Promise<void>((resolve) => {
const subscription = core.subscribe({
onRuntimeConnectionStatusChanged: ({ status }) => {
if (
status !== CopilotKitCoreRuntimeConnectionStatus.Connected &&
status !== CopilotKitCoreRuntimeConnectionStatus.Error
) {
return;
}
subscription.unsubscribe();
resolve();
},
});
});
}
function learningView(inspector: WebInspectorElement) {
return (
inspector.shadowRoot?.querySelector<
HTMLElement & { updateComplete: Promise<boolean> }
>("cpk-learning-view") ?? null
);
}
export async function readyIntegratedLearningState(
state: LearningScreenshotState,
inspector: WebInspectorElement,
): Promise<void> {
await inspector.updateComplete;
const internals = inspector as unknown as {
handleMenuSelect: (key: "memories") => void;
learningError: string | null;
};
internals.handleMenuSelect("memories");
await inspector.updateComplete;
if (state === "copy-error") {
const landing = await waitFor(
() =>
inspector.shadowRoot?.querySelector<HTMLElement>(
'[data-inspector-locked-feature="memory"]',
),
"the existing Learning landing surface",
);
landing
.querySelector<HTMLButtonElement>(
'[data-inspector-feature-setup-prompt="threads"]',
)
?.click();
await waitFor(
() => landing.querySelector('[data-copy-state="error"]'),
"the landing copy error",
);
return;
}
if (state === "landing") {
await waitFor(
() =>
inspector.shadowRoot?.querySelector<HTMLElement>(
'[data-inspector-locked-feature="memory"]',
) ??
learningView(inspector)?.shadowRoot?.querySelector(
'[data-learning-state="setup"]',
),
"the Learning landing or restored setup surface",
);
return;
}
const view = await waitFor(
() => learningView(inspector),
"the integrated Learning pane",
);
const expectedState: Record<LearningScreenshotState, string> = {
"no-threads": "setup",
"threads-available": "ready",
success: "results",
"insights-only": "results",
"multiple-skills": "results",
"new-threads": "results",
"empty-results": "empty",
"setup-error": "invalid",
unsupported: "unsupported",
loading: "loading",
"data-error": "error",
"selection-required": "selection_required",
"first-run": "first_run",
"candidates-only": "results",
"results-error": "results",
"results-evidence": "results",
"evidence-unavailable": "results",
"setup-prompt": "invalid",
landing: "landing",
"copy-error": "landing",
};
await waitFor(
() =>
view.shadowRoot?.querySelector(
`[data-learning-state="${expectedState[state]}"]`,
),
`${state} Learning state`,
);
if (state === "results-error") {
internals.learningError =
"Learning could not refresh. Existing results are still available.";
inspector.requestUpdate();
await inspector.updateComplete;
await view.updateComplete;
}
if (state === "results-evidence" || state === "evidence-unavailable") {
view.shadowRoot?.querySelector<HTMLButtonElement>(".insight-row")?.click();
await view.updateComplete;
await waitFor(
() => view.shadowRoot?.querySelector(".detail-panel"),
"Insight evidence",
);
}
if (state === "setup-prompt") {
view.shadowRoot?.querySelector<HTMLButtonElement>(".prompt-link")?.click();
await view.updateComplete;
await waitFor(
() => view.shadowRoot?.querySelector('[role="dialog"]'),
"the setup prompt",
);
}
}
export async function settleLearningState(): Promise<void> {
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
}
@@ -0,0 +1,563 @@
import type {
InspectorLearningInsight,
InspectorLearningSnapshotV1,
RuntimeInfo,
} from "@copilotkit/shared";
export const APPROVED_LEARNING_STATES = [
"no-threads",
"threads-available",
"success",
"insights-only",
"multiple-skills",
"new-threads",
"empty-results",
"setup-error",
] as const;
export const LEARNING_LAB_BASE_PATH = "/inspector-learning-lab";
export const BEHAVIOR_LEARNING_STATES = [
"landing",
"unsupported",
"loading",
"data-error",
"selection-required",
"first-run",
"candidates-only",
"results-error",
"results-evidence",
"evidence-unavailable",
"setup-prompt",
"copy-error",
] as const;
export type LearningScreenshotState =
| (typeof APPROVED_LEARNING_STATES)[number]
| (typeof BEHAVIOR_LEARNING_STATES)[number];
export type LearningWorkbenchScenarioKey =
`learning-${LearningScreenshotState}`;
/** Ordered state catalog shared by the root workbench and browser harness. */
export const LEARNING_WORKBENCH_SCENARIOS = [
{ state: "landing", key: "learning-landing", label: "Landing" },
{ state: "copy-error", key: "learning-copy-error", label: "Copy error" },
{
state: "no-threads",
key: "learning-no-threads",
label: "Waiting · no Threads",
},
{
state: "threads-available",
key: "learning-threads-available",
label: "Threads ready",
},
{ state: "first-run", key: "learning-first-run", label: "First run" },
{
state: "success",
key: "learning-success",
label: "Skills and Insights",
},
{
state: "insights-only",
key: "learning-insights-only",
label: "Insights only",
},
{
state: "multiple-skills",
key: "learning-multiple-skills",
label: "Multiple Skills",
},
{
state: "new-threads",
key: "learning-new-threads",
label: "New Threads · retained results",
},
{
state: "candidates-only",
key: "learning-candidates-only",
label: "Candidates only",
},
{
state: "empty-results",
key: "learning-empty-results",
label: "Empty results",
},
{
state: "setup-error",
key: "learning-setup-error",
label: "Setup error",
},
{
state: "unsupported",
key: "learning-unsupported",
label: "Unsupported",
},
{ state: "loading", key: "learning-loading", label: "Loading" },
{ state: "data-error", key: "learning-data-error", label: "Data error" },
{
state: "selection-required",
key: "learning-selection-required",
label: "Selection required",
},
{
state: "results-error",
key: "learning-results-error",
label: "Refresh error",
},
{
state: "results-evidence",
key: "learning-results-evidence",
label: "Evidence detail",
},
{
state: "evidence-unavailable",
key: "learning-evidence-unavailable",
label: "Unavailable evidence",
},
{
state: "setup-prompt",
key: "learning-setup-prompt",
label: "Setup prompt",
},
] as const satisfies readonly Readonly<{
state: LearningScreenshotState;
key: LearningWorkbenchScenarioKey;
label: string;
}>[];
export const LEARNING_SCREENSHOT_STATES: readonly LearningScreenshotState[] =
LEARNING_WORKBENCH_SCENARIOS.map(({ state }) => state);
export type LearningLabState = LearningScreenshotState | "pagination";
export function learningRuntimeInfo(state: LearningLabState): RuntimeInfo {
return {
mode: "intelligence",
version: "inspector-learning-v5-lab",
agents: {
"Checkout Assistant": {
name: "Checkout Assistant",
className: "HttpAgent",
description: "Checkout Assistant",
},
},
audioFileTranscriptionEnabled: false,
suggestions: false,
a2uiEnabled: false,
openGenerativeUIEnabled: false,
telemetryDisabled: true,
...(!["landing", "copy-error"].includes(state)
? { intelligence: { wsUrl: "ws://127.0.0.1:5177/intelligence-lab" } }
: {}),
...(state === "unsupported" ? {} : { inspectorLearning: true }),
};
}
const WEB_APP_ORIGIN = "https://app.copilotkit.ai";
const configured = {
state: "configured" as const,
container: {
id: "checkout-assistant-default",
name: "Checkout Assistant",
},
};
const evidence = (
threadId: string,
threadName: string | null,
messageId: string,
) => ({
status: "available" as const,
threadId,
threadName,
messageIds: [messageId],
updatedAt: "2026-09-03T18:00:00.000Z",
});
const insights: readonly InspectorLearningInsight[] = [
{
id: "refund",
statement:
"Verify the order before giving refund guidance. In the supporting Threads, customers had to correct the assistant after it discussed refund eligibility without reading the order or payment state.",
impact:
"Future refund responses should first retrieve the order, confirm which charge settled, and only then explain eligibility and timing. If the order cannot be found, ask for the checkout email instead of guessing.",
totalThreadCount: 12,
evidenceTruncated: false,
evidence: [
evidence("thread-1842", "Duplicate charge · #1842", "message-1842"),
evidence("thread-1764", "Refund status · #1764", "message-1764"),
evidence("thread-1691", "Wrong item received · #1691", "message-1691"),
],
},
{
id: "next-step",
statement:
"Lead policy answers with the action the customer can take. Customers repeatedly asked what to do next after receiving a correct return or exchange policy.",
impact:
"Future policy responses should state the next action and any deadline before the supporting details. Ask a follow-up question only when the required order or product information is missing.",
totalThreadCount: 7,
evidenceTruncated: false,
evidence: [
evidence("thread-1798", "Return window · #1798", "message-1798"),
evidence("thread-1750", "Exchange request · #1750", "message-1750"),
evidence("thread-1707", "Shipping delay · #1707", "message-1707"),
],
},
{
id: "delivery-address",
statement:
"Confirm the delivery address before replacing a missing shipment. Several Threads contained replacement offers before the assistant checked where the order was sent.",
impact:
"Future replacement responses must retrieve the saved address and carrier scan first. If the address is wrong, explain the correction path before promising another shipment.",
totalThreadCount: 9,
evidenceTruncated: false,
evidence: [
evidence("thread-1688", "Missing package · #1688", "message-1688"),
],
},
{
id: "payment-state",
statement:
"Separate pending authorizations from settled duplicate charges. Customers received refund promises for charges that were still pending.",
impact:
"Future payment responses must name the state of each charge. A pending authorization needs an expiration estimate, not a refund workflow.",
totalThreadCount: 6,
evidenceTruncated: false,
evidence: [
evidence("thread-1589", "Pending charge · #1589", "message-1589"),
],
},
{
id: "return-deadline",
statement:
"Calculate the return deadline from the delivery date. Customers received generic policy text when the assistant had enough order data to give an exact date.",
impact:
"Future return responses must state the final eligible date first. Then list any condition that can make the item ineligible.",
totalThreadCount: 8,
evidenceTruncated: false,
evidence: [evidence("thread-1497", "Return date · #1497", "message-1497")],
},
{
id: "tracking-escalation",
statement:
"Escalate stalled shipments after the carrier deadline passes. Customers repeated the same question after the assistant restated stale tracking details.",
impact:
"Future shipping responses must compare the last scan with the carrier window. If the last scan is too old, open the escalation path.",
totalThreadCount: 5,
evidenceTruncated: false,
evidence: [
evidence("thread-1390", "Tracking stalled · #1390", "message-1390"),
],
},
{
id: "damaged-item",
statement:
"Ask which item is damaged before offering an exchange. Multi-item orders caused the assistant to apply an exchange to the wrong product.",
impact:
"Future exchange responses must identify the item and quantity first. Then explain the replacement options for that product.",
totalThreadCount: 6,
evidenceTruncated: false,
evidence: [evidence("thread-1294", "Damaged item · #1294", "message-1294")],
},
{
id: "refund-timing",
statement:
"Name the bank-processing window after a refund is issued. Customers reopened Threads because the assistant only said the refund was complete.",
impact:
"Future refund-status responses must include the issue date and expected bank window. If that window has passed, provide the escalation step.",
totalThreadCount: 4,
evidenceTruncated: false,
evidence: [
evidence("thread-1196", "Refund pending · #1196", "message-1196"),
],
},
];
const skills = [
{
id: "skill-refund",
name: "verify-refund-request",
description:
"Use when a customer requests a refund or reports a duplicate charge. Do not use for billing questions without an order.",
revision: 3,
skillMd: `---
name: verify-refund-request
description: Use when a customer requests a refund or reports a duplicate charge. Do not use for billing questions without an order.
---
# Verify a Refund Request
## Instructions
1. Ask for the exact order ID and the email address used at checkout.
2. Retrieve the order and verify the payment state before discussing refund eligibility.
3. For a duplicate charge, distinguish a settled payment from a pending authorization.
4. Explain the result, expected timing, and the next action available to the customer.`,
sourceInsight: insights[0]!,
},
{
id: "skill-next-step",
name: "lead-with-policy-next-step",
description:
"Use when a customer needs help applying a support policy. Do not use for requests that only ask for the policy text.",
revision: 2,
skillMd: `---
name: lead-with-policy-next-step
description: Use when a customer needs help applying a support policy.
---
# Lead with the Next Step
1. Identify the action the customer can take.
2. State that action and any deadline before the policy details.
3. End with the next step the customer or support agent must take.`,
sourceInsight: insights[1]!,
},
{
id: "skill-address",
name: "confirm-delivery-address",
description:
"Use when a customer reports a missing delivery or requests a replacement. Do not use until the order and address are available.",
revision: 1,
skillMd:
"# Confirm the Delivery Address\n\nRetrieve the order, saved address, and latest carrier scan before offering a replacement.",
sourceInsight: insights[2]!,
},
{
id: "skill-payment",
name: "explain-payment-state",
description:
"Use when a customer reports a duplicate or pending charge. Do not use for a refund that is already complete.",
revision: 1,
skillMd:
"# Explain the Payment State\n\nName each charge as pending, settled, refunded, or failed.",
sourceInsight: insights[3]!,
},
{
id: "skill-return",
name: "calculate-return-deadline",
description:
"Use when a customer asks whether an item can be returned. Do not use without the delivery date and product policy.",
revision: 1,
skillMd:
"# Calculate the Return Deadline\n\nCalculate and state the final eligible return date.",
sourceInsight: insights[4]!,
},
{
id: "skill-shipment",
name: "escalate-stalled-shipment",
description:
"Use when shipment tracking remains unchanged past the carrier deadline. Do not use while the expected delivery window is open.",
revision: 1,
skillMd:
"# Escalate a Stalled Shipment\n\nCompare the last carrier scan with the promised delivery window.",
sourceInsight: insights[5]!,
},
] as const;
function page<T>(items: readonly T[], pageSize: 3 | 4, requested = 1) {
if (items.length === 0) {
return { page: 1, pageSize, total: 0, totalPages: 0, items: [] };
}
const totalPages = Math.ceil(items.length / pageSize);
const current = Math.min(Math.max(requested, 1), totalPages);
const start = (current - 1) * pageSize;
return {
page: current,
pageSize,
total: items.length,
totalPages,
items: items.slice(start, start + pageSize),
};
}
function links(container = false) {
const learning = container
? `${WEB_APP_ORIGIN}/learning?container=checkout-assistant-default`
: `${WEB_APP_ORIGIN}/learning`;
return {
learning,
candidates: container
? `${WEB_APP_ORIGIN}/o/acme/checkout/learning/checkout-assistant-default/skills`
: null,
runs: container ? `${learning}&tab=runs` : null,
};
}
function base(
overrides: Partial<InspectorLearningSnapshotV1>,
): InspectorLearningSnapshotV1 {
return {
schemaVersion: 1,
projectKey: "checkout-assistant-project",
snapshotVersion: "learning-state-fixture-v5",
webAppOrigin: WEB_APP_ORIGIN,
configuration: { state: "not_configured" },
pendingThreadCount: 0,
run: { hasActiveRun: false, hasEverSucceeded: false, latest: null },
pendingCandidateCount: 0,
skillsPage: page([], 3),
insightsPage: page([], 4),
links: links(false),
...overrides,
};
}
export function learningSnapshotForState(
state: LearningLabState,
requested: Readonly<{ skillsPage?: number; insightsPage?: number }> = {},
): InspectorLearningSnapshotV1 {
if (state === "pagination") {
return base({
configuration: configured,
pendingCandidateCount: 2,
run: {
hasActiveRun: false,
hasEverSucceeded: true,
latest: {
status: "succeeded",
completedAt: "2026-09-03T18:30:00.000Z",
},
},
skillsPage: page(skills, 3, requested.skillsPage),
insightsPage: page(insights, 4, requested.insightsPage),
links: links(true),
});
}
const resultsState = [
"success",
"new-threads",
"results-error",
"results-evidence",
"evidence-unavailable",
].includes(state);
if (resultsState) {
const resultInsights = insights.slice(1);
const visibleInsights =
state === "evidence-unavailable"
? [
{
...resultInsights[0]!,
evidence: [{ status: "unavailable" as const }],
},
...resultInsights.slice(1),
]
: resultInsights;
return base({
configuration: configured,
pendingThreadCount: state === "new-threads" ? 8 : 0,
pendingCandidateCount: 1,
run: {
hasActiveRun: false,
hasEverSucceeded: true,
latest: {
status: "succeeded",
completedAt: "2026-09-03T18:30:00.000Z",
},
},
skillsPage: page(skills.slice(0, 1), 3, requested.skillsPage),
insightsPage: page(visibleInsights, 4, requested.insightsPage),
links: links(true),
});
}
if (state === "multiple-skills") {
return base({
configuration: configured,
pendingCandidateCount: 2,
run: {
hasActiveRun: false,
hasEverSucceeded: true,
latest: {
status: "succeeded",
completedAt: "2026-09-03T18:30:00.000Z",
},
},
skillsPage: page(skills, 3, requested.skillsPage),
insightsPage: page(insights.slice(6), 4, requested.insightsPage),
links: links(true),
});
}
if (state === "insights-only") {
return base({
configuration: configured,
run: {
hasActiveRun: false,
hasEverSucceeded: true,
latest: {
status: "succeeded",
completedAt: "2026-09-03T18:30:00.000Z",
},
},
insightsPage: page(insights, 4, requested.insightsPage),
links: links(true),
});
}
if (state === "threads-available") {
return base({
configuration: configured,
pendingThreadCount: 8,
links: links(true),
});
}
if (state === "no-threads") {
return base({ configuration: configured, links: links(true) });
}
if (state === "setup-error" || state === "setup-prompt") {
return base({
configuration: { state: "invalid", reason: "instrumentation" },
});
}
if (state === "empty-results") {
return base({
configuration: configured,
run: {
hasActiveRun: false,
hasEverSucceeded: true,
latest: {
status: "succeeded",
completedAt: "2026-09-03T18:30:00.000Z",
},
},
links: links(true),
});
}
if (state === "first-run") {
return base({
configuration: configured,
run: {
hasActiveRun: true,
hasEverSucceeded: false,
latest: { status: "reducing", completedAt: null },
},
links: links(true),
});
}
if (state === "candidates-only") {
return base({
configuration: configured,
pendingCandidateCount: 2,
links: links(true),
});
}
if (state === "selection-required") {
return base({ configuration: { state: "selection_required" } });
}
return base({});
}
export function isLearningScreenshotState(
value: string | null,
): value is LearningScreenshotState {
return (
typeof value === "string" &&
(LEARNING_SCREENSHOT_STATES as readonly string[]).includes(value)
);
}
export function isLearningLabState(
value: string | null,
): value is LearningLabState {
return value === "pagination" || isLearningScreenshotState(value);
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { LEARNING_LAB_BASE_PATH } from "./learning-state-fixtures.js";
import { handleLearningStateLabRequest } from "./learning-state-lab-server.js";
describe("integrated Inspector Learning state lab runtime", () => {
it("negotiates the capability and serves paginated snapshots over REST", async () => {
const base = `http://127.0.0.1:5177${LEARNING_LAB_BASE_PATH}/multiple-skills`;
const info = await handleLearningStateLabRequest(
new Request(`${base}/info`),
);
expect(await info?.json()).toMatchObject({
inspectorLearning: true,
intelligence: { wsUrl: expect.stringMatching(/^ws:/) },
agents: { "Checkout Assistant": { name: "Checkout Assistant" } },
});
const response = await handleLearningStateLabRequest(
new Request(`${base}/inspector-learning?skillsPage=2&insightsPage=1`),
);
expect(await response?.json()).toMatchObject({
skillsPage: { page: 2, pageSize: 3, total: 6 },
insightsPage: { page: 1, pageSize: 4, total: 2 },
});
});
it("serves the same snapshot through the real single-route envelope", async () => {
const response = await handleLearningStateLabRequest(
new Request(`http://127.0.0.1:5177${LEARNING_LAB_BASE_PATH}/success`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
method: "inspector/learning",
params: { skillsPage: "1", insightsPage: "2" },
}),
}),
);
expect(await response?.json()).toMatchObject({
projectKey: "checkout-assistant-project",
skillsPage: { page: 1, pageSize: 3 },
insightsPage: { page: 2, pageSize: 4, total: 7 },
links: {
learning:
"https://app.copilotkit.ai/learning?container=checkout-assistant-default",
candidates:
"https://app.copilotkit.ai/o/acme/checkout/learning/checkout-assistant-default/skills",
runs: "https://app.copilotkit.ai/learning?container=checkout-assistant-default&tab=runs",
},
});
});
it("keeps unsupported and data-error states distinct", async () => {
const unsupportedBase = `http://127.0.0.1:5177${LEARNING_LAB_BASE_PATH}/unsupported`;
const info = await handleLearningStateLabRequest(
new Request(`${unsupportedBase}/info`),
);
expect(await info?.json()).not.toHaveProperty("inspectorLearning");
const unavailable = await handleLearningStateLabRequest(
new Request(
`http://127.0.0.1:5177${LEARNING_LAB_BASE_PATH}/data-error/inspector-learning`,
),
);
expect(unavailable?.status).toBe(503);
});
});
@@ -0,0 +1,152 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin, ViteDevServer } from "vite";
import {
isLearningLabState,
LEARNING_LAB_BASE_PATH,
learningRuntimeInfo,
learningSnapshotForState,
} from "./learning-state-fixtures.js";
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
},
});
async function singleRouteEnvelope(request: Request): Promise<{
method?: string;
params?: Record<string, unknown>;
}> {
try {
const value: unknown = await request.json();
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
const envelope = value as Record<string, unknown>;
return {
method: typeof envelope.method === "string" ? envelope.method : undefined,
params:
typeof envelope.params === "object" &&
envelope.params !== null &&
!Array.isArray(envelope.params)
? (envelope.params as Record<string, unknown>)
: undefined,
};
} catch {
return {};
}
}
/** Deterministic local Runtime used by the real Core and Inspector shell. */
export async function handleLearningStateLabRequest(
request: Request,
): Promise<Response | undefined> {
const url = new URL(request.url);
const parts = url.pathname
.slice(LEARNING_LAB_BASE_PATH.length)
.split("/")
.filter(Boolean);
const [stateValue, route] = parts;
if (!stateValue || !isLearningLabState(stateValue)) return undefined;
let method = route;
let params: Record<string, unknown> = Object.fromEntries(url.searchParams);
if (request.method === "POST" && route === undefined) {
const envelope = await singleRouteEnvelope(request);
method = envelope.method;
params = envelope.params ?? {};
}
if (method === "info") return json(learningRuntimeInfo(stateValue));
if (method !== "inspector-learning" && method !== "inspector/learning") {
return json({ error: "Not found" }, 404);
}
if (stateValue === "unsupported") return json({ error: "Not found" }, 404);
if (stateValue === "data-error") {
return json(
{ error: "Inspector Learning is temporarily unavailable" },
503,
);
}
if (stateValue === "loading") {
return new Promise<Response>(() => undefined);
}
const numberParam = (name: string): number | undefined => {
const value = params[name];
const parsed = typeof value === "string" ? Number(value) : undefined;
return Number.isSafeInteger(parsed) && parsed! > 0 ? parsed : undefined;
};
return json(
learningSnapshotForState(stateValue, {
skillsPage: numberParam("skillsPage"),
insightsPage: numberParam("insightsPage"),
}),
);
}
async function readBody(
request: IncomingMessage,
): Promise<Uint8Array | undefined> {
if (request.method === "GET" || request.method === "HEAD") return undefined;
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function writeResponse(response: Response, target: ServerResponse) {
target.statusCode = response.status;
response.headers.forEach((value, name) => target.setHeader(name, value));
target.end(Buffer.from(await response.arrayBuffer()));
}
export type LearningStateLabPlugin = Plugin &
Readonly<{
name: "web-inspector-learning-state-lab";
transform?: never;
}>;
export function createLearningStateLabPlugin(): LearningStateLabPlugin {
return {
name: "web-inspector-learning-state-lab" as const,
configureServer(server: ViteDevServer) {
server.middlewares.use((request, response, next) => {
const host = request.headers.host ?? "127.0.0.1";
const url = new URL(request.url ?? "/", `http://${host}`);
if (
url.pathname !== LEARNING_LAB_BASE_PATH &&
!url.pathname.startsWith(`${LEARNING_LAB_BASE_PATH}/`)
) {
next();
return;
}
void (async () => {
const body = await readBody(request);
const labResponse = await handleLearningStateLabRequest(
new Request(url, {
method: request.method,
headers: request.headers as HeadersInit,
body,
}),
);
await writeResponse(
labResponse ?? json({ error: "Not found" }, 404),
response,
);
})().catch((error: unknown) => {
response.statusCode = 500;
response.end(
JSON.stringify({
error: error instanceof Error ? error.message : "Lab failed",
}),
);
});
});
},
};
}
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inspector Learning integrated state lab</title>
<script type="module" src="/learning-states.ts"></script>
<style>
:root {
color-scheme: light;
background: #eeeef3;
}
* {
box-sizing: border-box;
}
html,
body,
#inspector-host {
width: 100%;
min-width: 320px;
min-height: 100%;
margin: 0;
}
body {
min-height: 100vh;
overflow: hidden;
background:
radial-gradient(circle at 50% 0, #f9f9fc 0, transparent 54%), #eeeef3;
}
</style>
</head>
<body>
<main id="inspector-host" aria-label="Integrated Inspector fixture"></main>
</body>
</html>
@@ -0,0 +1,64 @@
import { CopilotKitCore } from "@copilotkit/core";
import {
WEB_INSPECTOR_TAG,
configureWebInspectorElement,
} from "@copilotkit/web-inspector";
import type { WebInspectorElement } from "@copilotkit/web-inspector";
import {
learningLabRuntimeUrl,
prepareLearningStateClient,
readyIntegratedLearningState,
settleLearningState,
waitForLearningConnection,
} from "./learning-state-client.js";
import { isLearningScreenshotState } from "./learning-state-fixtures.js";
import type { LearningScreenshotState } from "./learning-state-fixtures.js";
const query = new URLSearchParams(window.location.search);
const requestedState = query.get("state");
const state: LearningScreenshotState = isLearningScreenshotState(requestedState)
? requestedState
: "success";
const runtimeTransport =
query.get("transport") === "single" ? "single" : "rest";
const runtimeState =
query.get("fixture") === "pagination" ? "pagination" : state;
const preserveLearningSetup = query.get("preserveSetup") === "true";
prepareLearningStateClient({ state, preserveSetup: preserveLearningSetup });
const runtimeUrl = learningLabRuntimeUrl(window.location.origin, runtimeState);
const core = new CopilotKitCore({
runtimeUrl,
runtimeTransport,
deferInitialConnection: true,
});
const inspector = configureWebInspectorElement(
document.createElement(WEB_INSPECTOR_TAG),
core,
);
document.querySelector("#inspector-host")?.replaceChildren(inspector);
async function boot(): Promise<void> {
core.connect();
await waitForLearningConnection(core);
await readyIntegratedLearningState(state, inspector);
await settleLearningState();
document.body.dataset.state = state;
document.body.dataset.transport = runtimeTransport;
document.documentElement.dataset.ready = "true";
}
void boot().catch((error: unknown) => {
document.documentElement.dataset.ready = "error";
document.body.dataset.error =
error instanceof Error ? error.message : String(error);
console.error("[Inspector Learning lab]", error);
});
declare global {
interface Window {
__learningInspector?: WebInspectorElement;
}
}
window.__learningInspector = inspector;
@@ -0,0 +1,171 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { LEARNING_WORKBENCH_SCENARIOS } from "./learning-state-fixtures.js";
async function openWorkbenchState(
page: Page,
state: (typeof LEARNING_WORKBENCH_SCENARIOS)[number]["state"],
): Promise<void> {
await page.goto(`/?scenario=learning-${state}&reset=1`, {
waitUntil: "domcontentloaded",
});
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
}
test("enumerates and navigates the complete Automatic Learning matrix at the root", async ({
page,
}) => {
await page.setViewportSize({ width: 1702, height: 1200 });
const infoResponse = page.waitForResponse(
(response) =>
response.url().endsWith("/inspector-learning-lab/landing/info") &&
response.request().method() === "GET",
);
await openWorkbenchState(page, "landing");
await expect(
page.getByRole("heading", { name: "Inspector state workbench" }),
).toBeVisible();
await expect(
page.getByRole("complementary", { name: "Scenario validation console" }),
).toBeVisible();
const options = page.locator(
'#scenario-select optgroup[label="Automatic Learning"] option',
);
await expect(options).toHaveCount(LEARNING_WORKBENCH_SCENARIOS.length);
expect(
await options.evaluateAll((items) =>
items.map((item) => ({
value: (item as HTMLOptionElement).value,
label: item.textContent,
})),
),
).toEqual(
LEARNING_WORKBENCH_SCENARIOS.map(({ key, label }) => ({
value: key,
label: `Automatic Learning · ${label}`,
})),
);
expect(await (await infoResponse).json()).toMatchObject({
mode: "intelligence",
inspectorLearning: true,
agents: { "Checkout Assistant": { name: "Checkout Assistant" } },
});
await expect(
page.getByRole("heading", {
name: "Turn every interaction into reusable context.",
}),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Copy setup prompt for Threads" }),
).toBeVisible();
await expect(
page.locator('[data-inspector-locked-feature-talk="memory"]'),
).toBeVisible();
await expect(
page.locator('iframe[title="CopilotKit Learning overview"]'),
).toBeVisible();
await page.locator("#scenario-select").click();
await page.locator("#scenario-select").press("Escape");
await page.locator("#scenario-select").selectOption("learning-success");
await page.waitForURL(/\?scenario=learning-success&reset=1$/);
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
await expect(
page.getByRole("region", { name: "Skills in registry" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "verify-refund-request" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "1 Skill for review in web app" }),
).toHaveAttribute(
"href",
"https://app.copilotkit.ai/o/acme/checkout/learning/checkout-assistant-default/skills",
);
await expect(
page.getByText("Supporting Insight", { exact: true }),
).toBeVisible();
await expect(
page.getByText("Not applicable to Learning fixtures."),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
});
test("selects every Automatic Learning state without editing the root URL", async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openWorkbenchState(page, "landing");
for (const { key, state } of LEARNING_WORKBENCH_SCENARIOS.slice(1)) {
await page.locator("#scenario-select").selectOption(key);
await page.waitForURL(`**/?scenario=${key}&reset=1`);
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
await expect(page.locator("body")).toHaveAttribute("data-scenario", key);
await expect(page.locator("body")).toHaveAttribute(
"data-learning-state",
state,
);
}
});
test("renders the narrow root workbench with an icon rail and stacked setup steps", async ({
page,
}) => {
await page.setViewportSize({ width: 768, height: 1200 });
await openWorkbenchState(page, "no-threads");
const inspectorWindow = page.locator(".inspector-window");
const inspectorBox = await inspectorWindow.boundingBox();
expect(inspectorBox).not.toBeNull();
expect(inspectorBox!.x).toBe(0);
expect(inspectorBox!.x + inspectorBox!.width).toBeLessThanOrEqual(768);
await expect(
page.locator('.inspector-sidebar[data-icon-rail="true"]'),
).toBeVisible();
const setup = page.getByRole("region", { name: "Set up Learning" });
await expect(setup.getByText("1 of 3 steps")).toBeVisible();
await expect(
setup.getByRole("heading", { name: "Waiting for the first Thread" }),
).toBeVisible();
const stepBoxes = await setup.locator(".step").evaluateAll((steps) =>
steps.map((step) => {
const bounds = step.getBoundingClientRect();
return { x: bounds.x, y: bounds.y };
}),
);
expect(stepBoxes).toHaveLength(3);
expect(new Set(stepBoxes.map(({ x }) => Math.round(x))).size).toBe(1);
expect(stepBoxes[1]!.y).toBeGreaterThan(stepBoxes[0]!.y);
expect(stepBoxes[2]!.y).toBeGreaterThan(stepBoxes[1]!.y);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
await page.getByRole("button", { name: "Close Web Inspector" }).click();
await expect(page.locator("#scenario-select")).toBeVisible();
await page.locator("#scenario-select").selectOption("learning-success");
await page.waitForURL(/\?scenario=learning-success&reset=1$/);
await expect(page.locator("html")).toHaveAttribute("data-ready", "true", {
timeout: 15_000,
});
await expect(
page.getByRole("region", { name: "Skills in registry" }),
).toBeVisible();
});
+68 -7
View File
@@ -29,6 +29,13 @@ import type {
ThreadsStateScenario,
} from "./threads-state-lab.js";
import type { ThreadRequestLog } from "./threads-state-lab-server.js";
import {
learningLabRuntimeUrl,
prepareLearningStateClient,
readyIntegratedLearningState,
settleLearningState,
waitForLearningConnection,
} from "./learning-state-client.js";
const scenarioSelect = requiredElement<HTMLSelectElement>("#scenario-select");
const notificationField = requiredElement<HTMLElement>("#notification-field");
@@ -76,8 +83,12 @@ const notificationConfig: NotificationConfig =
customNotificationText
? { source: "custom", text: customNotificationText }
: { source: "live" };
const runtimeUrl = runtimeUrlFor(window.location.origin, scenario.key);
const requestLogUrl = `${runtimeUrl}/request-log`;
const runtimeUrl = scenario.learningState
? learningLabRuntimeUrl(window.location.origin, scenario.learningState)
: runtimeUrlFor(window.location.origin, scenario.key);
const requestLogUrl = scenario.learningState
? null
: `${runtimeUrl}/request-log`;
let core: CopilotKitCore | null = null;
let inspector: WebInspectorElement | null = null;
@@ -263,6 +274,7 @@ function renderFixture(): void {
threads: scenario.threads,
learning: scenario.learning,
memories: scenario.memories,
learningState: scenario.learningState ?? null,
expectedNewestThreadId: scenario.expectedNewestThreadId ?? null,
expectedInitialRequests: scenario.expectedRequests,
media: scenario.media,
@@ -326,6 +338,9 @@ function renderLedger(log: ThreadRequestLog): void {
async function fetchRequestLog(
signal?: AbortSignal,
): Promise<ThreadRequestLog> {
if (!requestLogUrl) {
throw new Error("The Thread request ledger does not apply to Learning.");
}
const response = await fetch(requestLogUrl, {
headers: { accept: "application/json" },
signal,
@@ -336,8 +351,27 @@ async function fetchRequestLog(
return parseRequestLog(await response.json());
}
function renderLearningLedger(): void {
for (const kind of THREAD_REQUEST_KINDS) {
const actualCell = requiredElement<HTMLElement>(`#actual-${kind}`);
const outcomeCell = requiredElement<HTMLElement>(`#outcome-${kind}`);
actualCell.textContent = "—";
outcomeCell.textContent = "Not used";
outcomeCell.dataset.state = "match";
}
const empty = document.createElement("li");
empty.textContent = "Learning uses the shared Inspector Learning Runtime.";
requestLogOutput.replaceChildren(empty);
ledgerStatus.textContent = "Not applicable to Learning fixtures.";
ledgerStatus.dataset.state = "match";
}
async function refreshLedger(): Promise<void> {
if (teardownStarted) return;
if (scenario.learningState) {
renderLearningLedger();
return;
}
ledgerAbortController?.abort();
const controller = new AbortController();
ledgerAbortController = controller;
@@ -441,6 +475,7 @@ async function openInspectorSurface(
}
async function resetServerLedger(): Promise<void> {
if (!requestLogUrl) return;
const response = await fetch(`${requestLogUrl}/reset`, {
method: "POST",
headers: { "content-type": "application/json" },
@@ -537,6 +572,8 @@ function reportFatalError(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
actionStatus.textContent = message;
actionStatus.dataset.state = "error";
document.body.dataset.labReady = "error";
document.documentElement.dataset.ready = "error";
console.error("[Inspector Threads lab]", error);
}
@@ -545,7 +582,7 @@ async function boot(): Promise<void> {
renderNotificationEditor(notificationConfig);
installCustomNotificationResponse(notificationConfig);
renderFixture();
document.title = `${scenario.label} · Inspector Threads lab`;
document.title = `${scenario.label} · Inspector state workbench`;
document.body.dataset.scenario = scenario.key;
if (parsedScenario.rejectedKey) {
@@ -582,6 +619,13 @@ async function boot(): Promise<void> {
actionStatus.textContent = "Inspector state and fixture ledger reset.";
}
if (scenario.learningState) {
prepareLearningStateClient({
state: scenario.learningState,
embeddedWorkbench: true,
});
}
core = new CopilotKitCore({
runtimeUrl,
runtimeTransport: "rest",
@@ -604,14 +648,31 @@ async function boot(): Promise<void> {
refreshLedger().catch(reportFatalError);
mediaTimer = window.setInterval(updateMediaStatus, 400);
updateMediaStatus();
seedThreadsStateLabAgentEvents(inspector, scenario);
await inspector.updateComplete;
if (replayingNotification) {
actionStatus.textContent = "";
if (scenario.learningState) {
await waitForLearningConnection(core);
await readyIntegratedLearningState(scenario.learningState, inspector);
if (window.innerWidth <= 900) {
// The narrow Inspector remains truly docked, but the workbench itself
// must keep its normal viewport width so closing the Inspector reveals
// usable scenario controls instead of a page shifted off canvas.
document.body.style.marginLeft = "";
}
await settleLearningState();
document.body.dataset.learningState = scenario.learningState;
} else {
seedThreadsStateLabAgentEvents(inspector, scenario);
await inspector.updateComplete;
}
if (replayingNotification && !scenario.learningState) {
actionStatus.textContent = "";
} else if (!scenario.learningState) {
await openInspectorSurface(scenario.initialMenu);
actionStatus.textContent = "";
} else {
actionStatus.textContent = "";
}
document.body.dataset.labReady = "true";
document.documentElement.dataset.ready = "true";
}
const removeNavigationListeners = installThreadsStateLabNavigation(
@@ -0,0 +1,26 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "*.browser.ts",
fullyParallel: false,
workers: 1,
reporter: "list",
outputDir: join(tmpdir(), "copilotkit-web-inspector-playwright"),
use: {
baseURL: "http://127.0.0.1:5177",
screenshot: "off",
trace: "off",
video: "off",
},
webServer: {
command:
"pnpm exec vite dev dev --config dev/vite.config.ts --host 127.0.0.1 --port 5177",
cwd: "..",
url: "http://127.0.0.1:5177/",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});
@@ -36,6 +36,7 @@ import type {
ThreadRequestCounters,
ThreadsStateScenario,
} from "./threads-state-lab.js";
import { LEARNING_WORKBENCH_SCENARIOS } from "./learning-state-fixtures.js";
import {
createThreadsStateLabPlugin,
createThreadsStateLabRuntime,
@@ -89,9 +90,26 @@ const EXPECTED_EDGE_KEYS = [
] as const;
const EXPECTED_LEARNING_KEYS = [
"learning-enabled-existing",
"learning-enabled-empty",
"learning-disabled",
"learning-landing",
"learning-copy-error",
"learning-no-threads",
"learning-threads-available",
"learning-first-run",
"learning-success",
"learning-insights-only",
"learning-multiple-skills",
"learning-new-threads",
"learning-candidates-only",
"learning-empty-results",
"learning-setup-error",
"learning-unsupported",
"learning-loading",
"learning-data-error",
"learning-selection-required",
"learning-results-error",
"learning-results-evidence",
"learning-evidence-unavailable",
"learning-setup-prompt",
] as const;
const EXPECTED_RECORDING_THREADS = [
@@ -489,7 +507,7 @@ function nextSocketMessage(socket: WebSocket): Promise<unknown> {
});
}
test("exports the exact ordered 37-scenario route catalog", () => {
test("exports the exact ordered 54-scenario route catalog", () => {
expect(CORE_SCENARIO_KEYS).toEqual(EXPECTED_CORE_KEYS);
expect(LEARNING_SCENARIO_KEYS).toEqual(EXPECTED_LEARNING_KEYS);
expect(EDGE_SCENARIO_KEYS).toEqual(EXPECTED_EDGE_KEYS);
@@ -498,7 +516,7 @@ test("exports the exact ordered 37-scenario route catalog", () => {
...EXPECTED_LEARNING_KEYS,
...EXPECTED_EDGE_KEYS,
]);
expect(new Set(ALL_SCENARIO_KEYS).size).toBe(37);
expect(new Set(ALL_SCENARIO_KEYS).size).toBe(54);
expect(Object.keys(THREADS_STATE_SCENARIOS)).toEqual(ALL_SCENARIO_KEYS);
});
@@ -556,24 +574,26 @@ test("models every plan deployment capability and data matrix cell", () => {
}
});
test("models enabled, empty, and disabled Automatic Learning fixtures", () => {
const existing = getThreadsStateScenario("learning-enabled-existing");
const empty = getThreadsStateScenario("learning-enabled-empty");
const disabled = getThreadsStateScenario("learning-disabled");
test("models the complete Automatic Learning workbench matrix", () => {
expect(LEARNING_SCENARIO_KEYS).toEqual(
LEARNING_WORKBENCH_SCENARIOS.map(({ key }) => key),
);
expect(existing.learning).toBe("enabled");
expect(existing.initialMenu).toBe("memories");
expect(existing.memories.map((memory) => memory.kind)).toEqual([
"topical",
"episodic",
"operational",
]);
expect(empty.learning).toBe("enabled");
expect(empty.initialMenu).toBe("memories");
expect(empty.memories).toEqual([]);
expect(disabled.learning).toBe("disabled");
expect(disabled.initialMenu).toBe("memories");
expect(disabled.memories).toEqual([]);
for (const descriptor of LEARNING_WORKBENCH_SCENARIOS) {
const scenario = getThreadsStateScenario(descriptor.key);
expect(scenario.label).toBe(`Automatic Learning · ${descriptor.label}`);
expect(scenario.learningState).toBe(descriptor.state);
expect(scenario.initialMenu).toBe("memories");
expect(scenario.learning).toBe("disabled");
expect(scenario.memories).toEqual([]);
expect(scenario.runtimeInfo.inspectorLearning).toBe(
descriptor.state === "unsupported" ? undefined : true,
);
}
expect(LEARNING_SCENARIO_KEYS).not.toContain("learning-enabled-existing");
expect(LEARNING_SCENARIO_KEYS).not.toContain("learning-enabled-empty");
expect(LEARNING_SCENARIO_KEYS).not.toContain("learning-disabled");
});
test("models zero-thread routes with available usage as true zero states", () => {
@@ -777,50 +797,6 @@ test("serves deterministic list and bounded list-error responses", async () => {
await runtime.dispose();
});
test("serves Automatic Learning records, realtime credentials, recall, and the disabled gate", async () => {
const runtime = createThreadsStateLabRuntime();
const enabledBase =
"http://127.0.0.1/inspector-lab-runtime/learning-enabled-existing";
const list = await runtime.handleRequest(
new Request(`${enabledBase}/memories`),
);
expect(await readJson(list)).toMatchObject({
memories: [
{ kind: "topical" },
{ kind: "episodic" },
{ kind: "operational" },
],
});
const subscribe = await runtime.handleRequest(
new Request(`${enabledBase}/memories/subscribe`, {
method: "POST",
body: "{}",
}),
);
expect(await readJson(subscribe)).toEqual({
joinToken: "threads-lab-token-learning-enabled-existing",
joinCode: "memories-threads-lab-learning-enabled-existing",
});
const recall = await runtime.handleRequest(
new Request(`${enabledBase}/memories/recall`, {
method: "POST",
body: JSON.stringify({ query: "launch review" }),
}),
);
const recallBody = (await readJson(recall)) as { memories: unknown[] };
expect(recallBody).toMatchObject({
memories: expect.any(Array),
});
expect(recallBody.memories[0]).toMatchObject({ score: 0.95 });
const disabled = await runtime.handleRequest(
new Request(
"http://127.0.0.1/inspector-lab-runtime/learning-disabled/memories",
),
);
expect(disabled.status).toBe(404);
await runtime.dispose();
});
test("serves subscribe inspect messages events and state contracts", async () => {
const runtime = createThreadsStateLabRuntime();
const scenario = getThreadsStateScenario("pro-enabled-existing");
@@ -1363,7 +1339,7 @@ test("runs teardown before real select and reset control navigation", async () =
}
});
test("drives the real Core, Inspector, stores, surfaces, and ledger for all 37 routes", async () => {
test("drives the real Core, Inspector, stores, surfaces, and ledger for all 34 Thread routes", async () => {
const restoreNodeBridges = installNodeIntegrationBridges();
const matchMediaDescriptor = Object.getOwnPropertyDescriptor(
window,
@@ -1388,7 +1364,7 @@ test("drives the real Core, Inspector, stores, surfaces, and ledger for all 37 r
}),
});
}
for (const key of ALL_SCENARIO_KEYS) {
for (const key of [...CORE_SCENARIO_KEYS, ...EDGE_SCENARIO_KEYS]) {
const scenario = getThreadsStateScenario(key);
const runtimeUrl = runtimeUrlFor(lab.origin, key);
const resetResponse = await fetch(`${runtimeUrl}/request-log/reset`, {
+26 -68
View File
@@ -2,6 +2,15 @@ import type { CopilotKitCore, Memory } from "@copilotkit/core";
import type { InspectorMetadataV1, RuntimeInfo } from "@copilotkit/shared";
import type { WebInspectorElement } from "@copilotkit/web-inspector";
import {
LEARNING_WORKBENCH_SCENARIOS,
learningRuntimeInfo,
} from "./learning-state-fixtures.js";
import type {
LearningScreenshotState,
LearningWorkbenchScenarioKey,
} from "./learning-state-fixtures.js";
export const CORE_SCENARIO_KEYS = [
"pro-enabled-zero",
"pro-enabled-existing",
@@ -42,11 +51,8 @@ export const EDGE_SCENARIO_KEYS = [
"telemetry-disabled",
] as const;
export const LEARNING_SCENARIO_KEYS = [
"learning-enabled-existing",
"learning-enabled-empty",
"learning-disabled",
] as const;
export const LEARNING_SCENARIO_KEYS: readonly LearningWorkbenchScenarioKey[] =
LEARNING_WORKBENCH_SCENARIOS.map(({ key }) => key);
export const ALL_SCENARIO_KEYS = [
...CORE_SCENARIO_KEYS,
@@ -115,6 +121,7 @@ export interface ThreadsStateScenario {
readonly media: "normal" | "video_error" | "reduced_motion";
readonly learning: "enabled" | "disabled";
readonly memories: readonly Memory[];
readonly learningState?: LearningScreenshotState;
}
const INSPECTOR_STATE_STORAGE_KEY = "cpk:inspector:state";
@@ -842,89 +849,40 @@ function edgeScenario(
}
}
function learningMemoryFixtures(
key: (typeof LEARNING_SCENARIO_KEYS)[number],
threads: readonly ThreadFixture[],
): readonly Memory[] {
const [earlierThread, newestThread] = threads;
return [
{
id: `${key}-topical-preference`,
kind: "topical",
scope: "user",
content:
"Prefers concise implementation plans with the highest-risk verification step listed first.",
sourceThreadIds: newestThread ? [newestThread.id] : [],
invalidatedAt: null,
},
{
id: `${key}-episodic-resolution`,
kind: "episodic",
scope: "user",
content:
"Resolved the onboarding follow-up by validating the local Inspector flow before requesting review.",
sourceThreadIds: threads.map((thread) => thread.id),
invalidatedAt: null,
},
{
id: `${key}-operational-handoff`,
kind: "operational",
scope: "project",
content:
"For launch reviews, include a working local preview and a screenshot of the final state.",
sourceThreadIds: earlierThread ? [earlierThread.id] : [],
invalidatedAt: null,
},
];
}
function buildLearningScenario(
key: (typeof LEARNING_SCENARIO_KEYS)[number],
descriptor: (typeof LEARNING_WORKBENCH_SCENARIOS)[number],
): ThreadsStateScenario {
const threads = threadFixtures(key);
const enabled = key !== "learning-disabled";
const memories =
key === "learning-enabled-existing"
? learningMemoryFixtures(key, threads)
: [];
const { key, label, state } = descriptor;
const inspectorMetadata = metadata("pro", {
used: 122,
used: 0,
limit: { kind: "finite", value: 5_000 },
expiringSoonCount: 7,
expiringSoonCount: 0,
action: { kind: "manage_plan", url: MANAGE_PLAN_URL },
});
const label =
key === "learning-enabled-existing"
? "Automatic Learning · enabled · records"
: key === "learning-enabled-empty"
? "Automatic Learning · enabled · empty"
: "Automatic Learning · disabled";
const description = enabled
? `${memories.length || "No"} learning record${memories.length === 1 ? "" : "s"}; Memory API enabled.`
: "Memory API unavailable; the Learning setup gate is shown.";
return buildScenario({
key,
label,
description,
label: `Automatic Learning · ${label}`,
description: `Integrated Inspector fixture for the ${label.toLowerCase()} state.`,
deployment: "managed",
plan: "pro",
capability: "enabled",
data: "existing",
runtimeInfo: runtimeInfo(key, { capability: "enabled" }),
capability: "absent",
data: "zero",
runtimeInfo: learningRuntimeInfo(state),
inspectorMetadata,
inspectorMetadataBody: inspectorMetadata,
threads,
threads: [],
media: "normal",
initialMenu: "memories",
learning: enabled ? "enabled" : "disabled",
memories,
learning: "disabled",
memories: [],
learningState: state,
});
}
const scenarios = [
...CORE_SCENARIO_KEYS.map(buildCoreScenario),
...LEARNING_SCENARIO_KEYS.map(buildLearningScenario),
...LEARNING_WORKBENCH_SCENARIOS.map(buildLearningScenario),
...EDGE_SCENARIO_KEYS.map(edgeScenario),
];
+6 -1
View File
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import type { Plugin, UserConfig } from "vite";
import { createThreadsStateLabPlugin } from "./threads-state-lab-server.js";
import { createLearningStateLabPlugin } from "./learning-state-lab-server.js";
/**
* Mirrors the package bundler's CSS-as-string behavior for the standalone
@@ -29,7 +30,11 @@ const cssRawImportPlugin = {
} as const satisfies Plugin;
const config = {
plugins: [cssRawImportPlugin, createThreadsStateLabPlugin()],
plugins: [
cssRawImportPlugin,
createThreadsStateLabPlugin(),
createLearningStateLabPlugin(),
],
resolve: {
alias: {
"@copilotkit/web-inspector": fileURLToPath(
+2
View File
@@ -34,6 +34,7 @@
"dev:css": "tailwindcss -i ./src/styles/tailwind.css -o ./src/styles/generated.css --watch --minify",
"check-types": "pnpm run build:css && tsc --noEmit && tsc --project dev/tsconfig.json",
"test": "pnpm run build:css && vitest run",
"test:browser": "playwright test --config dev/playwright.config.ts",
"publint": "publint .",
"attw": "attw --pack . --profile node16"
},
@@ -47,6 +48,7 @@
},
"devDependencies": {
"@copilotkit/typescript-config": "workspace:*",
"@playwright/test": "^1.59.1",
"@tailwindcss/cli": "^4.1.11",
"@types/node": "^22.15.3",
"@types/ws": "^8.18.1",
+14
View File
@@ -4,6 +4,20 @@
"sourceRoot": "packages/web-inspector/src",
"projectType": "library",
"targets": {
"test:browser": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm run test:browser",
"cwd": "packages/web-inspector"
},
"dependsOn": [
{
"target": "build:css",
"projects": "self"
}
],
"cache": false
},
"dev:standalone": {
"executor": "nx:run-commands",
"options": {
@@ -1660,9 +1660,16 @@ test("persisted leaves restore after Inspector has been opened, and first upgrad
"Web Inspector shadow root was not rendered",
);
expectCurrentNavigation(root, expected.group, expected.leaf);
expect(root.querySelector("#cpk-main-scroll")?.textContent).toContain(
expected.marker,
);
const learningView = root.querySelector<HTMLElement>("cpk-learning-view");
if (expected.leaf === "memories" && learningView) {
await (learningView as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
}
const renderedText =
expected.leaf === "memories"
? learningView?.shadowRoot?.textContent
: root.querySelector("#cpk-main-scroll")?.textContent;
expect(renderedText).toContain(expected.marker);
expect(storedSelectedMenu()).toBe(expected.leaf);
} finally {
context.teardown();
@@ -1743,9 +1750,10 @@ test("Workbench remembers Learning, and Settings does not persist a settings lea
'"settings"',
);
await context.toggleSettings();
expect(root.querySelector("#cpk-main-scroll")?.textContent).toContain(
"Learning",
);
const learningView = root.querySelector<HTMLElement>("cpk-learning-view");
await (learningView as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
expect(learningView?.shadowRoot?.textContent).toContain("Learning");
} finally {
context.teardown();
}
@@ -2478,7 +2478,7 @@ test("reading the landing view clears the unread event, not the how-to-fix card"
expect(launcherDot(context.inspector)).toBeNull();
});
test("a Learning failure names itself and lands on Learning", async () => {
test("a legacy Memory failure does not masquerade as a Learning failure", async () => {
const context = await setup({ intelligence: true });
// The store is only reached from the view, so visiting it once is what makes
// the latch reachable at all. This mirrors how a developer gets here.
@@ -2492,22 +2492,8 @@ test("a Learning failure names itself and lands on Learning", async () => {
await context.failMemory("Failed to load memories: 500");
expect(dotSubject(context.inspector)).toBe("memory");
expect(launcherName(context.inspector)).toContain("learning error");
await context.advance(ERROR_BEAT_MS);
expect(pillHeading(context.inspector)).toBe("Failed to load learning data");
await context.activate(pill(context.inspector));
expect(currentMenu(context.inspector)).toBe("memories");
// Learning keeps its own error display rather than the shared banner that
// run and tool use, so this asserts what the view actually renders: the
// store's message, and the advice line from the shared guidance table.
const view = root(context.inspector).textContent;
expect(view).toContain("Failed to load memories: 500");
expect(view).toContain("Intelligence is connected");
// Advice is not a claim about this view, so nothing here promises a
// highlight — and there is none to promise.
expect(view).not.toContain("highlighted below");
expect(dotSubject(context.inspector)).toBeNull();
expect(launcherName(context.inspector)).not.toContain("learning error");
});
test("a Learning failure arms nothing while the view has never been opened", async () => {
@@ -2521,7 +2507,7 @@ test("a Learning failure arms nothing while the view has never been opened", asy
expect(markers(context.inspector)).toEqual([]);
});
test("a resolved Learning failure stays unread until Learning renders", async () => {
test("a resolved legacy Memory failure leaves Learning unread state alone", async () => {
const context = await setup({ intelligence: true });
await context.press(launcher(context.inspector));
await context.activate(
@@ -2531,11 +2517,11 @@ test("a resolved Learning failure stays unread until Learning renders", async ()
);
await context.closePanel();
await context.failMemory("Failed to load memories: 500");
expect(dotSubject(context.inspector)).toBe("memory");
expect(dotSubject(context.inspector)).toBeNull();
await context.failMemory(null);
expect(dotSubject(context.inspector)).toBe("memory");
expect(dotSubject(context.inspector)).toBeNull();
await context.press(launcher(context.inspector));
expect(currentMenu(context.inspector)).toBe("memories");
await context.closePanel();
@@ -469,16 +469,15 @@ test("disabled feature rows open their landing pages, where setup prompts can be
).click();
await settle(inspector);
expect(currentMenu(inspector)).toBe("memories");
requireElement(
root(inspector).querySelector<HTMLButtonElement>(
'[data-inspector-feature-setup-prompt="memory"]',
),
).click();
await settle(inspector);
expect(writeText).toHaveBeenCalledTimes(2);
expect(String(writeText.mock.calls[1]?.[0])).toContain(
"This task is specifically to enable Learning",
const learningView = requireElement(
root(inspector).querySelector<HTMLElement>("cpk-learning-view"),
);
await (learningView as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
expect(learningView.shadowRoot?.textContent).toContain(
"Learning is not available with this runtime version.",
);
expect(writeText).toHaveBeenCalledTimes(1);
expect(root(inspector).querySelector(".inspector-window")).not.toBeNull();
} finally {
if (originalClipboard) {
@@ -14,6 +14,7 @@ import type { CopilotKitCoreSubscriber } from "@copilotkit/core";
import type { Memory } from "@copilotkit/core";
import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
import type { InspectorOpenSource } from "../lib/telemetry.js";
import type { InspectorLearningSnapshotV1 } from "@copilotkit/shared";
import { describe, it, test, expect, vi, beforeEach, afterEach } from "vitest";
// --- Types for accessing LitElement-private reactive properties ---
@@ -2669,14 +2670,14 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
harness.emitHeadersChanged({ "X-CSRF": "2" });
let eventCalls: unknown[][] = [];
await vi.waitFor(() => {
expect(
fetchMock.mock.calls.filter((call) =>
String(call[0]).endsWith("/threads/thread-1/events"),
),
).toHaveLength(2);
eventCalls = fetchMock.mock.calls.filter((call) =>
String(call[0]).endsWith("/threads/thread-1/events"),
);
expect(eventCalls).toHaveLength(2);
});
expect(headersOf(fetchMock.mock.calls.at(-1)!)).toMatchObject({
expect(headersOf(eventCalls.at(-1)!)).toMatchObject({
"X-CSRF": "2",
});
});
@@ -3428,6 +3429,15 @@ describe("WebInspectorElement memories — tab presence", () => {
// ── 6.4 View states ──────────────────────────────────────────────────────
describe("WebInspectorElement memories — view states", () => {
async function learningSurface(el: WebInspectorElement) {
await el.updateComplete;
const view = el.shadowRoot?.querySelector<HTMLElement>("cpk-learning-view");
expect(view, "Learning surface should render").not.toBeNull();
await (view as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
return view!;
}
beforeEach(() => {
document.body.innerHTML = "";
vi.stubGlobal("localStorage", {
@@ -3446,40 +3456,377 @@ describe("WebInspectorElement memories — view states", () => {
vi.unstubAllGlobals();
});
it("renders the locked teaser when intelligence is absent", async () => {
const landingSnapshot: InspectorLearningSnapshotV1 = {
schemaVersion: 1,
projectKey: "project-safe-key",
snapshotVersion: "snapshot-landing",
webAppOrigin: "https://intelligence.customer.example",
configuration: { state: "not_configured" },
pendingThreadCount: 0,
run: { hasActiveRun: false, hasEverSucceeded: false, latest: null },
pendingCandidateCount: 0,
skillsPage: {
page: 1,
pageSize: 3,
total: 0,
totalPages: 0,
items: [],
},
insightsPage: {
page: 1,
pageSize: 4,
total: 0,
totalPages: 0,
items: [],
},
links: {
learning: "https://intelligence.customer.example/learning",
candidates: null,
runs: null,
},
};
const resultsSnapshot = (
overrides: Partial<InspectorLearningSnapshotV1> = {},
): InspectorLearningSnapshotV1 => ({
...landingSnapshot,
snapshotVersion: "snapshot-results",
configuration: {
state: "configured",
container: { id: "container-1", name: "Checkout Assistant" },
},
run: { hasActiveRun: false, hasEverSucceeded: true, latest: null },
skillsPage: {
page: 1,
pageSize: 3,
total: 0,
totalPages: 0,
items: [],
},
insightsPage: {
page: 1,
pageSize: 4,
total: 1,
totalPages: 1,
items: [
{
id: "insight-1",
statement: "Confirm the order before giving refund guidance.",
impact: "Customers get an accurate next step.",
totalThreadCount: 1,
evidenceTruncated: false,
evidence: [
{
status: "available",
threadId: "thread-1",
threadName: "Refund request #1798",
messageIds: ["message-1"],
updatedAt: "2026-09-03T19:00:00.000Z",
},
],
},
],
},
links: {
learning: "https://intelligence.customer.example/learning",
candidates: null,
runs: null,
},
...overrides,
});
const learningCore = (
fetch: ReturnType<typeof vi.fn>,
agents: Record<string, AbstractAgent> = {},
) =>
Object.assign(makeCoreWithMemory([]), {
agents,
runtimeUrl: "https://runtime.customer.example/api/copilotkit",
runtimeTransport: "rest" as const,
inspectorLearning: true,
ɵruntimeFetch: fetch,
});
it("advances the existing Learning landing copy action into setup progress", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const core = Object.assign(makeCoreWithMemory([]), {
runtimeUrl: "https://runtime.customer.example/api/copilotkit",
runtimeTransport: "rest" as const,
inspectorLearning: true,
});
const el = await mountMemories(core);
const internals = el as unknown as {
learningSupported: boolean;
learningSnapshot: InspectorLearningSnapshotV1 | null;
learningSetupMarker: {
runtimeUrl: string;
agentId: string | null;
} | null;
selectedMenu: string;
};
internals.learningSupported = true;
internals.learningSnapshot = landingSnapshot;
el.requestUpdate();
await el.updateComplete;
const landing = el.shadowRoot?.querySelector(
'[data-inspector-locked-feature="memory"]',
);
expect(landing?.textContent).toContain(
"Turn every interaction into reusable context.",
);
const copy = landing?.querySelector<HTMLButtonElement>(
'[data-inspector-feature-setup-prompt="threads"]',
);
expect(copy).not.toBeNull();
copy?.click();
await vi.waitFor(() => {
expect(writeText).toHaveBeenCalledOnce();
expect(internals.learningSetupMarker).toMatchObject({
runtimeUrl: "https://runtime.customer.example/api/copilotkit",
agentId: null,
});
});
expect(internals.selectedMenu).toBe("memories");
await el.updateComplete;
const view = el.shadowRoot?.querySelector<HTMLElement>("cpk-learning-view");
await (view as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
expect(
view?.shadowRoot?.querySelector('[data-learning-state="setup"]'),
).not.toBeNull();
});
it("keeps all-agents Learning unscoped when several agents are present", () => {
const alpha = createMockAgent("alpha").agent;
const beta = createMockAgent("beta").agent;
const core = makeCoreWithMemory([]);
core.agents = { alpha, beta };
const el = createInspectorWithCore(core as unknown as MockCore);
const internals = el as unknown as {
selectedContext: string;
getLearningAgentId: () => string | null;
};
internals.selectedContext = "all-agents";
expect(internals.getLearningAgentId()).toBeNull();
internals.selectedContext = "beta";
expect(internals.getLearningAgentId()).toBe("beta");
});
it("keeps Skills and Insights pagination independent through the integrated pane", async () => {
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const skillsPage = Number(url.searchParams.get("skillsPage") ?? "1");
const insightsPage = Number(url.searchParams.get("insightsPage") ?? "1");
return new Response(
JSON.stringify(
resultsSnapshot({
snapshotVersion: `snapshot-${skillsPage}-${insightsPage}`,
skillsPage: {
page: skillsPage,
pageSize: 3,
total: 4,
totalPages: 2,
items: Array.from(
{ length: skillsPage === 1 ? 3 : 1 },
(_, index) => ({
id: `skill-${skillsPage}-${index}`,
name: `skill-page-${skillsPage}-${index}`,
description: "Use this Skill for support requests.",
revision: 1,
skillMd: "# Skill",
sourceInsight: null,
}),
),
},
insightsPage: {
...resultsSnapshot().insightsPage,
page: insightsPage,
total: 5,
totalPages: 2,
items: Array.from(
{ length: insightsPage === 1 ? 4 : 1 },
(_, index) => ({
...resultsSnapshot().insightsPage.items[0]!,
id: `insight-${insightsPage}-${index}`,
}),
),
},
}),
),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
const el = await mountMemories(learningCore(fetch));
const view = await learningSurface(el);
await vi.waitFor(() => {
expect(
view.shadowRoot?.querySelector("[data-learning-state='results']"),
).not.toBeNull();
});
view.shadowRoot
?.querySelector<HTMLButtonElement>(
"nav[aria-label='insights pages'] button:last-child",
)
?.click();
await vi.waitFor(() => {
expect(
fetch.mock.calls.some(([input]) =>
String(input).includes("skillsPage=1&insightsPage=2"),
),
).toBe(true);
expect(
(
view as HTMLElement & {
snapshot: InspectorLearningSnapshotV1 | null;
}
).snapshot?.insightsPage.page,
).toBe(2);
});
view.shadowRoot
?.querySelector<HTMLButtonElement>(
"nav[aria-label='skills pages'] button:last-child",
)
?.click();
await vi.waitFor(() => {
expect(
fetch.mock.calls.some(([input]) =>
String(input).includes("skillsPage=2&insightsPage=2"),
),
).toBe(true);
});
el.remove();
});
it("clears the prior scope and refetches when the Inspector agent changes", async () => {
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const agentId = url.searchParams.get("agentId");
return new Response(
JSON.stringify(
resultsSnapshot({
projectKey: agentId ? `project-${agentId}` : "project-all",
snapshotVersion: agentId ? `snapshot-${agentId}` : "snapshot-all",
}),
),
{ status: 200, headers: { "content-type": "application/json" } },
);
});
const alpha = createMockAgent("alpha").agent;
const beta = createMockAgent("beta").agent;
const el = await mountMemories(learningCore(fetch, { alpha, beta }));
const internals = el as unknown as {
contextOptions: Array<{ key: string; label: string }>;
handleContextOptionSelect: (key: string) => void;
learningSnapshot: InspectorLearningSnapshotV1 | null;
};
await vi.waitFor(() => {
expect(internals.learningSnapshot?.projectKey).toBe("project-all");
});
internals.handleContextOptionSelect("beta");
expect(internals.learningSnapshot).toBeNull();
await vi.waitFor(() => {
expect(internals.learningSnapshot?.projectKey).toBe("project-beta");
});
expect(
fetch.mock.calls.some(([input]) =>
String(input).includes("agentId=beta"),
),
).toBe(true);
el.remove();
});
it("routes accessible evidence through the integrated Threads navigator", async () => {
const fetch = vi.fn(
async () =>
new Response(JSON.stringify(resultsSnapshot()), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const el = await mountMemories(learningCore(fetch));
const view = await learningSurface(el);
await vi.waitFor(() => {
expect(view.shadowRoot?.querySelector(".insight-row")).not.toBeNull();
});
const focusThread = vi.fn();
(el as unknown as { focusThread: typeof focusThread }).focusThread =
focusThread;
view.shadowRoot?.querySelector<HTMLButtonElement>(".insight-row")?.click();
await (view as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
view.shadowRoot
?.querySelector<HTMLButtonElement>(".evidence-link")
?.click();
expect(focusThread).toHaveBeenCalledWith({
threadId: "thread-1",
messageId: "message-1",
});
el.remove();
});
it("renders a retryable data error when a pending action link is missing", async () => {
const fetch = vi.fn(
async () =>
new Response(
JSON.stringify(
resultsSnapshot({
pendingThreadCount: 2,
links: {
learning: "https://intelligence.customer.example/learning",
candidates: null,
runs: null,
},
}),
),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
);
const el = await mountMemories(learningCore(fetch));
const view = await learningSurface(el);
await vi.waitFor(() => {
expect(
view.shadowRoot?.querySelector('[data-learning-state="error"]'),
).not.toBeNull();
});
expect(view.shadowRoot?.textContent).toContain(
"Learning snapshot response is invalid.",
);
expect(
Array.from(
view.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
).some((button) => button.textContent?.trim() === "Retry"),
).toBe(true);
expect(view.shadowRoot?.querySelector("a")).toBeNull();
el.remove();
});
it("renders the unsupported Learning surface when the capability is absent", async () => {
const core = makeCoreNoIntelligence();
const el = await mountMemories(core);
const text = el.shadowRoot?.textContent ?? "";
const view = await learningSurface(el);
const text = view.shadowRoot?.textContent ?? "";
expect(text).toContain("Learning");
expect(text).toContain(
"Learning captures durable information from agent interactions and brings it back when it matters, so your product gets more useful over time.",
"Learning is not available with this runtime version.",
);
expect(
el.shadowRoot?.querySelector('[data-inspector-locked-feature="memory"]'),
).not.toBeNull();
const video = el.shadowRoot?.querySelector<HTMLIFrameElement>(
'[data-inspector-feature-video="memory"]',
);
expect(video?.src).toBe(
"https://www.loom.com/embed/2978fbfe42324e509057ac5fd46b7a70?hide_owner=true&hide_share=true&hide_title=true&hideEmbedTopBar=true&hide_speed=true",
);
const outline = el.shadowRoot?.querySelector(
'[data-inspector-feature-outline="memory"]',
);
expect(outline?.getAttribute("aria-label")).toBe("Learning capabilities");
expect(outline?.textContent).toContain("Memory across sessions");
expect(outline?.textContent).toContain("Recall by meaning");
expect(outline?.textContent).toContain("Built-in structure");
expect(outline?.textContent).toContain("Full visibility");
const setupPrompt = el.shadowRoot?.querySelector<HTMLButtonElement>(
'[data-inspector-feature-setup-prompt="memory"]',
);
expect(setupPrompt?.classList.contains("inspector-account-cta")).toBe(true);
expect(
el.shadowRoot?.querySelector(
'.cpk-locked-feature-icon svg[viewBox="0 0 24 24"]',
),
view.shadowRoot?.querySelector('[data-learning-state="unsupported"]'),
).not.toBeNull();
expect(el.shadowRoot?.textContent).not.toContain(
"Sign up for Intelligence",
@@ -3491,43 +3838,24 @@ describe("WebInspectorElement memories — view states", () => {
).toBeNull();
});
it("renders the setup landing when Intelligence is present but unlicensed", async () => {
it("does not infer Learning support from a legacy Memory license", async () => {
const core = makeCoreWithMemory([], { licenseStatus: "none" });
const el = await mountMemories(core);
expect(
el.shadowRoot?.querySelector('[data-inspector-locked-feature="memory"]'),
).not.toBeNull();
expect(
el.shadowRoot?.querySelector(
'[data-inspector-feature-setup-prompt="memory"]',
),
).not.toBeNull();
const view = await learningSurface(el);
expect(view.shadowRoot?.textContent).toContain(
"Learning is not available with this runtime version.",
);
expect(el.shadowRoot?.querySelector("cpk-memory-list")).toBeNull();
});
it("does not use Threads onboarding UTM attribution for locked memory CTAs", async () => {
it("does not render setup or external actions when Learning is unsupported", async () => {
const core = makeCoreNoIntelligence();
const el = await mountMemories(core);
const lockedOverview = el.shadowRoot?.querySelector<HTMLElement>(
'[data-inspector-locked-feature="memory"]',
);
const view = await learningSurface(el);
const talkToEngineer = lockedOverview?.querySelector<HTMLAnchorElement>(
'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
);
expect(talkToEngineer).not.toBeNull();
expect(
lockedOverview?.querySelector(
'a[href^="https://intelligence.copilotkit.ai/?ref="]',
),
).toBeNull();
const url = new URL(talkToEngineer!.href);
expect(url.searchParams.get("ref")).toBeTruthy();
expect(url.searchParams.has("utm_source")).toBe(false);
expect(url.searchParams.has("utm_medium")).toBe(false);
expect(url.searchParams.has("utm_campaign")).toBe(false);
expect(view.shadowRoot?.querySelectorAll("a, button")).toHaveLength(0);
expect(view.shadowRoot?.textContent).not.toContain("setup prompt");
});
it("renders the locked teaser when memories are unavailable", async () => {
@@ -3543,23 +3871,18 @@ describe("WebInspectorElement memories — view states", () => {
).toBeNull();
});
it("renders cpk-memory-list with empty state when available and no memories", async () => {
it("does not render the legacy Memory list when Memory is available", async () => {
const core = makeCoreWithMemory([], { available: true });
const el = await mountMemories(core);
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
expect(
memoryList,
"cpk-memory-list should render when enabled",
).not.toBeNull();
await (memoryList as unknown as { updateComplete: Promise<void> })
.updateComplete;
const listText = memoryList?.shadowRoot?.textContent ?? "";
expect(listText).toContain("No learning records yet");
const view = await learningSurface(el);
expect(view.shadowRoot?.textContent).toContain(
"Learning is not available with this runtime version.",
);
expect(el.shadowRoot?.querySelector("cpk-memory-list")).toBeNull();
});
it("keeps the list rendered (not the full-screen error) when a mutation error arrives with memories present", async () => {
it("ignores a legacy Memory mutation error on the Learning surface", async () => {
// INSP-2: a failed remove/update sets the store error while a valid list is
// already on screen. That must NOT blank the list with the full-screen
// "Failed to load learning data" state — the error is surfaced inline instead.
@@ -3581,21 +3904,16 @@ describe("WebInspectorElement memories — view states", () => {
el.requestUpdate();
await el.updateComplete;
// The list survives.
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
expect(
memoryList,
"cpk-memory-list must remain rendered on a mutation error",
).not.toBeNull();
const text = el.shadowRoot?.textContent ?? "";
// Inline, non-blocking error with distinct copy.
expect(text).toContain("Action failed: could not delete memory");
// The full-screen load-failure copy must NOT appear.
expect(text).not.toContain("Failed to load learning data");
const view = await learningSurface(el);
const text = view.shadowRoot?.textContent ?? "";
expect(text).toContain(
"Learning is not available with this runtime version.",
);
expect(text).not.toContain("could not delete memory");
expect(el.shadowRoot?.querySelector("cpk-memory-list")).toBeNull();
});
it("shows the full-screen load error only when no memories are loaded", async () => {
it("ignores a legacy Memory load error on the Learning surface", async () => {
// INSP-2 counterpart: a snapshot-load failure (empty list) still shows the
// full-screen "Failed to load learning data" state.
const core = makeCoreWithMemory([]);
@@ -3606,10 +3924,12 @@ describe("WebInspectorElement memories — view states", () => {
el.requestUpdate();
await el.updateComplete;
const text = el.shadowRoot?.textContent ?? "";
expect(text).toContain("Failed to load learning data");
expect(text).toContain("network down");
expect(text).not.toContain("Action failed:");
const view = await learningSurface(el);
const text = view.shadowRoot?.textContent ?? "";
expect(text).toContain(
"Learning is not available with this runtime version.",
);
expect(text).not.toContain("network down");
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
expect(memoryList).toBeNull();
});
@@ -3627,33 +3947,37 @@ describe("WebInspectorElement memories — view states", () => {
expect(text).not.toContain("reconnecting");
});
it("shows a muted 'reconnecting' indicator while realtime is connecting", async () => {
it("does not expose legacy Memory reconnect state", async () => {
const core = makeCoreWithMemory([], {
available: true,
realtimeStatus: "connecting",
});
const el = await mountMemories(core);
const text = el.shadowRoot?.textContent ?? "";
expect(text).toContain("reconnecting");
// It must NOT claim "live" while still connecting.
expect(text).not.toMatch(/>\s*live\s*</);
const view = await learningSurface(el);
const text = view.shadowRoot?.textContent ?? "";
expect(text).toContain(
"Learning is not available with this runtime version.",
);
expect(text).not.toContain("reconnecting");
});
it("shows a muted 'offline' indicator when realtime has permanently given up", async () => {
it("does not expose legacy Memory offline state", async () => {
const core = makeCoreWithMemory([], {
available: true,
realtimeStatus: "unavailable",
});
const el = await mountMemories(core);
const text = el.shadowRoot?.textContent ?? "";
expect(text).toContain("offline");
// The frozen snapshot must NOT be labelled "live".
expect(text).not.toMatch(/>\s*live\s*</);
const view = await learningSurface(el);
const text = view.shadowRoot?.textContent ?? "";
expect(text).toContain(
"Learning is not available with this runtime version.",
);
expect(text).not.toContain("offline");
});
it("renders cpk-memory-list with a card when one memory is present", async () => {
it("does not project a legacy Memory record as a Learning result", async () => {
const oneMemory: Memory = {
id: "m1",
kind: "topical",
@@ -3666,13 +3990,9 @@ describe("WebInspectorElement memories — view states", () => {
const core = makeCoreWithMemory([oneMemory]);
const el = await mountMemories(core);
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
expect(memoryList, "cpk-memory-list should render").not.toBeNull();
await (memoryList as unknown as { updateComplete: Promise<void> })
.updateComplete;
const cards = memoryList?.shadowRoot?.querySelectorAll(".cpk-ml__card");
expect(cards?.length).toBe(1);
const view = await learningSurface(el);
expect(view.shadowRoot?.textContent).not.toContain("Prefers dark mode");
expect(el.shadowRoot?.querySelector("cpk-memory-list")).toBeNull();
});
});
@@ -3843,13 +4163,14 @@ describe("WebInspectorElement memories — passive store guard", () => {
expect(spy).not.toHaveBeenCalled();
// Activating the Memories tab is what creates + subscribes to the store.
// Learning reads the Intelligence projection and must not create the
// legacy browser Memory store as a side effect.
(
el as unknown as { handleMenuSelect: (k: string) => void }
).handleMenuSelect("memories");
await el.updateComplete;
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).not.toHaveBeenCalled();
});
it("does not double-subscribe when the Memories tab is re-activated", async () => {
@@ -4069,7 +4390,7 @@ describe("WebInspectorElement memories — older-core compat (no getMemoryStore)
).toBeNull();
});
it("shows the SDK-upgrade teaser (distinct from the not-enabled teaser) when getMemoryStore is absent", async () => {
it("uses the negotiated Learning capability instead of getMemoryStore presence", async () => {
// INSP-3: an older @copilotkit/core (no getMemoryStore) must guide an SDK
// upgrade, with copy distinct from the genuine "not enabled on this
// deployment" teaser shown by a current SDK against a memory-less backend.
@@ -4098,26 +4419,28 @@ describe("WebInspectorElement memories — older-core compat (no getMemoryStore)
internals.handleMenuSelect("memories");
await el.updateComplete;
const text = el.shadowRoot?.textContent ?? "";
expect(text).toContain("Upgrade to enable Learning");
expect(text).toContain("@copilotkit/core and @copilotkit/react");
// Must NOT show the deployment-not-enabled copy in this case.
expect(text).not.toContain(
"Learning captures durable information from agent interactions and brings it back when it matters, so your product gets more useful over time.",
const view = el.shadowRoot?.querySelector<HTMLElement>("cpk-learning-view");
await (view as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
expect(view?.shadowRoot?.textContent).toContain(
"Learning is not available with this runtime version.",
);
expect(view?.shadowRoot?.textContent).not.toContain("@copilotkit SDK");
});
it("shows the not-enabled teaser (distinct from the upgrade teaser) when the current SDK reports memory unavailable", async () => {
it("ignores legacy Memory availability when Learning was not negotiated", async () => {
// INSP-3 counterpart: a current SDK (getMemoryStore present) whose store
// reports available=false shows the deployment teaser, NOT upgrade copy.
const core = makeCoreWithMemory([], { available: false });
const el = await mountMemories(core);
const text = el.shadowRoot?.textContent ?? "";
expect(text).toContain(
"Learning captures durable information from agent interactions and brings it back when it matters, so your product gets more useful over time.",
const view = el.shadowRoot?.querySelector<HTMLElement>("cpk-learning-view");
await (view as HTMLElement & { updateComplete: Promise<void> })
.updateComplete;
expect(view?.shadowRoot?.textContent).toContain(
"Learning is not available with this runtime version.",
);
expect(text).not.toContain("Learning requires a newer version");
expect(view?.shadowRoot?.textContent).not.toContain("@copilotkit SDK");
});
});
@@ -4197,7 +4520,7 @@ describe("WebInspectorElement memories — tab telemetry + detach reset", () =>
const clicks = memoriesTabClicks();
expect(clicks).toHaveLength(1);
expect(clicks[0]!.properties).toMatchObject({
memory_count: 1,
memory_count: 0,
available: true,
});
});
@@ -0,0 +1,346 @@
import { describe, expect, it } from "vitest";
import type { InspectorLearningSnapshotV1 } from "@copilotkit/shared";
import type { CpkLearningView } from "./learning-view.js";
import { deriveLearningViewState } from "./learning-view.js";
function snapshot(
overrides: Partial<InspectorLearningSnapshotV1> = {},
): InspectorLearningSnapshotV1 {
return {
schemaVersion: 1,
projectKey: "project-safe-key",
snapshotVersion: "snapshot-1",
webAppOrigin: "https://app.copilotkit.ai",
configuration: { state: "not_configured" },
pendingThreadCount: 0,
run: { hasActiveRun: false, hasEverSucceeded: false, latest: null },
pendingCandidateCount: 0,
skillsPage: {
page: 1,
pageSize: 3,
total: 0,
totalPages: 0,
items: [],
},
insightsPage: {
page: 1,
pageSize: 4,
total: 0,
totalPages: 0,
items: [],
},
links: {
learning: "https://app.copilotkit.ai/learning",
candidates: null,
runs: null,
},
...overrides,
};
}
const state = (
current: InspectorLearningSnapshotV1 | null,
overrides: Partial<Parameters<typeof deriveLearningViewState>[0]> = {},
) =>
deriveLearningViewState({
supported: true,
loading: false,
error: null,
snapshot: current,
setupActive: false,
...overrides,
});
describe("Learning state precedence", () => {
it("prioritizes capability, initial load, fatal error, and scope failures", () => {
expect(state(null, { supported: false })).toBe("unsupported");
expect(state(null, { loading: true })).toBe("loading");
expect(state(null, { error: "offline" })).toBe("error");
expect(
state(snapshot({ configuration: { state: "selection_required" } })),
).toBe("selection_required");
expect(
state(
snapshot({
configuration: { state: "invalid", reason: "instrumentation" },
}),
),
).toBe("invalid");
});
it("orders results, first run, ready, empty, setup, and landing", () => {
const configured = {
state: "configured" as const,
container: { id: "container-1", name: "Production" },
};
expect(state(snapshot({ pendingCandidateCount: 1 }))).toBe("results");
expect(
state(
snapshot({
configuration: configured,
run: { hasActiveRun: true, hasEverSucceeded: false, latest: null },
}),
),
).toBe("first_run");
expect(
state(snapshot({ configuration: configured, pendingThreadCount: 2 })),
).toBe("ready");
expect(
state(
snapshot({
configuration: configured,
run: { hasActiveRun: false, hasEverSucceeded: true, latest: null },
}),
),
).toBe("empty");
expect(state(snapshot({ configuration: configured }))).toBe("setup");
expect(state(snapshot(), { setupActive: true })).toBe("setup");
expect(state(snapshot())).toBe("landing");
});
});
describe("Learning results hierarchy", () => {
const resultSnapshot = snapshot({
configuration: {
state: "configured",
container: { id: "container-1", name: "Production" },
},
skillsPage: {
page: 1,
pageSize: 3,
total: 1,
totalPages: 1,
items: [
{
id: "skill-1",
name: "escalation-summary",
description: "Summarize the decision before escalating.",
revision: 2,
skillMd: "---\nname: escalation-summary\n---\n<script>no()</script>",
sourceInsight: {
id: "source-1",
statement: "Escalations succeed with a decision summary.",
impact: "Reduces repeat questions.",
totalThreadCount: 3,
evidenceTruncated: false,
evidence: [],
},
},
],
},
insightsPage: {
page: 1,
pageSize: 4,
total: 1,
totalPages: 1,
items: [
{
id: "insight-1",
statement: "Customers confirm ownership faster with a named owner.",
impact: "Shortens handoffs.",
totalThreadCount: 2,
evidenceTruncated: false,
evidence: [],
},
],
},
});
async function renderResults() {
const view = document.createElement("cpk-learning-view") as CpkLearningView;
view.supported = true;
view.snapshot = resultSnapshot;
document.body.append(view);
await view.updateComplete;
return view;
}
it("keeps Skills first and renders the first SKILL.md in a native disclosure", async () => {
const view = await renderResults();
const headings = [...view.shadowRoot!.querySelectorAll("h2")].map(
(heading) => heading.textContent?.trim(),
);
expect(headings).toEqual(["Skills in registry", "More Insights"]);
const disclosure = view.shadowRoot!.querySelector("details");
expect(disclosure?.open).toBe(true);
expect(disclosure?.querySelector("summary")?.textContent).toContain(
"View SKILL.md",
);
expect(disclosure?.querySelector("pre")?.textContent).toContain(
"<script>no()</script>",
);
expect(disclosure?.querySelector("script")).toBeNull();
view.remove();
});
it("keeps the empty-result web-app management link quiet and safe", async () => {
const view = document.createElement("cpk-learning-view") as CpkLearningView;
view.supported = true;
view.snapshot = snapshot({
configuration: {
state: "configured",
container: { id: "container-1", name: "Production" },
},
run: {
hasActiveRun: false,
hasEverSucceeded: true,
latest: null,
},
links: {
learning: "https://app.copilotkit.ai/learning?project=project-safe-key",
candidates: null,
runs: null,
},
});
document.body.append(view);
await view.updateComplete;
const link =
view.shadowRoot!.querySelector<HTMLAnchorElement>("a.quiet-link");
expect(link?.textContent?.trim()).toBe("Open in web app ↗");
expect(link?.href).toBe(
"https://app.copilotkit.ai/learning?project=project-safe-key",
);
expect(link?.target).toBe("_blank");
expect(link?.rel.split(/\s+/).sort()).toEqual(["noopener", "noreferrer"]);
view.remove();
});
it("labels evidence, shows impact and Thread count, and emits on detail open", async () => {
const view = await renderResults();
let opened = 0;
view.addEventListener("learning-evidence-opened", () => {
opened += 1;
});
const labels = [
...view.shadowRoot!.querySelectorAll(".list-header span"),
].map((label) => label.textContent);
expect(labels).toEqual(["Pattern", "Evidence"]);
const row =
view.shadowRoot!.querySelector<HTMLButtonElement>(".insight-row");
expect(row?.textContent).toContain("Shortens handoffs.");
expect(row?.textContent).toMatch(/2\s*Threads/);
row?.click();
await view.updateComplete;
expect(opened).toBe(1);
expect(view.shadowRoot!.textContent).toContain(
"Evidence is no longer available",
);
view.remove();
});
it("keeps unavailable evidence inert and preserves the unnamed accessible Thread fallback", async () => {
const view = await renderResults();
view.snapshot = snapshot({
configuration: {
state: "configured",
container: { id: "container-1", name: "Production" },
},
insightsPage: {
page: 1,
pageSize: 4,
total: 1,
totalPages: 1,
items: [
{
id: "insight-private",
statement: "Evidence availability is checked before navigation.",
impact: "Deleted evidence stays private.",
totalThreadCount: 2,
evidenceTruncated: false,
evidence: [
{ status: "unavailable" },
{
status: "available",
threadId: "thread-accessible",
threadName: null,
messageIds: ["message-accessible"],
updatedAt: "2026-03-10T09:15:00.000Z",
},
],
},
],
},
});
await view.updateComplete;
view.shadowRoot?.querySelector<HTMLButtonElement>(".insight-row")?.click();
await view.updateComplete;
const unavailable = view.shadowRoot!.querySelector(".evidence-unavailable");
expect(unavailable?.textContent?.trim()).toBe(
"Evidence is no longer available",
);
expect(unavailable?.querySelector("button, a")).toBeNull();
const accessible =
view.shadowRoot!.querySelector<HTMLButtonElement>(".evidence-link");
expect(accessible?.textContent).toContain("Thread thread-a");
expect(view.shadowRoot!.textContent).not.toContain("message-accessible");
view.remove();
});
});
describe("Learning setup progress", () => {
async function renderProgress(
current: InspectorLearningSnapshotV1,
setupActive = false,
) {
const view = document.createElement("cpk-learning-view") as CpkLearningView;
view.supported = true;
view.snapshot = current;
view.setupActive = setupActive;
document.body.append(view);
await view.updateComplete;
return view;
}
it("shows all three setup steps and keeps analysis disabled while waiting", async () => {
const view = await renderProgress(snapshot(), true);
expect(view.shadowRoot!.textContent).toContain("1 of 3 steps");
expect(view.shadowRoot!.textContent).toContain(
"Waiting for the first Thread",
);
expect(view.shadowRoot!.querySelectorAll(".step")).toHaveLength(3);
expect(
view.shadowRoot!.querySelector<HTMLButtonElement>("button[disabled]")
?.textContent,
).toContain("Analyze Threads");
view.remove();
});
it("marks Thread capture complete before handing a ready run to the web app", async () => {
const view = await renderProgress(
snapshot({
configuration: {
state: "configured",
container: { id: "container-1", name: "Production" },
},
pendingThreadCount: 3,
links: {
learning: "https://app.copilotkit.ai/learning",
candidates: null,
runs: "https://app.copilotkit.ai/learning?tab=runs",
},
}),
);
expect(view.shadowRoot!.textContent).toContain("2 of 3 steps");
expect(view.shadowRoot!.textContent).toContain("Threads ready to analyze");
expect(view.shadowRoot!.textContent).toMatch(/3\s*New Threads/);
expect(view.shadowRoot!.querySelector("a")?.textContent).toContain(
"Open in web app",
);
view.remove();
});
it("renders setup failures as an alert at step two", async () => {
const view = await renderProgress(
snapshot({
configuration: { state: "invalid", reason: "instrumentation" },
}),
);
expect(view.shadowRoot!.textContent).toContain("Needs attention");
expect(
view.shadowRoot!.querySelector('[role="alert"]')?.textContent,
).toContain("Inspector did not find the Learning container");
view.remove();
});
});
File diff suppressed because it is too large Load Diff
+617 -21
View File
@@ -36,6 +36,9 @@ import type {
RuntimeLicenseStatus,
} from "@copilotkit/core";
import type { AbstractAgent, AgentSubscriber, Message } from "@ag-ui/client";
import type { InspectorLearningSnapshotV1 } from "@copilotkit/shared";
import { deriveLearningViewState } from "./components/learning-view.js";
import type { LearningViewState } from "./components/learning-view.js";
import type {
Anchor,
ContextKey,
@@ -70,6 +73,18 @@ import {
isValidDockMode,
} from "./lib/persistence.js";
import type { PersistedState } from "./lib/persistence.js";
import {
clearLearningSetupMarker,
learningSetupMarkerMatches,
readLearningSetupMarker,
subscribeToLearningSetupMarker,
writeLearningSetupMarker,
} from "./lib/learning-setup.js";
import type { LearningSetupMarker } from "./lib/learning-setup.js";
import {
fetchInspectorLearning,
InspectorLearningUnsupportedError,
} from "./lib/inspector-learning.js";
import {
buildPopOutFeatures,
ensureBrandFont,
@@ -114,6 +129,15 @@ import {
trackHomeStoryBeatSelected,
trackHomeViewed,
trackInspectorOpened,
learningCountBucket,
learningDurationBucket,
trackLearningEvidenceOpened,
trackLearningPageChanged,
trackLearningPaneViewed,
trackLearningSetupPromptClicked,
trackLearningSkillToggled,
trackLearningSnapshotLoaded,
trackLearningWebAppOpened,
trackMetadataActionClicked,
trackMetadataModuleViewed,
trackTalkToEngineerClicked,
@@ -6445,6 +6469,21 @@ export class WebInspectorElement extends LitElement {
// SDK). Distinct from `_memoriesAvailable` (memory not enabled on an
// otherwise-current deployment) so the teaser can show upgrade-the-SDK copy.
private _memoryStoreUnsupported = false;
private learningSnapshot: InspectorLearningSnapshotV1 | null = null;
private learningSnapshotScope: string | null = null;
private learningProjectIdentity: string | null = null;
private learningLoading = false;
private learningRefreshing = false;
private learningError: string | null = null;
private learningSupported = false;
private learningRequestGeneration = 0;
private learningAbortController: AbortController | null = null;
private learningPollTimer: ReturnType<typeof setTimeout> | null = null;
private learningPollFailureCount = 0;
private learningSetupMarker: LearningSetupMarker | null = null;
private learningSetupUnsubscribe: (() => void) | null = null;
private learningPromptCopyState: "idle" | "copied" | "error" = "idle";
private learningViewedState: LearningViewState | null = null;
// ── Semantic recall (B3) ──────────────────────────────────────────────
// `null` = no recall run yet (section hidden). `[]` = ran, no matches.
private _recallResults: Memory[] | null = null;
@@ -7217,6 +7256,14 @@ export class WebInspectorElement extends LitElement {
this._threadsByAgent.set(agentId, threads as ɵThread[]);
this.rebuildFlattenedThreads();
this.autoSelectLatestThread();
if (
this.selectedMenu === "memories" &&
this.shouldPollLearningSetup() &&
!this.learningLoading &&
!this.learningRefreshing
) {
void this.refreshLearningSnapshot({ preserve: true });
}
this.requestUpdate();
});
const statusSub = store
@@ -7505,6 +7552,10 @@ export class WebInspectorElement extends LitElement {
this.updateInspectorMetadataProjection(
this.readCoreInspectorMetadata(core),
);
this.learningSupported = core.inspectorLearning;
this.learningProjectIdentity = JSON.stringify(
this.inspectorMetadataProjection.identity ?? null,
);
this.coreSubscriber = {
onRuntimeConnectionStatusChanged: ({ status }) => {
@@ -7514,11 +7565,16 @@ export class WebInspectorElement extends LitElement {
this.threadCapabilityEnabled === true;
this.synchronizeThreadCapability();
if (status === "connected") {
this.learningSupported = core.inspectorLearning;
if (!core.telemetryDisabled) {
ensureTelemetryDistinctId();
maybeShowDisclosure();
}
this.flushPendingWhatsNewTelemetry();
if (this.isOpen && this.selectedMenu === "memories") {
this.clearLearningSnapshot();
void this.refreshLearningSnapshot({ preserve: false });
}
if (
threadCapabilityWasEnabled &&
this.areThreadEndpointsAvailable()
@@ -7532,6 +7588,7 @@ export class WebInspectorElement extends LitElement {
this._threadsByAgent.clear();
this._threads = [];
this.clearInspectorUsageRefresh();
this.clearLearningSnapshot();
}
this.requestUpdate();
},
@@ -7550,6 +7607,16 @@ export class WebInspectorElement extends LitElement {
return;
}
this.updateInspectorMetadataProjection(inspectorMetadata);
const identity = JSON.stringify(
this.inspectorMetadataProjection.identity ?? null,
);
if (identity !== this.learningProjectIdentity) {
this.learningProjectIdentity = identity;
this.clearLearningSnapshot();
if (this.isOpen && this.selectedMenu === "memories") {
void this.refreshLearningSnapshot({ preserve: false });
}
}
this.requestUpdate();
},
}
@@ -7770,6 +7837,375 @@ export class WebInspectorElement extends LitElement {
this.requestUpdate();
}
private getLearningAgentId(): string | null {
if (
this.selectedContext !== "all-agents" &&
this.core?.agents[this.selectedContext]
) {
return this.selectedContext;
}
// `all-agents` is intentionally unscoped. Choosing the first object key
// silently binds Learning to whichever agent happened to be enumerated
// first and can select the wrong container. Omit agentId so Intelligence
// applies its deterministic sole-container / selection-required rules.
return null;
}
private isLearningSetupActive(): boolean {
const runtimeUrl = this.core?.runtimeUrl;
if (!runtimeUrl) return false;
return learningSetupMarkerMatches(
this.learningSetupMarker,
runtimeUrl,
this.getLearningAgentId(),
);
}
private trackLearningViewState(): void {
if (
!this.isOpen ||
this.selectedMenu !== "memories" ||
this.core?.telemetryDisabled
) {
return;
}
const state = deriveLearningViewState({
supported: this.learningSupported,
loading: this.learningLoading,
error: this.learningError,
snapshot: this.learningSnapshot,
setupActive: this.isLearningSetupActive(),
});
if (state === this.learningViewedState) return;
this.learningViewedState = state;
trackLearningPaneViewed({ state });
}
private cancelLearningPoll(): void {
if (this.learningPollTimer !== null) {
clearTimeout(this.learningPollTimer);
this.learningPollTimer = null;
}
}
private shouldPollLearningSetup(): boolean {
if (
!this.isOpen ||
this.selectedMenu !== "memories" ||
document.visibilityState !== "visible" ||
!this.learningSupported
) {
return false;
}
const snapshot = this.learningSnapshot;
if (!snapshot) return this.isLearningSetupActive();
if (snapshot.configuration.state === "not_configured") {
return this.isLearningSetupActive();
}
return (
snapshot.configuration.state === "configured" &&
!snapshot.run.hasEverSucceeded &&
!snapshot.run.hasActiveRun &&
snapshot.pendingThreadCount === 0 &&
snapshot.skillsPage.total === 0 &&
snapshot.insightsPage.total === 0
);
}
private scheduleLearningPoll(): void {
this.cancelLearningPoll();
if (!this.shouldPollLearningSetup()) return;
const delay =
this.learningPollFailureCount === 0
? 5_000
: this.learningPollFailureCount === 1
? 10_000
: 30_000;
this.learningPollTimer = setTimeout(() => {
this.learningPollTimer = null;
void this.refreshLearningSnapshot({ preserve: true });
}, delay);
}
private clearLearningSnapshot(): void {
this.learningRequestGeneration += 1;
this.learningAbortController?.abort();
this.learningAbortController = null;
this.learningSnapshot = null;
this.learningSnapshotScope = null;
this.learningError = null;
this.learningLoading = false;
this.learningRefreshing = false;
this.cancelLearningPoll();
}
private refreshLearningSnapshot = async (
options: {
preserve?: boolean;
skillsPage?: number;
insightsPage?: number;
} = {},
): Promise<void> => {
const core = this.core;
const runtimeUrl = core?.runtimeUrl;
this.learningSupported = Boolean(core?.inspectorLearning);
if (!core || !runtimeUrl || !this.learningSupported) {
this.clearLearningSnapshot();
this.requestUpdate();
this.trackLearningViewState();
return;
}
this.cancelLearningPoll();
const startedAt = performance.now();
let loadOutcome: "success" | "unsupported" | "failure" = "failure";
let loadedSkills = 0;
let loadedInsights = 0;
let loadedPendingThreads = 0;
let resetSkillsPage = false;
let resetInsightsPage = false;
const previousSnapshot = this.learningSnapshot;
const agentId = this.getLearningAgentId();
const requestContext = `${runtimeUrl.replace(/\/+$/u, "")}|${agentId ?? ""}`;
if (
this.learningSnapshotScope &&
!this.learningSnapshotScope.startsWith(`${requestContext}|`)
) {
this.clearLearningSnapshot();
}
const generation = ++this.learningRequestGeneration;
this.learningAbortController?.abort();
const controller = new AbortController();
this.learningAbortController = controller;
const preserve =
options.preserve === true && this.learningSnapshot !== null;
this.learningLoading = !preserve;
this.learningRefreshing = preserve;
this.learningError = null;
this.requestUpdate();
try {
const snapshot = await fetchInspectorLearning({
runtimeUrl,
runtimeTransport: core.runtimeTransport,
request: {
...(agentId ? { agentId } : {}),
skillsPage:
options.skillsPage ?? this.learningSnapshot?.skillsPage.page ?? 1,
insightsPage:
options.insightsPage ??
this.learningSnapshot?.insightsPage.page ??
1,
},
fetch: core.ɵruntimeFetch,
headers: core.headers,
credentials: core.credentials,
signal: controller.signal,
});
if (generation !== this.learningRequestGeneration) return;
if (
(snapshot.pendingThreadCount > 0 && !snapshot.links.runs) ||
(snapshot.pendingCandidateCount > 0 && !snapshot.links.candidates)
) {
throw new Error(
"Learning snapshot is missing a required web-app link.",
);
}
const containerId =
snapshot.configuration.state === "configured"
? snapshot.configuration.container.id
: "";
const previousContainerId =
previousSnapshot?.configuration.state === "configured"
? previousSnapshot.configuration.container.id
: "";
const scopeChanged =
previousSnapshot !== null &&
(previousSnapshot.projectKey !== snapshot.projectKey ||
previousContainerId !== containerId);
const isBackgroundRefresh =
options.skillsPage === undefined && options.insightsPage === undefined;
resetSkillsPage = Boolean(
isBackgroundRefresh &&
previousSnapshot &&
previousSnapshot.skillsPage.page > 1 &&
(scopeChanged ||
JSON.stringify([
previousSnapshot.skillsPage.total,
previousSnapshot.skillsPage.items.map((skill) => [
skill.id,
skill.revision,
]),
]) !==
JSON.stringify([
snapshot.skillsPage.total,
snapshot.skillsPage.items.map((skill) => [
skill.id,
skill.revision,
]),
])),
);
resetInsightsPage = Boolean(
isBackgroundRefresh &&
previousSnapshot &&
previousSnapshot.insightsPage.page > 1 &&
(scopeChanged ||
JSON.stringify([
previousSnapshot.insightsPage.total,
previousSnapshot.insightsPage.items.map((insight) => insight.id),
]) !==
JSON.stringify([
snapshot.insightsPage.total,
snapshot.insightsPage.items.map((insight) => insight.id),
])),
);
this.learningSnapshot = snapshot;
loadOutcome = "success";
loadedSkills = snapshot.skillsPage.total;
loadedInsights = snapshot.insightsPage.total;
loadedPendingThreads = snapshot.pendingThreadCount;
this.learningSnapshotScope = `${requestContext}|${snapshot.projectKey}|${containerId}`;
this.learningError = null;
this.learningPollFailureCount = 0;
const setupConfirmed =
snapshot.pendingThreadCount > 0 ||
snapshot.run.hasActiveRun ||
snapshot.run.hasEverSucceeded ||
snapshot.pendingCandidateCount > 0 ||
snapshot.skillsPage.total > 0 ||
snapshot.insightsPage.total > 0;
if (snapshot.configuration.state === "configured" && setupConfirmed) {
clearLearningSetupMarker();
this.learningSetupMarker = null;
}
} catch (error) {
if (
generation !== this.learningRequestGeneration ||
controller.signal.aborted
)
return;
if (error instanceof InspectorLearningUnsupportedError) {
loadOutcome = "unsupported";
this.learningSupported = false;
this.learningSnapshot = null;
} else {
this.learningError =
error instanceof Error
? error.message
: "Learning data is unavailable.";
this.learningPollFailureCount += 1;
}
} finally {
if (generation === this.learningRequestGeneration) {
this.learningLoading = false;
this.learningRefreshing = false;
this.learningAbortController = null;
if (!core.telemetryDisabled) {
trackLearningSnapshotLoaded({
outcome: loadOutcome,
duration_bucket: learningDurationBucket(
performance.now() - startedAt,
),
skills_bucket: learningCountBucket(loadedSkills),
insights_bucket: learningCountBucket(loadedInsights),
pending_threads_bucket: learningCountBucket(loadedPendingThreads),
});
}
this.scheduleLearningPoll();
this.requestUpdate();
this.trackLearningViewState();
if (resetSkillsPage || resetInsightsPage) {
void this.refreshLearningSnapshot({
preserve: true,
...(resetSkillsPage ? { skillsPage: 1 } : {}),
...(resetInsightsPage ? { insightsPage: 1 } : {}),
});
}
}
}
};
private copyFeaturePromptToClipboard = async (
service: HomeFeaturePromptTarget,
event?: Event,
onboardingRunId = createOnboardingRunId(),
): Promise<boolean> => {
const clipboard = this.getClipboard(event);
if (!clipboard?.writeText) return false;
try {
await clipboard.writeText(
homeFeatureImplementationPrompt(service, {
onboardingRunId,
}),
);
return true;
} catch {
return false;
}
};
private handleLearningSetupCopy = async (event?: Event): Promise<void> => {
const service = this.getHomeFeaturePromptTarget("threads");
if (!service || !this.core?.runtimeUrl) return;
const copied = await this.copyFeaturePromptToClipboard(
service,
event,
this.getOnboardingRunId(),
);
if (!this.core.telemetryDisabled) {
trackLearningSetupPromptClicked({
outcome: copied ? "success" : "failure",
});
}
if (!copied) {
this.learningPromptCopyState = "error";
this.requestUpdate();
return;
}
this.learningPromptCopyState = "copied";
this.learningSetupMarker = writeLearningSetupMarker({
runtimeUrl: this.core.runtimeUrl,
agentId: this.getLearningAgentId(),
});
this.selectedMenu = "memories";
this.persistState();
this.requestUpdate();
void this.refreshLearningSnapshot({ preserve: false });
};
private handleLearningPage = (
event: CustomEvent<{
section: "skills" | "insights";
page: number;
}>,
): void => {
const { section, page } = event.detail;
const currentPage =
section === "skills"
? (this.learningSnapshot?.skillsPage.page ?? 1)
: (this.learningSnapshot?.insightsPage.page ?? 1);
if (!this.core?.telemetryDisabled) {
trackLearningPageChanged({
section,
direction: page < currentPage ? "previous" : "next",
});
}
void this.refreshLearningSnapshot({
preserve: true,
...(section === "skills" ? { skillsPage: page } : { insightsPage: page }),
});
};
private handleLearningEvidence = (
event: CustomEvent<{
threadId: string;
messageId?: string;
}>,
): void => {
this.focusThread({
threadId: event.detail.threadId,
...(event.detail.messageId ? { messageId: event.detail.messageId } : {}),
});
};
private detachFromCore(): void {
this.threadCapabilityGeneration += 1;
this.threadCapabilityEnabled = null;
@@ -7800,6 +8236,8 @@ export class WebInspectorElement extends LitElement {
this._recallLoading = false;
this._recallError = null;
this._recallQuery = "";
this.clearLearningSnapshot();
this.learningSupported = false;
this.coreSubscriber = null;
this.runtimeStatus = null;
this.cancelThreadRefreshDebounce();
@@ -7835,6 +8273,7 @@ export class WebInspectorElement extends LitElement {
private processAgentsChanged(
agents: Readonly<Record<string, AbstractAgent>>,
): void {
const previousLearningAgentId = this.getLearningAgentId();
this.synchronizeThreadCapability();
const seenAgentIds = new Set<string>();
@@ -7861,6 +8300,30 @@ export class WebInspectorElement extends LitElement {
}
this.updateContextOptions(seenAgentIds);
const learningAgentId = this.getLearningAgentId();
if (
learningAgentId &&
this.core?.runtimeUrl &&
this.learningSetupMarker?.agentId === null &&
learningSetupMarkerMatches(
this.learningSetupMarker,
this.core.runtimeUrl,
null,
)
) {
this.learningSetupMarker = writeLearningSetupMarker({
runtimeUrl: this.core.runtimeUrl,
agentId: learningAgentId,
});
}
if (
previousLearningAgentId !== learningAgentId &&
this.isOpen &&
this.selectedMenu === "memories"
) {
this.clearLearningSnapshot();
void this.refreshLearningSnapshot({ preserve: false });
}
this.refreshToolsSnapshot();
this.requestUpdate();
}
@@ -10876,6 +11339,14 @@ export class WebInspectorElement extends LitElement {
// Load state early (before first render) so menu selection is correct
this.hydrateStateFromStorageEarly();
this.learningSetupMarker = readLearningSetupMarker();
this.learningSetupUnsubscribe = subscribeToLearningSetupMarker(
(marker) => {
this.learningSetupMarker = marker;
this.requestUpdate();
this.scheduleLearningPoll();
},
);
this.refreshInspectorDismissalState();
this.subscribeToSystemColorScheme();
this.exampleTourDismissed = this.readThreadsExampleTourDismissed();
@@ -10908,6 +11379,15 @@ export class WebInspectorElement extends LitElement {
) {
this.flushPendingSignalPulse();
}
if (
document.visibilityState === "visible" &&
this.isOpen &&
this.selectedMenu === "memories"
) {
void this.refreshLearningSnapshot({ preserve: true });
} else {
this.cancelLearningPoll();
}
this.requestUpdate();
};
@@ -10938,6 +11418,9 @@ export class WebInspectorElement extends LitElement {
}
this.clearIconRailContextCloseTimer();
this.unsubscribeFromInspectorThreadBridge();
this.learningSetupUnsubscribe?.();
this.learningSetupUnsubscribe = null;
this.clearLearningSnapshot();
this.stopIntelligenceStory();
this.clearIntelligencePromptReset();
this.homeFeaturePromptCopyGeneration += 1;
@@ -12909,20 +13392,11 @@ export class WebInspectorElement extends LitElement {
});
}
const clipboard = this.getClipboard(event);
if (!clipboard?.writeText) {
this.showHomeFeaturePromptCopyState(service.id, "error", generation);
return;
}
try {
await clipboard.writeText(
homeFeatureImplementationPrompt(service, {
onboardingRunId,
}),
);
if (
await this.copyFeaturePromptToClipboard(service, event, onboardingRunId)
) {
this.showHomeFeaturePromptCopyState(service.id, "copied", generation);
} catch {
} else {
this.showHomeFeaturePromptCopyState(service.id, "error", generation);
}
};
@@ -12938,13 +13412,18 @@ export class WebInspectorElement extends LitElement {
private renderFeatureSetupPrompt(
serviceId: HomeFeaturePromptId,
className: string,
options?: Readonly<{
copyState?: HomeFeaturePromptCopyState;
onClick?: (event: Event) => void;
}>,
): TemplateResult | typeof nothing {
const service = this.getHomeFeaturePromptTarget(serviceId);
if (!service) return nothing;
const copyState =
this.homeFeaturePromptCopyState?.serviceId === service.id
options?.copyState ??
(this.homeFeaturePromptCopyState?.serviceId === service.id
? this.homeFeaturePromptCopyState.state
: "idle";
: "idle");
const label =
copyState === "copied"
? "Copied"
@@ -12968,7 +13447,9 @@ export class WebInspectorElement extends LitElement {
: `Copy setup prompt for ${service.label}`
}
@click=${(event: Event) =>
this.handleHomeFeaturePromptCopy(service, event)}
options?.onClick
? options.onClick(event)
: this.handleHomeFeaturePromptCopy(service, event)}
>
${this.renderIcon(copyState === "copied" ? "Check" : "Copy")}
${label}
@@ -14884,6 +15365,11 @@ export class WebInspectorElement extends LitElement {
this.ensureAnnouncementLoading();
this.isOpen = true;
if (this.selectedMenu === "memories") {
void this.refreshLearningSnapshot({
preserve: this.learningSnapshot !== null,
});
}
// The launcher is gone, so its gesture is gone with it — and the slot it
// was holding is free again for whatever beats after the panel closes.
this.cancelGestureTail();
@@ -14927,6 +15413,9 @@ export class WebInspectorElement extends LitElement {
}
this.isOpen = false;
this.cancelLearningPoll();
this.learningAbortController?.abort();
this.learningViewedState = null;
// Remove docking styles when closing
if (this.dockMode !== "floating") {
@@ -17585,6 +18074,7 @@ export class WebInspectorElement extends LitElement {
videoUrl,
videoTitle,
outlineItems,
setupPrompt,
}: {
serviceId: HomeFeaturePromptId;
featureName: string;
@@ -17593,6 +18083,11 @@ export class WebInspectorElement extends LitElement {
videoUrl: string;
videoTitle: string;
outlineItems: ReadonlyArray<LockedFeatureOutlineItem>;
setupPrompt?: Readonly<{
serviceId: HomeFeaturePromptId;
copyState: HomeFeaturePromptCopyState;
onClick: (event: Event) => void;
}>;
}) {
return html`
<div
@@ -17616,8 +18111,14 @@ export class WebInspectorElement extends LitElement {
<p class="cpk-locked-feature-description">${description}</p>
<div class="cpk-threads-overview-actions">
${this.renderFeatureSetupPrompt(
serviceId,
setupPrompt?.serviceId ?? serviceId,
"inspector-account-cta cpk-locked-feature-setup-cta",
setupPrompt
? {
copyState: setupPrompt.copyState,
onClick: setupPrompt.onClick,
}
: undefined,
)}
<a
data-inspector-locked-feature-talk=${serviceId}
@@ -17827,6 +18328,92 @@ export class WebInspectorElement extends LitElement {
}
private renderMemoriesView() {
const state = deriveLearningViewState({
supported: this.learningSupported,
loading: this.learningLoading,
error: this.learningError,
snapshot: this.learningSnapshot,
setupActive: this.isLearningSetupActive(),
});
if (state === "landing") {
return this.renderLockedFeatureOverview({
serviceId: "memory",
featureName: "Learning",
heading: "Turn every interaction into reusable context.",
description:
"Learning captures durable information from agent interactions and brings it back when it matters, so your product gets more useful over time.",
videoUrl: LEARNING_LOCKED_VIDEO_URL,
videoTitle: "CopilotKit Learning overview",
outlineItems: LEARNING_LOCKED_FEATURE_OUTLINE,
setupPrompt: {
serviceId: "threads",
copyState: this.learningPromptCopyState,
onClick: (event) => void this.handleLearningSetupCopy(event),
},
});
}
return html`
<cpk-learning-view
.supported=${this.learningSupported}
.loading=${this.learningLoading}
.refreshing=${this.learningRefreshing}
.error=${this.learningError}
.snapshot=${this.learningSnapshot}
.setupActive=${this.isLearningSetupActive()}
.copyState=${this.learningPromptCopyState}
.setupPrompt=${
this.getHomeFeaturePromptTarget("threads")
? homeFeatureImplementationPrompt(
this.getHomeFeaturePromptTarget("threads")!,
{ onboardingRunId: this.getOnboardingRunId() },
)
: ""
}
@learning-retry=${() =>
this.refreshLearningSnapshot({
preserve: this.learningSnapshot !== null,
})}
@learning-copy-setup=${(event: Event) =>
this.handleLearningSetupCopy(event)}
@learning-page=${(event: CustomEvent) =>
this.handleLearningPage(
event as CustomEvent<{
section: "skills" | "insights";
page: number;
}>,
)}
@learning-open-evidence=${(event: CustomEvent) =>
this.handleLearningEvidence(
event as CustomEvent<{
threadId: string;
messageId?: string;
}>,
)}
@learning-evidence-opened=${() => {
if (!this.core?.telemetryDisabled) trackLearningEvidenceOpened();
}}
@learning-skill-toggle=${(
event: CustomEvent<{ action: "expanded" | "collapsed" }>,
) => {
if (!this.core?.telemetryDisabled) {
trackLearningSkillToggled({ action: event.detail.action });
}
}}
@learning-web-link=${(
event: CustomEvent<{
category: "learning" | "runs" | "candidates";
}>,
) => {
if (!this.core?.telemetryDisabled) {
trackLearningWebAppOpened({ category: event.detail.category });
}
}}
></cpk-learning-view>
`;
}
/** Legacy Memory rendering kept isolated while published Memory APIs remain. */
private renderLegacyMemoriesView() {
// Once the user enters Learning, its lazy subscription is the capability
// probe. Preserve the loading state while that request is in flight, then
// let an unavailable response fall through to the setup gate.
@@ -19274,13 +19861,19 @@ export class WebInspectorElement extends LitElement {
}
if (key === "memories") {
// Lazily create + subscribe to the memory store on first activation. This
// is the only place that touches getMemoryStore(), so the store/realtime
// are never started just by attaching the inspector.
this.ensureMemorySubscription();
this.learningSupported = Boolean(this.core?.inspectorLearning);
if (previousMenu !== "memories" || this.learningSnapshot === null) {
void this.refreshLearningSnapshot({
preserve: previousMenu === "memories",
});
}
if (previousMenu !== "memories" && !this.core?.telemetryDisabled) {
trackMemoriesTabClicked(this.getMemoriesTelemetryProps());
}
} else if (previousMenu === "memories") {
this.learningViewedState = null;
this.cancelLearningPoll();
this.learningAbortController?.abort();
}
if (key === "home" && previousMenu !== "home") {
@@ -19390,6 +19983,9 @@ export class WebInspectorElement extends LitElement {
this.autoSelectLatestThread();
if (this.selectedMenu === "playground") {
this.startPlaygroundSession(false);
} else if (this.selectedMenu === "memories") {
this.clearLearningSnapshot();
void this.refreshLearningSnapshot({ preserve: false });
}
}
@@ -512,10 +512,10 @@ describe("event catalogue", () => {
]);
});
it("holds twenty-eight event names, all under the owned oss.inspector prefix", () => {
it("holds thirty-five event names, all under the owned oss.inspector prefix", () => {
const names = Object.values(TELEMETRY_EVENTS) as string[];
expect(names).toHaveLength(28);
expect(names).toHaveLength(35);
expect(names).toContain("oss.inspector.home_feature_prompt_clicked");
expect(names.filter((name) => !name.startsWith("oss.inspector."))).toEqual(
[],
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from "vitest";
import {
fetchInspectorLearning,
InspectorLearningUnsupportedError,
} from "./inspector-learning.js";
const snapshot = {
schemaVersion: 1,
projectKey: "project-safe-key",
snapshotVersion: "snapshot-1",
webAppOrigin: "https://app.copilotkit.ai",
configuration: { state: "not_configured" },
pendingThreadCount: 0,
run: { hasActiveRun: false, hasEverSucceeded: false, latest: null },
pendingCandidateCount: 0,
skillsPage: {
page: 1,
pageSize: 3,
total: 0,
totalPages: 0,
items: [],
},
insightsPage: {
page: 1,
pageSize: 4,
total: 0,
totalPages: 0,
items: [],
},
links: {
learning: "https://app.copilotkit.ai/learning",
candidates: null,
runs: null,
},
};
describe("fetchInspectorLearning", () => {
it("uses the REST route with runtime auth options", async () => {
const fetch = vi.fn().mockResolvedValue(Response.json(snapshot));
await fetchInspectorLearning({
runtimeUrl: "https://runtime.example/api/copilotkit/",
runtimeTransport: "rest",
request: { agentId: "support", skillsPage: 2 },
fetch,
headers: { Authorization: "Bearer host-session" },
credentials: "include",
});
expect(fetch).toHaveBeenCalledWith(
"https://runtime.example/api/copilotkit/inspector-learning?agentId=support&skillsPage=2",
expect.objectContaining({
method: "GET",
headers: { Authorization: "Bearer host-session" },
credentials: "include",
}),
);
});
it("uses the single endpoint envelope and maps 404 to unsupported", async () => {
const fetch = vi.fn().mockResolvedValue(Response.json(snapshot));
await fetchInspectorLearning({
runtimeUrl: "https://runtime.example/api/copilotkit",
runtimeTransport: "single",
request: { insightsPage: 3 },
fetch,
});
expect(fetch.mock.calls[0]?.[0]).toBe(
"https://runtime.example/api/copilotkit",
);
expect(JSON.parse(String(fetch.mock.calls[0]?.[1]?.body))).toEqual({
method: "inspector/learning",
params: { insightsPage: "3" },
});
await expect(
fetchInspectorLearning({
runtimeUrl: "https://runtime.example/api/copilotkit",
runtimeTransport: "rest",
request: {},
fetch: vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
}),
).rejects.toBeInstanceOf(InspectorLearningUnsupportedError);
});
it("rejects an invalid response at the browser boundary", async () => {
await expect(
fetchInspectorLearning({
runtimeUrl: "https://runtime.example/api/copilotkit",
runtimeTransport: "rest",
request: {},
fetch: vi.fn().mockResolvedValue(Response.json({ schemaVersion: 999 })),
}),
).rejects.toThrow("response is invalid");
});
});
@@ -0,0 +1,51 @@
import { parseInspectorLearningSnapshotV1 } from "@copilotkit/shared";
import type {
InspectorLearningRequestV1,
InspectorLearningSnapshotV1,
} from "@copilotkit/shared";
export class InspectorLearningUnsupportedError extends Error {}
/** Loads one Learning projection through either negotiated Runtime transport. */
export async function fetchInspectorLearning(input: {
readonly runtimeUrl: string;
readonly runtimeTransport: "rest" | "single" | "auto";
readonly request: InspectorLearningRequestV1;
readonly fetch: typeof globalThis.fetch;
readonly headers?: Readonly<Record<string, string>>;
readonly credentials?: RequestCredentials;
readonly signal?: AbortSignal;
}): Promise<InspectorLearningSnapshotV1> {
const base = input.runtimeUrl.replace(/\/+$/u, "");
const single = input.runtimeTransport === "single";
const url = single ? base : `${base}/inspector-learning`;
const query = new URLSearchParams();
if (input.request.agentId) query.set("agentId", input.request.agentId);
if (input.request.skillsPage)
query.set("skillsPage", String(input.request.skillsPage));
if (input.request.insightsPage) {
query.set("insightsPage", String(input.request.insightsPage));
}
const response = await input.fetch(single ? url : `${url}?${query}`, {
method: single ? "POST" : "GET",
headers: single
? { ...input.headers, "Content-Type": "application/json" }
: { ...input.headers },
credentials: input.credentials,
signal: input.signal,
...(single
? {
body: JSON.stringify({
method: "inspector/learning",
params: Object.fromEntries(query),
}),
}
: {}),
});
if (response.status === 404) throw new InspectorLearningUnsupportedError();
if (!response.ok)
throw new Error(`Learning snapshot failed (HTTP ${response.status}).`);
const snapshot = parseInspectorLearningSnapshotV1(await response.json());
if (!snapshot) throw new Error("Learning snapshot response is invalid.");
return snapshot;
}
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LEARNING_SETUP_MAX_AGE_MS,
LEARNING_SETUP_STORAGE_KEY,
_test,
clearLearningSetupMarker,
learningSetupMarkerMatches,
normalizeLearningRuntimeUrl,
readLearningSetupMarker,
writeLearningSetupMarker,
} from "./learning-setup.js";
beforeEach(() => {
localStorage.clear();
_test.resetMemory();
});
describe("Learning setup marker", () => {
it("normalizes runtime identity and expires after seven days", () => {
expect(
normalizeLearningRuntimeUrl(
"/api/copilotkit/?secret=hidden#fragment",
"https://example.test/app/",
),
).toBe("https://example.test/api/copilotkit");
const startedAt = new Date("2026-03-01T00:00:00.000Z");
const marker = writeLearningSetupMarker({
runtimeUrl: "/api/copilotkit/?secret=hidden",
agentId: "support",
now: startedAt,
});
expect(marker.runtimeUrl).not.toContain("secret");
expect(
learningSetupMarkerMatches(marker, "/api/copilotkit", "support"),
).toBe(true);
expect(learningSetupMarkerMatches(marker, "/api/copilotkit", "other")).toBe(
false,
);
expect(
readLearningSetupMarker(
startedAt.getTime() + LEARNING_SETUP_MAX_AGE_MS + 1,
),
).toBeNull();
});
it("normalizes an absolute Runtime URL when no browser document exists", () => {
vi.stubGlobal("document", undefined);
try {
expect(
normalizeLearningRuntimeUrl(
"https://runtime.example/api/copilotkit/?secret=hidden#fragment",
),
).toBe("https://runtime.example/api/copilotkit");
expect(normalizeLearningRuntimeUrl("/api/copilotkit")).toBeNull();
} finally {
vi.unstubAllGlobals();
}
});
it("retains a page-local marker when storage is unavailable", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
const marker = writeLearningSetupMarker({
runtimeUrl: "https://runtime.example/api/copilotkit",
agentId: null,
});
expect(readLearningSetupMarker()).toEqual(marker);
clearLearningSetupMarker();
expect(readLearningSetupMarker()).toBeNull();
expect(localStorage.getItem(LEARNING_SETUP_STORAGE_KEY)).toBeNull();
});
it.each([
["malformed", "not json"],
[
"unsupported",
JSON.stringify({
version: 2,
runtimeUrl: "https://runtime.example/api/copilotkit",
agentId: null,
startedAt: "2026-09-03T00:00:00.000Z",
}),
],
[
"expired",
JSON.stringify({
version: 1,
runtimeUrl: "https://runtime.example/api/copilotkit",
agentId: null,
startedAt: "2026-08-01T00:00:00.000Z",
}),
],
])("removes a %s persisted record", (_label, raw) => {
localStorage.setItem(LEARNING_SETUP_STORAGE_KEY, raw);
expect(
readLearningSetupMarker(Date.parse("2026-09-03T00:00:00.000Z")),
).toBeNull();
expect(localStorage.getItem(LEARNING_SETUP_STORAGE_KEY)).toBeNull();
});
it("removes invalid persistence without discarding a valid page-local fallback", () => {
const marker = writeLearningSetupMarker({
runtimeUrl: "https://runtime.example/api/copilotkit",
agentId: "checkout",
now: new Date("2026-09-03T00:00:00.000Z"),
});
localStorage.setItem(LEARNING_SETUP_STORAGE_KEY, "malformed");
expect(
readLearningSetupMarker(Date.parse("2026-09-03T01:00:00.000Z")),
).toEqual(marker);
expect(localStorage.getItem(LEARNING_SETUP_STORAGE_KEY)).toBeNull();
});
});
@@ -0,0 +1,144 @@
export const LEARNING_SETUP_STORAGE_KEY = "cpk:inspector:learning-setup:v1";
export const LEARNING_SETUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
export interface LearningSetupMarker {
readonly version: 1;
readonly runtimeUrl: string;
readonly agentId: string | null;
readonly startedAt: string;
}
let inMemoryMarker: LearningSetupMarker | null = null;
export function normalizeLearningRuntimeUrl(
runtimeUrl: string,
baseUri?: string,
): string | null {
try {
const browserBaseUri =
baseUri ??
(typeof document === "undefined" ? undefined : document.baseURI);
const url = browserBaseUri
? new URL(runtimeUrl, browserBaseUri)
: new URL(runtimeUrl);
url.search = "";
url.hash = "";
url.pathname =
url.pathname === "/" ? "/" : url.pathname.replace(/\/+$/u, "");
return url.toString();
} catch {
return null;
}
}
function parseMarker(
raw: string | null,
now: number,
): LearningSetupMarker | null {
if (!raw) return null;
try {
const value = JSON.parse(raw) as Partial<LearningSetupMarker>;
const started =
typeof value.startedAt === "string" ? Date.parse(value.startedAt) : NaN;
if (
value.version !== 1 ||
typeof value.runtimeUrl !== "string" ||
(value.agentId !== null && typeof value.agentId !== "string") ||
!Number.isFinite(started) ||
started > now ||
now - started > LEARNING_SETUP_MAX_AGE_MS
) {
return null;
}
return value as LearningSetupMarker;
} catch {
return null;
}
}
export function readLearningSetupMarker(
now = Date.now(),
): LearningSetupMarker | null {
try {
const raw = localStorage.getItem(LEARNING_SETUP_STORAGE_KEY);
const persisted = parseMarker(raw, now);
if (persisted) return persisted;
if (raw !== null) localStorage.removeItem(LEARNING_SETUP_STORAGE_KEY);
} catch {
// The page-local fallback below preserves the interaction.
}
return parseMarker(
inMemoryMarker ? JSON.stringify(inMemoryMarker) : null,
now,
);
}
export function writeLearningSetupMarker(input: {
readonly runtimeUrl: string;
readonly agentId: string | null;
readonly now?: Date;
}): LearningSetupMarker {
const runtimeUrl = normalizeLearningRuntimeUrl(input.runtimeUrl);
if (!runtimeUrl) throw new Error("Runtime URL is unavailable.");
const marker: LearningSetupMarker = {
version: 1,
runtimeUrl,
agentId: input.agentId,
startedAt: (input.now ?? new Date()).toISOString(),
};
inMemoryMarker = marker;
try {
localStorage.setItem(LEARNING_SETUP_STORAGE_KEY, JSON.stringify(marker));
} catch {
// Storage is optional; retain the marker in memory.
}
return marker;
}
export function clearLearningSetupMarker(): void {
inMemoryMarker = null;
try {
localStorage.removeItem(LEARNING_SETUP_STORAGE_KEY);
} catch {
// Best effort only.
}
}
export function learningSetupMarkerMatches(
marker: LearningSetupMarker | null,
runtimeUrl: string,
agentId: string | null,
): boolean {
return (
marker !== null &&
marker.runtimeUrl === normalizeLearningRuntimeUrl(runtimeUrl) &&
marker.agentId === agentId
);
}
/** Keeps multiple Inspector instances on the same origin in sync. */
export function subscribeToLearningSetupMarker(
listener: (marker: LearningSetupMarker | null) => void,
): () => void {
const handleStorage = (event: StorageEvent) => {
if (event.key === LEARNING_SETUP_STORAGE_KEY) {
const marker = parseMarker(event.newValue, Date.now());
if (event.newValue !== null && marker === null) {
try {
localStorage.removeItem(LEARNING_SETUP_STORAGE_KEY);
} catch {
// Storage is optional; still notify the listener below.
}
}
listener(marker);
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}
export const _test = {
resetMemory: () => {
inMemoryMarker = null;
},
};
@@ -52,6 +52,13 @@ export const TELEMETRY_EVENTS = {
threadsExampleTourCompleted: "oss.inspector.threads_example_tour_completed",
threadsExampleTourReopened: "oss.inspector.threads_example_tour_reopened",
memoriesTabClicked: "oss.inspector.memories_tab_clicked",
learningPaneViewed: "oss.inspector.learning_pane_viewed",
learningSetupPromptClicked: "oss.inspector.learning_setup_prompt_clicked",
learningSnapshotLoaded: "oss.inspector.learning_snapshot_loaded",
learningSkillToggled: "oss.inspector.learning_skill_toggled",
learningEvidenceOpened: "oss.inspector.learning_evidence_opened",
learningPageChanged: "oss.inspector.learning_page_changed",
learningWebAppOpened: "oss.inspector.learning_web_app_opened",
homeViewed: "oss.inspector.home_viewed",
homeCtaClicked: "oss.inspector.home_cta_clicked",
homeFeaturePromptClicked: "oss.inspector.home_feature_prompt_clicked",
@@ -593,6 +600,94 @@ export function trackMemoriesTabClicked(
track(TELEMETRY_EVENTS.memoriesTabClicked, props);
}
export type InspectorLearningViewState =
| "unsupported"
| "loading"
| "error"
| "selection_required"
| "invalid"
| "results"
| "first_run"
| "ready"
| "empty"
| "setup"
| "landing";
export type InspectorLearningCountBucket =
| "zero"
| "one"
| "two_to_five"
| "six_to_twenty"
| "twenty_one_plus";
export type InspectorLearningDurationBucket =
| "under_250ms"
| "250ms_to_1s"
| "1s_to_3s"
| "3s_plus";
export function learningCountBucket(
value: number,
): InspectorLearningCountBucket {
if (value <= 0) return "zero";
if (value === 1) return "one";
if (value <= 5) return "two_to_five";
if (value <= 20) return "six_to_twenty";
return "twenty_one_plus";
}
export function learningDurationBucket(
durationMs: number,
): InspectorLearningDurationBucket {
if (durationMs < 250) return "under_250ms";
if (durationMs < 1_000) return "250ms_to_1s";
if (durationMs < 3_000) return "1s_to_3s";
return "3s_plus";
}
export function trackLearningPaneViewed(props: {
state: InspectorLearningViewState;
}): void {
track(TELEMETRY_EVENTS.learningPaneViewed, props);
}
export function trackLearningSetupPromptClicked(props: {
outcome: "success" | "failure";
}): void {
track(TELEMETRY_EVENTS.learningSetupPromptClicked, props);
}
export function trackLearningSnapshotLoaded(props: {
outcome: "success" | "unsupported" | "failure";
duration_bucket: InspectorLearningDurationBucket;
skills_bucket: InspectorLearningCountBucket;
insights_bucket: InspectorLearningCountBucket;
pending_threads_bucket: InspectorLearningCountBucket;
}): void {
track(TELEMETRY_EVENTS.learningSnapshotLoaded, props);
}
export function trackLearningSkillToggled(props: {
action: "expanded" | "collapsed";
}): void {
track(TELEMETRY_EVENTS.learningSkillToggled, props);
}
export function trackLearningEvidenceOpened(): void {
track(TELEMETRY_EVENTS.learningEvidenceOpened);
}
export function trackLearningPageChanged(props: {
section: "skills" | "insights";
direction: "previous" | "next";
}): void {
track(TELEMETRY_EVENTS.learningPageChanged, props);
}
export function trackLearningWebAppOpened(props: {
category: "learning" | "runs" | "candidates";
}): void {
track(TELEMETRY_EVENTS.learningWebAppOpened, props);
}
export type InspectorMetadataTelemetryModule = "identity" | "plan" | "action";
export type InspectorMetadataLicenseBucket =
| "valid"
File diff suppressed because one or more lines are too long
+3
View File
@@ -3769,6 +3769,9 @@ importers:
'@copilotkit/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
'@tailwindcss/cli':
specifier: ^4.1.11
version: 4.1.18
@@ -2638,6 +2638,7 @@
"runtime",
"basePath",
"mode",
"inspectorLearning",
"cors",
"hooks",
"activateChannels"
@@ -2765,6 +2766,7 @@
"runtime",
"basePath",
"mode",
"inspectorLearning",
"cors",
"hooks",
"activateChannels"
@@ -1,6 +1,9 @@
<Callout type="info" title="See this in Inspector">
Open Inspector on localhost. Go to **Learning**.
Memory and learning tools for this session appear here.
Review published Skills, their supporting Insights, and the Thread evidence
behind each pattern. Inspector also shows when new Threads are ready for a
Learning run; start runs and review Skill candidates in the Intelligence web
app.
More detail: [Inspector](/inspector).
</Callout>
+1 -1
View File
@@ -13,7 +13,7 @@ Update this file in the same change that adds or removes a pane.
| 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 |
| Context | `useAgentContext` / agent-readonly | `open-inspector-pane-context.mdx` | Agents group |
| Learning | CopilotKit Intelligence overview | `open-inspector-pane-learning.mdx` | Primary nav |
| Learning | CopilotKit Intelligence overview | `open-inspector-pane-learning.mdx` | Primary nav; published Skills, supporting Insights, and Thread evidence |
| Capabilities | no page yet | | Client tool and catalog toggles. No dedicated docs page in this slice |
| Messages | no page yet | | Thread detail tab. Covered by Threads Callout |
| Angular Inspector | Angular frontend getting-started | `open-inspector-step-angular.mdx` | Install page first |