agent-runner.test.ts

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