From 82e3c034c3367323bce6a01b00155ff7d8a88000 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:24:59 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E2=9C=A8=20(errors):=20Add=20Configurati?= =?UTF-8?q?onError=20and=20GatewayError=20classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Python SDK's exception contract for the resolver chain landing in this Sub-task (AAASM-1847 / E17 S-G). ConfigurationError signals "cannot resolve gateway config" (e.g. aasm absent from PATH); GatewayError signals "gateway present but unreachable / not ready". Both extend Error directly — node-sdk has no AssemblyError base yet. --- src/errors/configuration-error.ts | 13 +++++++++++++ src/errors/gateway-error.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/errors/configuration-error.ts create mode 100644 src/errors/gateway-error.ts diff --git a/src/errors/configuration-error.ts b/src/errors/configuration-error.ts new file mode 100644 index 0000000..2c48b4f --- /dev/null +++ b/src/errors/configuration-error.ts @@ -0,0 +1,13 @@ +/** + * Thrown when the SDK cannot resolve the gateway configuration — + * e.g. the local gateway is absent and ``aasm`` is not on ``PATH``. + * + * Mirrors ``agent_assembly.exceptions.ConfigurationError`` in the + * Python SDK so the cross-SDK error contract stays aligned per Epic 17 S-G. + */ +export class ConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigurationError"; + } +} diff --git a/src/errors/gateway-error.ts b/src/errors/gateway-error.ts new file mode 100644 index 0000000..0568c72 --- /dev/null +++ b/src/errors/gateway-error.ts @@ -0,0 +1,13 @@ +/** + * Thrown when the SDK has a gateway URL but cannot talk to it — + * e.g. ``aasm`` was spawned but ``/healthz`` did not become ready + * within the auto-start timeout window. + * + * Mirrors ``agent_assembly.exceptions.GatewayError`` in the Python SDK. + */ +export class GatewayError extends Error { + constructor(message: string) { + super(message); + this.name = "GatewayError"; + } +} From 22ca6b3df0ed4e2059a50053f39918890d288057 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:25:08 +0800 Subject: [PATCH 02/20] =?UTF-8?q?=E2=9C=A8=20(errors):=20Export=20Configur?= =?UTF-8?q?ationError=20and=20GatewayError=20from=20barrel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the two new error classes consumable by SDK callers via ``import { ConfigurationError } from "@agent-assembly/sdk/errors"``. --- src/errors/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/errors/index.ts b/src/errors/index.ts index d125343..f49f6c8 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,2 +1,4 @@ +export { ConfigurationError } from "./configuration-error.js"; +export { GatewayError } from "./gateway-error.js"; export { OpTerminatedError } from "./op-terminated-error.js"; export { PolicyViolationError } from "./policy-violation-error.js"; From 7fbb75c1c647069d95b22258c5c25c096c0d5577 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:25:22 +0800 Subject: [PATCH 03/20] =?UTF-8?q?=F0=9F=93=9D=20(core):=20Add=20gateway-re?= =?UTF-8?q?solver=20module=20skeleton=20+=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays down the new module that will host the zero-config resolution logic for initAssembly (AAASM-1847 / E17 S-G). Exports the default gateway URL, healthz path, probe / auto-start timeouts (ms — Node convention), env-var names, and the auto-start argv tuple. No behavior yet. --- src/core/gateway-resolver.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/core/gateway-resolver.ts diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts new file mode 100644 index 0000000..f749feb --- /dev/null +++ b/src/core/gateway-resolver.ts @@ -0,0 +1,26 @@ +/** + * 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; From 96af6f785fc81fb735595810cc7448d044651724 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:25:39 +0800 Subject: [PATCH 04/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20probeHealthz?= =?UTF-8?q?=20to=20gateway-resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async global-fetch GET against ``{baseUrl}/healthz`` with an AbortController-driven default 500ms timeout. Any network / timeout error is swallowed and surfaces as false — keeps the local-dev probe near-instant when nothing is listening. --- src/core/gateway-resolver.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index f749feb..968435e 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -24,3 +24,29 @@ 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); + } +} + From edf3e77d81800e04db5cdc8fe3de704184272796 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:26:50 +0800 Subject: [PATCH 05/20] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20probeHealthz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behaviors: 2xx → true (also pins the /healthz suffix), fetch reject → false, non-2xx → false (table-driven across 400/404/500/503). globalThis.fetch is stubbed via vi.fn — no real network. --- tests/gateway-resolver.test.ts | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/gateway-resolver.test.ts diff --git a/tests/gateway-resolver.test.ts b/tests/gateway-resolver.test.ts new file mode 100644 index 0000000..c5c76d8 --- /dev/null +++ b/tests/gateway-resolver.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_GATEWAY_URL, + probeHealthz, +} from "../src/core/gateway-resolver.js"; + +describe("probeHealthz", () => { + 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); + }); +}); From d6042982aa6218e5db6ac7ad602659ecf9fc6041 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:27:02 +0800 Subject: [PATCH 06/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20waitForHealth?= =?UTF-8?q?z=20to=20gateway-resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polls the healthz endpoint until success or timeout. Used after autoStartGateway to know when the freshly-spawned local CP is ready to accept connections. Default 5000ms budget per Story AC; final re-probe after the deadline ensures borderline races resolve cleanly. --- src/core/gateway-resolver.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index 968435e..6994e39 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -50,3 +50,26 @@ export async function probeHealthz( } } +/** + * 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); +} + From ca50f759721e1d05c67a057c2252a63c10232a9d Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:28:19 +0800 Subject: [PATCH 07/20] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20waitForHealthz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behaviors: success on first probe, success after two prior fetch rejections (verifies the poll-then-sleep loop body), and false when the timeout elapses with no success. Stubs globalThis.fetch rather than spyOn the same-module probeHealthz export — ESM named exports aren't mutable from spyOn. --- tests/gateway-resolver.test.ts | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/gateway-resolver.test.ts b/tests/gateway-resolver.test.ts index c5c76d8..42a8752 100644 --- a/tests/gateway-resolver.test.ts +++ b/tests/gateway-resolver.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_GATEWAY_URL, probeHealthz, + waitForHealthz, } from "../src/core/gateway-resolver.js"; describe("probeHealthz", () => { @@ -37,3 +38,41 @@ describe("probeHealthz", () => { 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); + }); +}); From 7733aac3a673eb251e0c820decd0a4e2f0916268 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:29:27 +0800 Subject: [PATCH 08/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20loadConfigFil?= =?UTF-8?q?e=20with=20optional=20js-yaml=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads ~/.aasm/config.yaml when present. js-yaml is treated as a soft dependency via dynamic import() — missing module returns an empty record so the resolver falls through to the local-default step. File-missing, OS errors, parse errors, and non-object payloads all collapse to the same empty result. Tilde-expansion via os.homedir(). --- src/core/gateway-resolver.ts | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index 6994e39..b803844 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -1,3 +1,7 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve as resolvePath } from "node:path"; + /** * Resolve the gateway URL and API key for ``initAssembly``. * @@ -73,3 +77,43 @@ export async function waitForHealthz( 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 {}; + } +} + From 833540200969c5d7048f2499494018ebacb02c6b Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:33:29 +0800 Subject: [PATCH 09/20] =?UTF-8?q?=E2=AC=86=20(deps):=20Add=20js-yaml=20+?= =?UTF-8?q?=20@types/js-yaml=20as=20dev=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required for the loadConfigFile happy-path test in the upcoming commit. js-yaml stays a soft (dynamic-import) dependency at runtime — SDK consumers without it will simply skip the YAML config-file step. The dev pin lets the test suite exercise the parsing branch. --- package.json | 10 ++++++---- pnpm-lock.yaml | 11 +++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) 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': From 0341843623e8ea346921cd17c0332c238d3ac4c4 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:34:03 +0800 Subject: [PATCH 10/20] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20loadConfigFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four behaviors: missing file → {}, well-formed YAML → parsed mapping, non-mapping root (top-level list) → {}, malformed YAML → {}. Uses mkdtempSync for an isolated tmp dir; no real ~/.aasm/ touched. --- tests/gateway-resolver.test.ts | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/gateway-resolver.test.ts b/tests/gateway-resolver.test.ts index 42a8752..b21a730 100644 --- a/tests/gateway-resolver.test.ts +++ b/tests/gateway-resolver.test.ts @@ -1,7 +1,12 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_GATEWAY_URL, + loadConfigFile, probeHealthz, waitForHealthz, } from "../src/core/gateway-resolver.js"; @@ -76,3 +81,43 @@ describe("waitForHealthz", () => { 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({}); + }); +}); From e9c1c79c82090d9f8bde6b8f6f2ef5828c312200 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:34:33 +0800 Subject: [PATCH 11/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20findAasmOnPat?= =?UTF-8?q?h=20+=20spawnAasm=20seams=20+=20=5F=5Ftesting=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the two private primitives that autoStartGateway will compose: defaultFindAasmOnPath walks process.env.PATH (with .exe/.cmd suffixes on Windows) and defaultSpawnAasm launches the binary detached with stdio ignored — the docker-style daemon hand-off. Both are routed through a mutable _seams object exposed via ``__testing`` so tests can stub them without ESM module-mocking gymnastics. --- src/core/gateway-resolver.ts | 37 +++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index b803844..22a95c7 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -1,6 +1,7 @@ +import { spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { resolve as resolvePath } from "node:path"; +import { join, resolve as resolvePath } from "node:path"; /** * Resolve the gateway URL and API key for ``initAssembly``. @@ -117,3 +118,37 @@ export async function loadConfigFile( } } +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, +}; + +export const __testing = { _seams }; + From 20b72daf46f317bd51a53aca2c4d7949aa3ce8fd Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:34:52 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20autoStartGate?= =?UTF-8?q?way?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the seams: findAasmOnPath → ConfigurationError when missing, spawnAasm to launch detached, waitForHealthz to confirm readiness or raise GatewayError after the configured timeout. Matches the Python SDK's _auto_start_gateway contract semantically. --- src/core/gateway-resolver.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index 22a95c7..932b98d 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -3,6 +3,8 @@ 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``. * @@ -152,3 +154,36 @@ const _seams = { 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` + ); + } +} + From a74aa78de84e06f7735984b27eb6713a7621b562 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:35:13 +0800 Subject: [PATCH 13/20] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20tests=20for?= =?UTF-8?q?=20autoStartGateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three behaviors: aasm-missing → ConfigurationError with install hint, spawn succeeds + healthz ready → resolves and the path is passed to the spawn seam, spawn succeeds but timeout elapses → GatewayError. Stubs done via the __testing._seams handle plus fetch — no real subprocess, no real network. --- tests/gateway-resolver.test.ts | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/gateway-resolver.test.ts b/tests/gateway-resolver.test.ts index b21a730..deec929 100644 --- a/tests/gateway-resolver.test.ts +++ b/tests/gateway-resolver.test.ts @@ -5,11 +5,14 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + __testing, + autoStartGateway, DEFAULT_GATEWAY_URL, loadConfigFile, probeHealthz, waitForHealthz, } from "../src/core/gateway-resolver.js"; +import { ConfigurationError, GatewayError } from "../src/errors/index.js"; describe("probeHealthz", () => { let originalFetch: typeof globalThis.fetch; @@ -121,3 +124,47 @@ describe("loadConfigFile", () => { 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); + }); +}); From 0853ef89088bf22018f4c1848fc90b0863ad0566 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:35:45 +0800 Subject: [PATCH 14/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20resolveGatewa?= =?UTF-8?q?yUrl=20public=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 4-step precedence chain (explicit > AAASM_GATEWAY_URL > ~/.aasm/config.yaml agent.gateway_url > local default with probe + auto-start). probeHealthz / loadConfigFile / autoStartGateway are now routed through ``_seams`` too so unit tests can stub each step independently without spawning real aasm or hitting the network. --- src/core/gateway-resolver.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index 932b98d..c479fad 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -150,6 +150,9 @@ function defaultSpawnAasm(aasmPath: string): void { const _seams = { findAasmOnPath: defaultFindAasmOnPath, spawnAasm: defaultSpawnAasm, + probeHealthz: probeHealthz, + loadConfigFile: loadConfigFile, + autoStartGateway: autoStartGateway, }; export const __testing = { _seams }; @@ -187,3 +190,32 @@ export async function autoStartGateway( } } +/** + * 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; +} + From 99065ee607ce2374e1a728c6a2d9ac36ba7783a1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:36:10 +0800 Subject: [PATCH 15/20] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20precedence=20?= =?UTF-8?q?tests=20for=20resolveGatewayUrl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests exercising the 4-step chain: explicit > env > config > local-default; the local-default branch is split into probe-hit (no auto-start) and probe-miss (auto-start invoked with the canonical URL). __testing._seams handles cross-step stubbing without touching the real network or PATH. --- tests/gateway-resolver.test.ts | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/gateway-resolver.test.ts b/tests/gateway-resolver.test.ts index deec929..fdb2b62 100644 --- a/tests/gateway-resolver.test.ts +++ b/tests/gateway-resolver.test.ts @@ -8,8 +8,10 @@ import { __testing, autoStartGateway, DEFAULT_GATEWAY_URL, + ENV_GATEWAY_URL, loadConfigFile, probeHealthz, + resolveGatewayUrl, waitForHealthz, } from "../src/core/gateway-resolver.js"; import { ConfigurationError, GatewayError } from "../src/errors/index.js"; @@ -168,3 +170,58 @@ describe("autoStartGateway", () => { 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); + }); +}); From f9dce4502be5a73f42d6086a9f4a57bba56b6354 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:36:25 +0800 Subject: [PATCH 16/20] =?UTF-8?q?=E2=9C=A8=20(core):=20Add=20resolveApiKey?= =?UTF-8?q?=20public=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors resolveGatewayUrl's 4-step precedence for apiKey: explicit field → AAASM_API_KEY env → config-file agent.api_key → empty default. No auto-start path — local mode is unauth-accepting per the Epic, so the empty fallback is the documented default rather than an error. --- src/core/gateway-resolver.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/core/gateway-resolver.ts b/src/core/gateway-resolver.ts index c479fad..8dafffa 100644 --- a/src/core/gateway-resolver.ts +++ b/src/core/gateway-resolver.ts @@ -219,3 +219,26 @@ export async function resolveGatewayUrl(explicit?: string): Promise { 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 ""; +} + From 345d1aa7dee0983bac209336aa3d38829a5f47e1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:36:46 +0800 Subject: [PATCH 17/20] =?UTF-8?q?=E2=9C=85=20(core):=20Add=20precedence=20?= =?UTF-8?q?tests=20for=20resolveApiKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four tests mirroring resolveGatewayUrl's chain: explicit > env > config > empty-default. No rejection on missing — empty string is the documented local-mode default. --- tests/gateway-resolver.test.ts | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/gateway-resolver.test.ts b/tests/gateway-resolver.test.ts index fdb2b62..50384c8 100644 --- a/tests/gateway-resolver.test.ts +++ b/tests/gateway-resolver.test.ts @@ -8,9 +8,11 @@ import { __testing, autoStartGateway, DEFAULT_GATEWAY_URL, + ENV_API_KEY, ENV_GATEWAY_URL, loadConfigFile, probeHealthz, + resolveApiKey, resolveGatewayUrl, waitForHealthz, } from "../src/core/gateway-resolver.js"; @@ -225,3 +227,42 @@ describe("resolveGatewayUrl", () => { 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(""); + }); +}); From 1de26073ab534b326d5d653fbdfec3381816fb8e Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:37:40 +0800 Subject: [PATCH 18/20] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(core):=20Relax=20As?= =?UTF-8?q?semblyConfig=20+=20wire=20resolver=20into=20initAssembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gatewayUrl and apiKey are now optional on AssemblyConfig and the config arg itself defaults to {}, so initAssembly() with no arguments works per the Story AC. The body calls resolveGatewayUrl / resolveApiKey to fill the gaps via the 4-step precedence chain. Bundled with the type change to keep this commit bisectable — type relaxation alone left init-assembly.ts uncompilable. --- src/core/init-assembly.ts | 18 ++++++++++++------ src/types/assembly-config.ts | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 8 deletions(-) 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 Date: Sat, 23 May 2026 02:37:58 +0800 Subject: [PATCH 19/20] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20zero-arg=20in?= =?UTF-8?q?itAssembly=20resolves=20local=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two scenarios: probe-hit (no auto-start invoked, ctx returned bound to localhost default) and probe-miss (autoStartGateway invoked with the canonical URL). Uses __testing._seams to short-circuit the resolver and skip any real subprocess or network activity. --- tests/init-assembly-zero-config.test.ts | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/init-assembly-zero-config.test.ts diff --git a/tests/init-assembly-zero-config.test.ts b/tests/init-assembly-zero-config.test.ts new file mode 100644 index 0000000..a53fa91 --- /dev/null +++ b/tests/init-assembly-zero-config.test.ts @@ -0,0 +1,54 @@ +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(); + } + }); +}); From 2deb416bdf910c4178def7c4c49ce2e317c747b1 Mon Sep 17 00:00:00 2001 From: Chisanan232 Date: Sat, 23 May 2026 02:38:15 +0800 Subject: [PATCH 20/20] =?UTF-8?q?=E2=9C=85=20(test):=20Add=20regression=20?= =?UTF-8?q?for=20explicit=20gatewayUrl/apiKey=20callers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story AC: existing callers passing both gatewayUrl and apiKey must be unaffected by the resolver path. Stubs probeHealthz and autoStartGateway with sentinels that throw if invoked — proves the resolver short-circuits on explicit args. --- tests/init-assembly-zero-config.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/init-assembly-zero-config.test.ts b/tests/init-assembly-zero-config.test.ts index a53fa91..482cf80 100644 --- a/tests/init-assembly-zero-config.test.ts +++ b/tests/init-assembly-zero-config.test.ts @@ -51,4 +51,26 @@ describe("initAssembly zero-config", () => { 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(); + } + }); });