agent-runner.test.ts

  1import { describe, test, expect, beforeAll, afterAll } from "bun:test";
  2import { AgentError } from "../src/util/errors.js";
  3import { expandEnvVars, resolveConfigValue, resolveHeaders } from "../src/util/env.js";
  4import { buildGetApiKey } from "../src/agent/runner.js";
  5import type { RumiloConfig } from "../src/config/schema.js";
  6
  7const stubConfig: RumiloConfig = {
  8  defaults: { model: "anthropic:test", cleanup: true },
  9  web: { model: "anthropic:test" },
 10  repo: { model: "anthropic:test", default_depth: 1, blob_limit: "5m" },
 11};
 12
 13function customModel(provider: string, apiKey?: string): RumiloConfig {
 14  return {
 15    ...stubConfig,
 16    custom_models: {
 17      mymodel: {
 18        id: "m1",
 19        name: "M1",
 20        api: "openai-completions" as any,
 21        provider,
 22        base_url: "http://localhost:8000/v1",
 23        reasoning: false,
 24        input: ["text"],
 25        cost: { input: 0, output: 0 },
 26        context_window: 8192,
 27        max_tokens: 4096,
 28        ...(apiKey ? { api_key: apiKey } : {}),
 29      },
 30    },
 31  };
 32}
 33
 34describe("AgentError", () => {
 35  test("has correct name, code, and inherits from Error", () => {
 36    const err = new AgentError("boom");
 37    expect(err).toBeInstanceOf(Error);
 38    expect(err.name).toBe("AgentError");
 39    expect(err.code).toBe("AGENT_ERROR");
 40    expect(err.message).toBe("boom");
 41  });
 42});
 43
 44describe("resolveConfigValue", () => {
 45  const saved: Record<string, string | undefined> = {};
 46
 47  beforeAll(() => {
 48    saved["RUMILO_TEST_KEY"] = process.env["RUMILO_TEST_KEY"];
 49    process.env["RUMILO_TEST_KEY"] = "resolved-value";
 50  });
 51
 52  afterAll(() => {
 53    if (saved["RUMILO_TEST_KEY"] === undefined) delete process.env["RUMILO_TEST_KEY"];
 54    else process.env["RUMILO_TEST_KEY"] = saved["RUMILO_TEST_KEY"];
 55  });
 56
 57  test("resolves bare env var name", () => {
 58    expect(resolveConfigValue("RUMILO_TEST_KEY")).toBe("resolved-value");
 59  });
 60
 61  test("resolves $VAR reference", () => {
 62    expect(resolveConfigValue("$RUMILO_TEST_KEY")).toBe("resolved-value");
 63  });
 64
 65  test("resolves ${VAR} reference", () => {
 66    expect(resolveConfigValue("${RUMILO_TEST_KEY}")).toBe("resolved-value");
 67  });
 68
 69  test("treats unknown name as literal", () => {
 70    expect(resolveConfigValue("sk-literal-key-12345")).toBe("sk-literal-key-12345");
 71  });
 72
 73  test("returns undefined for unknown $VAR", () => {
 74    expect(resolveConfigValue("$RUMILO_NONEXISTENT_XYZ")).toBeUndefined();
 75  });
 76
 77  test("executes shell commands with ! prefix", () => {
 78    expect(resolveConfigValue("!echo hello")).toBe("hello");
 79  });
 80
 81  test("returns undefined for failing shell command", () => {
 82    expect(resolveConfigValue("!false")).toBeUndefined();
 83  });
 84});
 85
 86describe("expandEnvVars", () => {
 87  const saved: Record<string, string | undefined> = {};
 88
 89  beforeAll(() => {
 90    saved["FOO"] = process.env["FOO"];
 91    saved["BAR"] = process.env["BAR"];
 92    process.env["FOO"] = "hello";
 93    process.env["BAR"] = "world";
 94  });
 95
 96  afterAll(() => {
 97    if (saved["FOO"] === undefined) delete process.env["FOO"];
 98    else process.env["FOO"] = saved["FOO"];
 99    if (saved["BAR"] === undefined) delete process.env["BAR"];
100    else process.env["BAR"] = saved["BAR"];
101  });
102
103  test("expands $VAR", () => {
104    expect(expandEnvVars("Bearer $FOO")).toBe("Bearer hello");
105  });
106
107  test("expands ${VAR}", () => {
108    expect(expandEnvVars("Bearer ${FOO}")).toBe("Bearer hello");
109  });
110
111  test("expands multiple vars", () => {
112    expect(expandEnvVars("$FOO-$BAR")).toBe("hello-world");
113  });
114
115  test("missing var becomes empty string", () => {
116    expect(expandEnvVars("key=$NONEXISTENT_RUMILO_VAR_XYZ")).toBe("key=");
117  });
118
119  test("string without vars is unchanged", () => {
120    expect(expandEnvVars("plain text")).toBe("plain text");
121  });
122});
123
124describe("resolveHeaders", () => {
125  const saved: Record<string, string | undefined> = {};
126
127  beforeAll(() => {
128    saved["RUMILO_HDR_KEY"] = process.env["RUMILO_HDR_KEY"];
129    process.env["RUMILO_HDR_KEY"] = "hdr-value";
130  });
131
132  afterAll(() => {
133    if (saved["RUMILO_HDR_KEY"] === undefined) delete process.env["RUMILO_HDR_KEY"];
134    else process.env["RUMILO_HDR_KEY"] = saved["RUMILO_HDR_KEY"];
135  });
136
137  test("returns undefined for undefined input", () => {
138    expect(resolveHeaders(undefined)).toBeUndefined();
139  });
140
141  test("resolves header values via resolveConfigValue", () => {
142    const result = resolveHeaders({ "X-Key": "RUMILO_HDR_KEY" });
143    expect(result).toEqual({ "X-Key": "hdr-value" });
144  });
145
146  test("drops entries that resolve to undefined", () => {
147    const result = resolveHeaders({ "X-Key": "$RUMILO_NONEXISTENT_XYZ" });
148    expect(result).toBeUndefined();
149  });
150});
151
152describe("buildGetApiKey", () => {
153  const saved: Record<string, string | undefined> = {};
154
155  beforeAll(() => {
156    saved["ANTHROPIC_API_KEY"] = process.env["ANTHROPIC_API_KEY"];
157    saved["CUSTOM_KEY"] = process.env["CUSTOM_KEY"];
158    process.env["ANTHROPIC_API_KEY"] = "sk-ant-test";
159    process.env["CUSTOM_KEY"] = "sk-custom-test";
160  });
161
162  afterAll(() => {
163    for (const [k, v] of Object.entries(saved)) {
164      if (v === undefined) delete process.env[k];
165      else process.env[k] = v;
166    }
167  });
168
169  test("falls back to pi-ai env var lookup for built-in providers", () => {
170    const getKey = buildGetApiKey(stubConfig);
171    expect(getKey("anthropic")).toBe("sk-ant-test");
172  });
173
174  test("returns undefined for unknown provider with no config", () => {
175    const getKey = buildGetApiKey(stubConfig);
176    expect(getKey("unknown-provider")).toBeUndefined();
177  });
178
179  test("resolves literal api_key from custom model", () => {
180    const config = customModel("myprovider", "sk-literal-key");
181    const getKey = buildGetApiKey(config);
182    expect(getKey("myprovider")).toBe("sk-literal-key");
183  });
184
185  test("resolves api_key via env var name", () => {
186    const config = customModel("myprovider", "CUSTOM_KEY");
187    const getKey = buildGetApiKey(config);
188    expect(getKey("myprovider")).toBe("sk-custom-test");
189  });
190
191  test("resolves api_key via $VAR reference", () => {
192    const config = customModel("myprovider", "$CUSTOM_KEY");
193    const getKey = buildGetApiKey(config);
194    expect(getKey("myprovider")).toBe("sk-custom-test");
195  });
196
197  test("resolves api_key via shell command", () => {
198    const config = customModel("myprovider", "!echo shell-key");
199    const getKey = buildGetApiKey(config);
200    expect(getKey("myprovider")).toBe("shell-key");
201  });
202
203  test("custom model provider doesn't shadow built-in provider lookup", () => {
204    const config = customModel("other-provider", "sk-other");
205    const getKey = buildGetApiKey(config);
206    expect(getKey("anthropic")).toBe("sk-ant-test");
207  });
208
209  test("falls back to env var lookup when custom model has no api_key", () => {
210    const config = customModel("anthropic");
211    const getKey = buildGetApiKey(config);
212    expect(getKey("anthropic")).toBe("sk-ant-test");
213  });
214});