feat(angular): CopilotChat consumes ambient CopilotChatConfiguration

CopilotChat reads the ambient config when a provideCopilotChatConfiguration
provider is in scope: input-first precedence ([agentId]/[threadId] win over the
config, matching React), seeding the config from a set [threadId], and driving
the connector with loading-cursor hooks. Standalone <copilot-chat [threadId]>
usage is unchanged when no provider is present. Exports CopilotChatConfiguration
from the package entry point.
This commit is contained in:
Benjamin Taylor
2026-06-27 20:52:01 -05:00
parent 4400a7c081
commit 01cd9eeabc
3 changed files with 375 additions and 24 deletions
@@ -0,0 +1,296 @@
import { TestBed } from "@angular/core/testing";
import { test, expect } from "vitest";
import { Observable } from "rxjs";
import { AbstractAgent } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import { CopilotChat } from "../copilot-chat";
import { provideCopilotKit } from "../../../config";
import {
injectChatConfiguration,
provideCopilotChatConfiguration,
type CopilotChatConfiguration,
} from "../../../chat-configuration";
/**
* Minimal agent stub: `injectAgentStore` resolves it from the configured
* agents map and subscribes to it. Its `run` never emits, so connecting is a
* no-op for the purposes of welcome-state assertions.
*/
class MockAgent extends AbstractAgent {
constructor(id: string) {
super();
this.agentId = id;
}
run(_input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>();
}
}
/**
* Renders {@link CopilotChat} under an ambient
* {@link CopilotChatConfiguration} plus a registered default agent.
*
* @returns The rendered fixture, the chat configuration service, and a
* query helper for the welcome screen.
*/
function setup() {
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotChat],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
agents: { default: new MockAgent("default") },
}),
provideCopilotChatConfiguration(),
],
});
const config = TestBed.runInInjectionContext(() =>
injectChatConfiguration(),
) as CopilotChatConfiguration;
const fixture = TestBed.createComponent(CopilotChat);
fixture.detectChanges();
const welcomeScreen = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-testid="copilot-welcome-screen"]',
);
return { fixture, config, welcomeScreen };
}
/**
* Renders {@link CopilotChat} with a custom config {@link agentId} and
* optional component input overrides, registering an agent for every id used.
*
* @param configAgentId - The agent id passed to {@link provideCopilotChatConfiguration}.
* @param componentAgentId - Optional `[agentId]` input bound on the component.
* @param componentThreadId - Optional `[threadId]` input bound on the component.
* @returns The rendered fixture and a helper to read the resolved agent id.
*/
function setupAgentPrecedence({
configAgentId,
componentAgentId,
componentThreadId,
configThreadId,
}: {
configAgentId: string;
componentAgentId?: string;
componentThreadId?: string;
configThreadId?: string;
}) {
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
// Register an agent for every id in play so injectAgentStore never throws.
const agentIds = Array.from(
new Set(
[configAgentId, componentAgentId, "default"].filter(
(id): id is string => id !== undefined,
),
),
);
const agents = Object.fromEntries(
agentIds.map((id) => [id, new MockAgent(id)]),
);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotChat],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
agents,
}),
provideCopilotChatConfiguration({
agentId: configAgentId,
...(configThreadId !== undefined ? { threadId: configThreadId } : {}),
}),
],
});
const config = TestBed.runInInjectionContext(() =>
injectChatConfiguration(),
) as CopilotChatConfiguration;
const fixture = TestBed.createComponent(CopilotChat);
if (componentAgentId !== undefined) {
fixture.componentRef.setInput("agentId", componentAgentId);
}
if (componentThreadId !== undefined) {
fixture.componentRef.setInput("threadId", componentThreadId);
}
fixture.detectChanges();
/** Returns the agent id of the resolved agent store's agent. */
const resolvedAgentId = () =>
fixture.componentInstance.agentStore().agent.agentId;
/** Returns true when the component's hasExplicitThreadId signal is true. */
const isThreadExplicit = () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fixture.componentInstance as any)["hasExplicitThreadId"]();
/** Returns the threadId pinned onto the resolved agent. */
const agentThreadId = () =>
fixture.componentInstance.agentStore().agent.threadId;
return { fixture, config, resolvedAgentId, isThreadExplicit, agentThreadId };
}
/** Reads the component's protected `showCursor` signal. */
function readShowCursor(fixture: { componentInstance: CopilotChat }): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fixture.componentInstance as any)["showCursor"]() as boolean;
}
test("shows the loading cursor while the ambient config connects an explicit thread", () => {
const { fixture, config } = setup();
// The MockAgent's run Observable never emits, so the connect stays pending —
// the cursor must remain on for the duration of the connect.
config.setActiveThreadId("x", { explicit: true });
TestBed.flushEffects();
fixture.detectChanges();
expect(readShowCursor(fixture)).toBe(true);
});
test("clears the loading cursor once the ambient-config connect settles", async () => {
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value: () => undefined,
});
// An agent whose run completes immediately so the connect promise settles,
// mirroring the standalone path that clears the cursor when connect settles.
class CompletingAgent extends AbstractAgent {
constructor(id: string) {
super();
this.agentId = id;
}
run(_input: RunAgentInput): Observable<BaseEvent> {
return new Observable<BaseEvent>((subscriber) => {
subscriber.complete();
});
}
}
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [CopilotChat],
providers: [
provideCopilotKit({
licenseKey: "ck_pub_00000000000000000000000000000000",
agents: { default: new CompletingAgent("default") },
}),
provideCopilotChatConfiguration(),
],
});
const config = TestBed.runInInjectionContext(() =>
injectChatConfiguration(),
) as CopilotChatConfiguration;
const fixture = TestBed.createComponent(CopilotChat);
fixture.detectChanges();
config.setActiveThreadId("x", { explicit: true });
TestBed.flushEffects();
// Let the connect promise (and its `.finally`) settle and clear the cursor,
// mirroring the standalone settle-then-clear. The connect resolution spans
// macrotasks (agent teardown), so poll across a few.
for (let i = 0; i < 10 && readShowCursor(fixture); i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
TestBed.flushEffects();
}
expect(readShowCursor(fixture)).toBe(false);
// Drain the directive's deferred initial-scroll timer so it does not fire
// after the test environment tears down (scrollTo is stubbed; this only
// settles the pending macrotask).
await new Promise((resolve) => setTimeout(resolve, 0));
});
test("shows the welcome screen while the configuration thread is non-explicit", () => {
const { config, welcomeScreen } = setup();
expect(config.hasExplicitThreadId()).toBe(false);
expect(welcomeScreen()).not.toBeNull();
});
test("hides the welcome screen once the configuration activates an explicit thread", () => {
const { fixture, config, welcomeScreen } = setup();
config.setActiveThreadId("x");
TestBed.flushEffects();
fixture.detectChanges();
expect(config.hasExplicitThreadId()).toBe(true);
expect(welcomeScreen()).toBeNull();
});
// ─── A1: component [agentId] input takes precedence over ambient config ───────
test("component [agentId] input wins over ambient config agentId", () => {
const { resolvedAgentId } = setupAgentPrecedence({
configAgentId: "cfg-agent",
componentAgentId: "input-agent",
});
expect(resolvedAgentId()).toBe("input-agent");
});
test("config agentId drives resolution when component [agentId] is not set", () => {
const { resolvedAgentId } = setupAgentPrecedence({
configAgentId: "cfg-agent",
});
expect(resolvedAgentId()).toBe("cfg-agent");
});
// ─── A5: component [threadId] input seeds the ambient config ──────────────────
test("[threadId] input drives the config thread and agent under an uncontrolled config", () => {
const { fixture, config, isThreadExplicit, agentThreadId } =
setupAgentPrecedence({
configAgentId: "cfg-agent",
componentThreadId: "t1",
});
TestBed.flushEffects();
fixture.detectChanges();
expect(config.threadId()).toBe("t1");
expect(agentThreadId()).toBe("t1");
expect(isThreadExplicit()).toBe(true);
});
test("a controlled config thread wins over the [threadId] input (input ignored)", () => {
const { fixture, config, agentThreadId } = setupAgentPrecedence({
configAgentId: "cfg-agent",
configThreadId: "cfg-thread",
componentThreadId: "t1",
});
TestBed.flushEffects();
fixture.detectChanges();
expect(config.threadId()).toBe("cfg-thread");
expect(agentThreadId()).toBe("cfg-thread");
});
@@ -33,6 +33,8 @@ import { injectAgentStore } from "../../agent";
import { CopilotKit } from "../../copilotkit";
import { ChatState } from "../../chat-state";
import { transcribeAudio } from "../../transcription";
import { COPILOT_CHAT_CONFIGURATION } from "../../chat-configuration";
import { connectActiveThread } from "../../active-thread-connector";
/**
* CopilotChat component - Angular equivalent of React's <CopilotChat>
@@ -86,19 +88,29 @@ export class CopilotChat extends ChatState {
readonly attachmentsConfig = input<AttachmentsConfig | undefined>(undefined, {
alias: "attachments",
});
/**
* Ambient chat configuration, when a {@link provideCopilotChatConfiguration}
* provider is in scope. Absent (`null`) for standalone
* `<copilot-chat [threadId]>` usage, which has no provider — resolved via the
* optional inject so the component does not throw without one.
*/
private readonly config = inject(COPILOT_CHAT_CONFIGURATION, {
optional: true,
});
protected readonly resolvedAgentId = computed(
() => this.agentId() ?? DEFAULT_AGENT_ID,
() => this.agentId() ?? this.config?.agentId() ?? DEFAULT_AGENT_ID,
);
readonly agentStore = injectAgentStore(this.resolvedAgentId);
private readonly copilotKit = inject(CopilotKit);
// readonly chatConfig = injectChatConfig();
readonly cdr = inject(ChangeDetectorRef);
readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
protected messages = computed(() => this.agentStore().messages());
protected isRunning = computed(() => this.agentStore().isRunning());
protected hasExplicitThreadId = computed(() => Boolean(this.threadId()));
protected readonly hasExplicitThreadId =
this.config?.hasExplicitThreadId ??
computed(() => Boolean(this.threadId()));
protected readonly agentRef = computed(() => this.agentStore().agent);
protected readonly resolvedThreadId = computed(
() => this.threadId() || this.generatedThreadId,
@@ -165,28 +177,70 @@ export class CopilotChat extends ChatState {
this.copilotKit.reloadSuggestions(agentId);
});
effect((onCleanup) => {
const agent = this.agentRef();
const threadId = this.resolvedThreadId();
agent.threadId = threadId;
if (!this.hasExplicitThreadId()) return;
let detached = false;
const abortController = new AbortController();
if (agent instanceof HttpAgent) {
agent.abortController = abortController;
}
void this.connectToAgent(agent, () => detached);
onCleanup(() => {
detached = true;
abortController.abort();
void agent.detachActiveRun().catch(() => {});
if (this.config) {
// A set `[threadId]` input seeds the ambient config so the input
// actually drives the active thread (not just the welcome flag). When
// the config is controlled by a host-provided `threadId` option,
// `setActiveThreadId` no-ops — so a controlled config wins over the
// input, matching React's prop-precedence. When `[threadId]` is unset,
// the effect does nothing and the config drives as before.
effect(() => {
const inputThreadId = this.threadId();
if (inputThreadId) {
this.config!.setActiveThreadId(inputThreadId, { explicit: true });
}
});
});
// Ambient configuration drives the active thread: the connector pins
// `agent.threadId` from the config's resolved thread signal and connects
// on explicit switches (or clears messages on a fresh thread).
//
// The connector receives the RAW `core.connectAgent` and owns the loading
// cursor + abort + detach lifecycle, matching the standalone
// `connectToAgent` path exactly: cursor on at connect start, off when the
// connect settles (guarded against a superseded run). Connect errors still
// surface via the AgentStore's run/error subscription, not here.
connectActiveThread(
this.config,
this.agentStore,
(params) => this.copilotKit.core.connectAgent(params),
{
onConnectStart: () => {
this.showCursor.set(true);
this.cdr.markForCheck();
},
onConnectSettle: () => {
this.showCursor.set(false);
this.cdr.markForCheck();
},
},
);
} else {
// Standalone `<copilot-chat [threadId]>` usage with no configuration
// provider: the active thread is input-driven exactly as before.
effect((onCleanup) => {
const agent = this.agentRef();
const threadId = this.resolvedThreadId();
agent.threadId = threadId;
if (!this.hasExplicitThreadId()) return;
let detached = false;
const abortController = new AbortController();
if (agent instanceof HttpAgent) {
agent.abortController = abortController;
}
void this.connectToAgent(agent, () => detached);
onCleanup(() => {
detached = true;
abortController.abort();
void agent.detachActiveRun().catch(() => {});
});
});
}
}
private async connectToAgent(
+1
View File
@@ -7,6 +7,7 @@ export * from "./lib/open-generative-ui";
export * from "./lib/agent";
export * from "./lib/threads";
export * from "./lib/chat-config";
export * from "./lib/chat-configuration";
export * from "./lib/chat-state";
export * from "./lib/transcription";
export * from "./lib/scroll-position";