diff --git a/packages/angular/src/lib/components/chat/__tests__/copilot-chat.spec.ts b/packages/angular/src/lib/components/chat/__tests__/copilot-chat.spec.ts new file mode 100644 index 0000000000..3ffc6f5e44 --- /dev/null +++ b/packages/angular/src/lib/components/chat/__tests__/copilot-chat.spec.ts @@ -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 { + return new Observable(); + } +} + +/** + * 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 { + return new Observable((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"); +}); diff --git a/packages/angular/src/lib/components/chat/copilot-chat.ts b/packages/angular/src/lib/components/chat/copilot-chat.ts index 10d1ff4cbe..2692e75aa1 100644 --- a/packages/angular/src/lib/components/chat/copilot-chat.ts +++ b/packages/angular/src/lib/components/chat/copilot-chat.ts @@ -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 @@ -86,19 +88,29 @@ export class CopilotChat extends ChatState { readonly attachmentsConfig = input(undefined, { alias: "attachments", }); + /** + * Ambient chat configuration, when a {@link provideCopilotChatConfiguration} + * provider is in scope. Absent (`null`) for standalone + * `` 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 `` 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( diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index e3bd5fa2d2..5bea979f6c 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -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";