Skip to content

Commit ac75fc3

Browse files
authored
feat(inspector): CPK-7193 thread store registry, runtime handlers, and Threads tab UI (CopilotKit#3869)
## What this PR does Adds the **Threads tab** to the CopilotKit web inspector. The tab lists every thread the current agent has run and, when you click one, shows three per-thread sub-tabs: - **Conversation** — historical messages - **Agent State** — state snapshot at the end of the thread - **AG-UI Events** — full AG-UI event stream for the thread (tool calls, state deltas, text chunks, etc.) Data flows through new backend endpoints plus a Lit-based UI inside `@copilotkit/web-inspector`. No extra package required for consumers. ## Changes by layer ### `@copilotkit/core` - **`ThreadStoreRegistry`** — new class; keyed by `agentId`, lets `useThreads()` and the inspector share a reference to the same store without coupling the two packages directly - **`onAgentRunStarted` subscriber event** — fires before `agent.runAgent()` snapshots the subscriber list, so the inspector can subscribe in time to receive run events - `CopilotKitCore.getThreadStore()` / `registerThreadStore()` / `unregisterThreadStore()` / `getThreadStores()` — public surface for hook + inspector to interact with the registry ### `@copilotkit/runtime` - **Thread HTTP handlers** — `handleListThreads`, `handleUpdateThread`, `handleArchiveThread`, `handleDeleteThread`, `handleSubscribeToThreads`, `handleGetThreadMessages`, plus the two new ones below - **New: `GET /threads/:id/events`** and **`GET /threads/:id/state`** — return the thread's AG-UI event stream and last `STATE_SNAPSHOT` payload. Wired through both the in-memory runner and the Intelligence platform's `_inspect/threads/:id/{events,state}` endpoints (consumed by `CopilotKitIntelligence.getThreadEvents()` / `getThreadState()`) - All mutations authenticate via `identifyUser(request)`; `userId` in the request body is ignored - **`InMemoryAgentRunner`** — stores thread history (messages + compacted events per run); new `getThreadEvents()` and `getThreadState()` methods; `getThreadState()` walks the compacted events and returns the payload of the last `STATE_SNAPSHOT` ### `@copilotkit/react-core` - **`useThreads` hook** — fetches threads, subscribes to a Phoenix WebSocket channel for real-time metadata events, and registers/unregisters its thread store with `CopilotKitCore` on mount/unmount ### `@copilotkit/web-inspector` - **Full Threads tab UI** — implemented in Lit as two custom elements (`cpk-thread-list`, `ɵCpkThreadDetails`) living in-file alongside the main `WebInspectorElement` - Thread details fetches per-thread history via the new endpoints and renders: - Conversation: user/assistant bubbles, tool-call blocks with expand/collapse, tool-call groups, reasoning/state-update chips, generative-UI placeholders. Tool-call status is derived from parsed-args presence — frontend-rendered generative-UI tools (charts, custom UI) read `DONE` once args have streamed in, since they never produce a `role: tool` result message - Agent State: syntax-highlighted JSON of the last state snapshot - AG-UI Events: colored event rows (by type family) with timestamped, highlighted payloads. Off-screen rows use `content-visibility: auto` so reveal cost is independent of total event count - Right-side detail panel with thread metadata + activity counts, toggled from the tab bar - Tab DOM is mounted once per activation and hidden via `display:none` when inactive, so switching between Conversation / Agent State / AG-UI Events is a CSS swap rather than a render. Each panel's TemplateResult is memoized by tuple of input references (`_conversation` + expand-state Sets for conversation; `_fetchedState` for agent state; events array for AG-UI events), so when the underlying data hasn't changed Lit's diff short-circuits. JSON syntax highlighting is WeakMap-memoized by payload reference - `attachToCore()` guards the `core.getThreadStores()` call so consumers still on an older `@copilotkit/core` don't throw when assigning `inspector.core` ## Architectural notes **Events/state via Intelligence:** the Intelligence platform persists every AG-UI event in `cpki.run_events` keyed by run → thread and exposes them via `_inspect/threads/:id/{events,state}`. The runtime's `CopilotKitIntelligence.getThreadEvents()` / `getThreadState()` consume those, so the same per-thread HTTP endpoints used by the in-memory path serve Intelligence-backed consumers identically. ## Tests added | File | What's new | |---|---| | `packages/core/src/__tests__/thread-store-registry.test.ts` | New — register/get, replacement, no-op unregister, subscriber events | | `packages/runtime/src/v2/runtime/__tests__/handle-threads.test.ts` | `handleClearThreads`, `handleGetThreadMessages`, plus new `handleGetThreadEvents` and `handleGetThreadState` describe blocks | | `packages/runtime/src/v2/runtime/runner/__tests__/in-memory-runner.test.ts` | `getThreadMessages`, new `getThreadEvents` (stored events, unknown thread, multi-run flattening), new `getThreadState` (null without snapshot, returns last STATE_SNAPSHOT, most-recent across runs) | | `packages/react-core/src/v2/hooks/__tests__/use-threads.test.tsx` | Registry lifecycle (register on mount, unregister on unmount) | | `packages/web-inspector/src/__tests__/web-inspector.spec.ts` | New `ɵCpkThreadDetails caching` describe — threadId-change drops all panel caches; conversation cache invalidates on `_conversation` reassignment and on expand-state change; state and events caches invalidate on their fetched-data reassignment | 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents 0139ebb + df3c961 commit ac75fc3

29 files changed

Lines changed: 6439 additions & 736 deletions

examples/v2/angular/demo/angular.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"browser": "src/main.ts",
1616
"tsConfig": "tsconfig.app.json",
1717
"assets": ["src/favicon.ico", "src/assets"],
18-
"styles": ["node_modules/@copilotkit/angular/dist/styles.css"],
18+
"styles": ["node_modules/@copilotkitnext/angular/dist/styles.css"],
1919
"optimization": false
2020
}
2121
},

examples/v2/angular/demo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@angular/forms": "^19.0.0",
2424
"@angular/platform-browser": "^19.0.0",
2525
"@angular/platform-browser-dynamic": "^19.0.0",
26+
"@copilotkit/angular": "link:../../../../packages/angular",
2627
"@copilotkit/web-inspector": "workspace:*",
2728
"@copilotkitnext/angular": "workspace:*",
2829
"compare-versions": "^6.1.1",

examples/v2/angular/demo/src/app/routes/headless/headless-chat.component.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,27 @@
1+
import type { OnDestroy, OnInit } from "@angular/core";
12
import {
23
Component,
34
ChangeDetectionStrategy,
45
computed,
56
inject,
67
input,
78
signal,
8-
OnDestroy,
9-
OnInit,
109
} from "@angular/core";
1110
import { CommonModule } from "@angular/common";
1211
import { FormsModule } from "@angular/forms";
12+
import type {
13+
HumanInTheLoopToolCall,
14+
HumanInTheLoopToolRenderer,
15+
} from "@copilotkitnext/angular";
1316
import {
1417
connectAgentContext,
1518
CopilotKit,
16-
HumanInTheLoopToolCall,
17-
HumanInTheLoopToolRenderer,
1819
injectAgentStore,
1920
registerHumanInTheLoop,
20-
} from "@copilotkit/angular";
21-
import { RenderToolCalls } from "@copilotkit/angular";
22-
import {
23-
WEB_INSPECTOR_TAG,
24-
type WebInspectorElement,
25-
} from "@copilotkit/web-inspector";
21+
} from "@copilotkitnext/angular";
22+
import { RenderToolCalls } from "@copilotkitnext/angular";
23+
import { WEB_INSPECTOR_TAG } from "@copilotkit/web-inspector";
24+
import type { WebInspectorElement } from "@copilotkit/web-inspector";
2625
import { z } from "zod";
2726

2827
@Component({
@@ -123,6 +122,20 @@ export class RequireApprovalComponent implements HumanInTheLoopToolRenderer {
123122
>
124123
Send
125124
</button>
125+
<button
126+
type="button"
127+
(click)="clearThreads()"
128+
style="
129+
padding: 10px 14px;
130+
border-radius: 8px;
131+
border: 1px solid #d1d5db;
132+
background: #ffffff;
133+
color: #374151;
134+
cursor: pointer;
135+
"
136+
>
137+
Clear threads
138+
</button>
126139
</form>
127140
</div>
128141
`,
@@ -181,6 +194,25 @@ export class HeadlessChatComponent implements OnInit, OnDestroy {
181194
this.inspectorElement = null;
182195
}
183196

197+
async clearThreads() {
198+
const runtimeUrl = this.copilotkit.core?.runtimeUrl;
199+
if (!runtimeUrl) return;
200+
const url = runtimeUrl.replace(/\/$/, "");
201+
try {
202+
const res = await fetch(`${url}/threads/clear`, {
203+
method: "POST",
204+
credentials: "include",
205+
});
206+
if (!res.ok) {
207+
console.error(
208+
`Failed to clear threads: HTTP ${res.status} ${res.statusText}`,
209+
);
210+
}
211+
} catch (err) {
212+
console.error("Failed to clear threads", err);
213+
}
214+
}
215+
184216
async send() {
185217
const content = this.inputValue.trim();
186218
const agent = this.agent();

examples/v2/angular/demo/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"paths": {
1515
"@copilotkit/angular": [
1616
"../../packages/angular/dist/index.d.ts",
17-
"../../packages/angular/dist/fesm2022/copilotkit-angular.mjs"
17+
"../../packages/angular/dist/fesm2022/copilotkitnext-angular.mjs"
1818
],
1919
"@copilotkit/core": [
2020
"../../packages/core/dist/index.d.ts",
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import { CopilotKitCore } from "../core";
3+
import type { CopilotKitCoreSubscriber } from "../core/core";
4+
import type { ɵThreadStore } from "../threads";
5+
import { MockAgent } from "./test-utils";
6+
7+
/**
8+
* Pins the integration contract between `CopilotKitCore.onAgentsChanged`
9+
* and the thread-store registry.
10+
*
11+
* Background: when `agents` change, the core auto-unregisters thread stores
12+
* for any agentId that has been removed. The "previously had" guard exists
13+
* so that the FIRST onAgentsChanged({ agents: {} }) notification — which
14+
* fires for published cores BEFORE the published agents are merged in —
15+
* does not rip out a store that a consumer (e.g. useThreads) just
16+
* registered.
17+
*/
18+
19+
function makeStore(id = "store"): ɵThreadStore & { __testId: string } {
20+
const store: ɵThreadStore = {
21+
start: vi.fn(),
22+
stop: vi.fn(),
23+
setContext: vi.fn(),
24+
refresh: vi.fn(),
25+
fetchNextPage: vi.fn(),
26+
renameThread: vi.fn(),
27+
archiveThread: vi.fn(),
28+
deleteThread: vi.fn(),
29+
getState: vi.fn(),
30+
select: vi.fn(),
31+
};
32+
return Object.assign(store, { __testId: id });
33+
}
34+
35+
describe("CopilotKitCore — onAgentsChanged auto-unregister", () => {
36+
it("unregisters a thread store when its agent is removed from agents", async () => {
37+
// Start with agent-1 registered. Add a thread store for it. Then remove
38+
// agent-1 and confirm the store is auto-unregistered AND the
39+
// onThreadStoreUnregistered subscriber is notified with the previous
40+
// store as `prevStore`.
41+
const agent1 = new MockAgent({ agentId: "agent-1" });
42+
const core = new CopilotKitCore({
43+
agents__unsafe_dev_only: { "agent-1": agent1 as never },
44+
});
45+
46+
const store = makeStore("agent-1-store");
47+
core.registerThreadStore("agent-1", store);
48+
expect(core.getThreadStore("agent-1")).toBe(store);
49+
50+
const onUnregistered = vi.fn();
51+
const subscriber: CopilotKitCoreSubscriber = {
52+
onThreadStoreUnregistered: onUnregistered,
53+
};
54+
core.subscribe(subscriber);
55+
56+
// Removing agent-1 triggers onAgentsChanged({ agents: {} }) — but agent-1
57+
// WAS in the previous snapshot, so the auto-unregister must fire here.
58+
core.removeAgent__unsafe_dev_only("agent-1");
59+
// Subscriber notification is fire-and-forget; flush microtasks.
60+
await Promise.resolve();
61+
await Promise.resolve();
62+
63+
expect(core.getThreadStore("agent-1")).toBeUndefined();
64+
expect(onUnregistered).toHaveBeenCalledWith(
65+
expect.objectContaining({ agentId: "agent-1", prevStore: store }),
66+
);
67+
});
68+
69+
it("does NOT unregister a freshly-registered store on the FIRST empty-agents notification", async () => {
70+
// Reproduces the published-core race: the core is created with no agents
71+
// (agents are populated asynchronously), a consumer registers a thread
72+
// store immediately, and the very first onAgentsChanged({ agents: {} })
73+
// notification arrives. Without the "previously had" guard, that empty
74+
// notification would rip out the freshly-registered store.
75+
const core = new CopilotKitCore({});
76+
77+
const store = makeStore("agent-1-store");
78+
core.registerThreadStore("agent-1", store);
79+
80+
const onUnregistered = vi.fn();
81+
const subscriber: CopilotKitCoreSubscriber = {
82+
onThreadStoreUnregistered: onUnregistered,
83+
};
84+
core.subscribe(subscriber);
85+
86+
// Trigger an onAgentsChanged({ agents: {} }) notification by calling
87+
// setAgents__unsafe_dev_only({}) — this models the initial empty-agents
88+
// notification a published core receives before its agents are merged in.
89+
core.setAgents__unsafe_dev_only({});
90+
await Promise.resolve();
91+
await Promise.resolve();
92+
93+
// The store must survive — agent-1 was never in the previous snapshot,
94+
// so there is no transition from "had" to "missing" and nothing to
95+
// unregister.
96+
expect(core.getThreadStore("agent-1")).toBe(store);
97+
expect(onUnregistered).not.toHaveBeenCalled();
98+
});
99+
100+
it("unregisters on the SECOND notification when agent appears then disappears", async () => {
101+
// Add agent-1, register a store, then remove agent-1. This time the
102+
// previous snapshot DID contain agent-1, so the auto-unregister must
103+
// fire. Complements the "first empty notification" test by exercising
104+
// the same code path's positive branch.
105+
const agent1 = new MockAgent({ agentId: "agent-1" });
106+
const core = new CopilotKitCore({});
107+
108+
core.addAgent__unsafe_dev_only({ id: "agent-1", agent: agent1 as never });
109+
const store = makeStore("agent-1-store");
110+
core.registerThreadStore("agent-1", store);
111+
112+
const onUnregistered = vi.fn();
113+
core.subscribe({
114+
onThreadStoreUnregistered: onUnregistered,
115+
});
116+
117+
core.removeAgent__unsafe_dev_only("agent-1");
118+
await Promise.resolve();
119+
await Promise.resolve();
120+
121+
expect(core.getThreadStore("agent-1")).toBeUndefined();
122+
expect(onUnregistered).toHaveBeenCalledTimes(1);
123+
});
124+
});

0 commit comments

Comments
 (0)