@gr8ful/spf 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +122 -4
  2. package/assets/defaults/spf.config.yaml +6 -0
  3. package/assets/prompts/reviewer/system.md +1 -1
  4. package/assets/skill/SKILL.md +1 -0
  5. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  6. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  7. package/assets/skill/cookbooks/roster.md +15 -4
  8. package/assets/skill/cookbooks/spf_overview.md +1 -0
  9. package/assets/skill/references/config.md +69 -4
  10. package/assets/skill/references/observability.md +11 -2
  11. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  12. package/assets/templates/ts.spf.config.yaml +5 -0
  13. package/dist/chains/context.d.ts +30 -0
  14. package/dist/chains/index.d.ts +94 -10
  15. package/dist/chains/index.js +70 -5
  16. package/dist/chains/repo_chains.d.ts +139 -0
  17. package/dist/chains/repo_chains.js +428 -0
  18. package/dist/chains/simple_sdlc.d.ts +74 -1
  19. package/dist/chains/simple_sdlc.js +134 -4
  20. package/dist/chains/steps.d.ts +215 -20
  21. package/dist/chains/steps.js +429 -61
  22. package/dist/cli/ask.d.ts +14 -1
  23. package/dist/cli/ask.js +32 -2
  24. package/dist/cli/commands/doctor.d.ts +1 -1
  25. package/dist/cli/commands/doctor.js +319 -11
  26. package/dist/cli/commands/init.d.ts +12 -0
  27. package/dist/cli/commands/init.js +78 -1
  28. package/dist/cli/commands/list.js +42 -5
  29. package/dist/cli/commands/run.js +25 -2
  30. package/dist/cli/commands/watch.d.ts +18 -0
  31. package/dist/cli/commands/watch.js +147 -10
  32. package/dist/cli/index.js +60 -3
  33. package/dist/cli/interview.js +65 -10
  34. package/dist/core/agent_cc.d.ts +40 -1
  35. package/dist/core/agent_cc.js +51 -4
  36. package/dist/core/agent_flue.js +28 -4
  37. package/dist/core/agents.d.ts +8 -0
  38. package/dist/core/agents.js +43 -3
  39. package/dist/core/data_types.d.ts +104 -4
  40. package/dist/core/data_types.js +99 -2
  41. package/dist/core/git_helper.d.ts +29 -0
  42. package/dist/core/git_helper.js +41 -1
  43. package/dist/core/ollama_provider.d.ts +70 -0
  44. package/dist/core/ollama_provider.js +208 -0
  45. package/dist/core/otel.d.ts +352 -0
  46. package/dist/core/otel.js +793 -0
  47. package/dist/core/providers.js +4 -0
  48. package/dist/core/refine.js +11 -3
  49. package/dist/core/session.js +39 -2
  50. package/dist/core/tracer.d.ts +31 -2
  51. package/dist/core/tracer.js +69 -11
  52. package/dist/core/watch.d.ts +11 -0
  53. package/dist/core/watch.js +17 -2
  54. package/dist/test/chains.test.js +8 -3
  55. package/dist/test/data_types.test.js +140 -2
  56. package/dist/test/git_helper.test.d.ts +1 -0
  57. package/dist/test/git_helper.test.js +59 -0
  58. package/dist/test/hermetic_git.d.ts +1 -0
  59. package/dist/test/hermetic_git.js +22 -0
  60. package/dist/test/init_command.test.d.ts +14 -1
  61. package/dist/test/init_command.test.js +54 -1
  62. package/dist/test/interview.test.d.ts +15 -1
  63. package/dist/test/interview.test.js +127 -0
  64. package/dist/test/ollama_provider.test.d.ts +1 -0
  65. package/dist/test/ollama_provider.test.js +103 -0
  66. package/dist/test/otel.test.d.ts +26 -0
  67. package/dist/test/otel.test.js +512 -0
  68. package/dist/test/refine.test.js +64 -1
  69. package/dist/test/repo_chains.test.d.ts +21 -0
  70. package/dist/test/repo_chains.test.js +416 -0
  71. package/dist/test/signoff.test.d.ts +1 -0
  72. package/dist/test/signoff.test.js +329 -0
  73. package/dist/test/ui_server.test.d.ts +7 -1
  74. package/dist/test/ui_server.test.js +1 -0
  75. package/dist/test/watch.test.js +124 -1
  76. package/package.json +5 -5
@@ -1 +1,14 @@
1
- export {};
1
+ /**
2
+ * Regression guard for `spf init`'s non-interactive paths — `--yes`,
3
+ * `--template <name>`, and (implicitly, since the test runner's stdin/stdout
4
+ * are never a TTY) plain `spf init` with no flags. All three must keep
5
+ * writing the exact same content they always have, byte for byte, and must
6
+ * never touch stdin — a scripted `spf init` in CI must not hang.
7
+ *
8
+ * The interactive interview itself is covered directly against `Asker`
9
+ * in `interview.test.ts`; `initCommand`'s interactive branch always calls
10
+ * the real `createAsker()` (backed by `node:readline` on `process.stdin`),
11
+ * which has nothing to read in a test process — exercising it here would
12
+ * just hang, so it isn't.
13
+ */
14
+ import "./hermetic_git.js";
@@ -11,13 +11,16 @@
11
11
  * which has nothing to read in a test process — exercising it here would
12
12
  * just hang, so it isn't.
13
13
  */
14
+ import "./hermetic_git.js";
14
15
  import { test, beforeEach, afterEach } from "node:test";
15
16
  import assert from "node:assert/strict";
16
17
  import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
17
18
  import { tmpdir } from "node:os";
18
19
  import { join } from "node:path";
19
20
  import { execFileSync } from "node:child_process";
20
- import { initCommand } from "../cli/commands/init.js";
21
+ import { initCommand, EXAMPLE_CHAIN_YAML } from "../cli/commands/init.js";
22
+ import { loadRepoChains } from "../chains/repo_chains.js";
23
+ import { resolveAnchor } from "../core/paths.js";
21
24
  let dir;
22
25
  beforeEach(() => {
23
26
  dir = mkdtempSync(join(tmpdir(), "spf-init-test-"));
@@ -74,6 +77,56 @@ test("--no-skills skips the skill install", async () => {
74
77
  assert.equal(code, 0);
75
78
  assert.equal(existsSync(join(dir, ".claude", "skills", "spf")), false);
76
79
  });
80
+ test("scaffolds .spf/chains/example.yaml, fully commented out (registers nothing)", async () => {
81
+ const code = await initCommand(["--cwd", dir, "--yes"]);
82
+ assert.equal(code, 0);
83
+ const examplePath = join(dir, ".spf", "chains", "example.yaml");
84
+ assert.ok(existsSync(examplePath), "spf init should scaffold .spf/chains/example.yaml");
85
+ const content = readFileSync(examplePath, "utf-8");
86
+ for (const line of content.split("\n")) {
87
+ if (line.trim() === "")
88
+ continue;
89
+ assert.ok(line.startsWith("#"), `every non-blank line in the scaffolded example must be commented out: ${JSON.stringify(line)}`);
90
+ }
91
+ });
92
+ test("never adds .spf/chains to .gitignore — chain files must stay tracked for protected_files to cover them", async () => {
93
+ await initCommand(["--cwd", dir, "--yes"]);
94
+ const gitignore = readFileSync(join(dir, ".gitignore"), "utf-8");
95
+ assert.doesNotMatch(gitignore, /\.spf\/chains/);
96
+ });
97
+ test("the scaffolded example.yaml loads clean — zero chains, zero problems (it must not report a problem against spf's own scaffold)", async () => {
98
+ const code = await initCommand(["--cwd", dir, "--yes"]);
99
+ assert.equal(code, 0);
100
+ const anchor = resolveAnchor(dir);
101
+ const { chains, problems } = loadRepoChains(anchor);
102
+ assert.deepEqual(chains, []);
103
+ assert.deepEqual(problems, []);
104
+ });
105
+ test("EXAMPLE_CHAIN_YAML's commented-out template, uncommented verbatim, is a chain the loader accepts", async () => {
106
+ const lines = EXAMPLE_CHAIN_YAML.split("\n");
107
+ const startIdx = lines.findIndex((l) => l.startsWith("# name: example"));
108
+ assert.ok(startIdx !== -1, "expected a `# name: example` line to anchor the template block");
109
+ const uncommented = lines
110
+ .slice(startIdx)
111
+ .map((l) => l.replace(/^#\s?/, ""))
112
+ .join("\n");
113
+ const chainsDir = join(dir, ".spf", "chains");
114
+ execFileSync("mkdir", ["-p", chainsDir]);
115
+ const { writeFileSync } = await import("node:fs");
116
+ writeFileSync(join(chainsDir, "uncommented.yaml"), uncommented);
117
+ const anchor = resolveAnchor(dir);
118
+ const { chains, problems } = loadRepoChains(anchor);
119
+ assert.deepEqual(problems, [], `expected zero problems, got: ${JSON.stringify(problems)}`);
120
+ assert.equal(chains.length, 1);
121
+ assert.equal(chains[0]?.name, "example");
122
+ });
123
+ test("re-running spf init doesn't overwrite an already-scaffolded example.yaml", async () => {
124
+ await initCommand(["--cwd", dir, "--yes"]);
125
+ const examplePath = join(dir, ".spf", "chains", "example.yaml");
126
+ const before = readFileSync(examplePath, "utf-8");
127
+ await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force
128
+ assert.equal(readFileSync(examplePath, "utf-8"), before);
129
+ });
77
130
  test("re-running spf init doesn't re-copy an unchanged skill install (install-skill's own idempotency)", async () => {
78
131
  await initCommand(["--cwd", dir, "--yes"]);
79
132
  const manifestPath = join(dir, ".claude", "skills", "spf", ".spf-skill-version");
@@ -1 +1,15 @@
1
- export {};
1
+ /**
2
+ * `spf init`'s interactive interview (`cli/interview.ts`) — driven through
3
+ * the `Asker` seam with a scripted fake (`./fake_asker.ts`), never a real
4
+ * TTY. Covers the three things a wrong answer would actually break:
5
+ * 1. the pinned-roster trap (planner/reviewer/documenter overridden
6
+ * whenever the backend switches to claude_code — see agent_cc.ts/
7
+ * agents.ts's back-fill comment),
8
+ * 2. the generated config is a genuine override — validating it requires
9
+ * merging with the packaged built-in roster first, exactly like
10
+ * `spf doctor` does, never the raw document alone,
11
+ * 3. each `spf watch` combination collects exactly the credentials
12
+ * `cli/commands/watch.ts`'s resolveIssueProvider/resolveCodeHostProvider
13
+ * actually read — no more, no less.
14
+ */
15
+ import "./hermetic_git.js";
@@ -12,6 +12,7 @@
12
12
  * `cli/commands/watch.ts`'s resolveIssueProvider/resolveCodeHostProvider
13
13
  * actually read — no more, no less.
14
14
  */
15
+ import "./hermetic_git.js";
15
16
  import { test, before, after } from "node:test";
16
17
  import assert from "node:assert/strict";
17
18
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -107,6 +108,61 @@ test("flue + openrouter: no pinned-agent overrides needed, provider key collecte
107
108
  assert.equal(result.env.OPENROUTER_API_KEY, "sk-or-v1-test");
108
109
  assert.ok(!("watch" in config));
109
110
  });
111
+ test("flue + ollama: keyless provider skips the API-key prompt, but still asks for OLLAMA_BASE_URL", async () => {
112
+ const ctx = gatherContext(dir, new Map());
113
+ const asker = createFakeAsker({
114
+ select: { "backend runs": "flue", Provider: "ollama" },
115
+ text: { "Model id": "llama3" }, // deliberately no "OLLAMA_BASE_URL" entry — the fake asker falls back to its default
116
+ confirm: {
117
+ 'Add a "typecheck"': false,
118
+ 'Add a "lint"': false,
119
+ 'Add a "build"': false,
120
+ 'Add a "test"': false,
121
+ "Enable spf watch": false,
122
+ "Configure advanced": false,
123
+ "Write .spf": true,
124
+ },
125
+ // Deliberately no `secret` entries: a keyless provider must never call
126
+ // asker.secret() at all — PROVIDER_ENV_KEYS.ollama is `[]`, so indexing
127
+ // [0] for a prompt label would be `undefined`, not skip the prompt.
128
+ });
129
+ const result = await runInterview(asker, ctx);
130
+ assert.ok(result);
131
+ const config = result.config;
132
+ assert.equal(config.defaults.coding_agent, "flue");
133
+ assert.equal(config.defaults.model, "ollama/llama3");
134
+ assert.deepEqual(result.env, { OLLAMA_BASE_URL: "http://localhost:11434/v1" }, "no API key, but the base URL prompt's default is written");
135
+ assert.deepEqual(result.envExampleKeys, ["OLLAMA_BASE_URL"], "OLLAMA_BASE_URL is the only key name to record for a keyless provider");
136
+ // Unlike the openrouter case above, ollama DOES need the pinned-roster
137
+ // fix: planner/reviewer/documenter pin their own fireworks/gemini/openai
138
+ // model strings in the packaged roster, which would otherwise always win
139
+ // over defaults.model and leave three agents needing keys this interview
140
+ // never asked for.
141
+ assert.deepEqual(config.agents, [
142
+ { name: "planner", model: "ollama/llama3" },
143
+ { name: "reviewer", model: "ollama/llama3" },
144
+ { name: "documenter", model: "ollama/llama3" },
145
+ ], "planner/reviewer/documenter are pinned to the chosen ollama model, same fix as the claude_code branch");
146
+ });
147
+ test("flue + ollama: a custom OLLAMA_BASE_URL answer is written verbatim", async () => {
148
+ const ctx = gatherContext(dir, new Map());
149
+ const asker = createFakeAsker({
150
+ select: { "backend runs": "flue", Provider: "ollama" },
151
+ text: { "Model id": "llama3", OLLAMA_BASE_URL: "http://gpu-box.local:11434/v1" },
152
+ confirm: {
153
+ 'Add a "typecheck"': false,
154
+ 'Add a "lint"': false,
155
+ 'Add a "build"': false,
156
+ 'Add a "test"': false,
157
+ "Enable spf watch": false,
158
+ "Configure advanced": false,
159
+ "Write .spf": true,
160
+ },
161
+ });
162
+ const result = await runInterview(asker, ctx);
163
+ assert.ok(result);
164
+ assert.equal(result.env.OLLAMA_BASE_URL, "http://gpu-box.local:11434/v1");
165
+ });
110
166
  test("watch(jira/bitbucket): collects exactly JIRA_* + BITBUCKET_*, never GITHUB_TOKEN", async () => {
111
167
  const ctx = gatherContext(dir, new Map());
112
168
  const asker = createFakeAsker({
@@ -145,6 +201,77 @@ test("watch(jira/bitbucket): collects exactly JIRA_* + BITBUCKET_*, never GITHUB
145
201
  assert.equal(result.env.BITBUCKET_API_TOKEN, "bb-token");
146
202
  assert.equal(result.env.GITHUB_TOKEN, undefined, "neither provider needs GitHub — must not ask for or write GITHUB_TOKEN");
147
203
  });
204
+ test("watch(github issues + bitbucket code): asks two repo questions and writes issue_repo separately from repo", async () => {
205
+ const ctx = gatherContext(dir, new Map());
206
+ const asker = createFakeAsker({
207
+ select: {
208
+ "backend runs": "claude_code",
209
+ "Model (Claude": "sonnet",
210
+ Authentication: "login",
211
+ "Issue tracker": "github",
212
+ "Code host": "bitbucket",
213
+ "Chain to run": "plan-build-test",
214
+ },
215
+ text: {
216
+ "Code repo, where PRs open (workspace/repo_slug)": "acme-workspace/widgets-code",
217
+ "Issue repo, where spf:ready issues live (owner/name)": "acme/widgets-issues",
218
+ },
219
+ confirm: {
220
+ 'Add a "typecheck"': false,
221
+ 'Add a "lint"': false,
222
+ 'Add a "build"': false,
223
+ 'Add a "test"': false,
224
+ "Enable spf watch": true,
225
+ "Also enable the refine lane": false,
226
+ "Configure advanced": false,
227
+ "Write .spf": true,
228
+ },
229
+ secret: { GITHUB_TOKEN: "ghp_x", BITBUCKET_API_TOKEN: "bb-token" },
230
+ });
231
+ const result = await runInterview(asker, ctx);
232
+ assert.ok(result);
233
+ const config = result.config;
234
+ assert.equal(config.watch.issue_provider, "github");
235
+ assert.equal(config.watch.code_host, "bitbucket");
236
+ assert.equal(config.watch.repo, "acme-workspace/widgets-code", "repo is the CODE HOST's repo");
237
+ assert.equal(config.watch.issue_repo, "acme/widgets-issues", "issue_repo overrides repo for the issue tracker side");
238
+ const configPath = mergedConfigPath();
239
+ const { stringify } = await import("yaml");
240
+ writeFileSync(configPath, stringify(config));
241
+ const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
242
+ assert.equal(cfg.watch.repo, "acme-workspace/widgets-code");
243
+ assert.equal(cfg.watch.issue_repo, "acme/widgets-issues");
244
+ });
245
+ test("watch(github issues + github code): a single repo answers both, issue_repo stays unset", async () => {
246
+ const ctx = gatherContext(dir, new Map());
247
+ const asker = createFakeAsker({
248
+ select: {
249
+ "backend runs": "claude_code",
250
+ "Model (Claude": "sonnet",
251
+ Authentication: "login",
252
+ "Issue tracker": "github",
253
+ "Code host": "github",
254
+ "Chain to run": "plan-build-test",
255
+ },
256
+ text: { "Repo (owner/name)": "acme/widgets" },
257
+ confirm: {
258
+ 'Add a "typecheck"': false,
259
+ 'Add a "lint"': false,
260
+ 'Add a "build"': false,
261
+ 'Add a "test"': false,
262
+ "Enable spf watch": true,
263
+ "Also enable the refine lane": false,
264
+ "Configure advanced": false,
265
+ "Write .spf": true,
266
+ },
267
+ secret: { GITHUB_TOKEN: "ghp_x" },
268
+ });
269
+ const result = await runInterview(asker, ctx);
270
+ assert.ok(result);
271
+ const config = result.config;
272
+ assert.equal(config.watch.repo, "acme/widgets");
273
+ assert.equal(config.watch.issue_repo, undefined, "the single-repo case never writes issue_repo at all");
274
+ });
148
275
  test("declining the final confirm returns null — nothing to write", async () => {
149
276
  const ctx = gatherContext(dir, new Map());
150
277
  const asker = createFakeAsker({ defaultConfirm: false }); // every confirm, including the final one, says no
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Hermetic — never touches a live Ollama server. These exercise Flue's own
3
+ * in-process provider registry (via `@flue/runtime/internal`) plus this
4
+ * module's accumulation/idempotence logic, using synthetic model ids.
5
+ */
6
+ import { test, beforeEach, afterEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { hasProvider, resetModelsForTests, resolveModel as flueResolveModel } from "@flue/runtime/internal";
9
+ import { providerForTest, registerOllamaModel, resetOllamaRegistrationForTest, } from "../core/ollama_provider.js";
10
+ // A minimal stand-in for pi-ai's `AuthContext` — our resolver ignores it
11
+ // entirely (it has no ambient env/file lookups to do), but the `resolve()`
12
+ // call site still needs something shaped right to pass.
13
+ const fakeAuthContext = { env: async () => undefined, fileExists: async () => false };
14
+ const ORIGINAL_BASE_URL = process.env.OLLAMA_BASE_URL;
15
+ beforeEach(() => {
16
+ resetOllamaRegistrationForTest();
17
+ resetModelsForTests();
18
+ delete process.env.OLLAMA_BASE_URL;
19
+ });
20
+ afterEach(() => {
21
+ if (ORIGINAL_BASE_URL === undefined)
22
+ delete process.env.OLLAMA_BASE_URL;
23
+ else
24
+ process.env.OLLAMA_BASE_URL = ORIGINAL_BASE_URL;
25
+ });
26
+ test("registerOllamaModel: union accumulation — registering a second id doesn't orphan the first", async () => {
27
+ await registerOllamaModel("model-a");
28
+ await registerOllamaModel("model-b");
29
+ assert.equal(hasProvider("ollama"), true);
30
+ // `setProvider()` REPLACES the whole provider (models list included) on
31
+ // every call. If this module regressed from "re-register the full union"
32
+ // to "register just the newest id", model-a would throw "Unknown model
33
+ // ID … for provider \"ollama\"" here — exactly the failure the spike hit
34
+ // and the `registeredIds` Set exists to prevent.
35
+ const a = flueResolveModel("ollama/model-a");
36
+ const b = flueResolveModel("ollama/model-b");
37
+ assert.equal(a.id, "model-a");
38
+ assert.equal(b.id, "model-b");
39
+ const idsOnProvider = providerForTest()
40
+ .getModels()
41
+ .map((m) => m.id)
42
+ .sort();
43
+ assert.deepEqual(idsOnProvider, ["model-a", "model-b"], "the provider's own model list carries both ids, not just the latest");
44
+ });
45
+ test("registerOllamaModel: flue's registry resolves the SAME model object our provider registered — pins the pi-ai single-copy dedupe invariant", async () => {
46
+ // package.json pins @earendil-works/pi-ai to the exact version
47
+ // @flue/runtime depends on so npm dedupes to one physical copy — see that
48
+ // pin's own comment for why. If a future dependency bump ever re-splits
49
+ // that into two copies, `setProvider()`'s `Model`/`Provider` values would
50
+ // stop being instances flue's OWN copy of pi-ai recognizes, and
51
+ // `resolveModel()` would resolve to a DIFFERENT object than the one on
52
+ // our provider — same string id, wrong identity. Reference equality here
53
+ // is the one assertion that would actually catch that regression; a
54
+ // structural `deepEqual` would keep passing right through it.
55
+ await registerOllamaModel("model-a");
56
+ const resolved = flueResolveModel("ollama/model-a");
57
+ const onProvider = providerForTest().getModels().find((m) => m.id === "model-a");
58
+ assert.equal(resolved, onProvider, "flue's registry and our provider must hand back the identical object, not merely an equal one");
59
+ });
60
+ test("registerOllamaModel: idempotent for an already-registered id", async () => {
61
+ await registerOllamaModel("model-a");
62
+ const providerAfterFirst = providerForTest();
63
+ await registerOllamaModel("model-a");
64
+ assert.equal(providerForTest(), providerAfterFirst, "a repeat registration never calls setProvider again — same object identity");
65
+ });
66
+ test("registerOllamaModel: concurrent calls for the same new id both resolve to one registration, not a second no-op", async () => {
67
+ // Regression guard for committing to `registeredIds` before `setProvider`
68
+ // finishes: if the second call saw the id "already registered" before
69
+ // the first call's registration actually completed, it would resolve
70
+ // immediately with nothing registered yet, and a caller awaiting it could
71
+ // start dispatching before `setProvider()` ran.
72
+ const [a, b] = await Promise.all([registerOllamaModel("model-c"), registerOllamaModel("model-c")]);
73
+ assert.equal(a, undefined);
74
+ assert.equal(b, undefined);
75
+ const model = flueResolveModel("ollama/model-c");
76
+ assert.equal(model.id, "model-c");
77
+ });
78
+ test("registerOllamaModel: defaults to http://localhost:11434/v1 when OLLAMA_BASE_URL is unset", async () => {
79
+ await registerOllamaModel("model-a");
80
+ const model = flueResolveModel("ollama/model-a");
81
+ assert.equal(model.baseUrl, "http://localhost:11434/v1");
82
+ assert.equal(providerForTest().baseUrl, "http://localhost:11434/v1");
83
+ });
84
+ test("registerOllamaModel: OLLAMA_BASE_URL override is respected", async () => {
85
+ process.env.OLLAMA_BASE_URL = "http://example-ollama-host:9999/v1";
86
+ await registerOllamaModel("model-a");
87
+ const model = flueResolveModel("ollama/model-a");
88
+ assert.equal(model.baseUrl, "http://example-ollama-host:9999/v1");
89
+ assert.equal(providerForTest().baseUrl, "http://example-ollama-host:9999/v1");
90
+ });
91
+ test("registerOllamaModel: registered models carry contextWindow 0 (disables threshold compaction)", async () => {
92
+ await registerOllamaModel("model-a");
93
+ const model = flueResolveModel("ollama/model-a");
94
+ assert.equal(model.contextWindow, 0);
95
+ });
96
+ test("registerOllamaModel: auth.apiKey resolves a truthy dummy key, never the upstream 'No API key' failure", async () => {
97
+ await registerOllamaModel("model-a");
98
+ const apiKeyAuth = providerForTest().auth.apiKey;
99
+ assert.ok(apiKeyAuth, "auth.apiKey must be present — pi-ai requires at least one of apiKey/oauth even for a keyless provider");
100
+ const resolved = await apiKeyAuth.resolve({ ctx: fakeAuthContext, credential: undefined });
101
+ assert.ok(resolved, "resolve() must report the provider as configured, not 'unconfigured'");
102
+ assert.ok(typeof resolved.auth.apiKey === "string" && resolved.auth.apiKey.length > 0, "the resolved apiKey must be a non-empty string — a falsy one is exactly what pi-ai's getClientApiKey() throws on at dispatch");
103
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The OTLP span exporter. Six things are pinned here, and every one of them is
3
+ * a thing a "simplification" would quietly break:
4
+ *
5
+ * 1. ID derivation — deterministic, right length, lowercase hex, never zero.
6
+ * 2. `traceparent` parsing — strict W3C, and GARBAGE IS SILENT (a malformed CI
7
+ * variable must degrade to "own root", never throw, never half-parse).
8
+ * 3. THE ALLOWLIST — a synthetic EventRecord whose payload carries a marker
9
+ * string, asserted absent from the literal JSON body the exporter would
10
+ * POST. This is the exfiltration test: `EventRecord.payload` holds tool
11
+ * args, result snippets, and the repo's own source. If someone adds a
12
+ * `stringValue` read from a payload, this test is what says no.
13
+ * 4. The queue bound — 3000 spans pushed synchronously stay <= the bound, with
14
+ * the overflow counted (drop-OLDEST, so the newest spans survive).
15
+ * 5. The wire shape — against an in-process `node:http` receiver, not a fetch
16
+ * mock, so the assertions are on real bytes: resourceSpans -> scopeSpans ->
17
+ * spans nesting, hex ids, AnyValue-wrapped attributes, and nanosecond
18
+ * timestamps AS STRINGS (a JSON number loses precision past 2^53).
19
+ * 6. Failure isolation — an endpoint that 500s or does not exist must resolve
20
+ * quietly, because export is never allowed to fail a run.
21
+ *
22
+ * Hermetic: every exporter is constructed with an explicit `env` (so the
23
+ * developer's own TRACEPARENT cannot leak in) and every network case talks to a
24
+ * loopback server on an ephemeral port.
25
+ */
26
+ export {};