Skip to content

Commit 14ae28c

Browse files
marthakellyclaude
andcommitted
feat(inspector): add thread store registry, runtime handlers, and useThreads hook
- CopilotKitCore gains a ThreadStoreRegistry (register/unregister by agentId) and a new onAgentRunStarted subscriber event so the inspector can subscribe before agent.runAgent() snapshots the subscriber list - Runtime gains handleListThreads, handleUpdateThread, handleArchiveThread, handleDeleteThread, handleSubscribeToThreads, and handleGetThreadMessages handlers; all mutations are authenticated via identifyUser (request body userId is ignored) - InMemoryAgentRunner now stores thread history for the local-dev fallback path; debug console.log removed; InMemoryThread uses literal types for constant-value fields (organizationId: "", createdById: "", archived: false) - useThreads hook registers its store with CopilotKitCore on mount and unregisters on unmount so the inspector can read thread state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 640138d commit 14ae28c

28 files changed

Lines changed: 4122 additions & 313 deletions

File tree

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
inject,
66
input,
77
signal,
8+
NgZone,
89
OnDestroy,
910
OnInit,
1011
} from "@angular/core";
@@ -17,12 +18,13 @@ import {
1718
HumanInTheLoopToolRenderer,
1819
injectAgentStore,
1920
registerHumanInTheLoop,
20-
} from "@copilotkit/angular";
21-
import { RenderToolCalls } from "@copilotkit/angular";
21+
} from "@copilotkitnext/angular";
22+
import { RenderToolCalls } from "@copilotkitnext/angular";
2223
import {
2324
WEB_INSPECTOR_TAG,
2425
type WebInspectorElement,
2526
} from "@copilotkit/web-inspector";
27+
import { defineInspectorElements } from "@copilotkit/web-inspector-angular";
2628
import { z } from "zod";
2729

2830
@Component({
@@ -123,6 +125,20 @@ export class RequireApprovalComponent implements HumanInTheLoopToolRenderer {
123125
>
124126
Send
125127
</button>
128+
<button
129+
type="button"
130+
(click)="clearThreads()"
131+
style="
132+
padding: 10px 14px;
133+
border-radius: 8px;
134+
border: 1px solid #d1d5db;
135+
background: #ffffff;
136+
color: #374151;
137+
cursor: pointer;
138+
"
139+
>
140+
Clear threads
141+
</button>
126142
</form>
127143
</div>
128144
`,
@@ -156,9 +172,13 @@ export class HeadlessChatComponent implements OnInit, OnDestroy {
156172
);
157173
}
158174

175+
private ngZone = inject(NgZone);
176+
159177
ngOnInit(): void {
160178
if (typeof document === "undefined") return;
161179

180+
this.ngZone.runOutsideAngular(() => defineInspectorElements());
181+
162182
const existing =
163183
document.querySelector<WebInspectorElement>(WEB_INSPECTOR_TAG);
164184
const inspector =
@@ -181,6 +201,13 @@ export class HeadlessChatComponent implements OnInit, OnDestroy {
181201
this.inspectorElement = null;
182202
}
183203

204+
async clearThreads() {
205+
const runtimeUrl = this.copilotkit.core?.runtimeUrl;
206+
if (!runtimeUrl) return;
207+
const url = runtimeUrl.replace(/\/$/, "");
208+
await fetch(`${url}/threads`, { method: "DELETE", credentials: "include" });
209+
}
210+
184211
async send() {
185212
const content = this.inputValue.trim();
186213
const agent = this.agent();

lefthook.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ pre-commit:
3838
3939
for f in $STAGED; do
4040
[ ! -f "$f" ] && continue
41-
case "$f" in pnpm-lock.yaml|*/package-lock.json) continue ;; esac
41+
case "$f" in pnpm-lock.yaml|*/package-lock.json|showcase/shell/src/data/demo-content.json|showcase/shell-dojolike/src/data/demo-content.json) continue ;; esac
4242
SIZE=$(wc -c < "$f" | tr -d ' ')
4343
if [ "$SIZE" -gt 1048576 ]; then
4444
echo "Oversized file: $f ($((SIZE / 1024)) KB)"

packages/core/src/core/core.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
CopilotKitCoreRunToolResult,
2121
} from "./run-handler";
2222
import { StateManager } from "./state-manager";
23+
import { ThreadStoreRegistry } from "./thread-store-registry";
24+
import { type ɵThreadStore } from "../threads";
2325

2426
/** Configuration options for `CopilotKitCore`. */
2527
export interface CopilotKitCoreConfig {
@@ -153,10 +155,20 @@ export interface CopilotKitCoreSubscriber {
153155
code: CopilotKitCoreErrorCode;
154156
context: Record<string, any>;
155157
}) => void | Promise<void>;
158+
onThreadStoreRegistered?: (event: {
159+
copilotkit: CopilotKitCore;
160+
agentId: string;
161+
store: ɵThreadStore;
162+
}) => void | Promise<void>;
163+
onThreadStoreUnregistered?: (event: {
164+
copilotkit: CopilotKitCore;
165+
agentId: string;
166+
}) => void | Promise<void>;
156167
/**
157-
* Fired when an agent run or connect begins. The `agent` may be a per-thread
158-
* clone that is not present in `core.agents`. Subscribers (e.g. the inspector)
159-
* can use this to subscribe to the clone's AG-UI events.
168+
* Fired immediately before each agent run, including per-thread clones that
169+
* are not in the agent registry and therefore not surfaced via onAgentsChanged.
170+
* Subscribers that track agent events (e.g. the web inspector) can use this
171+
* to subscribe to the concrete agent instance that will emit events.
160172
*/
161173
onAgentRunStarted?: (event: {
162174
copilotkit: CopilotKitCore;
@@ -237,6 +249,7 @@ export class CopilotKitCore {
237249
private suggestionEngine: SuggestionEngine;
238250
private runHandler: RunHandler;
239251
private stateManager: StateManager;
252+
private threadStoreRegistry: ThreadStoreRegistry;
240253

241254
constructor({
242255
runtimeUrl,
@@ -258,6 +271,7 @@ export class CopilotKitCore {
258271
this.suggestionEngine = new SuggestionEngine(this);
259272
this.runHandler = new RunHandler(this);
260273
this.stateManager = new StateManager(this);
274+
this.threadStoreRegistry = new ThreadStoreRegistry(this);
261275

262276
// Initialize each subsystem
263277
this.agentRegistry.initialize(agents__unsafe_dev_only);
@@ -276,6 +290,14 @@ export class CopilotKitCore {
276290
this.stateManager.subscribeToAgent(agent);
277291
}
278292
});
293+
294+
// Unregister thread stores for agents that are no longer present
295+
const currentAgentIds = new Set(Object.keys(agents));
296+
for (const agentId of Object.keys(this.threadStoreRegistry.getAll())) {
297+
if (!currentAgentIds.has(agentId)) {
298+
this.threadStoreRegistry.unregister(agentId);
299+
}
300+
}
279301
},
280302
});
281303
}
@@ -482,6 +504,25 @@ export class CopilotKitCore {
482504
this.contextStore.removeContext(id);
483505
}
484506

507+
/**
508+
* Thread store registry (delegated to ThreadStoreRegistry)
509+
*/
510+
registerThreadStore(agentId: string, store: ɵThreadStore): void {
511+
this.threadStoreRegistry.register(agentId, store);
512+
}
513+
514+
unregisterThreadStore(agentId: string): void {
515+
this.threadStoreRegistry.unregister(agentId);
516+
}
517+
518+
getThreadStore(agentId: string): ɵThreadStore | undefined {
519+
return this.threadStoreRegistry.get(agentId);
520+
}
521+
522+
getThreadStores(): Readonly<Record<string, ɵThreadStore>> {
523+
return this.threadStoreRegistry.getAll();
524+
}
525+
485526
/**
486527
* Suggestions management (delegated to SuggestionEngine)
487528
*/

packages/core/src/core/run-handler.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,15 +294,13 @@ export class RunHandler {
294294
// does not overwrite the registry agent's subscription.
295295
this._internal.subscribeAgentToStateManager(agent);
296296

297-
// Notify subscribers (e.g. the inspector) about the agent that is about
298-
// to run. This is critical for per-thread clones that are not present in
299-
// the agent registry and would otherwise be invisible to subscribers.
297+
// Notify subscribers (e.g. the web inspector) that a run is about to start
298+
// on this specific agent instance. Must be awaited so that subscribers can
299+
// call agent.subscribe() before agent.runAgent() captures its subscriber
300+
// snapshot — agent.runAgent() snapshots [this.subscribers] synchronously.
300301
await this._internal.notifySubscribers(
301302
(subscriber) =>
302-
subscriber.onAgentRunStarted?.({
303-
copilotkit: this.core,
304-
agent,
305-
}),
303+
subscriber.onAgentRunStarted?.({ copilotkit: this.core, agent }),
306304
"Subscriber onAgentRunStarted error:",
307305
);
308306

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type ɵThreadStore } from "../threads";
2+
import type { CopilotKitCore } from "./core";
3+
import { CopilotKitCoreFriendsAccess, CopilotKitCoreSubscriber } from "./core";
4+
5+
export class ThreadStoreRegistry {
6+
private _stores: Record<string, ɵThreadStore> = {};
7+
8+
constructor(private core: CopilotKitCore) {}
9+
10+
register(agentId: string, store: ɵThreadStore): void {
11+
this._stores[agentId] = store;
12+
void this.notifyRegistered(agentId, store);
13+
}
14+
15+
unregister(agentId: string): void {
16+
if (!(agentId in this._stores)) return;
17+
delete this._stores[agentId];
18+
void this.notifyUnregistered(agentId);
19+
}
20+
21+
get(agentId: string): ɵThreadStore | undefined {
22+
return this._stores[agentId];
23+
}
24+
25+
getAll(): Readonly<Record<string, ɵThreadStore>> {
26+
return this._stores;
27+
}
28+
29+
private async notifyRegistered(
30+
agentId: string,
31+
store: ɵThreadStore,
32+
): Promise<void> {
33+
await (
34+
this.core as unknown as CopilotKitCoreFriendsAccess
35+
).notifySubscribers(
36+
(subscriber: CopilotKitCoreSubscriber) =>
37+
subscriber.onThreadStoreRegistered?.({
38+
copilotkit: this.core,
39+
agentId,
40+
store,
41+
}),
42+
"Subscriber onThreadStoreRegistered error:",
43+
);
44+
}
45+
46+
private async notifyUnregistered(agentId: string): Promise<void> {
47+
await (
48+
this.core as unknown as CopilotKitCoreFriendsAccess
49+
).notifySubscribers(
50+
(subscriber: CopilotKitCoreSubscriber) =>
51+
subscriber.onThreadStoreUnregistered?.({
52+
copilotkit: this.core,
53+
agentId,
54+
}),
55+
"Subscriber onThreadStoreUnregistered error:",
56+
);
57+
}
58+
}

packages/core/src/threads.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ interface ThreadStore {
374374
start(): void;
375375
stop(): void;
376376
setContext(context: ThreadRuntimeContext | null): void;
377+
/** Re-fetches the thread list without resetting the current list to empty. */
378+
refresh(): void;
377379
fetchNextPage(): void;
378380
renameThread(threadId: string, name: string): Promise<void>;
379381
archiveThread(threadId: string): Promise<void>;
@@ -959,6 +961,11 @@ function createThreadStore(environment: ThreadEnvironment): ThreadStore {
959961
setContext(context: ThreadRuntimeContext | null): void {
960962
store.dispatch(threadAdapterEvents.contextChanged({ context }));
961963
},
964+
refresh(): void {
965+
const { sessionId, context } = store.getState();
966+
if (!context) return;
967+
store.dispatch(threadRestEvents.listRequested({ sessionId }));
968+
},
962969
fetchNextPage(): void {
963970
store.dispatch(threadAdapterEvents.fetchNextPageRequested());
964971
},

packages/react-core/src/v2/hooks/__tests__/use-threads.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ function setupCopilotKit(runtimeUrl = "http://localhost:4000") {
152152
intelligence: {
153153
wsUrl: "ws://localhost:4000/client",
154154
},
155+
registerThreadStore: vi.fn(),
156+
unregisterThreadStore: vi.fn(),
155157
},
156158
});
157159
}

packages/react-core/src/v2/hooks/use-threads.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,13 @@ export function useThreads({
221221
};
222222
}, [store]);
223223

224+
useEffect(() => {
225+
copilotkit.registerThreadStore(agentId, store);
226+
return () => {
227+
copilotkit.unregisterThreadStore(agentId);
228+
};
229+
}, [copilotkit, agentId, store]);
230+
224231
useEffect(() => {
225232
const context: ɵThreadRuntimeContext | null = copilotkit.runtimeUrl
226233
? {

packages/runtime/src/v2/runtime/__tests__/handle-threads.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,18 @@ describe("thread handlers", () => {
4646
body: JSON.stringify(body),
4747
});
4848

49-
it("returns 422 when intelligence is not configured for listThreads", async () => {
49+
it("returns empty thread list when intelligence is not configured for listThreads", async () => {
5050
const runtime = new CopilotRuntime({ agents: {} });
5151

5252
const response = await handleListThreads({
5353
runtime,
5454
request: new Request("https://example.com/threads?agentId=agent-1"),
5555
});
5656

57-
expect(response.status).toBe(422);
57+
expect(response.status).toBe(200);
5858
await expect(response.json()).resolves.toEqual({
59-
error:
60-
"Missing CopilotKitIntelligence configuration. Thread operations require a CopilotKitIntelligence instance to be provided in CopilotRuntime options.",
59+
threads: [],
60+
nextCursor: null,
6161
});
6262
});
6363

packages/runtime/src/v2/runtime/handlers/handle-run.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export async function handleRunAgent({
3535
return agent;
3636
}
3737

38+
// Ensure the clone carries the registry key so InMemoryAgentRunner can
39+
// tag historic runs with the correct agentId for filtering.
40+
agent.agentId = agentId;
41+
3842
configureAgentForRequest({ runtime, request, agentId, agent });
3943

4044
if (

0 commit comments

Comments
 (0)