import { describe, it, expect, vi, beforeEach } from "vitest"; import { BaseComposerRuntimeCore } from "../legacy-runtime/runtime-cores/composer/BaseComposerRuntimeCore"; import type { AttachmentAdapter } from "../legacy-runtime/runtime-cores/adapters/attachment"; import type { DictationAdapter } from "../legacy-runtime/runtime-cores/adapters/speech/SpeechAdapterTypes"; import type { AppendMessage, CreateAttachment, PendingAttachment, SendOptions, } from "@assistant-ui/core"; class TestComposerCore extends BaseComposerRuntimeCore { private _attachmentAdapter: AttachmentAdapter | undefined; private _dictationAdapter: DictationAdapter | undefined; public sentMessages: Array> = []; public sentOptions: Array = []; public cancelCalled = false; protected getAttachmentAdapter() { return this._attachmentAdapter; } protected getDictationAdapter() { return this._dictationAdapter; } setAttachmentAdapter(adapter: AttachmentAdapter | undefined) { this._attachmentAdapter = adapter; } setDictationAdapter(adapter: DictationAdapter | undefined) { this._dictationAdapter = adapter; } get canCancel() { return false; } get canSend() { return !this.isEmpty; } protected handleSend( message: Omit, options?: SendOptions, ) { this.sentMessages.push(message); this.sentOptions.push(options); } protected handleCancel() { this.cancelCalled = true; } } const makePendingAttachment = ( id: string, name = "file.txt", ): PendingAttachment => ({ id, type: "file", name, contentType: "text/plain", file: new File(["content"], name), status: { type: "requires-action", reason: "composer-send" }, }); describe("BaseComposerRuntimeCore", () => { let composer: TestComposerCore; beforeEach(() => { composer = new TestComposerCore(); }); it("sets and gets text", () => { composer.setText("hello"); expect(composer.text).toBe("hello"); }); it("setText does not notify when value unchanged", () => { composer.setText("same"); const listener = vi.fn(); composer.subscribe(listener); composer.setText("same"); expect(listener).not.toHaveBeenCalled(); }); it("isEmpty returns true when no text and no attachments", () => { expect(composer.isEmpty).toBe(true); }); it("isEmpty returns false when text is present", () => { composer.setText("hi"); expect(composer.isEmpty).toBe(false); }); it("isEmpty returns true for whitespace-only text", () => { composer.setText(" "); expect(composer.isEmpty).toBe(true); }); it("sets and gets role", () => { composer.setRole("assistant"); expect(composer.role).toBe("assistant"); }); it("setRole does not notify when value unchanged", () => { composer.setRole("user"); const listener = vi.fn(); composer.subscribe(listener); composer.setRole("user"); expect(listener).not.toHaveBeenCalled(); }); it("sets and gets runConfig", () => { const config = { custom: { model: "gpt-4" } }; composer.setRunConfig(config); expect(composer.runConfig).toBe(config); }); it("sets and gets quote", () => { const quote = { text: "some quote", messageId: "msg-1" }; composer.setQuote(quote); expect(composer.quote).toBe(quote); }); it("setQuote does not notify when value unchanged", () => { const quote = { text: "q", messageId: "m1" }; composer.setQuote(quote); const listener = vi.fn(); composer.subscribe(listener); composer.setQuote(quote); expect(listener).not.toHaveBeenCalled(); }); it("reset clears text, attachments, role, runConfig, and quote", async () => { composer.setText("hello"); composer.setRole("assistant"); composer.setRunConfig({ custom: { k: "v" } }); composer.setQuote({ text: "q", messageId: "m1" }); await composer.reset(); expect(composer.text).toBe(""); expect(composer.role).toBe("user"); expect(composer.runConfig).toEqual({}); expect(composer.quote).toBeUndefined(); expect(composer.attachments).toEqual([]); }); it("reset does nothing when already in default state", async () => { const listener = vi.fn(); composer.subscribe(listener); await composer.reset(); expect(listener).not.toHaveBeenCalled(); }); it("send constructs message with text content and fires send event", async () => { composer.setText("hello world"); await composer.send(); expect(composer.sentMessages).toHaveLength(1); const msg = composer.sentMessages[0]!; expect(msg.role).toBe("user"); expect(msg.content).toEqual([{ type: "text", text: "hello world" }]); expect(msg.attachments).toEqual([]); }); it("send clears text and attachments after sending", async () => { composer.setText("hello"); await composer.send(); expect(composer.text).toBe(""); expect(composer.attachments).toEqual([]); }); it("send includes quote in metadata and clears it", async () => { const quote = { text: "quoted", messageId: "m1" }; composer.setText("reply"); composer.setQuote(quote); await composer.send(); const msg = composer.sentMessages[0]!; expect(msg.metadata).toEqual({ custom: { quote } }); expect(composer.quote).toBeUndefined(); }); it("send with empty text is a no-op", async () => { await composer.send(); expect(composer.sentMessages).toHaveLength(0); }); it("addAttachment throws when no adapter", async () => { const file = new File(["data"], "test.txt"); await expect(composer.addAttachment(file)).rejects.toThrow( "Attachments are not supported", ); }); it("addAttachment adds via adapter", async () => { const pending = makePendingAttachment("att-1", "test.txt"); const adapter: AttachmentAdapter = { accept: "text/*", add: vi.fn().mockResolvedValue(pending), remove: vi.fn().mockResolvedValue(undefined), send: vi.fn(), }; composer.setAttachmentAdapter(adapter); await composer.addAttachment( new File(["data"], "test.txt", { type: "text/plain" }), ); expect(composer.attachments).toHaveLength(1); expect(composer.attachments[0]!.id).toBe("att-1"); }); it("removeAttachment removes via adapter", async () => { const pending = makePendingAttachment("att-1"); const adapter: AttachmentAdapter = { accept: "*", add: vi.fn().mockResolvedValue(pending), remove: vi.fn().mockResolvedValue(undefined), send: vi.fn(), }; composer.setAttachmentAdapter(adapter); await composer.addAttachment(new File([""], "f.txt")); expect(composer.attachments).toHaveLength(1); await composer.removeAttachment("att-1"); expect(composer.attachments).toHaveLength(0); expect(adapter.remove).toHaveBeenCalledWith(pending); }); it("removeAttachment throws for unknown id", async () => { const adapter: AttachmentAdapter = { accept: "*", add: vi.fn(), remove: vi.fn(), send: vi.fn(), }; composer.setAttachmentAdapter(adapter); await expect(composer.removeAttachment("nonexistent")).rejects.toThrow( "Attachment not found", ); }); it("unstable_on registers event listener for send", async () => { const callback = vi.fn(); composer.unstable_on("send", callback); composer.setText("test"); await composer.send(); expect(callback).toHaveBeenCalledTimes(1); }); it("unstable_on unsubscribe stops notifications", async () => { const callback = vi.fn(); const unsub = composer.unstable_on("send", callback); unsub(); composer.setText("test"); await composer.send(); expect(callback).not.toHaveBeenCalled(); }); it("unstable_on fires attachmentAdd event", async () => { const callback = vi.fn(); composer.unstable_on("attachmentAdd", callback); const pending = makePendingAttachment("att-1"); const adapter: AttachmentAdapter = { accept: "*", add: vi.fn().mockResolvedValue(pending), remove: vi.fn(), send: vi.fn(), }; composer.setAttachmentAdapter(adapter); await composer.addAttachment(new File([""], "f.txt")); expect(callback).toHaveBeenCalledTimes(1); }); it("isEditing is always true", () => { expect(composer.isEditing).toBe(true); }); it("attachmentAccept returns adapter accept or default", () => { expect(composer.attachmentAccept).toBe("*"); const adapter: AttachmentAdapter = { accept: "image/*", add: vi.fn(), remove: vi.fn(), send: vi.fn(), }; composer.setAttachmentAdapter(adapter); expect(composer.attachmentAccept).toBe("image/*"); }); it("cancel delegates to handleCancel", () => { composer.cancel(); expect(composer.cancelCalled).toBe(true); }); it("subscribe notifies on text change", () => { const listener = vi.fn(); composer.subscribe(listener); composer.setText("new"); expect(listener).toHaveBeenCalledTimes(1); }); it("subscribe returns working unsubscribe function", () => { const listener = vi.fn(); const unsub = composer.subscribe(listener); unsub(); composer.setText("change"); expect(listener).not.toHaveBeenCalled(); }); describe("CreateAttachment (external source)", () => { const makeCreateAttachment = ( overrides?: Partial, ): CreateAttachment => ({ name: "external-doc.pdf", content: [{ type: "text", text: "extracted content" }], ...overrides, }); it("addAttachment with CreateAttachment adds without adapter", async () => { // No adapter set — should still work const att = makeCreateAttachment(); await composer.addAttachment(att); expect(composer.attachments).toHaveLength(1); expect(composer.attachments[0]!.name).toBe("external-doc.pdf"); expect(composer.attachments[0]!.status).toEqual({ type: "complete" }); expect(composer.attachments[0]!.type).toBe("document"); }); it("addAttachment with CreateAttachment uses provided id and type", async () => { const att = makeCreateAttachment({ id: "custom-id", type: "image", contentType: "image/png", }); await composer.addAttachment(att); expect(composer.attachments[0]!.id).toBe("custom-id"); expect(composer.attachments[0]!.type).toBe("image"); expect(composer.attachments[0]!.contentType).toBe("image/png"); }); it("addAttachment with CreateAttachment generates id when not provided", async () => { const att = makeCreateAttachment(); await composer.addAttachment(att); expect(composer.attachments[0]!.id).toBeTruthy(); expect(typeof composer.attachments[0]!.id).toBe("string"); }); it("addAttachment with CreateAttachment fires attachmentAdd event", async () => { const callback = vi.fn(); composer.unstable_on("attachmentAdd", callback); await composer.addAttachment(makeCreateAttachment()); expect(callback).toHaveBeenCalledTimes(1); }); it("removeAttachment on complete attachment does not require adapter", async () => { await composer.addAttachment(makeCreateAttachment({ id: "ext-1" })); expect(composer.attachments).toHaveLength(1); // No adapter set — should still remove complete attachments await composer.removeAttachment("ext-1"); expect(composer.attachments).toHaveLength(0); }); it("isEmpty returns false when external attachment present", async () => { expect(composer.isEmpty).toBe(true); await composer.addAttachment(makeCreateAttachment()); expect(composer.isEmpty).toBe(false); }); it("send with mixed pending + complete attachments", async () => { const pending = makePendingAttachment("att-pending"); const completedFromAdapter = { id: "att-pending", type: "file" as const, name: "file.txt", contentType: "text/plain", content: [{ type: "text" as const, text: "file content" }], status: { type: "complete" as const }, }; const adapter: AttachmentAdapter = { accept: "*", add: vi.fn().mockResolvedValue(pending), remove: vi.fn(), send: vi.fn().mockResolvedValue(completedFromAdapter), }; composer.setAttachmentAdapter(adapter); // Add a file-based attachment via adapter await composer.addAttachment(new File(["data"], "file.txt")); // Add an external attachment await composer.addAttachment(makeCreateAttachment({ id: "ext-1" })); expect(composer.attachments).toHaveLength(2); composer.setText("hello"); await composer.send(); expect(composer.sentMessages).toHaveLength(1); const msg = composer.sentMessages[0]!; expect(msg.attachments).toHaveLength(2); // Pending was sent through adapter expect(adapter.send).toHaveBeenCalledTimes(1); }); describe("adapter.accept enforcement", () => { const makeImageAdapter = (): AttachmentAdapter => ({ accept: "image/*", add: vi.fn(), remove: vi.fn(), send: vi.fn(), }); it("rejects external attachment with contentType not matching adapter.accept", async () => { composer.setAttachmentAdapter(makeImageAdapter()); const onError = vi.fn(); const onAdd = vi.fn(); composer.unstable_on("attachmentAddError", onError); composer.unstable_on("attachmentAdd", onAdd); await expect( composer.addAttachment( makeCreateAttachment({ name: "doc.pdf", contentType: "application/pdf", }), ), ).rejects.toThrow(/File type application\/pdf is not accepted/); expect(composer.attachments).toHaveLength(0); expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith( expect.objectContaining({ reason: "not-accepted", message: expect.stringContaining( "File type application/pdf is not accepted", ), error: expect.any(Error), }), ); expect(onAdd).not.toHaveBeenCalled(); }); it("accepts external attachment with contentType matching adapter.accept", async () => { composer.setAttachmentAdapter(makeImageAdapter()); const onError = vi.fn(); composer.unstable_on("attachmentAddError", onError); await composer.addAttachment( makeCreateAttachment({ name: "photo.png", contentType: "image/png", }), ); expect(composer.attachments).toHaveLength(1); expect(onError).not.toHaveBeenCalled(); }); it("accepts external attachment when only filename extension matches adapter.accept", async () => { composer.setAttachmentAdapter({ accept: ".pdf", add: vi.fn(), remove: vi.fn(), send: vi.fn(), }); await composer.addAttachment( makeCreateAttachment({ name: "report.pdf" }), ); expect(composer.attachments).toHaveLength(1); }); it("rejects external attachment when contentType missing and extension does not match", async () => { composer.setAttachmentAdapter(makeImageAdapter()); const onError = vi.fn(); composer.unstable_on("attachmentAddError", onError); await expect( composer.addAttachment(makeCreateAttachment({ name: "notes.txt" })), ).rejects.toThrow(/is not accepted/); expect(onError).toHaveBeenCalledTimes(1); }); it("adds external attachment without check when no adapter is configured", async () => { // No adapter — preserves the original "no adapter required" guarantee await composer.addAttachment( makeCreateAttachment({ name: "anything.xyz", contentType: "application/x-anything", }), ); expect(composer.attachments).toHaveLength(1); }); it("does not let subscriber errors mask the original throw", async () => { composer.setAttachmentAdapter(makeImageAdapter()); composer.unstable_on("attachmentAddError", () => { throw new Error("subscriber boom"); }); await expect( composer.addAttachment( makeCreateAttachment({ name: "doc.pdf", contentType: "application/pdf", }), ), ).rejects.toThrow(/is not accepted/); }); it("does not fire attachmentAddError when an attachmentAdd subscriber throws", async () => { composer.setAttachmentAdapter(makeImageAdapter()); const onError = vi.fn(); composer.unstable_on("attachmentAdd", () => { throw new Error("add subscriber boom"); }); composer.unstable_on("attachmentAddError", onError); await expect( composer.addAttachment( makeCreateAttachment({ name: "photo.png", contentType: "image/png", }), ), ).rejects.toThrow("add subscriber boom"); expect(composer.attachments).toHaveLength(1); expect(onError).not.toHaveBeenCalled(); }); it("does not fire attachmentAddError when a state subscriber throws on add", async () => { composer.setAttachmentAdapter(makeImageAdapter()); const onError = vi.fn(); composer.subscribe(() => { throw new Error("state subscriber boom"); }); composer.unstable_on("attachmentAddError", onError); await expect( composer.addAttachment( makeCreateAttachment({ name: "photo.png", contentType: "image/png", }), ), ).rejects.toThrow("state subscriber boom"); expect(composer.attachments).toHaveLength(1); expect(onError).not.toHaveBeenCalled(); }); }); }); describe("send options", () => { it("send() passes undefined options by default", async () => { composer.setText("hello"); await composer.send(); expect(composer.sentOptions).toHaveLength(1); expect(composer.sentOptions[0]).toBeUndefined(); }); it("send({ startRun: true }) forwards options to handleSend", async () => { composer.setText("hello"); await composer.send({ startRun: true }); expect(composer.sentOptions).toHaveLength(1); expect(composer.sentOptions[0]).toEqual({ startRun: true }); }); it("send({ startRun: false }) forwards options to handleSend", async () => { composer.setText("hello"); await composer.send({ startRun: false }); expect(composer.sentOptions).toHaveLength(1); expect(composer.sentOptions[0]).toEqual({ startRun: false }); }); }); });