mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user