import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatSdkStatePersistence } from "./chat-sdk-state.js"; const captures = vi.hoisted(() => ({ chatConfigs: [] as Array>, chats: [] as Array>, discordConfigs: [] as Array>, discordGatewayDurations: [] as number[], discordRootThreads: [] as Array<{ channelId: string; content: string; messageId: string; }>, githubConfigs: [] as Array>, slackConfigs: [] as Array>, teamsConfigs: [] as Array>, telegramConfigs: [] as Array>, })); vi.mock("@chat-adapter/discord", () => ({ createDiscordAdapter: (config: Record) => { captures.discordConfigs.push(config); return { name: "discord", paperclipCompatibilityRevision: config.botToken === "unpatched-discord" ? undefined : "paperclip-discord-v6", async ensureRootThread( channelId: string, messageId: string, content: string, ) { captures.discordRootThreads.push({ channelId, content, messageId }); return { id: messageId, name: content }; }, async startGatewayListener( options: { waitUntil?: (task: Promise) => void }, durationMs: number, ) { captures.discordGatewayDurations.push(durationMs); if (config.botToken === "gateway-start-failure") { throw new Error("Gateway failed to start"); } if (config.botToken === "gateway-fatal") { await ( config.onGatewayEvent as ( event: Record, ) => Promise )({ type: "failure", fatal: true, error: { name: "Error", code: 4014 }, }); const error = new Error("Gateway privileged intent rejected"); Object.assign(error, { code: 4014 }); throw error; } options.waitUntil?.(Promise.resolve()); return new Response("listening"); }, }; }, })); vi.mock("@chat-adapter/slack", () => ({ createSlackAdapter: (config: Record) => { captures.slackConfigs.push(config); return { name: "slack" }; }, })); vi.mock("@chat-adapter/github", () => ({ createGitHubAdapter: (config: Record) => { captures.githubConfigs.push(config); return { name: "github" }; }, })); vi.mock("@chat-adapter/teams", () => ({ createTeamsAdapter: (config: Record) => { captures.teamsConfigs.push(config); class MockTeamsApi { readonly _apiClientSettings: unknown; readonly http: unknown; constructor( readonly serviceUrl: string, http: unknown = {}, settings?: unknown, ) { this.http = http; this._apiClientSettings = settings; } } const noop = async () => undefined; return { name: "teams", app: { api: new MockTeamsApi("https://smba.trafficmanager.net/teams"), }, cacheUserContext: () => undefined, getIncomingUser: async () => null, getUser: async () => null, decodeThreadId: () => ({ conversationId: "teams-mock-conversation", serviceUrl: "https://smba.trafficmanager.net/teams", }), openDM: async () => "teams:mock:thread", postMessage: noop, postEphemeral: noop, editMessage: noop, deleteMessage: noop, addReaction: noop, removeReaction: noop, startTyping: noop, stream: noop, postChannelMessage: noop, }; }, })); vi.mock("@chat-adapter/telegram", () => ({ createTelegramAdapter: (config: Record) => { captures.telegramConfigs.push(config); return { name: "telegram", processUpdate: () => undefined, parseTelegramMessage: config.botToken === "missing-telegram-rich-parser" ? undefined : () => ({}), extractAttachments: config.botToken === "missing-telegram-parser" ? undefined : () => [], createAttachment: config.botToken === "missing-telegram-factory" ? undefined : (type: string) => ({ type }), }; }, })); vi.mock("chat", () => ({ Chat: class MockChat { readonly handlers: Record unknown> = {}; readonly handlerRegistrations: Record = {}; readonly webhooks: Record Promise> = {}; initializeCalls = 0; shutdownCalls = 0; constructor(config: Record) { captures.chatConfigs.push(config); captures.chats.push(this as unknown as Record); for (const key of Object.keys( config.adapters as Record, )) { this.webhooks[key] = async () => new Response(key, { status: 202 }); } } onDirectMessage(handler: (...args: unknown[]) => unknown) { this.handlerRegistrations.direct = (this.handlerRegistrations.direct ?? 0) + 1; this.handlers.direct = handler; } onNewMention(handler: (...args: unknown[]) => unknown) { this.handlerRegistrations.mention = (this.handlerRegistrations.mention ?? 0) + 1; this.handlers.mention = handler; } onSubscribedMessage(handler: (...args: unknown[]) => unknown) { this.handlerRegistrations.subscribed = (this.handlerRegistrations.subscribed ?? 0) + 1; this.handlers.subscribed = handler; } onNewMessage(_pattern: RegExp, handler: (...args: unknown[]) => unknown) { this.handlerRegistrations.unaddressed = (this.handlerRegistrations.unaddressed ?? 0) + 1; this.handlers.unaddressed = handler; } onMessageUpdated(handler: (...args: unknown[]) => unknown) { this.handlers.updated = handler; } onMessageDeleted(handler: (...args: unknown[]) => unknown) { this.handlers.deleted = handler; } onReaction(handler: (...args: unknown[]) => unknown) { this.handlers.reaction = handler; } onAction(handler: (...args: unknown[]) => unknown) { this.handlers.action = handler; } onOptionsLoad(handler: (...args: unknown[]) => unknown) { this.handlers.options = handler; } onModalSubmit(handler: (...args: unknown[]) => unknown) { this.handlers.modalSubmit = handler; } onModalClose(handler: (...args: unknown[]) => unknown) { this.handlers.modalClose = handler; } onSlashCommand(handler: (...args: unknown[]) => unknown) { this.handlers.slash = handler; } async initialize() { this.initializeCalls += 1; } async shutdown() { this.shutdownCalls += 1; } thread(id: string) { return { id }; } channel(id: string) { return { id }; } async openDM(user: unknown) { return { user }; } async getUser(user: unknown) { return { user }; } async abortTurn() {} async handleActionEvent() {} async handleIncomingMessage() {} async handleReactionEvent() {} async processMessageDeleted() {} async processMessageUpdated() {} }, })); import { CHAT_SDK_SOURCE_REVISION, CHAT_SDK_VERSION, DiscordAdapterCompatibilityError, ChatSdkEndpointNotRegisteredError, createChatSdkEndpointRuntime, createChatSdkRuntime, type CreateChatSdkEndpointRuntimeOptions, } from "./chat-sdk-runtime.js"; const persistence: ChatSdkStatePersistence = { async compareAndSet() { return true; }, async deleteIfVersion() { return true; }, async read() { return null; }, }; function baseOptions( providerConfig: CreateChatSdkEndpointRuntimeOptions["providerConfig"], ): CreateChatSdkEndpointRuntimeOptions { return { callbacks: { onMessage: vi.fn() }, companyId: "company-1", endpointId: "endpoint-1", logger: "silent", persistence, providerConfig, }; } function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, reject, resolve }; } interface CapturedWebhookOptions { waitUntil?: (task: Promise) => void; } describe("Chat SDK endpoint runtime", () => { beforeEach(() => { for (const value of Object.values(captures)) value.length = 0; }); it("pins the audited SDK release and configures Slack's automatic feature path", () => { expect(CHAT_SDK_VERSION).toBe("4.39.0"); expect(CHAT_SDK_SOURCE_REVISION).toBe( "51322dde8f4aafd8a7fc7a20cbfd7ae45cafaa5c", ); const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "slack", userName: "paperclip-agent", credentials: { botToken: "xoxb-test", signingSecret: "signing-secret", botUserId: "U123", }, }), ); expect(runtime.provider).toBe("slack"); expect(runtime.sdkAdapterKey).toBe("slack"); expect(captures.slackConfigs[0]).toMatchObject({ botToken: "xoxb-test", signingSecret: "signing-secret", botUserId: "U123", mode: "webhook", nativeStreaming: true, userName: "paperclip-agent", webClientOptions: { rejectRateLimitedCalls: true, retryConfig: { retries: 0 }, timeout: 45_000, }, }); expect(captures.chatConfigs[0]).toMatchObject({ concurrency: "concurrent", }); }); it("configures GitHub mention matching with the human-facing App slug", () => { createChatSdkEndpointRuntime( baseOptions({ provider: "github", userName: "paperclip-agent[bot]", credentials: { token: "github-token", webhookSecret: "github-secret", }, }), ); expect(captures.githubConfigs[0]).toMatchObject({ token: "github-token", webhookSecret: "github-secret", userName: "paperclip-agent", }); }); it("leaves pre-callback SDK retry markers unclaimed so the delivery ledger remains authoritative", async () => { createChatSdkEndpointRuntime( baseOptions({ provider: "slack", userName: "agent", credentials: { botToken: "token", signingSecret: "secret" }, }), ); const state = captures.chatConfigs[0]?.state as { connect(): Promise; get(key: string): Promise; set(key: string, value: unknown, ttlMs?: number): Promise; setIfNotExists( key: string, value: unknown, ttlMs?: number, ): Promise; }; await state.connect(); expect( await state.setIfNotExists("dedupe:slack:message-1", true, 60_000), ).toBe(true); expect( await state.setIfNotExists("dedupe:slack:message-1", true, 60_000), ).toBe(true); await state.set("slack:event-delivered:event-1", true, 60_000); expect(await state.get("slack:event-delivered:event-1")).toBeNull(); expect( await state.setIfNotExists( "telegram:webhook-update:bot-1:42", true, 60_000, ), ).toBe(true); expect( await state.setIfNotExists( "telegram:webhook-update:bot-1:42", true, 60_000, ), ).toBe(true); }); it.each([ { expectedKey: "discord", providerConfig: { provider: "discord" as const, userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "discord-token", guildId: "1457808928258658549", }, }, }, { expectedKey: "github", providerConfig: { provider: "github" as const, userName: "paperclip-agent[bot]", credentials: { token: "github-token", webhookSecret: "github-secret" }, }, }, { expectedKey: "teams", providerConfig: { provider: "microsoft-teams" as const, userName: "Paperclip Agent", credentials: { appId: "teams-app", appPassword: "teams-password", appTenantId: "teams-tenant", appType: "SingleTenant" as const, }, }, }, { expectedKey: "telegram", providerConfig: { provider: "telegram" as const, userName: "paperclip_agent_bot", credentials: { botToken: "telegram-token", secretToken: "telegram-secret", }, }, }, ])( "maps $providerConfig.provider to the $expectedKey SDK adapter", ({ expectedKey, providerConfig }) => { const runtime = createChatSdkEndpointRuntime(baseOptions(providerConfig)); expect(runtime.sdkAdapterKey).toBe(expectedKey); expect( Object.keys((captures.chatConfigs[0]?.adapters ?? {}) as object), ).toEqual([expectedKey]); }, ); it("passes only the Discord application credentials to the adapter and fences guild callbacks", async () => { const onDiscordGatewayEvent = vi.fn(); const options = baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { apiUrl: "https://discord.example.test/api/v10", applicationId: "123456789012345678", botToken: "discord-token", guildId: "1457808928258658549", }, }); options.callbacks.onDiscordGatewayEvent = onDiscordGatewayEvent; const runtime = createChatSdkEndpointRuntime(options); expect(captures.discordConfigs[0]).toMatchObject({ apiUrl: "https://discord.example.test/api/v10", applicationId: "123456789012345678", botToken: "discord-token", userName: "paperclip-agent", }); expect(captures.discordConfigs[0]).not.toHaveProperty("guildId"); expect(captures.discordConfigs[0]).not.toHaveProperty("publicKey"); expect(captures.discordConfigs[0]?.webhookVerifier).toEqual( expect.any(Function), ); expect(captures.discordConfigs[0]?.shouldCreateThread).toEqual( expect.any(Function), ); expect(captures.discordConfigs[0]?.onGatewayEvent).toEqual( expect.any(Function), ); expect( runtime.acceptsProviderScope({ guild_id: "1457808928258658549" }), ).toBe(true); expect( runtime.acceptsProviderScope({ guild_id: "999999999999999999" }), ).toBe(false); expect(runtime.acceptsProviderScope({ guild_id: "@me" })).toBe(true); await runtime.ensureDiscordRootThread({ channelId: "333333333333333333", messageId: "555555555555555555", content: "Investigate the queue", }); expect(captures.discordRootThreads).toEqual([ { channelId: "333333333333333333", messageId: "555555555555555555", content: "Investigate the queue", }, ]); const onGatewayEvent = captures.discordConfigs[0]?.onGatewayEvent as ( event: Record, ) => Promise; await onGatewayEvent({ type: "guild_removed", guildId: "999999999999999999", }); expect(onDiscordGatewayEvent).not.toHaveBeenCalled(); await onGatewayEvent({ type: "ready", botUserId: "123456789012345678", }); await onGatewayEvent({ type: "disconnected", fatal: false, code: 1001, }); expect(onDiscordGatewayEvent).toHaveBeenNthCalledWith(1, { endpointId: "endpoint-1", event: { type: "ready", botUserId: "123456789012345678", }, provider: "discord", sequence: 1, }); expect(onDiscordGatewayEvent).toHaveBeenNthCalledWith(2, { endpointId: "endpoint-1", event: { type: "disconnected", fatal: false, code: 1001 }, provider: "discord", sequence: 2, }); }); it("fails loudly when the pinned Discord patch revision is unavailable", () => { expect(() => createChatSdkEndpointRuntime( baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "unpatched-discord", guildId: "1457808928258658549", }, }), ), ).toThrow(DiscordAdapterCompatibilityError); }); it.each([ "missing-telegram-parser", "missing-telegram-factory", "missing-telegram-rich-parser", ])( "fails closed when the pinned Telegram attachment contract is unavailable (%s)", (botToken) => { expect(() => createChatSdkEndpointRuntime( baseOptions({ provider: "telegram", userName: "paperclip-agent", credentials: { botToken, secretToken: "synthetic-secret", }, }), ), ).toThrow("Telegram attachment parser contract is unavailable"); }, ); it("keeps one long-lived Discord Gateway session and shuts it down cleanly", async () => { const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "discord-token", guildId: "1457808928258658549", }, }), ); await runtime.initialize(); await vi.waitFor(() => expect(captures.discordGatewayDurations).toEqual([24 * 60 * 60_000]), ); await runtime.shutdown(); expect(captures.discordGatewayDurations).toHaveLength(1); }); it("keeps a non-owner Discord runtime send-only", async () => { const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "discord-token", guildId: "1457808928258658549", }, }), enableDiscordGateway: false, }); await runtime.initialize(); expect(captures.discordGatewayDurations).toEqual([]); await runtime.shutdown(); }); it("removes each Discord restart-delay abort listener after the timer settles", async () => { vi.useFakeTimers(); const addEventListener = vi.spyOn( AbortSignal.prototype, "addEventListener", ); const removeEventListener = vi.spyOn( AbortSignal.prototype, "removeEventListener", ); try { const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "discord-token", guildId: "1457808928258658549", }, }), ); await runtime.initialize(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(1_000); expect(captures.discordGatewayDurations.length).toBeGreaterThanOrEqual(2); expect(addEventListener).toHaveBeenCalledWith( "abort", expect.any(Function), { once: true }, ); expect(removeEventListener).toHaveBeenCalledWith( "abort", expect.any(Function), ); await runtime.shutdown(); } finally { addEventListener.mockRestore(); removeEventListener.mockRestore(); vi.useRealTimers(); } }); it("supervises a failed Discord Gateway start without leaking a rejected task", async () => { const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "gateway-start-failure", guildId: "1457808928258658549", }, }), ); await runtime.initialize(); await vi.waitFor(() => expect(captures.discordGatewayDurations).toEqual([24 * 60 * 60_000]), ); await expect(runtime.shutdown()).resolves.toBeUndefined(); expect(captures.discordGatewayDurations).toHaveLength(1); }); it("does not restart a Discord Gateway session after a fatal provider failure", async () => { vi.useFakeTimers(); try { const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "discord", userName: "paperclip-agent", credentials: { applicationId: "123456789012345678", botToken: "gateway-fatal", guildId: "1457808928258658549", }, }), ); await runtime.initialize(); await vi.advanceTimersByTimeAsync(120_000); expect(captures.discordGatewayDurations).toEqual([24 * 60 * 60_000]); await runtime.shutdown(); } finally { vi.useRealTimers(); } }); it("wraps each normalized message route with endpoint and public provider identity", async () => { const onMessage = vi.fn(); createChatSdkEndpointRuntime({ ...baseOptions({ provider: "microsoft-teams", userName: "Paperclip Agent", credentials: { appId: "app", appPassword: "password" }, }), callbacks: { onMessage }, }); const chat = captures.chats[0] as unknown as { handlerRegistrations: Record; handlers: Record Promise>; }; const thread = { id: "teams:thread" }; const message = { id: "message-1", raw: { conversation: { tenantId: "teams-tenant" } }, }; await chat.handlers.mention?.(thread, message, { totalSinceLastHandler: 1, skipped: [], }); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ endpointId: "endpoint-1", provider: "microsoft-teams", trigger: "mention", thread, message, }), ); expect(chat.handlerRegistrations).toMatchObject({ direct: 1, mention: 1, subscribed: 1, unaddressed: 1, }); }); it("rejects every Teams callback outside the configured tenant", async () => { const callbacks = { onAction: vi.fn(), onMessage: vi.fn(), onMessageDeleted: vi.fn(), onMessageUpdated: vi.fn(), onModalClose: vi.fn(), onModalSubmit: vi.fn(), onOptionsLoad: vi.fn(), onReaction: vi.fn(), onSlashCommand: vi.fn(), }; const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "microsoft-teams", userName: "Paperclip Agent", credentials: { appId: "app", appPassword: "password", appTenantId: " Tenant-Expected ", appType: "SingleTenant", }, }), callbacks, }); const chat = captures.chats[0] as unknown as { handlers: Record Promise>; }; const thread = { id: "teams:thread" }; const foreign = { id: "message-foreign", raw: { channelData: { tenant: { id: "tenant-foreign" } } }, }; const missing = { id: "message-missing", raw: {} }; expect(runtime.acceptsProviderScope(foreign.raw)).toBe(false); expect(runtime.acceptsProviderScope(missing.raw)).toBe(false); await chat.handlers.direct?.(thread, foreign, {}, {}); await chat.handlers.mention?.(thread, missing, {}); await chat.handlers.subscribed?.(thread, foreign, {}); await chat.handlers.unaddressed?.(thread, missing, {}); await chat.handlers.updated?.(thread, foreign, undefined); await chat.handlers.deleted?.({ raw: missing.raw }); await chat.handlers.reaction?.({ raw: foreign.raw }); await chat.handlers.action?.({ raw: missing.raw }); await chat.handlers.options?.({ raw: foreign.raw }); await chat.handlers.modalSubmit?.({ raw: missing.raw }); await chat.handlers.modalClose?.({ raw: foreign.raw }); await chat.handlers.slash?.({ raw: missing.raw }); for (const callback of Object.values(callbacks)) expect(callback).not.toHaveBeenCalled(); const expectedRaw = { conversation: { tenantId: "TENANT-EXPECTED" }, channelData: { tenant: { id: "tenant-expected" } }, }; expect(runtime.acceptsProviderScope(expectedRaw)).toBe(true); expect( runtime.acceptsProviderScope({ ...expectedRaw, channelData: { tenant: { id: "tenant-foreign" } }, }), ).toBe(false); await chat.handlers.mention?.( thread, { id: "message-expected", raw: expectedRaw }, {}, ); await chat.handlers.reaction?.({ raw: expectedRaw }); await chat.handlers.action?.({ raw: expectedRaw }); await chat.handlers.modalSubmit?.({ raw: expectedRaw }); expect(callbacks.onMessage).toHaveBeenCalledTimes(1); expect(callbacks.onReaction).toHaveBeenCalledTimes(1); expect(callbacks.onAction).toHaveBeenCalledTimes(1); expect(callbacks.onModalSubmit).toHaveBeenCalledTimes(1); }); it("registers optional interaction callbacks only when supplied", () => { createChatSdkEndpointRuntime({ ...baseOptions({ provider: "telegram", userName: "paperclip_bot", credentials: { botToken: "token", secretToken: "secret" }, }), callbacks: { onMessage: vi.fn(), onAction: vi.fn(), onModalSubmit: vi.fn(), onOptionsLoad: vi.fn(), onReaction: vi.fn(), onSlashCommand: vi.fn(), }, }); const chat = captures.chats[0] as unknown as { handlers: Record; }; expect(Object.keys(chat.handlers).sort()).toEqual([ "action", "direct", "mention", "modalSubmit", "options", "reaction", "slash", "subscribed", "unaddressed", ]); }); it("routes webhooks and shuts down replaced endpoint instances", async () => { const registry = createChatSdkRuntime(); const options = baseOptions({ provider: "slack", userName: "agent", credentials: { botToken: "token", signingSecret: "secret" }, }); await registry.replaceEndpoint(options); const firstChat = captures.chats[0] as unknown as { shutdownCalls: number }; const response = await registry.handleWebhook( "endpoint-1", new Request("https://paperclip.test/api/chat/webhooks/endpoint-1", { method: "POST", }), ); expect(response.status).toBe(202); expect(await response.text()).toBe("slack"); await registry.replaceEndpoint(options); expect(firstChat.shutdownCalls).toBe(1); expect(registry.list()).toHaveLength(1); expect(await registry.removeEndpoint("endpoint-1")).toBe(true); expect(registry.list()).toHaveLength(0); }); describe("registry lifecycle serialization", () => { function registryOptions(endpointId = "endpoint-1") { return { ...baseOptions({ provider: "slack", userName: "agent", credentials: { botToken: "token", signingSecret: "secret" }, }), endpointId, }; } it.each(["remove", "replace", "shutdown"] as const)( "joins held initialization before %s and cannot start a retired Discord gateway", async (operation) => { const registry = createChatSdkRuntime(); const options = { ...baseOptions({ provider: "discord", userName: "agent", credentials: { botToken: "token", applicationId: "app", guildId: "guild", }, }), enableDiscordGateway: true, }; const first = await registry.replaceEndpoint(options); const chat = captures.chats[0] as unknown as { initialize(): Promise; shutdown(): Promise; }; const held = deferred(); const initialize = vi .spyOn(chat, "initialize") .mockReturnValue(held.promise); const sdkShutdown = vi.spyOn(chat, "shutdown"); const shutdown = vi.spyOn(first, "shutdown"); const initializing = first.initialize().then( () => "initialized", (error: Error) => error.message, ); let retired = false; let retirement: Promise | undefined; try { await vi.waitFor(() => expect(initialize).toHaveBeenCalledOnce()); retirement = ( operation === "remove" ? registry.removeEndpoint(options.endpointId) : operation === "replace" ? registry.replaceEndpoint(options) : registry.shutdown() ).then((value) => { retired = true; return value; }); await vi.waitFor(() => expect(shutdown).toHaveBeenCalledOnce()); await new Promise((resolve) => setTimeout(resolve, 0)); const beforeInitializationSettled = { retired, shutdownCalls: sdkShutdown.mock.calls.length, }; held.resolve(); const initializationResult = await initializing; await retirement; expect(beforeInitializationSettled).toEqual({ retired: false, shutdownCalls: 0, }); expect(initializationResult).toMatch(/retired/); expect(captures.discordGatewayDurations).toEqual([]); expect(sdkShutdown).toHaveBeenCalledOnce(); await expect(first.initialize()).rejects.toThrow(/retired/); await expect( first.handleWebhook(new Request("https://paperclip.test/webhook")), ).rejects.toThrow(/retired/); expect(initialize).toHaveBeenCalledOnce(); if (operation === "replace") { expect(registry.get(options.endpointId)).not.toBe(first); expect(captures.chats[1]).toMatchObject({ initializeCalls: 0 }); } else expect(registry.get(options.endpointId)).toBeNull(); } finally { held.resolve(); await Promise.allSettled([initializing, retirement ?? Promise.resolve()]); await first.shutdown(); await registry.shutdown(); } }, ); it("joins failed initialization and retains a failed shutdown owner for explicit retry", async () => { const registry = createChatSdkRuntime(); const options = registryOptions(); const first = await registry.replaceEndpoint(options); const chat = captures.chats[0] as unknown as { initialize(): Promise; shutdown(): Promise; }; const held = deferred(); const initialize = vi.spyOn(chat, "initialize").mockReturnValue(held.promise); const shutdown = vi .spyOn(chat, "shutdown") .mockRejectedValueOnce(new Error("SDK disconnect failed")) .mockResolvedValue(undefined); const initializing = first.initialize().then( () => "initialized", (error: Error) => error.message, ); const removing = registry.removeEndpoint(options.endpointId).then( () => "removed", (error: Error) => error.message, ); try { held.reject(new Error("SDK initialization failed")); expect(await initializing).toBe("SDK initialization failed"); expect(await removing).toBe("SDK disconnect failed"); expect(registry.get(options.endpointId)).toBeNull(); expect(captures.chats).toHaveLength(1); await expect(first.initialize()).rejects.toThrow(/retired/); const next = await registry.replaceEndpoint(options); expect(registry.get(options.endpointId)).toBe(next); expect(shutdown).toHaveBeenCalledTimes(2); expect(initialize).toHaveBeenCalledOnce(); expect(captures.chats[1]).toMatchObject({ initializeCalls: 0 }); } finally { held.reject(new Error("SDK initialization failed")); await Promise.allSettled([initializing, removing]); await registry.shutdown(); } }); it("joins implicit webhook initialization instead of allowing it to reconnect a retired runtime", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const chat = captures.chats[0] as unknown as { initialize(): Promise; shutdown(): Promise; webhooks: Record Promise>; }; const held = deferred(); const initialize = vi.spyOn(chat, "initialize").mockReturnValue(held.promise); const shutdown = vi.spyOn(chat, "shutdown"); // The pinned SDK's webhook entry automatically initializes its adapter. chat.webhooks.slack = async () => { await chat.initialize(); return new Response("accepted", { status: 202 }); }; const ingress = first .handleWebhook(new Request("https://paperclip.test/webhook")) .then( (response) => response.status, (error: Error) => error.message, ); let retired = false; let removing: Promise | undefined; try { await vi.waitFor(() => expect(initialize).toHaveBeenCalledOnce()); removing = registry.removeEndpoint("endpoint-1").then((value) => { retired = true; return value; }); await new Promise((resolve) => setTimeout(resolve, 0)); const beforeSettlement = { retired, shutdownCalls: shutdown.mock.calls.length, }; held.resolve(); const outcome = await ingress; await removing; expect(beforeSettlement).toEqual({ retired: false, shutdownCalls: 0 }); expect(outcome).toMatch(/retired/); expect(shutdown).toHaveBeenCalledOnce(); } finally { held.resolve(); await Promise.allSettled([ingress, removing ?? Promise.resolve()]); await registry.shutdown(); } }); it("rechecks retirement between SDK initialization settlement and Gateway startup", async () => { const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "discord", userName: "agent", credentials: { botToken: "gateway-fatal", applicationId: "app", guildId: "guild", }, }), enableDiscordGateway: true, }); const initialized = runtime.initialize().then( () => "initialized", (error: Error) => error.message, ); const retired = new Promise((resolve, reject) => { queueMicrotask(() => { void runtime.shutdown().then(resolve, reject); }); }); try { const outcome = await initialized; await retired; expect(outcome).toMatch(/retired/); expect(captures.discordGatewayDurations).toEqual([]); } finally { await runtime.shutdown(); } }); it("rechecks retirement after the initialization deadline wrapper before webhook admission", async () => { const runtime = createChatSdkEndpointRuntime(registryOptions()); const chat = captures.chats[0] as unknown as { webhooks: Record Promise>; }; const handler = vi.fn(async () => new Response("accepted", { status: 202 })); chat.webhooks.slack = handler; const boundary = runtime as unknown as { initializeChat(): Promise }; const initializeChat = boundary.initializeChat.bind(runtime); let retiring: Promise | undefined; vi.spyOn(boundary, "initializeChat").mockImplementation(async () => { await initializeChat(); // Initialization has already passed its internal fence. Retirement // wins before the outer deadline wrapper can admit the handler. retiring = runtime.shutdown(); }); try { const outcome = await runtime .handleWebhook(new Request("https://paperclip.test/webhook")) .then( (response) => response.status, (error: Error) => error.message, ); expect(outcome).toMatch(/retired/); expect(handler).not.toHaveBeenCalled(); } finally { await retiring; await runtime.shutdown(); } }); it("does not expose a replacement while its predecessor drains and leaves initialization to its caller", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const drain = deferred(); const shutdown = vi .spyOn(first, "shutdown") .mockReturnValue(drain.promise); const replacement = registry.replaceEndpoint(registryOptions()); await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1)); const whileDraining = { current: registry.get("endpoint-1"), listed: registry.list(), constructed: captures.chats.length, }; const webhook = await registry .handleWebhook( "endpoint-1", new Request("https://paperclip.test/webhook"), ) .then( () => "accepted", (error: Error) => error.name, ); drain.resolve(); const next = await replacement; expect(whileDraining).toEqual({ current: null, listed: [], constructed: 1, }); expect(webhook).toBe("ChatSdkEndpointNotRegisteredError"); expect(registry.get("endpoint-1")).toBe(next); expect(captures.chats[1]).toMatchObject({ initializeCalls: 0 }); await next.initialize(); expect(captures.chats[1]).toMatchObject({ initializeCalls: 1 }); await registry.shutdown(); }); it("retains failed retirement ownership and only constructs a replacement after an explicit successful retry", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const shutdown = vi .spyOn(first, "shutdown") .mockRejectedValueOnce(new Error("shutdown uncertain")) .mockResolvedValue(undefined); await expect(registry.replaceEndpoint(registryOptions())).rejects.toThrow( "shutdown uncertain", ); const afterFailure = { current: registry.get("endpoint-1"), constructed: captures.chats.length, }; const next = await registry.replaceEndpoint(registryOptions()); expect(afterFailure).toEqual({ current: null, constructed: 1 }); expect(shutdown).toHaveBeenCalledTimes(2); expect(registry.get("endpoint-1")).toBe(next); expect(captures.chats).toHaveLength(2); await registry.shutdown(); }); it("publishes only the latest requested replacement after a held predecessor drains", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const drain = deferred(); const shutdown = vi .spyOn(first, "shutdown") .mockReturnValue(drain.promise); const superseded = registry.replaceEndpoint(registryOptions()).then( () => "published", (error: Error) => error.message, ); await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1)); const latest = registry.replaceEndpoint(registryOptions()); drain.resolve(); expect(await superseded).toMatch(/superseded/); const next = await latest; expect(registry.get("endpoint-1")).toBe(next); expect(shutdown).toHaveBeenCalledTimes(1); expect(captures.chats).toHaveLength(2); await registry.shutdown(); }); it("cannot resurrect a pending replacement after removal", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const drain = deferred(); const shutdown = vi .spyOn(first, "shutdown") .mockReturnValue(drain.promise); const replacement = registry.replaceEndpoint(registryOptions()).then( () => "published", (error: Error) => error.message, ); await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1)); const removal = registry.removeEndpoint("endpoint-1"); drain.resolve(); const result = await replacement; await removal; expect(result).toMatch(/superseded/); expect(registry.get("endpoint-1")).toBeNull(); expect(registry.list()).toEqual([]); expect(captures.chats).toHaveLength(1); expect(shutdown).toHaveBeenCalledTimes(1); }); it("allows a later explicit replacement after removal without blocking unrelated endpoints", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const drain = deferred(); const shutdown = vi .spyOn(first, "shutdown") .mockReturnValue(drain.promise); const removal = registry.removeEndpoint("endpoint-1"); await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1)); let replacementSettled = false; const replacement = registry .replaceEndpoint(registryOptions()) .then((next) => { replacementSettled = true; return next; }); const other = await registry.replaceEndpoint(registryOptions("other")); const settledBeforeDrain = replacementSettled; drain.resolve(); expect(await removal).toBe(true); const next = await replacement; expect(settledBeforeDrain).toBe(false); expect(registry.get("endpoint-1")).toBe(next); expect(registry.get("other")).toBe(other); expect(shutdown).toHaveBeenCalledTimes(1); await registry.shutdown(); }); it("fences pending and future replacements once whole-registry shutdown starts", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions()); const drain = deferred(); const shutdown = vi .spyOn(first, "shutdown") .mockReturnValue(drain.promise); const replacement = registry.replaceEndpoint(registryOptions()).then( () => "published", (error: Error) => error.message, ); await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1)); const closing = registry.shutdown(); drain.resolve(); const result = await replacement; await closing; expect(result).toMatch(/shutting down/); expect(registry.list()).toEqual([]); await expect(registry.replaceEndpoint(registryOptions())).rejects.toThrow( /shutting down/, ); expect(captures.chats).toHaveLength(1); expect(shutdown).toHaveBeenCalledTimes(1); }); it("waits for every shutdown even when one fails and retains failed owners for a retry", async () => { const registry = createChatSdkRuntime(); const first = await registry.replaceEndpoint(registryOptions("first")); const second = await registry.replaceEndpoint(registryOptions("second")); const drain = deferred(); const firstShutdown = vi .spyOn(first, "shutdown") .mockRejectedValueOnce(new Error("first shutdown failed")) .mockResolvedValue(undefined); const secondShutdown = vi .spyOn(second, "shutdown") .mockReturnValue(drain.promise); let settled = false; const closing = registry.shutdown().then( () => { settled = true; return "closed"; }, (error: Error) => { settled = true; return error.message; }, ); await vi.waitFor(() => expect(secondShutdown).toHaveBeenCalledTimes(1)); await new Promise((resolve) => setTimeout(resolve, 0)); const settledBeforeDrain = settled; drain.resolve(); expect(await closing).toBe("first shutdown failed"); expect(settledBeforeDrain).toBe(false); expect(registry.list()).toEqual([]); await registry.shutdown(); expect(firstShutdown).toHaveBeenCalledTimes(2); expect(secondShutdown).toHaveBeenCalledTimes(1); }); }); it("waits for every SDK background callback without an external waitUntil", async () => { const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "slack", userName: "agent", credentials: { botToken: "token", signingSecret: "secret" }, }), ); const firstTask = deferred(); const rejectedTask = deferred(); let callbacksRegistered = false; const chat = captures.chats[0] as unknown as { webhooks: Record< string, ( request: Request, options?: CapturedWebhookOptions, ) => Promise >; }; chat.webhooks.slack = async (_request, options) => { options?.waitUntil?.(firstTask.promise); options?.waitUntil?.(rejectedTask.promise); callbacksRegistered = true; return new Response("accepted", { status: 202 }); }; let responseSettled = false; const responsePromise = runtime .handleWebhook( new Request("https://paperclip.test/api/chat/webhooks/endpoint-1", { method: "POST", }), ) .then((response) => { responseSettled = true; return response; }); await vi.waitFor(() => expect(callbacksRegistered).toBe(true)); expect(responseSettled).toBe(false); firstTask.resolve(); await Promise.resolve(); expect(responseSettled).toBe(false); rejectedTask.reject(new Error("durable callback failed")); const response = await responsePromise; expect(response.status).toBe(202); expect(await response.text()).toBe("accepted"); }); it("still waits for durable ingress when an external waitUntil owns background lifetime", async () => { const runtime = createChatSdkEndpointRuntime( baseOptions({ provider: "slack", userName: "agent", credentials: { botToken: "token", signingSecret: "secret" }, }), ); const backgroundTask = deferred(); let backgroundSettled = false; void backgroundTask.promise.then( () => { backgroundSettled = true; }, () => { backgroundSettled = true; }, ); const externalWaitUntil = vi.fn((task: Promise) => { // A real platform lifecycle owns this promise. Attach a rejection handler // in the test so the delegated task can be cleaned up safely. void task.catch(() => undefined); }); const chat = captures.chats[0] as unknown as { webhooks: Record< string, ( request: Request, options?: CapturedWebhookOptions, ) => Promise >; }; chat.webhooks.slack = async (_request, options) => { options?.waitUntil?.(backgroundTask.promise); return new Response("delegated", { status: 202 }); }; let responseSettled = false; const responsePromise = runtime .handleWebhook( new Request("https://paperclip.test/api/chat/webhooks/endpoint-1", { method: "POST", }), { waitUntil: externalWaitUntil }, ) .then((response) => { responseSettled = true; return response; }); await vi.waitFor(() => expect(externalWaitUntil).toHaveBeenCalledTimes(1)); expect(responseSettled).toBe(false); expect(externalWaitUntil).toHaveBeenCalledTimes(1); expect(externalWaitUntil).toHaveBeenCalledWith(backgroundTask.promise); expect(backgroundSettled).toBe(false); backgroundTask.resolve(); await backgroundTask.promise; const response = await responsePromise; expect(response.status).toBe(202); expect(await response.text()).toBe("delegated"); }); it("returns a retryable non-2xx when the durable delivery callback fails, then accepts a provider retry", async () => { const onMessage = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("chat_deliveries insert failed")) .mockResolvedValueOnce(undefined); const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "slack", userName: "agent", credentials: { botToken: "token", signingSecret: "secret" }, }), callbacks: { onMessage }, }); const chat = captures.chats[0] as unknown as { handlers: Record Promise>; webhooks: Record< string, ( request: Request, options?: CapturedWebhookOptions, ) => Promise >; }; const thread = { id: "slack:C1:1", channelId: "C1" }; const message = { id: "1", author: { isMe: false } }; chat.webhooks.slack = async (_request, options) => { const callback = chat.handlers.mention?.(thread, message); options?.waitUntil?.(callback.catch(() => undefined)); return new Response("ok", { status: 200 }); }; const first = await runtime.handleWebhook( new Request("https://paperclip.test/api/chat/webhooks/endpoint-1", { method: "POST", }), ); expect(first.status).toBe(503); expect(first.headers.get("retry-after")).toBe("1"); expect(await first.text()).toContain("durably accept"); const retried = await runtime.handleWebhook( new Request("https://paperclip.test/api/chat/webhooks/endpoint-1", { method: "POST", }), ); expect(retried.status).toBe(200); expect(await retried.text()).toBe("ok"); expect(onMessage).toHaveBeenCalledTimes(2); }); it("waits for delayed callback registration before acknowledging durable ingress", async () => { const callbackGate = deferred(); const onMessage = vi .fn<() => Promise>() .mockRejectedValueOnce(new Error("delayed durable insert failed")); const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "telegram", userName: "agent", credentials: { botToken: "token", secretToken: "secret", }, }), callbacks: { onMessage }, webhookIngressTimeoutMs: 1_000, }); const chat = captures.chats[0] as unknown as { handlers: Record Promise>; webhooks: Record< string, ( request: Request, options?: CapturedWebhookOptions, ) => Promise >; }; const thread = { id: "telegram:1", channelId: "telegram:1" }; const message = { id: "telegram:1:1", author: { isMe: false } }; chat.webhooks.telegram = async (_request, options) => { const delayedDispatch = (async () => { await callbackGate.promise; await chat.handlers.direct?.(thread, message); })(); options?.waitUntil?.(delayedDispatch); return new Response("ok", { status: 200 }); }; let settled = false; const responsePromise = runtime .handleWebhook(new Request("https://paperclip.test/telegram")) .then((response) => { settled = true; return response; }); await Promise.resolve(); expect(settled).toBe(false); callbackGate.resolve(); const response = await responsePromise; expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("1"); expect(onMessage).toHaveBeenCalledTimes(1); }); it("carries Telegram update_id into the normalized message callback", async () => { const onMessage = vi.fn(); const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "telegram", userName: "agent", credentials: { botToken: "token", secretToken: "secret", }, }), callbacks: { onMessage }, }); const chat = captures.chats[0] as unknown as { handlers: Record Promise>; webhooks: Record< string, ( request: Request, options?: CapturedWebhookOptions, ) => Promise >; }; const thread = { id: "telegram:1", channelId: "telegram:1" }; const message = { id: "telegram:1:42", raw: { message_id: 42 }, author: { isMe: false }, }; chat.webhooks.telegram = async (_request, options) => { options?.waitUntil?.(chat.handlers.direct?.(thread, message)); return new Response("ok", { status: 200 }); }; const response = await runtime.handleWebhook( new Request("https://paperclip.test/telegram", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ update_id: 987654, message: { message_id: 42 }, }), }), ); expect(response.status).toBe(200); expect(onMessage).toHaveBeenCalledWith( expect.objectContaining({ providerUpdateId: 987654 }), ); }); it("returns a retryable response before a provider deadline when ingress work stalls", async () => { const stalled = deferred(); const runtime = createChatSdkEndpointRuntime({ ...baseOptions({ provider: "telegram", userName: "agent", credentials: { botToken: "token", secretToken: "secret", }, }), webhookIngressTimeoutMs: 25, }); const chat = captures.chats[0] as unknown as { webhooks: Record< string, ( request: Request, options?: CapturedWebhookOptions, ) => Promise >; }; chat.webhooks.telegram = async (_request, options) => { options?.waitUntil?.(stalled.promise); return new Response("ok", { status: 200 }); }; const startedAt = Date.now(); const response = await runtime.handleWebhook( new Request("https://paperclip.test/telegram"), ); expect(response.status).toBe(503); expect(response.headers.get("retry-after")).toBe("1"); expect(Date.now() - startedAt).toBeLessThan(500); stalled.resolve(); await stalled.promise; }); it("fails closed for unknown endpoint ids", async () => { const registry = createChatSdkRuntime(); await expect( registry.handleWebhook( "missing", new Request("https://paperclip.test/api/chat/webhooks/missing", { method: "POST", }), ), ).rejects.toBeInstanceOf(ChatSdkEndpointNotRegisteredError); }); });