summaryrefslogtreecommitdiffhomepage
path: root/packages/kernel/src/host/host.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:03:19 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commit727c98c9dae516a2070eb950410314380a20c974 (patch)
tree52aa1022c54f11770be7e4e2a324f0a8b8b8deec /packages/kernel/src/host/host.test.ts
parente59dc11f63b1df51142259bb2c406af8c9c8c2bb (diff)
downloaddispatch-727c98c9dae516a2070eb950410314380a20c974.tar.gz
dispatch-727c98c9dae516a2070eb950410314380a20c974.zip
style: switch from tabs to 2-space indentation
Diffstat (limited to 'packages/kernel/src/host/host.test.ts')
-rw-r--r--packages/kernel/src/host/host.test.ts2438
1 files changed, 1219 insertions, 1219 deletions
diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts
index 0067091..88e2836 100644
--- a/packages/kernel/src/host/host.test.ts
+++ b/packages/kernel/src/host/host.test.ts
@@ -2,1276 +2,1276 @@ import { beforeEach, describe, expect, it } from "vitest";
import { createBus } from "../bus/bus.js";
import type { AuthContract } from "../contracts/auth.js";
import type {
- ConfigAccess,
- EventsEmitter,
- Extension,
- HostAPI,
- Manifest,
- ManifestContributions,
- PermissionDecision,
- PermissionGate,
- PermissionRequest,
- ScheduledJob,
- SecretsAccess,
- StorageNamespace,
+ ConfigAccess,
+ EventsEmitter,
+ Extension,
+ HostAPI,
+ Manifest,
+ ManifestContributions,
+ PermissionDecision,
+ PermissionGate,
+ PermissionRequest,
+ ScheduledJob,
+ SecretsAccess,
+ StorageNamespace,
} from "../contracts/extension.js";
import { defineEventHook, defineFilter, defineService } from "../contracts/hooks.js";
import type {
- Attributes,
- ErrorAttributes,
- LogDeps,
- Logger,
- LogRecord,
- LogSink,
+ Attributes,
+ ErrorAttributes,
+ LogDeps,
+ Logger,
+ LogRecord,
+ LogSink,
} from "../contracts/logging.js";
import type { ProviderContract } from "../contracts/provider.js";
import type { ToolContract } from "../contracts/tool.js";
import { createHost, type HostDeps, KERNEL_API_VERSION } from "./host.js";
interface FakeLogger extends Logger {
- readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>;
+ readonly logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }>;
}
function createFakeLogger(): FakeLogger {
- const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = [];
- return {
- logs,
- debug: (message: string, attrs?: Attributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "debug", message, attrs });
- } else {
- logs.push({ level: "debug", message });
- }
- },
- info: (message: string, attrs?: Attributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "info", message, attrs });
- } else {
- logs.push({ level: "info", message });
- }
- },
- warn: (message: string, attrs?: Attributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "warn", message, attrs });
- } else {
- logs.push({ level: "warn", message });
- }
- },
- error: (message: string, attrs?: ErrorAttributes) => {
- if (attrs !== undefined) {
- logs.push({ level: "error", message, attrs });
- } else {
- logs.push({ level: "error", message });
- }
- },
- child(
- _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes },
- ): Logger {
- return createFakeLogger();
- },
- span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span {
- return {
- id: "fake-span",
- log: createFakeLogger(),
- setAttributes() {},
- addLink() {},
- child() {
- return this;
- },
- end() {},
- };
- },
- };
+ const logs: Array<{ level: string; message: string; attrs?: Attributes | ErrorAttributes }> = [];
+ return {
+ logs,
+ debug: (message: string, attrs?: Attributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "debug", message, attrs });
+ } else {
+ logs.push({ level: "debug", message });
+ }
+ },
+ info: (message: string, attrs?: Attributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "info", message, attrs });
+ } else {
+ logs.push({ level: "info", message });
+ }
+ },
+ warn: (message: string, attrs?: Attributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "warn", message, attrs });
+ } else {
+ logs.push({ level: "warn", message });
+ }
+ },
+ error: (message: string, attrs?: ErrorAttributes) => {
+ if (attrs !== undefined) {
+ logs.push({ level: "error", message, attrs });
+ } else {
+ logs.push({ level: "error", message });
+ }
+ },
+ child(
+ _ctx: Partial<import("../contracts/logging.js").LogContext> & { readonly attrs?: Attributes },
+ ): Logger {
+ return createFakeLogger();
+ },
+ span(_name: string, _attrs?: Attributes): import("../contracts/logging.js").Span {
+ return {
+ id: "fake-span",
+ log: createFakeLogger(),
+ setAttributes() {},
+ addLink() {},
+ child() {
+ return this;
+ },
+ end() {},
+ };
+ },
+ };
}
function createFakeLogSink(): LogSink & { readonly records: LogRecord[] } {
- const records: LogRecord[] = [];
- return {
- records,
- emit: (record: LogRecord) => {
- records.push(record);
- },
- };
+ const records: LogRecord[] = [];
+ return {
+ records,
+ emit: (record: LogRecord) => {
+ records.push(record);
+ },
+ };
}
function createFakeLogDeps(): LogDeps {
- let idCounter = 0;
- return {
- now: () => 1000 + idCounter * 100,
- newId: () => `span-${++idCounter}`,
- };
+ let idCounter = 0;
+ return {
+ now: () => 1000 + idCounter * 100,
+ newId: () => `span-${++idCounter}`,
+ };
}
function createFakeConfig(): ConfigAccess {
- return {
- get: () => undefined,
- getAll: () => ({}),
- };
+ return {
+ get: () => undefined,
+ getAll: () => ({}),
+ };
}
function createFakeStorageFactory(): (ns: string) => StorageNamespace {
- const stores = new Map<string, Map<string, string>>();
- return (ns: string) => {
- let store = stores.get(ns);
- if (!store) {
- store = new Map();
- stores.set(ns, store);
- }
- const s = store;
- return {
- get: async (key: string) => s.get(key) ?? null,
- set: async (key: string, value: string) => {
- s.set(key, value);
- },
- delete: async (key: string) => {
- s.delete(key);
- },
- has: async (key: string) => s.has(key),
- keys: async () => [...s.keys()],
- };
- };
+ const stores = new Map<string, Map<string, string>>();
+ return (ns: string) => {
+ let store = stores.get(ns);
+ if (!store) {
+ store = new Map();
+ stores.set(ns, store);
+ }
+ const s = store;
+ return {
+ get: async (key: string) => s.get(key) ?? null,
+ set: async (key: string, value: string) => {
+ s.set(key, value);
+ },
+ delete: async (key: string) => {
+ s.delete(key);
+ },
+ has: async (key: string) => s.has(key),
+ keys: async () => [...s.keys()],
+ };
+ };
}
function createFakeSecrets(): SecretsAccess {
- const store = new Map<string, string>();
- return {
- get: async (key: string) => store.get(key) ?? null,
- set: async (key: string, value: string) => {
- store.set(key, value);
- },
- delete: async (key: string) => {
- store.delete(key);
- },
- };
+ const store = new Map<string, string>();
+ return {
+ get: async (key: string) => store.get(key) ?? null,
+ set: async (key: string, value: string) => {
+ store.set(key, value);
+ },
+ delete: async (key: string) => {
+ store.delete(key);
+ },
+ };
}
function createFakePermissions(): PermissionGate {
- return {
- check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({
- allowed: true,
- }),
- };
+ return {
+ check: async (_request: PermissionRequest): Promise<PermissionDecision> => ({
+ allowed: true,
+ }),
+ };
}
function createFakeScheduler(): {
- readonly register: (job: ScheduledJob) => void;
- readonly jobs: ScheduledJob[];
+ readonly register: (job: ScheduledJob) => void;
+ readonly jobs: ScheduledJob[];
} {
- const jobs: ScheduledJob[] = [];
- return {
- register: (job: ScheduledJob) => {
- jobs.push(job);
- },
- jobs,
- };
+ const jobs: ScheduledJob[] = [];
+ return {
+ register: (job: ScheduledJob) => {
+ jobs.push(job);
+ },
+ jobs,
+ };
}
function createFakeEvents(): EventsEmitter & { readonly emitted: unknown[] } {
- const emitted: unknown[] = [];
- return {
- emitted,
- emit: (event) => {
- emitted.push(event);
- },
- };
+ const emitted: unknown[] = [];
+ return {
+ emitted,
+ emit: (event) => {
+ emitted.push(event);
+ },
+ };
}
function createExtension(
- id: string,
- opts: {
- readonly dependsOn?: readonly string[];
- readonly apiVersion?: string;
- readonly activate?: (host: HostAPI) => void | Promise<void>;
- readonly deactivate?: () => void | Promise<void>;
- readonly contributes?: ManifestContributions;
- } = {},
+ id: string,
+ opts: {
+ readonly dependsOn?: readonly string[];
+ readonly apiVersion?: string;
+ readonly activate?: (host: HostAPI) => void | Promise<void>;
+ readonly deactivate?: () => void | Promise<void>;
+ readonly contributes?: ManifestContributions;
+ } = {},
): Extension {
- const base: Manifest = {
- id,
- name: id,
- version: "1.0.0",
- apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`,
- trust: "bundled",
- };
- const manifest: Manifest =
- opts.dependsOn !== undefined
- ? { ...base, dependsOn: opts.dependsOn }
- : opts.contributes !== undefined
- ? { ...base, contributes: opts.contributes }
- : base;
- const ext: Extension = {
- manifest,
- activate: opts.activate ?? (() => {}),
- };
- if (opts.deactivate !== undefined) {
- return { ...ext, deactivate: opts.deactivate };
- }
- return ext;
+ const base: Manifest = {
+ id,
+ name: id,
+ version: "1.0.0",
+ apiVersion: opts.apiVersion ?? `^${KERNEL_API_VERSION}`,
+ trust: "bundled",
+ };
+ const manifest: Manifest =
+ opts.dependsOn !== undefined
+ ? { ...base, dependsOn: opts.dependsOn }
+ : opts.contributes !== undefined
+ ? { ...base, contributes: opts.contributes }
+ : base;
+ const ext: Extension = {
+ manifest,
+ activate: opts.activate ?? (() => {}),
+ };
+ if (opts.deactivate !== undefined) {
+ return { ...ext, deactivate: opts.deactivate };
+ }
+ return ext;
}
function createFakeTool(name: string): ToolContract {
- return {
- name,
- description: `Tool ${name}`,
- parameters: { type: "object" },
- execute: async () => ({ content: "ok" }),
- };
+ return {
+ name,
+ description: `Tool ${name}`,
+ parameters: { type: "object" },
+ execute: async () => ({ content: "ok" }),
+ };
}
function createFakeProvider(id: string): ProviderContract {
- return {
- id,
- stream: async function* () {},
- };
+ return {
+ id,
+ stream: async function* () {},
+ };
}
function createFakeAuth(id: string): AuthContract {
- return {
- id,
- resolve: async () => null,
- };
+ return {
+ id,
+ resolve: async () => null,
+ };
}
describe("createHost", () => {
- let logger: FakeLogger;
- let logSink: ReturnType<typeof createFakeLogSink>;
- let logDeps: LogDeps;
- let deps: HostDeps;
- let scheduler: ReturnType<typeof createFakeScheduler>;
- let events: ReturnType<typeof createFakeEvents>;
-
- beforeEach(() => {
- logger = createFakeLogger();
- logSink = createFakeLogSink();
- logDeps = createFakeLogDeps();
- scheduler = createFakeScheduler();
- events = createFakeEvents();
- deps = {
- logger,
- config: createFakeConfig(),
- storageFactory: createFakeStorageFactory(),
- secrets: createFakeSecrets(),
- permissions: createFakePermissions(),
- scheduler,
- bus: createBus(logger),
- events,
- logSink,
- logDeps,
- };
- });
-
- describe("activation order", () => {
- it("activates extensions in topological order", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("a");
- },
- });
- const b = createExtension("b", {
- dependsOn: ["a"],
- activate: () => {
- order.push("b");
- },
- });
- const c = createExtension("c", {
- dependsOn: ["b"],
- activate: () => {
- order.push("c");
- },
- });
-
- const host = createHost([c, b, a], deps);
- await host.activate();
-
- expect(order).toEqual(["a", "b", "c"]);
- });
-
- it("activates independent extensions", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("a");
- },
- });
- const b = createExtension("b", {
- activate: () => {
- order.push("b");
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- expect(order).toHaveLength(2);
- expect(order).toContain("a");
- expect(order).toContain("b");
- });
- });
-
- describe("fault isolation", () => {
- it("a throwing extension is isolated — others still activate", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("a");
- },
- });
- const b = createExtension("b", {
- activate: () => {
- throw new Error("boom");
- },
- });
- const c = createExtension("c", {
- activate: () => {
- order.push("c");
- },
- });
-
- const host = createHost([a, b, c], deps);
- await host.activate();
-
- expect(order).toEqual(["a", "c"]);
- expect(host.getDisabled()).toHaveLength(1);
- expect(host.getDisabled()[0]?.manifest.id).toBe("b");
- expect(host.getDisabled()[0]?.reason).toContain("boom");
- });
-
- it("an async-rejecting extension is isolated", async () => {
- const a = createExtension("a", {
- activate: async () => {
- throw new Error("async fail");
- },
- });
- const b = createExtension("b", {
- activate: () => {},
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(1);
- expect(host.getDisabled()[0]?.manifest.id).toBe("a");
- });
- });
-
- describe("apiVersion compatibility", () => {
- it("activates compatible extensions", async () => {
- const ext = createExtension("good", {
- apiVersion: `^${KERNEL_API_VERSION}`,
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(0);
- });
-
- it("disables incompatible extensions without crashing", async () => {
- const ext = createExtension("bad", {
- apiVersion: "^99.0.0",
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(1);
- expect(host.getDisabled()[0]?.manifest.id).toBe("bad");
- expect(host.getDisabled()[0]?.reason).toContain("incompatible");
- });
-
- it("logs a warning for disabled extensions", async () => {
- const ext = createExtension("bad", {
- apiVersion: "^99.0.0",
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const warnings = logger.logs.filter((l) => l.level === "warn");
- expect(warnings).toHaveLength(1);
- expect(warnings[0]?.message).toContain("bad");
- });
- });
-
- describe("registries", () => {
- it("defineTool populates the tool registry", async () => {
- const tool = createFakeTool("read-file");
- const ext = createExtension("tools-fs", {
- activate: (host) => {
- host.defineTool(tool);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getTools().size).toBe(1);
- expect(host.getTool("read-file")).toBe(tool);
- });
-
- it("defineProvider populates the provider registry", async () => {
- const provider = createFakeProvider("anthropic");
- const ext = createExtension("provider-anthropic", {
- activate: (host) => {
- host.defineProvider(provider);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getProviders().size).toBe(1);
- expect(host.getProvider("anthropic")).toBe(provider);
- });
-
- it("defineAuth populates the auth registry", async () => {
- const auth = createFakeAuth("apikey");
- const ext = createExtension("auth-apikey", {
- activate: (host) => {
- host.defineAuth(auth);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getAuthProviders().size).toBe(1);
- expect(host.getAuthProvider("apikey")).toBe(auth);
- });
-
- it("getService returns what an extension provided via provideService", async () => {
- const handle = defineService<{ value: number }>("test/svc");
- const ext = createExtension("svc-provider", {
- activate: (host) => {
- host.provideService(handle, { value: 42 });
- },
- });
- const consumer = createExtension("svc-consumer", {
- dependsOn: ["svc-provider"],
- activate: (host) => {
- const svc = host.getService(handle);
- expect(svc.value).toBe(42);
- },
- });
-
- const host = createHost([ext, consumer], deps);
- await host.activate();
-
- expect(host.getDisabled()).toHaveLength(0);
- });
-
- it("multiple extensions contribute to the same registry", async () => {
- const ext1 = createExtension("tools-a", {
- activate: (host) => {
- host.defineTool(createFakeTool("tool-a"));
- },
- });
- const ext2 = createExtension("tools-b", {
- activate: (host) => {
- host.defineTool(createFakeTool("tool-b"));
- },
- });
-
- const host = createHost([ext1, ext2], deps);
- await host.activate();
-
- expect(host.getTools().size).toBe(2);
- expect(host.getTool("tool-a")).toBeDefined();
- expect(host.getTool("tool-b")).toBeDefined();
- });
- });
-
- describe("scheduler", () => {
- it("collects scheduled jobs and forwards to sink", async () => {
- const job: ScheduledJob = {
- id: "cache-warm",
- cron: "*/5 * * * *",
- execute: () => {},
- };
- const ext = createExtension("scheduler-ext", {
- activate: (host) => {
- host.scheduler.register(job);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getScheduledJobs()).toHaveLength(1);
- expect(host.getScheduledJobs()[0]).toBe(job);
- expect(scheduler.jobs).toHaveLength(1);
- expect(scheduler.jobs[0]).toBe(job);
- });
- });
-
- describe("migrations", () => {
- it("collects migrations from manifests", async () => {
- const ext = createExtension("store-ext", {
- contributes: { migrations: ["001-init", "002-add-index"] },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]);
- });
- });
-
- describe("deactivation", () => {
- it("deactivates in reverse activation order", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {
- order.push("activate-a");
- },
- deactivate: () => {
- order.push("deactivate-a");
- },
- });
- const b = createExtension("b", {
- activate: () => {
- order.push("activate-b");
- },
- deactivate: () => {
- order.push("deactivate-b");
- },
- });
- const c = createExtension("c", {
- activate: () => {
- order.push("activate-c");
- },
- deactivate: () => {
- order.push("deactivate-c");
- },
- });
-
- const host = createHost([a, b, c], deps);
- await host.activate();
- await host.deactivate();
-
- expect(order).toEqual([
- "activate-a",
- "activate-b",
- "activate-c",
- "deactivate-c",
- "deactivate-b",
- "deactivate-a",
- ]);
- });
-
- it("a failing deactivate does not prevent others", async () => {
- const order: string[] = [];
-
- const a = createExtension("a", {
- activate: () => {},
- deactivate: () => {
- order.push("deactivate-a");
- },
- });
- const b = createExtension("b", {
- activate: () => {},
- deactivate: () => {
- throw new Error("deactivate boom");
- },
- });
- const c = createExtension("c", {
- activate: () => {},
- deactivate: () => {
- order.push("deactivate-c");
- },
- });
-
- const host = createHost([a, b, c], deps);
- await host.activate();
- await host.deactivate();
-
- expect(order).toEqual(["deactivate-c", "deactivate-a"]);
- const errors = logger.logs.filter((l) => l.level === "error");
- expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true);
- expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true);
- });
- });
-
- describe("HostAPI delegation", () => {
- it("on/addFilter delegate to the bus", async () => {
- const hook = defineEventHook<string>("test/host-event");
- const received: string[] = [];
-
- const ext = createExtension("hook-ext", {
- activate: (host) => {
- host.on(hook, (payload) => {
- received.push(payload);
- });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- deps.bus.emit(hook, "hello");
- expect(received).toEqual(["hello"]);
- });
-
- it("emit dispatches to handlers registered via on", async () => {
- const hook = defineEventHook<string>("test/emit-dispatch");
- const received: string[] = [];
-
- const ext = createExtension("emit-ext", {
- activate: (host) => {
- host.on(hook, (payload) => {
- received.push(payload);
- });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- api.emit(hook, "world");
- expect(received).toEqual(["world"]);
- });
-
- it("emit isolates a throwing handler (does not propagate)", async () => {
- const hook = defineEventHook<string>("test/emit-isolation");
- const received: string[] = [];
-
- const ext = createExtension("emit-isolation-ext", {
- activate: (host) => {
- host.on(hook, () => {
- throw new Error("handler boom");
- });
- host.on(hook, (payload) => {
- received.push(payload);
- });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.emit(hook, "safe")).not.toThrow();
- expect(received).toEqual(["safe"]);
- });
-
- it("applyFilters threads a value through registered filters in order", async () => {
- const hook = defineFilter<string>("test/text-transform");
-
- const ext = createExtension("filter-ext", {
- activate: (host) => {
- host.addFilter(hook, (value) => `${value}-first`);
- host.addFilter(hook, (value) => `${value}-second`);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- const result = await api.applyFilters(hook, "start");
- expect(result).toBe("start-first-second");
- });
-
- it("applyFilters returns the input unchanged when no filters are registered", async () => {
- const hook = defineFilter<string>("test/unused-filter");
-
- const ext = createExtension("no-filter-ext", {
- activate: () => {},
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- const result = await api.applyFilters(hook, "unchanged");
- expect(result).toBe("unchanged");
- });
-
- it("storage delegates to the factory", async () => {
- let storageResult: StorageNamespace | undefined;
-
- const ext = createExtension("storage-ext", {
- activate: (host) => {
- storageResult = host.storage("my-ns");
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(storageResult).toBeDefined();
- await storageResult?.set("key", "value");
- expect(await storageResult?.get("key")).toBe("value");
- });
-
- it("events delegates to the emitter", async () => {
- const ext = createExtension("event-ext", {
- activate: (host) => {
- host.events.emit({ type: "custom", data: 42 });
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- expect(events.emitted).toHaveLength(1);
- expect(events.emitted[0]).toEqual({ type: "custom", data: 42 });
- });
- });
-
- describe("HostAPI registry access", () => {
- it("getTools returns registered tools via HostAPI", async () => {
- const tool = createFakeTool("read-file");
- let capturedTools: ReadonlyMap<string, ToolContract> | undefined;
-
- const producer = createExtension("tools-fs", {
- activate: (host) => {
- host.defineTool(tool);
- },
- });
- const consumer = createExtension("consumer", {
- dependsOn: ["tools-fs"],
- activate: (host) => {
- capturedTools = host.getTools();
- },
- });
-
- const host = createHost([producer, consumer], deps);
- await host.activate();
-
- expect(capturedTools).toBeDefined();
- expect(capturedTools?.size).toBe(1);
- expect(capturedTools?.get("read-file")).toBe(tool);
- });
-
- it("getProviders returns registered providers via HostAPI", async () => {
- const provider = createFakeProvider("anthropic");
- let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined;
-
- const producer = createExtension("provider-anthropic", {
- activate: (host) => {
- host.defineProvider(provider);
- },
- });
- const consumer = createExtension("consumer", {
- dependsOn: ["provider-anthropic"],
- activate: (host) => {
- capturedProviders = host.getProviders();
- },
- });
-
- const host = createHost([producer, consumer], deps);
- await host.activate();
-
- expect(capturedProviders).toBeDefined();
- expect(capturedProviders?.size).toBe(1);
- expect(capturedProviders?.get("anthropic")).toBe(provider);
- });
-
- it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => {
- const auth = createFakeAuth("apikey");
- let capturedAuth: ReadonlyMap<string, AuthContract> | undefined;
- let capturedSingle: AuthContract | undefined;
-
- const producer = createExtension("auth-apikey", {
- activate: (host) => {
- host.defineAuth(auth);
- },
- });
- const consumer = createExtension("consumer", {
- dependsOn: ["auth-apikey"],
- activate: (host) => {
- capturedAuth = host.getAuthProviders();
- capturedSingle = host.getAuthProvider("apikey");
- },
- });
-
- const host = createHost([producer, consumer], deps);
- await host.activate();
-
- expect(capturedAuth).toBeDefined();
- expect(capturedAuth?.size).toBe(1);
- expect(capturedAuth?.get("apikey")).toBe(auth);
- expect(capturedSingle).toBe(auth);
- });
- });
-
- describe("getExtensions", () => {
- it("returns empty array when no extensions are activated", async () => {
- const host = createHost([], deps);
- await host.activate();
-
- expect(host.getExtensions()).toEqual([]);
- });
-
- it("returns manifests of all activated extensions", async () => {
- const a = createExtension("ext-a");
- const b = createExtension("ext-b");
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts).toHaveLength(2);
- expect(exts.map((e) => e.id)).toContain("ext-a");
- expect(exts.map((e) => e.id)).toContain("ext-b");
- });
-
- it("returns manifests in activation order", async () => {
- const a = createExtension("a");
- const b = createExtension("b", { dependsOn: ["a"] });
- const c = createExtension("c", { dependsOn: ["b"] });
-
- const host = createHost([c, b, a], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]);
- });
-
- it("excludes extensions that failed to activate", async () => {
- const a = createExtension("good");
- const b = createExtension("bad", {
- activate: () => {
- throw new Error("boom");
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts).toHaveLength(1);
- expect(exts[0]?.id).toBe("good");
- });
-
- it("excludes extensions disabled by apiVersion incompatibility", async () => {
- const good = createExtension("good");
- const bad = createExtension("bad", { apiVersion: "^99.0.0" });
-
- const host = createHost([good, bad], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(exts).toHaveLength(1);
- expect(exts[0]?.id).toBe("good");
- });
-
- it("returns a frozen array", async () => {
- const ext = createExtension("ext");
- const host = createHost([ext], deps);
- await host.activate();
-
- const exts = host.getExtensions();
- expect(Object.isFrozen(exts)).toBe(true);
- });
-
- it("HostAPI getExtensions reflects activated extensions after full activation", async () => {
- const a = createExtension("ext-a");
- const b = createExtension("ext-b", {
- dependsOn: ["ext-a"],
- activate: () => {},
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- // Use getHostAPI() to verify the post-activation view
- const api = host.getHostAPI();
- const capturedExtsAfter = api.getExtensions();
-
- expect(capturedExtsAfter).toHaveLength(2);
- expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]);
- });
-
- it("HostAPI getExtensions during activation sees only previously activated", async () => {
- const seenDuringActivation: string[][] = [];
-
- const a = createExtension("a", {
- activate: (host) => {
- seenDuringActivation.push(host.getExtensions().map((e) => e.id));
- },
- });
- const b = createExtension("b", {
- activate: (host) => {
- seenDuringActivation.push(host.getExtensions().map((e) => e.id));
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- // When a activates, activated[] is empty (a hasn't been pushed yet)
- // When b activates, activated[] has [a] (b hasn't been pushed yet)
- expect(seenDuringActivation).toEqual([[], ["a"]]);
- });
- });
-
- describe("DAG errors", () => {
- it("throws on missing dependency", () => {
- const ext = createExtension("a", { dependsOn: ["missing"] });
- expect(() => createHost([ext], deps)).toThrow(/not available/);
- });
-
- it("throws on dependency cycle", () => {
- const a = createExtension("a", { dependsOn: ["b"] });
- const b = createExtension("b", { dependsOn: ["a"] });
- expect(() => createHost([a, b], deps)).toThrow(/cycle/i);
- });
- });
-
- describe("empty host", () => {
- it("works with no extensions", async () => {
- const host = createHost([], deps);
- await host.activate();
-
- expect(host.getTools().size).toBe(0);
- expect(host.getProviders().size).toBe(0);
- expect(host.getAuthProviders().size).toBe(0);
- expect(host.getScheduledJobs()).toHaveLength(0);
- expect(host.getMigrations()).toHaveLength(0);
- expect(host.getDisabled()).toHaveLength(0);
- });
- });
-
- describe("getHostAPI", () => {
- it("returns a HostAPI whose read-views reflect registrations from activation", async () => {
- const tool = createFakeTool("read-file");
- const provider = createFakeProvider("anthropic");
- const auth = createFakeAuth("apikey");
-
- const ext = createExtension("multi-ext", {
- activate: (host) => {
- host.defineTool(tool);
- host.defineProvider(provider);
- host.defineAuth(auth);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
-
- expect(api.getTools().size).toBe(1);
- expect(api.getTools().get("read-file")).toBe(tool);
-
- expect(api.getProviders().size).toBe(1);
- expect(api.getProviders().get("anthropic")).toBe(provider);
-
- expect(api.getAuthProviders().size).toBe(1);
- expect(api.getAuthProvider("apikey")).toBe(auth);
- });
-
- it("throws on defineTool after activation", async () => {
- const ext = createExtension("ext", { activate: () => {} });
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.defineTool(createFakeTool("late"))).toThrow(
- "Registration not available after activation",
- );
- });
-
- it("throws on defineProvider after activation", async () => {
- const ext = createExtension("ext", { activate: () => {} });
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.defineProvider(createFakeProvider("late"))).toThrow(
- "Registration not available after activation",
- );
- });
-
- it("throws on defineAuth after activation", async () => {
- const ext = createExtension("ext", { activate: () => {} });
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- expect(() => api.defineAuth(createFakeAuth("late"))).toThrow(
- "Registration not available after activation",
- );
- });
-
- it("applyFilters is available on registration-closed HostAPI", async () => {
- const hook = defineFilter<string>("test/closed-filter");
-
- const ext = createExtension("filter-ext", {
- activate: (host) => {
- host.addFilter(hook, (value) => `${value}-filtered`);
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const api = host.getHostAPI();
- const result = await api.applyFilters(hook, "input");
- expect(result).toBe("input-filtered");
- });
- });
-
- describe("auto-scoped logger (D6)", () => {
- it("each extension's logger stamps its own manifest.id as extensionId", async () => {
- let extALogger: Logger | undefined;
- let extBLogger: Logger | undefined;
-
- const a = createExtension("ext-a", {
- activate: (host) => {
- extALogger = host.logger;
- },
- });
- const b = createExtension("ext-b", {
- activate: (host) => {
- extBLogger = host.logger;
- },
- });
-
- const host = createHost([a, b], deps);
- await host.activate();
-
- extALogger?.info("from-a");
- extBLogger?.info("from-b");
-
- const logRecords = logSink.records.filter((r) => r.kind === "log");
- expect(logRecords).toHaveLength(2);
- if (logRecords[0]?.kind === "log") {
- expect(logRecords[0].extensionId).toBe("ext-a");
- expect(logRecords[0].msg).toBe("from-a");
- }
- if (logRecords[1]?.kind === "log") {
- expect(logRecords[1].extensionId).toBe("ext-b");
- expect(logRecords[1].msg).toBe("from-b");
- }
- });
-
- it("an extension cannot spoof extensionId — it is auto-stamped", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("real-id", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- // child() cannot override extensionId
- const child = extLogger?.child({ extensionId: "spoofed" });
- child?.info("msg");
-
- const logRecords = logSink.records.filter((r) => r.kind === "log");
- expect(logRecords).toHaveLength(1);
- if (logRecords[0]?.kind === "log") {
- expect(logRecords[0].extensionId).toBe("real-id");
- }
- });
-
- it("host.logger.error uses structured { err } shape", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- extLogger?.error("something broke", { err: new Error("boom") });
-
- const logRecords = logSink.records.filter((r) => r.kind === "log");
- expect(logRecords).toHaveLength(1);
- if (logRecords[0]?.kind === "log") {
- expect(logRecords[0].level).toBe("error");
- expect(logRecords[0].msg).toBe("something broke");
- expect(logRecords[0].attributes?.["error.message"]).toBe("boom");
- }
- });
-
- it("a throwing sink does NOT break the caller", async () => {
- const brokenSink: LogSink = {
- emit() {
- throw new Error("sink down");
- },
- };
- const brokenDeps: HostDeps = {
- ...deps,
- logSink: brokenSink,
- };
-
- let extLogger: Logger | undefined;
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], brokenDeps);
- await host.activate();
-
- // Should not throw
- expect(() => extLogger?.info("msg")).not.toThrow();
- });
-
- it("span() + end() emit incremental span-open and span-close records", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("my-span", { key: "value" });
- span?.setAttributes({ extra: "attr" });
- span?.end({ attrs: { result: "ok" } });
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
-
- expect(spanOpens).toHaveLength(1);
- expect(spanCloses).toHaveLength(1);
-
- if (spanOpens[0]?.kind === "span-open") {
- expect(spanOpens[0].name).toBe("my-span");
- expect(spanOpens[0].extensionId).toBe("ext");
- expect(spanOpens[0].attributes?.key).toBe("value");
- }
- if (spanCloses[0]?.kind === "span-close") {
- expect(spanCloses[0].name).toBe("my-span");
- expect(spanCloses[0].status).toBe("ok");
- expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0);
- expect(spanCloses[0].attributes?.extra).toBe("attr");
- expect(spanCloses[0].attributes?.result).toBe("ok");
- }
- });
-
- it("span() with body emits body on span-open record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}');
- span?.end();
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- expect(spanOpens).toHaveLength(1);
- if (spanOpens[0]?.kind === "span-open") {
- expect(spanOpens[0].body).toBe('{"payload":"hello"}');
- }
- });
-
- it("span() without body omits body field on span-open record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("no-body");
- span?.end();
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- expect(spanOpens).toHaveLength(1);
- if (spanOpens[0]?.kind === "span-open") {
- expect(spanOpens[0].body).toBeUndefined();
- }
- });
-
- it("child() with body emits body on child span-open record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("parent");
- const child = span?.child("child-name", { k: "v" }, '{"child":"body"}');
- child?.end();
- span?.end();
-
- const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
- const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name");
- expect(childOpen).toBeDefined();
- if (childOpen?.kind === "span-open") {
- expect(childOpen.body).toBe('{"child":"body"}');
- }
- });
-
- it("end() with body emits body on span-close record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("close-body");
- span?.end({ body: '{"result":"data"}' });
-
- const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
- expect(spanCloses).toHaveLength(1);
- if (spanCloses[0]?.kind === "span-close") {
- expect(spanCloses[0].body).toBe('{"result":"data"}');
- }
- });
-
- it("end() without body omits body field on span-close record", async () => {
- let extLogger: Logger | undefined;
-
- const ext = createExtension("ext", {
- activate: (host) => {
- extLogger = host.logger;
- },
- });
-
- const host = createHost([ext], deps);
- await host.activate();
-
- const span = extLogger?.span("no-close-body");
- span?.end();
-
- const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
- expect(spanCloses).toHaveLength(1);
- if (spanCloses[0]?.kind === "span-close") {
- expect(spanCloses[0].body).toBeUndefined();
- }
- });
- });
+ let logger: FakeLogger;
+ let logSink: ReturnType<typeof createFakeLogSink>;
+ let logDeps: LogDeps;
+ let deps: HostDeps;
+ let scheduler: ReturnType<typeof createFakeScheduler>;
+ let events: ReturnType<typeof createFakeEvents>;
+
+ beforeEach(() => {
+ logger = createFakeLogger();
+ logSink = createFakeLogSink();
+ logDeps = createFakeLogDeps();
+ scheduler = createFakeScheduler();
+ events = createFakeEvents();
+ deps = {
+ logger,
+ config: createFakeConfig(),
+ storageFactory: createFakeStorageFactory(),
+ secrets: createFakeSecrets(),
+ permissions: createFakePermissions(),
+ scheduler,
+ bus: createBus(logger),
+ events,
+ logSink,
+ logDeps,
+ };
+ });
+
+ describe("activation order", () => {
+ it("activates extensions in topological order", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("a");
+ },
+ });
+ const b = createExtension("b", {
+ dependsOn: ["a"],
+ activate: () => {
+ order.push("b");
+ },
+ });
+ const c = createExtension("c", {
+ dependsOn: ["b"],
+ activate: () => {
+ order.push("c");
+ },
+ });
+
+ const host = createHost([c, b, a], deps);
+ await host.activate();
+
+ expect(order).toEqual(["a", "b", "c"]);
+ });
+
+ it("activates independent extensions", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {
+ order.push("b");
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ expect(order).toHaveLength(2);
+ expect(order).toContain("a");
+ expect(order).toContain("b");
+ });
+ });
+
+ describe("fault isolation", () => {
+ it("a throwing extension is isolated — others still activate", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {
+ throw new Error("boom");
+ },
+ });
+ const c = createExtension("c", {
+ activate: () => {
+ order.push("c");
+ },
+ });
+
+ const host = createHost([a, b, c], deps);
+ await host.activate();
+
+ expect(order).toEqual(["a", "c"]);
+ expect(host.getDisabled()).toHaveLength(1);
+ expect(host.getDisabled()[0]?.manifest.id).toBe("b");
+ expect(host.getDisabled()[0]?.reason).toContain("boom");
+ });
+
+ it("an async-rejecting extension is isolated", async () => {
+ const a = createExtension("a", {
+ activate: async () => {
+ throw new Error("async fail");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {},
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(1);
+ expect(host.getDisabled()[0]?.manifest.id).toBe("a");
+ });
+ });
+
+ describe("apiVersion compatibility", () => {
+ it("activates compatible extensions", async () => {
+ const ext = createExtension("good", {
+ apiVersion: `^${KERNEL_API_VERSION}`,
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(0);
+ });
+
+ it("disables incompatible extensions without crashing", async () => {
+ const ext = createExtension("bad", {
+ apiVersion: "^99.0.0",
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(1);
+ expect(host.getDisabled()[0]?.manifest.id).toBe("bad");
+ expect(host.getDisabled()[0]?.reason).toContain("incompatible");
+ });
+
+ it("logs a warning for disabled extensions", async () => {
+ const ext = createExtension("bad", {
+ apiVersion: "^99.0.0",
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const warnings = logger.logs.filter((l) => l.level === "warn");
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]?.message).toContain("bad");
+ });
+ });
+
+ describe("registries", () => {
+ it("defineTool populates the tool registry", async () => {
+ const tool = createFakeTool("read-file");
+ const ext = createExtension("tools-fs", {
+ activate: (host) => {
+ host.defineTool(tool);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getTools().size).toBe(1);
+ expect(host.getTool("read-file")).toBe(tool);
+ });
+
+ it("defineProvider populates the provider registry", async () => {
+ const provider = createFakeProvider("anthropic");
+ const ext = createExtension("provider-anthropic", {
+ activate: (host) => {
+ host.defineProvider(provider);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getProviders().size).toBe(1);
+ expect(host.getProvider("anthropic")).toBe(provider);
+ });
+
+ it("defineAuth populates the auth registry", async () => {
+ const auth = createFakeAuth("apikey");
+ const ext = createExtension("auth-apikey", {
+ activate: (host) => {
+ host.defineAuth(auth);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getAuthProviders().size).toBe(1);
+ expect(host.getAuthProvider("apikey")).toBe(auth);
+ });
+
+ it("getService returns what an extension provided via provideService", async () => {
+ const handle = defineService<{ value: number }>("test/svc");
+ const ext = createExtension("svc-provider", {
+ activate: (host) => {
+ host.provideService(handle, { value: 42 });
+ },
+ });
+ const consumer = createExtension("svc-consumer", {
+ dependsOn: ["svc-provider"],
+ activate: (host) => {
+ const svc = host.getService(handle);
+ expect(svc.value).toBe(42);
+ },
+ });
+
+ const host = createHost([ext, consumer], deps);
+ await host.activate();
+
+ expect(host.getDisabled()).toHaveLength(0);
+ });
+
+ it("multiple extensions contribute to the same registry", async () => {
+ const ext1 = createExtension("tools-a", {
+ activate: (host) => {
+ host.defineTool(createFakeTool("tool-a"));
+ },
+ });
+ const ext2 = createExtension("tools-b", {
+ activate: (host) => {
+ host.defineTool(createFakeTool("tool-b"));
+ },
+ });
+
+ const host = createHost([ext1, ext2], deps);
+ await host.activate();
+
+ expect(host.getTools().size).toBe(2);
+ expect(host.getTool("tool-a")).toBeDefined();
+ expect(host.getTool("tool-b")).toBeDefined();
+ });
+ });
+
+ describe("scheduler", () => {
+ it("collects scheduled jobs and forwards to sink", async () => {
+ const job: ScheduledJob = {
+ id: "cache-warm",
+ cron: "*/5 * * * *",
+ execute: () => {},
+ };
+ const ext = createExtension("scheduler-ext", {
+ activate: (host) => {
+ host.scheduler.register(job);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getScheduledJobs()).toHaveLength(1);
+ expect(host.getScheduledJobs()[0]).toBe(job);
+ expect(scheduler.jobs).toHaveLength(1);
+ expect(scheduler.jobs[0]).toBe(job);
+ });
+ });
+
+ describe("migrations", () => {
+ it("collects migrations from manifests", async () => {
+ const ext = createExtension("store-ext", {
+ contributes: { migrations: ["001-init", "002-add-index"] },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(host.getMigrations()).toEqual(["001-init", "002-add-index"]);
+ });
+ });
+
+ describe("deactivation", () => {
+ it("deactivates in reverse activation order", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {
+ order.push("activate-a");
+ },
+ deactivate: () => {
+ order.push("deactivate-a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {
+ order.push("activate-b");
+ },
+ deactivate: () => {
+ order.push("deactivate-b");
+ },
+ });
+ const c = createExtension("c", {
+ activate: () => {
+ order.push("activate-c");
+ },
+ deactivate: () => {
+ order.push("deactivate-c");
+ },
+ });
+
+ const host = createHost([a, b, c], deps);
+ await host.activate();
+ await host.deactivate();
+
+ expect(order).toEqual([
+ "activate-a",
+ "activate-b",
+ "activate-c",
+ "deactivate-c",
+ "deactivate-b",
+ "deactivate-a",
+ ]);
+ });
+
+ it("a failing deactivate does not prevent others", async () => {
+ const order: string[] = [];
+
+ const a = createExtension("a", {
+ activate: () => {},
+ deactivate: () => {
+ order.push("deactivate-a");
+ },
+ });
+ const b = createExtension("b", {
+ activate: () => {},
+ deactivate: () => {
+ throw new Error("deactivate boom");
+ },
+ });
+ const c = createExtension("c", {
+ activate: () => {},
+ deactivate: () => {
+ order.push("deactivate-c");
+ },
+ });
+
+ const host = createHost([a, b, c], deps);
+ await host.activate();
+ await host.deactivate();
+
+ expect(order).toEqual(["deactivate-c", "deactivate-a"]);
+ const errors = logger.logs.filter((l) => l.level === "error");
+ expect(errors.some((e) => e.message.includes("deactivate"))).toBe(true);
+ expect(errors.some((e) => (e.attrs as { err?: unknown })?.err instanceof Error)).toBe(true);
+ });
+ });
+
+ describe("HostAPI delegation", () => {
+ it("on/addFilter delegate to the bus", async () => {
+ const hook = defineEventHook<string>("test/host-event");
+ const received: string[] = [];
+
+ const ext = createExtension("hook-ext", {
+ activate: (host) => {
+ host.on(hook, (payload) => {
+ received.push(payload);
+ });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ deps.bus.emit(hook, "hello");
+ expect(received).toEqual(["hello"]);
+ });
+
+ it("emit dispatches to handlers registered via on", async () => {
+ const hook = defineEventHook<string>("test/emit-dispatch");
+ const received: string[] = [];
+
+ const ext = createExtension("emit-ext", {
+ activate: (host) => {
+ host.on(hook, (payload) => {
+ received.push(payload);
+ });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ api.emit(hook, "world");
+ expect(received).toEqual(["world"]);
+ });
+
+ it("emit isolates a throwing handler (does not propagate)", async () => {
+ const hook = defineEventHook<string>("test/emit-isolation");
+ const received: string[] = [];
+
+ const ext = createExtension("emit-isolation-ext", {
+ activate: (host) => {
+ host.on(hook, () => {
+ throw new Error("handler boom");
+ });
+ host.on(hook, (payload) => {
+ received.push(payload);
+ });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.emit(hook, "safe")).not.toThrow();
+ expect(received).toEqual(["safe"]);
+ });
+
+ it("applyFilters threads a value through registered filters in order", async () => {
+ const hook = defineFilter<string>("test/text-transform");
+
+ const ext = createExtension("filter-ext", {
+ activate: (host) => {
+ host.addFilter(hook, (value) => `${value}-first`);
+ host.addFilter(hook, (value) => `${value}-second`);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ const result = await api.applyFilters(hook, "start");
+ expect(result).toBe("start-first-second");
+ });
+
+ it("applyFilters returns the input unchanged when no filters are registered", async () => {
+ const hook = defineFilter<string>("test/unused-filter");
+
+ const ext = createExtension("no-filter-ext", {
+ activate: () => {},
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ const result = await api.applyFilters(hook, "unchanged");
+ expect(result).toBe("unchanged");
+ });
+
+ it("storage delegates to the factory", async () => {
+ let storageResult: StorageNamespace | undefined;
+
+ const ext = createExtension("storage-ext", {
+ activate: (host) => {
+ storageResult = host.storage("my-ns");
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(storageResult).toBeDefined();
+ await storageResult?.set("key", "value");
+ expect(await storageResult?.get("key")).toBe("value");
+ });
+
+ it("events delegates to the emitter", async () => {
+ const ext = createExtension("event-ext", {
+ activate: (host) => {
+ host.events.emit({ type: "custom", data: 42 });
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ expect(events.emitted).toHaveLength(1);
+ expect(events.emitted[0]).toEqual({ type: "custom", data: 42 });
+ });
+ });
+
+ describe("HostAPI registry access", () => {
+ it("getTools returns registered tools via HostAPI", async () => {
+ const tool = createFakeTool("read-file");
+ let capturedTools: ReadonlyMap<string, ToolContract> | undefined;
+
+ const producer = createExtension("tools-fs", {
+ activate: (host) => {
+ host.defineTool(tool);
+ },
+ });
+ const consumer = createExtension("consumer", {
+ dependsOn: ["tools-fs"],
+ activate: (host) => {
+ capturedTools = host.getTools();
+ },
+ });
+
+ const host = createHost([producer, consumer], deps);
+ await host.activate();
+
+ expect(capturedTools).toBeDefined();
+ expect(capturedTools?.size).toBe(1);
+ expect(capturedTools?.get("read-file")).toBe(tool);
+ });
+
+ it("getProviders returns registered providers via HostAPI", async () => {
+ const provider = createFakeProvider("anthropic");
+ let capturedProviders: ReadonlyMap<string, ProviderContract> | undefined;
+
+ const producer = createExtension("provider-anthropic", {
+ activate: (host) => {
+ host.defineProvider(provider);
+ },
+ });
+ const consumer = createExtension("consumer", {
+ dependsOn: ["provider-anthropic"],
+ activate: (host) => {
+ capturedProviders = host.getProviders();
+ },
+ });
+
+ const host = createHost([producer, consumer], deps);
+ await host.activate();
+
+ expect(capturedProviders).toBeDefined();
+ expect(capturedProviders?.size).toBe(1);
+ expect(capturedProviders?.get("anthropic")).toBe(provider);
+ });
+
+ it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => {
+ const auth = createFakeAuth("apikey");
+ let capturedAuth: ReadonlyMap<string, AuthContract> | undefined;
+ let capturedSingle: AuthContract | undefined;
+
+ const producer = createExtension("auth-apikey", {
+ activate: (host) => {
+ host.defineAuth(auth);
+ },
+ });
+ const consumer = createExtension("consumer", {
+ dependsOn: ["auth-apikey"],
+ activate: (host) => {
+ capturedAuth = host.getAuthProviders();
+ capturedSingle = host.getAuthProvider("apikey");
+ },
+ });
+
+ const host = createHost([producer, consumer], deps);
+ await host.activate();
+
+ expect(capturedAuth).toBeDefined();
+ expect(capturedAuth?.size).toBe(1);
+ expect(capturedAuth?.get("apikey")).toBe(auth);
+ expect(capturedSingle).toBe(auth);
+ });
+ });
+
+ describe("getExtensions", () => {
+ it("returns empty array when no extensions are activated", async () => {
+ const host = createHost([], deps);
+ await host.activate();
+
+ expect(host.getExtensions()).toEqual([]);
+ });
+
+ it("returns manifests of all activated extensions", async () => {
+ const a = createExtension("ext-a");
+ const b = createExtension("ext-b");
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(2);
+ expect(exts.map((e) => e.id)).toContain("ext-a");
+ expect(exts.map((e) => e.id)).toContain("ext-b");
+ });
+
+ it("returns manifests in activation order", async () => {
+ const a = createExtension("a");
+ const b = createExtension("b", { dependsOn: ["a"] });
+ const c = createExtension("c", { dependsOn: ["b"] });
+
+ const host = createHost([c, b, a], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts.map((e) => e.id)).toEqual(["a", "b", "c"]);
+ });
+
+ it("excludes extensions that failed to activate", async () => {
+ const a = createExtension("good");
+ const b = createExtension("bad", {
+ activate: () => {
+ throw new Error("boom");
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(1);
+ expect(exts[0]?.id).toBe("good");
+ });
+
+ it("excludes extensions disabled by apiVersion incompatibility", async () => {
+ const good = createExtension("good");
+ const bad = createExtension("bad", { apiVersion: "^99.0.0" });
+
+ const host = createHost([good, bad], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(exts).toHaveLength(1);
+ expect(exts[0]?.id).toBe("good");
+ });
+
+ it("returns a frozen array", async () => {
+ const ext = createExtension("ext");
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const exts = host.getExtensions();
+ expect(Object.isFrozen(exts)).toBe(true);
+ });
+
+ it("HostAPI getExtensions reflects activated extensions after full activation", async () => {
+ const a = createExtension("ext-a");
+ const b = createExtension("ext-b", {
+ dependsOn: ["ext-a"],
+ activate: () => {},
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ // Use getHostAPI() to verify the post-activation view
+ const api = host.getHostAPI();
+ const capturedExtsAfter = api.getExtensions();
+
+ expect(capturedExtsAfter).toHaveLength(2);
+ expect(capturedExtsAfter.map((e) => e.id)).toEqual(["ext-a", "ext-b"]);
+ });
+
+ it("HostAPI getExtensions during activation sees only previously activated", async () => {
+ const seenDuringActivation: string[][] = [];
+
+ const a = createExtension("a", {
+ activate: (host) => {
+ seenDuringActivation.push(host.getExtensions().map((e) => e.id));
+ },
+ });
+ const b = createExtension("b", {
+ activate: (host) => {
+ seenDuringActivation.push(host.getExtensions().map((e) => e.id));
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ // When a activates, activated[] is empty (a hasn't been pushed yet)
+ // When b activates, activated[] has [a] (b hasn't been pushed yet)
+ expect(seenDuringActivation).toEqual([[], ["a"]]);
+ });
+ });
+
+ describe("DAG errors", () => {
+ it("throws on missing dependency", () => {
+ const ext = createExtension("a", { dependsOn: ["missing"] });
+ expect(() => createHost([ext], deps)).toThrow(/not available/);
+ });
+
+ it("throws on dependency cycle", () => {
+ const a = createExtension("a", { dependsOn: ["b"] });
+ const b = createExtension("b", { dependsOn: ["a"] });
+ expect(() => createHost([a, b], deps)).toThrow(/cycle/i);
+ });
+ });
+
+ describe("empty host", () => {
+ it("works with no extensions", async () => {
+ const host = createHost([], deps);
+ await host.activate();
+
+ expect(host.getTools().size).toBe(0);
+ expect(host.getProviders().size).toBe(0);
+ expect(host.getAuthProviders().size).toBe(0);
+ expect(host.getScheduledJobs()).toHaveLength(0);
+ expect(host.getMigrations()).toHaveLength(0);
+ expect(host.getDisabled()).toHaveLength(0);
+ });
+ });
+
+ describe("getHostAPI", () => {
+ it("returns a HostAPI whose read-views reflect registrations from activation", async () => {
+ const tool = createFakeTool("read-file");
+ const provider = createFakeProvider("anthropic");
+ const auth = createFakeAuth("apikey");
+
+ const ext = createExtension("multi-ext", {
+ activate: (host) => {
+ host.defineTool(tool);
+ host.defineProvider(provider);
+ host.defineAuth(auth);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+
+ expect(api.getTools().size).toBe(1);
+ expect(api.getTools().get("read-file")).toBe(tool);
+
+ expect(api.getProviders().size).toBe(1);
+ expect(api.getProviders().get("anthropic")).toBe(provider);
+
+ expect(api.getAuthProviders().size).toBe(1);
+ expect(api.getAuthProvider("apikey")).toBe(auth);
+ });
+
+ it("throws on defineTool after activation", async () => {
+ const ext = createExtension("ext", { activate: () => {} });
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.defineTool(createFakeTool("late"))).toThrow(
+ "Registration not available after activation",
+ );
+ });
+
+ it("throws on defineProvider after activation", async () => {
+ const ext = createExtension("ext", { activate: () => {} });
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.defineProvider(createFakeProvider("late"))).toThrow(
+ "Registration not available after activation",
+ );
+ });
+
+ it("throws on defineAuth after activation", async () => {
+ const ext = createExtension("ext", { activate: () => {} });
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ expect(() => api.defineAuth(createFakeAuth("late"))).toThrow(
+ "Registration not available after activation",
+ );
+ });
+
+ it("applyFilters is available on registration-closed HostAPI", async () => {
+ const hook = defineFilter<string>("test/closed-filter");
+
+ const ext = createExtension("filter-ext", {
+ activate: (host) => {
+ host.addFilter(hook, (value) => `${value}-filtered`);
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const api = host.getHostAPI();
+ const result = await api.applyFilters(hook, "input");
+ expect(result).toBe("input-filtered");
+ });
+ });
+
+ describe("auto-scoped logger (D6)", () => {
+ it("each extension's logger stamps its own manifest.id as extensionId", async () => {
+ let extALogger: Logger | undefined;
+ let extBLogger: Logger | undefined;
+
+ const a = createExtension("ext-a", {
+ activate: (host) => {
+ extALogger = host.logger;
+ },
+ });
+ const b = createExtension("ext-b", {
+ activate: (host) => {
+ extBLogger = host.logger;
+ },
+ });
+
+ const host = createHost([a, b], deps);
+ await host.activate();
+
+ extALogger?.info("from-a");
+ extBLogger?.info("from-b");
+
+ const logRecords = logSink.records.filter((r) => r.kind === "log");
+ expect(logRecords).toHaveLength(2);
+ if (logRecords[0]?.kind === "log") {
+ expect(logRecords[0].extensionId).toBe("ext-a");
+ expect(logRecords[0].msg).toBe("from-a");
+ }
+ if (logRecords[1]?.kind === "log") {
+ expect(logRecords[1].extensionId).toBe("ext-b");
+ expect(logRecords[1].msg).toBe("from-b");
+ }
+ });
+
+ it("an extension cannot spoof extensionId — it is auto-stamped", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("real-id", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ // child() cannot override extensionId
+ const child = extLogger?.child({ extensionId: "spoofed" });
+ child?.info("msg");
+
+ const logRecords = logSink.records.filter((r) => r.kind === "log");
+ expect(logRecords).toHaveLength(1);
+ if (logRecords[0]?.kind === "log") {
+ expect(logRecords[0].extensionId).toBe("real-id");
+ }
+ });
+
+ it("host.logger.error uses structured { err } shape", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ extLogger?.error("something broke", { err: new Error("boom") });
+
+ const logRecords = logSink.records.filter((r) => r.kind === "log");
+ expect(logRecords).toHaveLength(1);
+ if (logRecords[0]?.kind === "log") {
+ expect(logRecords[0].level).toBe("error");
+ expect(logRecords[0].msg).toBe("something broke");
+ expect(logRecords[0].attributes?.["error.message"]).toBe("boom");
+ }
+ });
+
+ it("a throwing sink does NOT break the caller", async () => {
+ const brokenSink: LogSink = {
+ emit() {
+ throw new Error("sink down");
+ },
+ };
+ const brokenDeps: HostDeps = {
+ ...deps,
+ logSink: brokenSink,
+ };
+
+ let extLogger: Logger | undefined;
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], brokenDeps);
+ await host.activate();
+
+ // Should not throw
+ expect(() => extLogger?.info("msg")).not.toThrow();
+ });
+
+ it("span() + end() emit incremental span-open and span-close records", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("my-span", { key: "value" });
+ span?.setAttributes({ extra: "attr" });
+ span?.end({ attrs: { result: "ok" } });
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
+
+ expect(spanOpens).toHaveLength(1);
+ expect(spanCloses).toHaveLength(1);
+
+ if (spanOpens[0]?.kind === "span-open") {
+ expect(spanOpens[0].name).toBe("my-span");
+ expect(spanOpens[0].extensionId).toBe("ext");
+ expect(spanOpens[0].attributes?.key).toBe("value");
+ }
+ if (spanCloses[0]?.kind === "span-close") {
+ expect(spanCloses[0].name).toBe("my-span");
+ expect(spanCloses[0].status).toBe("ok");
+ expect(spanCloses[0].durationMs).toBeGreaterThanOrEqual(0);
+ expect(spanCloses[0].attributes?.extra).toBe("attr");
+ expect(spanCloses[0].attributes?.result).toBe("ok");
+ }
+ });
+
+ it("span() with body emits body on span-open record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("with-body", { key: "value" }, '{"payload":"hello"}');
+ span?.end();
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ expect(spanOpens).toHaveLength(1);
+ if (spanOpens[0]?.kind === "span-open") {
+ expect(spanOpens[0].body).toBe('{"payload":"hello"}');
+ }
+ });
+
+ it("span() without body omits body field on span-open record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("no-body");
+ span?.end();
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ expect(spanOpens).toHaveLength(1);
+ if (spanOpens[0]?.kind === "span-open") {
+ expect(spanOpens[0].body).toBeUndefined();
+ }
+ });
+
+ it("child() with body emits body on child span-open record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("parent");
+ const child = span?.child("child-name", { k: "v" }, '{"child":"body"}');
+ child?.end();
+ span?.end();
+
+ const spanOpens = logSink.records.filter((r) => r.kind === "span-open");
+ const childOpen = spanOpens.find((r) => r.kind === "span-open" && r.name === "child-name");
+ expect(childOpen).toBeDefined();
+ if (childOpen?.kind === "span-open") {
+ expect(childOpen.body).toBe('{"child":"body"}');
+ }
+ });
+
+ it("end() with body emits body on span-close record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("close-body");
+ span?.end({ body: '{"result":"data"}' });
+
+ const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
+ expect(spanCloses).toHaveLength(1);
+ if (spanCloses[0]?.kind === "span-close") {
+ expect(spanCloses[0].body).toBe('{"result":"data"}');
+ }
+ });
+
+ it("end() without body omits body field on span-close record", async () => {
+ let extLogger: Logger | undefined;
+
+ const ext = createExtension("ext", {
+ activate: (host) => {
+ extLogger = host.logger;
+ },
+ });
+
+ const host = createHost([ext], deps);
+ await host.activate();
+
+ const span = extLogger?.span("no-close-body");
+ span?.end();
+
+ const spanCloses = logSink.records.filter((r) => r.kind === "span-close");
+ expect(spanCloses).toHaveLength(1);
+ if (spanCloses[0]?.kind === "span-close") {
+ expect(spanCloses[0].body).toBeUndefined();
+ }
+ });
+ });
});