diff --git a/package.json b/package.json index 6bce6df..883ffa4 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,13 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@napi-rs/cli": "3.6.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^25.8.0", "@vitest/coverage-v8": "^2.1.8", "blocked-at": "^1.2.0", "eslint": "^10.4.0", "grpc-tools": "^1.13.1", + "js-yaml": "^4.1.1", "langchain": "^1.4.0", "prettier": "^3.5.3", "ts-proto": "^2.11.8", @@ -74,11 +76,11 @@ ], "optionalDependencies": { "@agent-assembly/darwin-arm64": "0.0.0", - "@agent-assembly/win32-x64-msvc": "0.0.0", - "@agent-assembly/runtime-linux-x64": "0.0.0", - "@agent-assembly/runtime-linux-arm64": "0.0.0", + "@agent-assembly/runtime-darwin-arm64": "0.0.0", "@agent-assembly/runtime-darwin-x64": "0.0.0", - "@agent-assembly/runtime-darwin-arm64": "0.0.0" + "@agent-assembly/runtime-linux-arm64": "0.0.0", + "@agent-assembly/runtime-linux-x64": "0.0.0", + "@agent-assembly/win32-x64-msvc": "0.0.0" }, "files": [ "dist/", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bfb56..c54054e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@napi-rs/cli': specifier: 3.6.2 version: 3.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.8.0) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^25.8.0 version: 25.8.0 @@ -42,6 +45,9 @@ importers: grpc-tools: specifier: ^1.13.1 version: 1.13.1 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 langchain: specifier: ^1.4.0 version: 1.4.0(@langchain/core@1.1.45(openai@6.35.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0))(openai@6.35.0(ws@8.20.0)(zod@4.4.3))(ws@8.20.0)(zod-to-json-schema@3.25.2(zod@4.4.3)) @@ -1155,6 +1161,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3380,6 +3389,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node@25.8.0': diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts new file mode 100644 index 0000000..8dafffa --- /dev/null +++ b/src/core/gateway-resolver.ts @@ -0,0 +1,244 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; + +import { ConfigurationError, GatewayError } from "../errors/index.js"; + +/** + * Resolve the gateway URL and API key for ``initAssembly``. + * + * Implements the zero-config developer-experience contract from Epic 17 (S-G): + * ``initAssembly({})`` with no fields and no environment variables should + * discover a local gateway at ``http://localhost:7391`` — probing it, and + * auto-starting ``aasm start --mode local --foreground`` when not running. + * + * Resolution precedence (highest first): + * + * 1. Explicit field on the AssemblyConfig + * 2. Environment variable (AAASM_GATEWAY_URL / AAASM_API_KEY) + * 3. Config file (~/.aasm/config.yaml, optional js-yaml soft dep) + * 4. Local default: probe http://localhost:7391, auto-start if absent + */ + +export const DEFAULT_GATEWAY_URL = "http://localhost:7391"; +export const DEFAULT_HEALTHZ_PATH = "/healthz"; +export const DEFAULT_PROBE_TIMEOUT_MS = 500; +export const DEFAULT_AUTO_START_TIMEOUT_MS = 5000; +export const DEFAULT_CONFIG_FILE_PATH = "~/.aasm/config.yaml"; + +export const ENV_GATEWAY_URL = "AAASM_GATEWAY_URL"; +export const ENV_API_KEY = "AAASM_API_KEY"; + +export const AASM_AUTO_START_ARGV = ["start", "--mode", "local", "--foreground"] as const; + +/** + * Return true if a gateway responds with a 2xx status at ``{baseUrl}/healthz``. + * + * Uses the global ``fetch`` (Node 18+) with an AbortController-driven + * timeout. Any network / timeout / parse error is swallowed and reported + * as ``false`` — the resolver treats unreachable as "absent" rather than + * fatal. + */ +export async function probeHealthz( + baseUrl: string, + timeoutMs: number = DEFAULT_PROBE_TIMEOUT_MS +): Promise { + const url = baseUrl.replace(/\/+$/, "") + DEFAULT_HEALTHZ_PATH; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + return response.status >= 200 && response.status < 300; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +/** + * Poll the gateway healthz endpoint until success or timeout. + * + * Resolves ``true`` as soon as ``probeHealthz`` succeeds, ``false`` if + * the gateway has not become ready within ``timeoutMs``. The poll + * interval is short (default 100ms) so the auto-start path feels + * instant when the local CP comes up quickly. + */ +export async function waitForHealthz( + baseUrl: string, + timeoutMs: number = DEFAULT_AUTO_START_TIMEOUT_MS, + pollIntervalMs: number = 100 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await probeHealthz(baseUrl)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + return probeHealthz(baseUrl); +} + +function expandHome(p: string): string { + return p.startsWith("~") ? resolvePath(homedir(), p.slice(p.startsWith("~/") ? 2 : 1)) : p; +} + +/** + * Load ``~/.aasm/config.yaml`` if present. + * + * Returns an empty record when the file is missing, when ``js-yaml`` is + * not installed (it is a soft dependency for SDK consumers), or when + * the file's contents are not an object. This keeps the resolver chain + * purely advisory at step 3 — never throws. + */ +export async function loadConfigFile( + configPath: string = DEFAULT_CONFIG_FILE_PATH +): Promise> { + // Indirect specifier defeats static module resolution so missing js-yaml + // surfaces at runtime (caught below) rather than as a TS compile error. + const yamlSpec = "js-yaml"; + let yamlMod: { load: (input: string) => unknown }; + try { + yamlMod = (await import(yamlSpec)) as { load: (input: string) => unknown }; + } catch { + return {}; + } + + const expanded = expandHome(configPath); + if (!existsSync(expanded)) { + return {}; + } + + try { + const parsed = yamlMod.load(readFileSync(expanded, "utf8")); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function defaultFindAasmOnPath(): string | null { + const PATH = process.env.PATH ?? ""; + const sep = process.platform === "win32" ? ";" : ":"; + const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""]; + for (const dir of PATH.split(sep)) { + if (!dir) continue; + for (const ext of exts) { + const candidate = join(dir, `aasm${ext}`); + if (existsSync(candidate)) return candidate; + } + } + return null; +} + +function defaultSpawnAasm(aasmPath: string): void { + const child = spawn(aasmPath, [...AASM_AUTO_START_ARGV], { + detached: true, + stdio: "ignore", + }); + child.unref(); +} + +/** + * Mutable seams used by ``autoStartGateway`` — exposed via ``__testing`` + * so tests can stub the PATH lookup and subprocess spawn without using + * ESM module mocking. Production callers should treat this as private. + */ +const _seams = { + findAasmOnPath: defaultFindAasmOnPath, + spawnAasm: defaultSpawnAasm, + probeHealthz: probeHealthz, + loadConfigFile: loadConfigFile, + autoStartGateway: autoStartGateway, +}; + +export const __testing = { _seams }; + +/** + * Spawn ``aasm start --mode local --foreground`` and wait until ``/healthz`` + * responds. + * + * Throws ``ConfigurationError`` when the ``aasm`` binary is missing from + * PATH — the SDK cannot meaningfully auto-start without it. Throws + * ``GatewayError`` when the spawned gateway does not become ready within + * ``timeoutMs``. The subprocess is launched detached + stdio:"ignore" so + * it survives the parent Node process — the docker-style daemon hand-off + * described in Epic 17 S-G. + */ +export async function autoStartGateway( + baseUrl: string = DEFAULT_GATEWAY_URL, + timeoutMs: number = DEFAULT_AUTO_START_TIMEOUT_MS +): Promise { + const aasmPath = _seams.findAasmOnPath(); + if (aasmPath === null) { + throw new ConfigurationError( + `No gateway found at ${baseUrl} and 'aasm' is not on PATH. ` + + "Install it with: npm install -g @agent-assembly/cli (or pnpm add -g)" + ); + } + + _seams.spawnAasm(aasmPath); + + if (!(await waitForHealthz(baseUrl, timeoutMs))) { + throw new GatewayError( + `Auto-started gateway at ${baseUrl} did not become ready ` + + `within ${(timeoutMs / 1000).toFixed(0)} seconds` + ); + } +} + +/** + * Resolve the gateway URL using the 4-step precedence chain. + * + * Returns the resolved URL. May spawn a local ``aasm`` subprocess + * (step 4 only). Propagates ``ConfigurationError`` / ``GatewayError`` + * from ``autoStartGateway`` when the local default is needed but + * cannot be brought up. + */ +export async function resolveGatewayUrl(explicit?: string): Promise { + if (explicit) return explicit; + + const fromEnv = process.env[ENV_GATEWAY_URL]; + if (fromEnv) return fromEnv; + + const config = await _seams.loadConfigFile(); + const agent = config["agent"]; + if (agent !== null && typeof agent === "object") { + const url = (agent as Record)["gateway_url"]; + if (typeof url === "string" && url.length > 0) return url; + } + + if (await _seams.probeHealthz(DEFAULT_GATEWAY_URL)) { + return DEFAULT_GATEWAY_URL; + } + + await _seams.autoStartGateway(DEFAULT_GATEWAY_URL); + return DEFAULT_GATEWAY_URL; +} + +/** + * Resolve the API key using the same 4-step precedence as the URL. + * + * Returns the resolved key (possibly empty for local mode, which + * accepts unauthenticated agents). Never rejects — an empty API key + * is the documented "local dev" default per Epic 17. + */ +export async function resolveApiKey(explicit?: string): Promise { + if (explicit) return explicit; + + const fromEnv = process.env[ENV_API_KEY]; + if (fromEnv) return fromEnv; + + const config = await _seams.loadConfigFile(); + const agent = config["agent"]; + if (agent !== null && typeof agent === "object") { + const apiKey = (agent as Record)["api_key"]; + if (typeof apiKey === "string" && apiKey.length > 0) return apiKey; + } + + return ""; +} + diff --git a/src/core/init-assembly.ts b/src/core/init-assembly.ts index dbd8bc9..4ea362d 100644 --- a/src/core/init-assembly.ts +++ b/src/core/init-assembly.ts @@ -22,6 +22,7 @@ import { patchMastra } from "../hooks/mastra.js"; import { hasOpenAIAgentsSDK } from "../hooks/openai-agents-detection.js"; import { patchOpenAIAgents } from "../hooks/openai-agents.js"; import { currentAgentId } from "../lineage/index.js"; +import { resolveApiKey, resolveGatewayUrl } from "./gateway-resolver.js"; const requireFromCwd = createRequire(`${process.cwd()}/`); @@ -200,16 +201,21 @@ async function patchDetectedOpenAIAgents( return patchOpenAIAgents({ gatewayClient: client }); } -export async function initAssembly(config: AssemblyConfig): Promise { +export async function initAssembly(config: AssemblyConfig = {}): Promise { if (config.delegationReason !== undefined && config.delegationReason.length > 256) { throw new RangeError("delegationReason must be <= 256 characters"); } // Auto-populate parentAgentId from the async context store when not explicitly provided. // This allows child agents spawned inside framework hooks to inherit lineage automatically. const resolvedParentAgentId = config.parentAgentId ?? currentAgentId(); - const resolvedConfig: AssemblyConfig = resolvedParentAgentId - ? { ...config, parentAgentId: resolvedParentAgentId } - : config; + const resolvedGatewayUrl = await resolveGatewayUrl(config.gatewayUrl); + const resolvedApiKey = await resolveApiKey(config.apiKey); + const resolvedConfig: AssemblyConfig = { + ...config, + gatewayUrl: resolvedGatewayUrl, + apiKey: resolvedApiKey, + ...(resolvedParentAgentId ? { parentAgentId: resolvedParentAgentId } : {}) + }; const client = createClient(resolvedConfig); const frameworks = detectFrameworks(); @@ -222,8 +228,8 @@ export async function initAssembly(config: AssemblyConfig): Promise { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns true on 2xx response and probes the /healthz suffix", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200 } as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(probeHealthz(DEFAULT_GATEWAY_URL)).resolves.toBe(true); + + const [calledUrl] = fetchMock.mock.calls[0]!; + expect(calledUrl).toBe("http://localhost:7391/healthz"); + }); + + it("returns false when fetch rejects", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as unknown as typeof fetch; + await expect(probeHealthz(DEFAULT_GATEWAY_URL)).resolves.toBe(false); + }); + + it.each([400, 404, 500, 503])("returns false on non-2xx status %i", async (status) => { + globalThis.fetch = vi.fn().mockResolvedValue({ status } as Response) as unknown as typeof fetch; + await expect(probeHealthz(DEFAULT_GATEWAY_URL)).resolves.toBe(false); + }); +}); + +describe("waitForHealthz", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("returns true on first probe success", async () => { + const fetchMock = vi.fn().mockResolvedValue({ status: 200 } as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(waitForHealthz(DEFAULT_GATEWAY_URL, 5000)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("returns true after initial failures", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("refused")) + .mockRejectedValueOnce(new Error("refused")) + .mockResolvedValueOnce({ status: 200 } as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(waitForHealthz(DEFAULT_GATEWAY_URL, 5000, 5)).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("returns false when timeout elapses with no success", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("refused")) as unknown as typeof fetch; + await expect(waitForHealthz(DEFAULT_GATEWAY_URL, 30, 10)).resolves.toBe(false); + }); +}); + +describe("loadConfigFile", () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "aaasm-1847-cfg-")); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("returns empty when the file is missing", async () => { + await expect(loadConfigFile(join(tmp, "absent.yaml"))).resolves.toEqual({}); + }); + + it("returns the parsed mapping for well-formed YAML", async () => { + const cfg = join(tmp, "config.yaml"); + writeFileSync( + cfg, + 'agent:\n gateway_url: "http://staging.internal:7391"\n api_key: "k-1"\n', + "utf8" + ); + await expect(loadConfigFile(cfg)).resolves.toEqual({ + agent: { gateway_url: "http://staging.internal:7391", api_key: "k-1" } + }); + }); + + it("returns empty when the root is a YAML list (non-mapping)", async () => { + const cfg = join(tmp, "config.yaml"); + writeFileSync(cfg, "- just-a-list\n", "utf8"); + await expect(loadConfigFile(cfg)).resolves.toEqual({}); + }); + + it("returns empty when the YAML is malformed", async () => { + const cfg = join(tmp, "config.yaml"); + writeFileSync(cfg, ":\n not: valid: yaml: at all\n", "utf8"); + await expect(loadConfigFile(cfg)).resolves.toEqual({}); + }); +}); + +describe("autoStartGateway", () => { + let originalFetch: typeof globalThis.fetch; + let originalFind: (typeof __testing._seams)["findAasmOnPath"]; + let originalSpawn: (typeof __testing._seams)["spawnAasm"]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + originalFind = __testing._seams.findAasmOnPath; + originalSpawn = __testing._seams.spawnAasm; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + __testing._seams.findAasmOnPath = originalFind; + __testing._seams.spawnAasm = originalSpawn; + vi.restoreAllMocks(); + }); + + it("throws ConfigurationError when aasm is not on PATH", async () => { + __testing._seams.findAasmOnPath = () => null; + await expect(autoStartGateway()).rejects.toBeInstanceOf(ConfigurationError); + await expect(autoStartGateway()).rejects.toThrow(/'aasm' is not on PATH/); + }); + + it("spawns aasm and resolves when healthz becomes ready", async () => { + const spawnSpy = vi.fn(); + __testing._seams.findAasmOnPath = () => "/usr/local/bin/aasm"; + __testing._seams.spawnAasm = spawnSpy; + globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 } as Response) as unknown as typeof fetch; + + await expect(autoStartGateway()).resolves.toBeUndefined(); + expect(spawnSpy).toHaveBeenCalledTimes(1); + expect(spawnSpy).toHaveBeenCalledWith("/usr/local/bin/aasm"); + }); + + it("throws GatewayError when the spawned gateway never becomes ready", async () => { + __testing._seams.findAasmOnPath = () => "/usr/local/bin/aasm"; + __testing._seams.spawnAasm = vi.fn(); + globalThis.fetch = vi.fn().mockRejectedValue(new Error("refused")) as unknown as typeof fetch; + + await expect(autoStartGateway(DEFAULT_GATEWAY_URL, 30)).rejects.toBeInstanceOf(GatewayError); + }); +}); + +describe("resolveGatewayUrl", () => { + const originalSeams = { ...__testing._seams }; + const originalEnv = process.env[ENV_GATEWAY_URL]; + + afterEach(() => { + Object.assign(__testing._seams, originalSeams); + if (originalEnv === undefined) delete process.env[ENV_GATEWAY_URL]; + else process.env[ENV_GATEWAY_URL] = originalEnv; + vi.restoreAllMocks(); + }); + + it("short-circuits on the explicit argument", async () => { + process.env[ENV_GATEWAY_URL] = "http://from-env:7391"; + await expect(resolveGatewayUrl("http://explicit:7391")).resolves.toBe("http://explicit:7391"); + }); + + it("uses AAASM_GATEWAY_URL over config + default", async () => { + process.env[ENV_GATEWAY_URL] = "http://from-env:7391"; + __testing._seams.loadConfigFile = async () => ({ + agent: { gateway_url: "http://from-config:7391" } + }); + await expect(resolveGatewayUrl()).resolves.toBe("http://from-env:7391"); + }); + + it("falls back to config file when env is unset", async () => { + delete process.env[ENV_GATEWAY_URL]; + __testing._seams.loadConfigFile = async () => ({ + agent: { gateway_url: "http://from-config:7391" } + }); + await expect(resolveGatewayUrl()).resolves.toBe("http://from-config:7391"); + }); + + it("returns the local default when probe succeeds (no auto-start)", async () => { + delete process.env[ENV_GATEWAY_URL]; + __testing._seams.loadConfigFile = async () => ({}); + __testing._seams.probeHealthz = async () => true; + const autoStartSpy = vi.fn(); + __testing._seams.autoStartGateway = autoStartSpy; + + await expect(resolveGatewayUrl()).resolves.toBe(DEFAULT_GATEWAY_URL); + expect(autoStartSpy).not.toHaveBeenCalled(); + }); + + it("invokes autoStartGateway when probe fails", async () => { + delete process.env[ENV_GATEWAY_URL]; + __testing._seams.loadConfigFile = async () => ({}); + __testing._seams.probeHealthz = async () => false; + const autoStartSpy = vi.fn().mockResolvedValue(undefined); + __testing._seams.autoStartGateway = autoStartSpy; + + await expect(resolveGatewayUrl()).resolves.toBe(DEFAULT_GATEWAY_URL); + expect(autoStartSpy).toHaveBeenCalledWith(DEFAULT_GATEWAY_URL); + }); +}); + +describe("resolveApiKey", () => { + const originalSeams = { ...__testing._seams }; + const originalEnv = process.env[ENV_API_KEY]; + + afterEach(() => { + Object.assign(__testing._seams, originalSeams); + if (originalEnv === undefined) delete process.env[ENV_API_KEY]; + else process.env[ENV_API_KEY] = originalEnv; + vi.restoreAllMocks(); + }); + + it("short-circuits on the explicit argument", async () => { + process.env[ENV_API_KEY] = "k-env"; + await expect(resolveApiKey("k-explicit")).resolves.toBe("k-explicit"); + }); + + it("uses AAASM_API_KEY over config-file value", async () => { + process.env[ENV_API_KEY] = "k-env"; + __testing._seams.loadConfigFile = async () => ({ + agent: { api_key: "k-config" } + }); + await expect(resolveApiKey()).resolves.toBe("k-env"); + }); + + it("falls back to config file when env is unset", async () => { + delete process.env[ENV_API_KEY]; + __testing._seams.loadConfigFile = async () => ({ + agent: { api_key: "k-config" } + }); + await expect(resolveApiKey()).resolves.toBe("k-config"); + }); + + it("returns empty string as the documented local-mode default", async () => { + delete process.env[ENV_API_KEY]; + __testing._seams.loadConfigFile = async () => ({}); + await expect(resolveApiKey()).resolves.toBe(""); + }); +}); diff --git a/tests/init-assembly-zero-config.test.ts b/tests/init-assembly-zero-config.test.ts new file mode 100644 index 0000000..482cf80 --- /dev/null +++ b/tests/init-assembly-zero-config.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { __testing, DEFAULT_GATEWAY_URL, ENV_API_KEY, ENV_GATEWAY_URL } from "../src/core/gateway-resolver.js"; +import { initAssembly } from "../src/index.js"; + +describe("initAssembly zero-config", () => { + const originalSeams = { ...__testing._seams }; + const originalGatewayEnv = process.env[ENV_GATEWAY_URL]; + const originalApiKeyEnv = process.env[ENV_API_KEY]; + + beforeEach(() => { + delete process.env[ENV_GATEWAY_URL]; + delete process.env[ENV_API_KEY]; + }); + + afterEach(async () => { + Object.assign(__testing._seams, originalSeams); + if (originalGatewayEnv === undefined) delete process.env[ENV_GATEWAY_URL]; + else process.env[ENV_GATEWAY_URL] = originalGatewayEnv; + if (originalApiKeyEnv === undefined) delete process.env[ENV_API_KEY]; + else process.env[ENV_API_KEY] = originalApiKeyEnv; + vi.restoreAllMocks(); + }); + + it("AAASM-1847 AC: initAssembly() with no args resolves the local default", async () => { + __testing._seams.loadConfigFile = async () => ({}); + __testing._seams.probeHealthz = async () => true; + __testing._seams.autoStartGateway = vi.fn(); + + const ctx = await initAssembly(); + try { + expect(ctx).toBeDefined(); + expect(Array.isArray(ctx.activeAdapters)).toBe(true); + } finally { + await ctx.shutdown(); + } + + expect(__testing._seams.autoStartGateway).not.toHaveBeenCalled(); + }); + + it("triggers auto-start when no gateway is listening", async () => { + __testing._seams.loadConfigFile = async () => ({}); + __testing._seams.probeHealthz = async () => false; + const autoStartSpy = vi.fn().mockResolvedValue(undefined); + __testing._seams.autoStartGateway = autoStartSpy; + + const ctx = await initAssembly(); + try { + expect(autoStartSpy).toHaveBeenCalledWith(DEFAULT_GATEWAY_URL); + } finally { + await ctx.shutdown(); + } + }); + + it("explicit gatewayUrl + apiKey bypass the resolver entirely", async () => { + const probeSpy = vi.fn().mockImplementation(() => { + throw new Error("resolver should not probe when explicit args provided"); + }); + const autoStartSpy = vi.fn().mockImplementation(() => { + throw new Error("resolver should not auto-start when explicit args provided"); + }); + __testing._seams.probeHealthz = probeSpy; + __testing._seams.autoStartGateway = autoStartSpy; + + const ctx = await initAssembly({ + gatewayUrl: "http://explicit.gw:9999", + apiKey: "explicit-key" + }); + try { + expect(probeSpy).not.toHaveBeenCalled(); + expect(autoStartSpy).not.toHaveBeenCalled(); + } finally { + await ctx.shutdown(); + } + }); +});