@ryanfw/prompt-orchestration-pipeline 1.3.4 → 1.3.5

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 (45) hide show
  1. package/docs/http-api.md +3 -0
  2. package/docs/pop-task-guide.md +8 -0
  3. package/package.json +5 -2
  4. package/src/cli/__tests__/analyze-task.test.ts +1 -2
  5. package/src/config/__tests__/config-schema.test.ts +42 -0
  6. package/src/config/__tests__/legacy-literal-guard.test.ts +156 -0
  7. package/src/config/__tests__/models.test.ts +139 -1
  8. package/src/config/models.ts +121 -19
  9. package/src/core/__tests__/agent-step.test.ts +52 -0
  10. package/src/core/__tests__/job-view.test.ts +98 -0
  11. package/src/core/agent-step.ts +5 -1
  12. package/src/core/agent-types.ts +6 -1
  13. package/src/core/job-view.ts +21 -1
  14. package/src/llm/__tests__/dispatch.test.ts +130 -0
  15. package/src/llm/__tests__/index.test.ts +17 -18
  16. package/src/llm/__tests__/legacy-normalization.test.ts +109 -0
  17. package/src/llm/index.ts +109 -112
  18. package/src/providers/__tests__/claude-code.test.ts +9 -3
  19. package/src/providers/__tests__/moonshot.test.ts +33 -3
  20. package/src/providers/__tests__/types.test.ts +43 -0
  21. package/src/providers/__tests__/zhipu.test.ts +24 -28
  22. package/src/providers/claude-code.ts +2 -2
  23. package/src/providers/moonshot.ts +7 -0
  24. package/src/providers/types.ts +7 -5
  25. package/src/providers/zhipu.ts +0 -2
  26. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  27. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +36 -1
  28. package/src/task-analysis/analyzer.ts +4 -10
  29. package/src/task-analysis/enrichers/schema-deducer.ts +8 -9
  30. package/src/ui/dist/assets/{index-DN3-zvtP.js → index-lBbVeNZC.js} +5 -2
  31. package/src/ui/dist/assets/{index-DN3-zvtP.js.map → index-lBbVeNZC.js.map} +1 -1
  32. package/src/ui/dist/index.html +1 -1
  33. package/src/ui/pages/Code.tsx +5 -2
  34. package/src/ui/server/__tests__/http-api-contract.test.ts +838 -0
  35. package/src/ui/server/__tests__/job-control-endpoints.test.ts +134 -27
  36. package/src/ui/server/__tests__/job-control-service.test.ts +282 -0
  37. package/src/ui/server/__tests__/job-process-registry.test.ts +151 -0
  38. package/src/ui/server/__tests__/job-repository.test.ts +142 -0
  39. package/src/ui/server/config-bridge.ts +9 -6
  40. package/src/ui/server/embedded-assets.ts +4 -4
  41. package/src/ui/server/endpoints/gate-endpoints.ts +24 -12
  42. package/src/ui/server/endpoints/job-control-endpoints.ts +19 -763
  43. package/src/ui/server/job-control-service.ts +520 -0
  44. package/src/ui/server/job-process-registry.ts +250 -0
  45. package/src/ui/server/job-repository.ts +316 -0
package/docs/http-api.md CHANGED
@@ -81,7 +81,10 @@ or on error:
81
81
  | `forbidden_origin` | 403 | Cross-origin mutation rejected |
82
82
  | `status_unavailable` | 500 | Job status file unreadable |
83
83
  | `no_pending_gate` | 409 | Job has no pending gate decision |
84
+ | `INVALID_JSON` | 409 | Job status JSON is invalid |
85
+ | `STATUS_CORRUPT` | 409 | Job status file is corrupt |
84
86
  | `FS_ERROR` | 500 | Filesystem operation failed |
87
+ | `BAD_PATH` | 400 | Task analysis path is invalid |
85
88
  | `BAD_REQUEST` | 400 | Malformed request body |
86
89
  | `NOT_FOUND` | 404 | Route or resource not found |
87
90
 
@@ -209,6 +209,7 @@ export const inference = async ({ runAgent, io, data, flags }) => {
209
209
  // timeoutMs?: number // optional wall-clock cap
210
210
  // idleTimeoutMs?: number // optional no-output cap
211
211
  // captureDiff?: boolean // capture a git diff as 'agent.patch'
212
+ // onEvent?: (event) => {} // observe adapter harness events during the run
212
213
  });
213
214
 
214
215
  if (!result.ok) {
@@ -226,6 +227,13 @@ By default (`io` is `true`) the agent shares the task's file I/O: it can call th
226
227
  task sees, and its `agent-result.md` is written automatically. Token usage and
227
228
  cost flow into the job status like any other LLM call.
228
229
 
230
+ Use `onEvent` when a task needs to observe live harness activity, such as
231
+ assistant messages, tool calls, usage events, or run completion. POP invokes the
232
+ callback after it has buffered the event and appended the redacted debug log, so
233
+ omitting the callback leaves behavior unchanged. The `event.raw` payload comes
234
+ directly from the underlying CLI adapter; treat it as unstable and avoid
235
+ persisting it unless you have considered sensitive values in the raw stream.
236
+
229
237
  **`runAgent` vs `llm`**: use `llm.<provider>.chat()` for a single request/response
230
238
  LLM call; use `runAgent()` when you need a tool-using CLI agent that reads and
231
239
  writes files over multiple turns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
5
5
  "type": "module",
6
6
  "main": "src/ui/server/index.ts",
@@ -32,7 +32,10 @@
32
32
  "ui": "NODE_ENV=development bun --watch src/ui/server/index.ts",
33
33
  "ui:dev": "vite",
34
34
  "ui:build": "vite build && bun run generate:embedded-assets",
35
- "prepack": "bun run ui:build",
35
+ "verify:package": "bun scripts/verify-package-assets.js",
36
+ "smoke:packed-consumer": "bun scripts/smoke-packed-consumer.js",
37
+ "release:check": "bun run ui:build && bun run verify:package && bun run smoke:packed-consumer",
38
+ "prepack": "bun run ui:build && bun run verify:package",
36
39
  "ui:prod": "bun src/ui/server/index.ts",
37
40
  "demo:ui": "NODE_ENV=production PO_ROOT=demo bun src/ui/server/index.ts",
38
41
  "demo:orchestrator": "PO_ROOT=demo NODE_ENV=production bun -e \"import('./src/core/orchestrator.ts').then(m => m.startOrchestrator({ dataDir: process.env.PO_ROOT || 'demo' })).catch(err => { console.error(err); process.exit(1) })\"",
@@ -12,11 +12,10 @@ const availability: ProviderAvailability = {
12
12
  anthropic: false,
13
13
  gemini: false,
14
14
  zai: false,
15
- zhipu: false,
16
- claudecode: false,
17
15
  moonshot: false,
18
16
  alibaba: false,
19
17
  opencode: false,
18
+ "claude-code": false,
20
19
  mock: false,
21
20
  };
22
21
 
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import Ajv from "ajv";
5
+
6
+ const schemaPath = join(process.cwd(), "config.schema.json");
7
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8")) as {
8
+ properties: { llm: { properties: { defaultProvider: { enum: string[] } } } };
9
+ };
10
+
11
+ const ajv = new Ajv({ allErrors: true, strict: false });
12
+ const validate = ajv.compile(schema);
13
+
14
+ function llmConfig(defaultProvider: string): unknown {
15
+ return { llm: { defaultProvider } };
16
+ }
17
+
18
+ describe("config.schema.json defaultProvider enum", () => {
19
+ it("accepts canonical provider ids", () => {
20
+ expect(validate(llmConfig("openai"))).toBe(true);
21
+ expect(validate(llmConfig("zai"))).toBe(true);
22
+ expect(validate(llmConfig("claude-code"))).toBe(true);
23
+ expect(validate(llmConfig("moonshot"))).toBe(true);
24
+ });
25
+
26
+ it("rejects legacy provider ids", () => {
27
+ expect(validate(llmConfig("zhipu"))).toBe(false);
28
+ expect(validate(llmConfig("claudecode"))).toBe(false);
29
+ });
30
+
31
+ it("rejects unknown provider ids", () => {
32
+ expect(validate(llmConfig("neverheard"))).toBe(false);
33
+ });
34
+
35
+ it("enum matches the canonical ProviderName set (no legacy spellings)", () => {
36
+ const enumValues = schema.properties.llm.properties.defaultProvider.enum;
37
+ expect(enumValues).not.toContain("zhipu");
38
+ expect(enumValues).not.toContain("claudecode");
39
+ expect(enumValues).toContain("zai");
40
+ expect(enumValues).toContain("claude-code");
41
+ });
42
+ });
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+
5
+ const FORBIDDEN_LITERALS: ReadonlySet<string> = new Set([
6
+ "zhipu",
7
+ "claudecode",
8
+ ]);
9
+
10
+ const STRING_LITERAL_RE = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g;
11
+
12
+ const ALLOWED_FILES: ReadonlySet<string> = new Set([
13
+ "src/config/models.ts",
14
+ "src/config/__tests__/models.test.ts",
15
+ "src/config/__tests__/config-schema.test.ts",
16
+ "src/config/__tests__/legacy-literal-guard.test.ts",
17
+ "src/llm/__tests__/legacy-normalization.test.ts",
18
+ "src/llm/__tests__/index.test.ts",
19
+ "src/providers/__tests__/types.test.ts",
20
+ "src/task-analysis/__tests__/analyzer.test.ts",
21
+ "src/task-analysis/__tests__/enrichers-schema-deducer.test.ts",
22
+ ]);
23
+
24
+ export interface LegacyLiteralViolation {
25
+ path: string;
26
+ literal: string;
27
+ }
28
+
29
+ export interface SourceFile {
30
+ path: string;
31
+ content: string;
32
+ }
33
+
34
+ export function extractStringLiterals(source: string): string[] {
35
+ const out: string[] = [];
36
+ for (const match of source.matchAll(STRING_LITERAL_RE)) {
37
+ const raw = match[0] as string;
38
+ out.push(raw.slice(1, -1));
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function scanFiles(
44
+ files: SourceFile[],
45
+ allowlist: ReadonlySet<string>,
46
+ ): LegacyLiteralViolation[] {
47
+ const violations: LegacyLiteralViolation[] = [];
48
+ for (const file of files) {
49
+ if (allowlist.has(file.path)) continue;
50
+ for (const literal of extractStringLiterals(file.content)) {
51
+ if (FORBIDDEN_LITERALS.has(literal)) {
52
+ violations.push({ path: file.path, literal });
53
+ }
54
+ }
55
+ }
56
+ return violations;
57
+ }
58
+
59
+ export function listSourceTsFiles(scanRoot: string, base: string): SourceFile[] {
60
+ const rootAbs = resolve(scanRoot);
61
+ const baseAbs = resolve(base);
62
+ const out: SourceFile[] = [];
63
+
64
+ function walk(dir: string): void {
65
+ for (const entry of readdirSync(dir)) {
66
+ const full = join(dir, entry);
67
+ const st = statSync(full);
68
+ if (st.isDirectory()) {
69
+ walk(full);
70
+ } else if (st.isFile() && /\.(ts|tsx)$/.test(entry)) {
71
+ const rel = relative(baseAbs, full).split(sep).join("/");
72
+ out.push({ path: rel, content: readFileSync(full, "utf-8") });
73
+ }
74
+ }
75
+ }
76
+
77
+ walk(rootAbs);
78
+ return out;
79
+ }
80
+
81
+ describe("extractStringLiterals", () => {
82
+ it("captures a double-quoted forbidden literal", () => {
83
+ expect(extractStringLiterals('const p = "zhipu";')).toContain("zhipu");
84
+ });
85
+
86
+ it("ignores the bare legacy word in a comment", () => {
87
+ expect(extractStringLiterals("// legacy zhipu id\nconst x = 1;")).toEqual([]);
88
+ });
89
+ });
90
+
91
+ describe("scanFiles — forbidden literal detection", () => {
92
+ it('flags "zhipu" outside the allowlist', () => {
93
+ const violations = scanFiles(
94
+ [{ path: "src/x.ts", content: 'const p = "zhipu";' }],
95
+ new Set(),
96
+ );
97
+ expect(violations).toEqual([{ path: "src/x.ts", literal: "zhipu" }]);
98
+ });
99
+
100
+ it('flags "claudecode" outside the allowlist', () => {
101
+ const violations = scanFiles(
102
+ [{ path: "src/y.ts", content: "const q = \"claudecode\";" }],
103
+ new Set(),
104
+ );
105
+ expect(violations).toEqual([{ path: "src/y.ts", literal: "claudecode" }]);
106
+ });
107
+
108
+ it("skips files listed in the allowlist", () => {
109
+ const violations = scanFiles(
110
+ [{ path: "src/config/models.ts", content: 'legacyAliases: ["zhipu"]' }],
111
+ new Set(["src/config/models.ts"]),
112
+ );
113
+ expect(violations).toEqual([]);
114
+ });
115
+ });
116
+
117
+ describe("scanFiles — legitimate substrings are not flagged", () => {
118
+ it("ignores the ZHIPU_API_KEY env-var name", () => {
119
+ const violations = scanFiles(
120
+ [{ path: "src/env.ts", content: 'const k = "ZHIPU_API_KEY";' }],
121
+ new Set(),
122
+ );
123
+ expect(violations).toEqual([]);
124
+ });
125
+
126
+ it("ignores an import path that ends in zhipu.ts", () => {
127
+ const violations = scanFiles(
128
+ [
129
+ {
130
+ path: "src/llm/index.ts",
131
+ content: 'import { zaiChat } from "../providers/zhipu.ts";',
132
+ },
133
+ ],
134
+ new Set(),
135
+ );
136
+ expect(violations).toEqual([]);
137
+ });
138
+
139
+ it("ignores the legacy word inside a comment", () => {
140
+ const violations = scanFiles(
141
+ [{ path: "src/cmt.ts", content: "// legacy zhipu id\nconst x = 1;" }],
142
+ new Set(),
143
+ );
144
+ expect(violations).toEqual([]);
145
+ });
146
+ });
147
+
148
+ describe("legacy-literal source guard (AC-3)", () => {
149
+ it("src/ has no forbidden legacy literals outside the allowlist", () => {
150
+ const cwd = process.cwd();
151
+ const files = listSourceTsFiles(join(cwd, "src"), cwd);
152
+ const violations = scanFiles(files, ALLOWED_FILES);
153
+
154
+ expect(violations).toEqual([]);
155
+ });
156
+ });
@@ -6,17 +6,20 @@ import {
6
6
  DEFAULT_MODEL_BY_PROVIDER,
7
7
  FUNCTION_NAME_BY_ALIAS,
8
8
  PROVIDER_FUNCTIONS,
9
+ PROVIDERS,
9
10
  aliasToFunctionName,
10
11
  getProviderFromAlias,
11
12
  getModelFromAlias,
12
13
  getModelConfig,
13
14
  buildProviderFunctionsIndex,
15
+ normalizeLegacyProvider,
14
16
  validateModelRegistry,
15
17
  } from "../models";
16
- import type { ModelConfigEntry } from "../models";
18
+ import type { ModelConfigEntry, ProviderName, ProviderMeta } from "../models";
17
19
 
18
20
  const MODEL_COUNT = 51;
19
21
  const PROVIDER_COUNT = 9;
22
+ const REGISTRY_PROVIDER_COUNT = 10;
20
23
 
21
24
  describe("ModelAlias", () => {
22
25
  it(`has exactly ${MODEL_COUNT} entries`, () => {
@@ -378,3 +381,138 @@ describe("validateModelRegistry — invariant failures", () => {
378
381
  expect(() => validateModelRegistry(config, set)).not.toThrow();
379
382
  });
380
383
  });
384
+
385
+ describe("normalizeLegacyProvider", () => {
386
+ it('maps legacy "zhipu" to canonical "zai"', () => {
387
+ expect(normalizeLegacyProvider("zhipu")).toBe("zai");
388
+ });
389
+
390
+ it('maps legacy "claudecode" to canonical "claude-code"', () => {
391
+ expect(normalizeLegacyProvider("claudecode")).toBe("claude-code");
392
+ });
393
+
394
+ it('returns canonical "zai" for itself', () => {
395
+ expect(normalizeLegacyProvider("zai")).toBe("zai");
396
+ });
397
+
398
+ it('returns canonical "claude-code" for itself', () => {
399
+ expect(normalizeLegacyProvider("claude-code")).toBe("claude-code");
400
+ });
401
+
402
+ it('returns canonical "openai" for itself', () => {
403
+ expect(normalizeLegacyProvider("openai")).toBe("openai");
404
+ });
405
+
406
+ it("returns null for an unknown provider string", () => {
407
+ expect(normalizeLegacyProvider("unknown")).toBeNull();
408
+ });
409
+
410
+ it("returns null for the empty string", () => {
411
+ expect(normalizeLegacyProvider("")).toBeNull();
412
+ });
413
+
414
+ it("returns null for Object.prototype property names", () => {
415
+ expect(normalizeLegacyProvider("constructor")).toBeNull();
416
+ expect(normalizeLegacyProvider("toString")).toBeNull();
417
+ expect(normalizeLegacyProvider("valueOf")).toBeNull();
418
+ expect(normalizeLegacyProvider("hasOwnProperty")).toBeNull();
419
+ expect(normalizeLegacyProvider("__proto__")).toBeNull();
420
+ });
421
+ });
422
+
423
+ describe("PROVIDERS registry invariants", () => {
424
+ const CANONICAL_PROVIDER_IDS: ProviderName[] = [
425
+ "openai",
426
+ "anthropic",
427
+ "deepseek",
428
+ "gemini",
429
+ "zai",
430
+ "claude-code",
431
+ "moonshot",
432
+ "alibaba",
433
+ "opencode",
434
+ "mock",
435
+ ];
436
+
437
+ it(`has exactly ${REGISTRY_PROVIDER_COUNT} rows, one per canonical ProviderName`, () => {
438
+ expect(Object.keys(PROVIDERS).length).toBe(REGISTRY_PROVIDER_COUNT);
439
+ for (const id of CANONICAL_PROVIDER_IDS) {
440
+ expect(id in PROVIDERS).toBe(true);
441
+ }
442
+ });
443
+
444
+ it("table is frozen", () => {
445
+ expect(Object.isFrozen(PROVIDERS)).toBe(true);
446
+ });
447
+
448
+ it("legacyAliases are unique across the registry (no alias maps to two providers)", () => {
449
+ const seen = new Map<string, ProviderName>();
450
+ for (const [canonical, meta] of Object.entries(PROVIDERS) as [ProviderName, ProviderMeta][]) {
451
+ for (const alias of meta.legacyAliases) {
452
+ const prior = seen.get(alias);
453
+ if (prior !== undefined) {
454
+ throw new Error(
455
+ `legacy alias "${alias}" is claimed by both "${prior}" and "${canonical}"`,
456
+ );
457
+ }
458
+ seen.set(alias, canonical);
459
+ }
460
+ }
461
+ });
462
+
463
+ it("no legacy alias collides with a canonical id", () => {
464
+ const canonicalSet = new Set<string>(CANONICAL_PROVIDER_IDS);
465
+ for (const meta of Object.values(PROVIDERS)) {
466
+ for (const alias of meta.legacyAliases) {
467
+ expect(canonicalSet.has(alias)).toBe(false);
468
+ }
469
+ }
470
+ });
471
+
472
+ it("MODEL_CONFIG providers are all registered catalog providers (no orphans, no mock)", () => {
473
+ for (const entry of Object.values(MODEL_CONFIG)) {
474
+ expect(entry.provider in PROVIDERS).toBe(true);
475
+ expect(entry.provider).not.toBe("mock");
476
+ }
477
+ });
478
+
479
+ it("MODEL_CONFIG / DEFAULT_MODEL_BY_PROVIDER / PROVIDER_FUNCTIONS exclude only mock from the registry", () => {
480
+ const registryKeys = new Set<string>(Object.keys(PROVIDERS));
481
+ const catalogKeys = new Set<string>(Object.keys(PROVIDER_FUNCTIONS));
482
+ const defaultKeys = new Set<string>(
483
+ Object.keys(DEFAULT_MODEL_BY_PROVIDER) as string[],
484
+ );
485
+
486
+ for (const key of catalogKeys) expect(registryKeys.has(key)).toBe(true);
487
+ for (const key of defaultKeys) expect(registryKeys.has(key)).toBe(true);
488
+
489
+ const missingFromCatalog = [...registryKeys].filter(
490
+ (k) => !catalogKeys.has(k),
491
+ );
492
+ const missingFromDefaults = [...registryKeys].filter(
493
+ (k) => !defaultKeys.has(k),
494
+ );
495
+ expect(missingFromCatalog).toEqual(["mock"]);
496
+ expect(missingFromDefaults).toEqual(["mock"]);
497
+ });
498
+
499
+ it("canonical<->alias round-trip via normalizeLegacyProvider", () => {
500
+ for (const [canonical, meta] of Object.entries(PROVIDERS) as [ProviderName, ProviderMeta][]) {
501
+ expect(normalizeLegacyProvider(canonical)).toBe(canonical);
502
+ for (const alias of meta.legacyAliases) {
503
+ expect(normalizeLegacyProvider(alias)).toBe(canonical);
504
+ }
505
+ }
506
+ });
507
+
508
+ it("CLI providers declare sampling: []", () => {
509
+ expect(PROVIDERS["claude-code"]!.sampling).toEqual([]);
510
+ expect(PROVIDERS.opencode!.sampling).toEqual([]);
511
+ });
512
+
513
+ it("mock is runtime-only: empty envKeys/sampling, isCLI false", () => {
514
+ expect(PROVIDERS.mock!.envKeys).toEqual([]);
515
+ expect(PROVIDERS.mock!.sampling).toEqual([]);
516
+ expect(PROVIDERS.mock!.isCLI).toBe(false);
517
+ });
518
+ });
@@ -1,16 +1,118 @@
1
- export type ProviderName =
2
- | "openai"
3
- | "anthropic"
4
- | "gemini"
5
- | "deepseek"
6
- | "moonshot"
7
- | "claude-code"
8
- | "zai"
9
- | "alibaba"
10
- | "opencode";
1
+ import type { ProviderName } from "../providers/types.ts";
2
+
3
+ export type { ProviderName };
4
+
5
+ // ─── Provider Facts Registry (pure data; behavior wired in llm/index.ts) ─────
6
+
7
+ export type SamplingParam =
8
+ | "temperature"
9
+ | "topP"
10
+ | "stop"
11
+ | "frequencyPenalty"
12
+ | "presencePenalty"
13
+ | "seed";
14
+
15
+ export interface ProviderMeta {
16
+ usageAvailable: boolean;
17
+ isCLI: boolean;
18
+ sampling: SamplingParam[];
19
+ envKeys: string[];
20
+ legacyAliases: string[];
21
+ }
22
+
23
+ export type ModelCatalogProviderName = Exclude<ProviderName, "mock">;
24
+
25
+ export const PROVIDERS: Readonly<Record<ProviderName, ProviderMeta>> = Object.freeze({
26
+ openai: {
27
+ usageAvailable: true,
28
+ isCLI: false,
29
+ sampling: ["temperature", "topP", "stop", "frequencyPenalty", "presencePenalty", "seed"],
30
+ envKeys: ["OPENAI_API_KEY"],
31
+ legacyAliases: [],
32
+ },
33
+ anthropic: {
34
+ usageAvailable: true,
35
+ isCLI: false,
36
+ sampling: ["temperature", "topP", "stop"],
37
+ envKeys: ["ANTHROPIC_API_KEY"],
38
+ legacyAliases: [],
39
+ },
40
+ gemini: {
41
+ usageAvailable: true,
42
+ isCLI: false,
43
+ sampling: ["temperature", "topP", "stop"],
44
+ envKeys: ["GEMINI_API_KEY"],
45
+ legacyAliases: [],
46
+ },
47
+ deepseek: {
48
+ usageAvailable: true,
49
+ isCLI: false,
50
+ sampling: ["temperature", "topP", "stop", "frequencyPenalty", "presencePenalty"],
51
+ envKeys: ["DEEPSEEK_API_KEY"],
52
+ legacyAliases: [],
53
+ },
54
+ moonshot: {
55
+ usageAvailable: true,
56
+ isCLI: false,
57
+ sampling: ["temperature", "topP", "stop"],
58
+ envKeys: ["MOONSHOT_API_KEY"],
59
+ legacyAliases: [],
60
+ },
61
+ zai: {
62
+ usageAvailable: true,
63
+ isCLI: false,
64
+ sampling: ["temperature", "topP", "stop"],
65
+ envKeys: ["ZAI_API_KEY", "ZHIPU_API_KEY"],
66
+ legacyAliases: ["zhipu"],
67
+ },
68
+ alibaba: {
69
+ usageAvailable: true,
70
+ isCLI: false,
71
+ sampling: ["temperature", "topP", "stop", "frequencyPenalty", "presencePenalty"],
72
+ envKeys: ["ALIBABA_API_KEY"],
73
+ legacyAliases: [],
74
+ },
75
+ "claude-code": {
76
+ usageAvailable: false,
77
+ isCLI: true,
78
+ sampling: [],
79
+ envKeys: [],
80
+ legacyAliases: ["claudecode"],
81
+ },
82
+ opencode: {
83
+ usageAvailable: true,
84
+ isCLI: true,
85
+ sampling: [],
86
+ envKeys: [],
87
+ legacyAliases: [],
88
+ },
89
+ mock: {
90
+ usageAvailable: true,
91
+ isCLI: false,
92
+ sampling: [],
93
+ envKeys: [],
94
+ legacyAliases: [],
95
+ },
96
+ });
97
+
98
+ /**
99
+ * Resolves a runtime provider string to its canonical ProviderName. Returns the
100
+ * canonical id for canonical input and for registered legacy aliases; returns
101
+ * `null` for unknown strings. Derived from PROVIDERS[*].legacyAliases so the
102
+ * alias map stays single-source.
103
+ */
104
+ export function normalizeLegacyProvider(name: string): ProviderName | null {
105
+ if (Object.hasOwn(PROVIDERS, name)) return name as ProviderName;
106
+ for (const [canonical, meta] of Object.entries(PROVIDERS) as [ProviderName, ProviderMeta][]) {
107
+ if (meta.legacyAliases.includes(name)) return canonical;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ // ─── Model Catalog Types ─────────────────────────────────────────────────────
11
113
 
12
114
  export interface ModelConfigEntry {
13
- readonly provider: ProviderName;
115
+ readonly provider: ModelCatalogProviderName;
14
116
  readonly model: string;
15
117
  readonly tokenCostInPerMillion: number;
16
118
  readonly tokenCostOutPerMillion: number;
@@ -18,7 +120,7 @@ export interface ModelConfigEntry {
18
120
 
19
121
  export interface ProviderFunctionEntry {
20
122
  readonly alias: string;
21
- readonly provider: ProviderName;
123
+ readonly provider: ModelCatalogProviderName;
22
124
  readonly model: string;
23
125
  readonly functionName: string;
24
126
  readonly fullPath: string;
@@ -452,7 +554,7 @@ export function aliasToFunctionName(alias: string): string {
452
554
  );
453
555
  }
454
556
 
455
- export function getProviderFromAlias(alias: string): ProviderName {
557
+ export function getProviderFromAlias(alias: string): ModelCatalogProviderName {
456
558
  if (typeof alias !== "string") {
457
559
  throw new Error(
458
560
  `Invalid model alias: expected string, got ${typeof alias}`,
@@ -461,7 +563,7 @@ export function getProviderFromAlias(alias: string): ProviderName {
461
563
  if (!alias.includes(":")) {
462
564
  throw new Error(`Invalid model alias: "${alias}" does not contain a colon`);
463
565
  }
464
- return alias.split(":")[0] as ProviderName;
566
+ return alias.split(":")[0] as ModelCatalogProviderName;
465
567
  }
466
568
 
467
569
  export function getModelFromAlias(alias: string): string {
@@ -486,7 +588,7 @@ export function getModelConfig(alias: string): ModelConfigEntry | null {
486
588
  // ─── Default Model By Provider ───────────────────────────────────────────────
487
589
 
488
590
  export const DEFAULT_MODEL_BY_PROVIDER: Readonly<
489
- Record<ProviderName, ModelAliasKey>
591
+ Record<ModelCatalogProviderName, ModelAliasKey>
490
592
  > = Object.freeze({
491
593
  openai: "openai:gpt-5.4",
492
594
  anthropic: "anthropic:sonnet-4-6",
@@ -497,7 +599,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Readonly<
497
599
  zai: "zai:glm-5-1",
498
600
  alibaba: "alibaba:qwen3-max",
499
601
  opencode: "opencode:default",
500
- } as const);
602
+ } satisfies Record<ModelCatalogProviderName, ModelAliasKey>);
501
603
 
502
604
  // ─── Function Name Derived Index ─────────────────────────────────────────────
503
605
 
@@ -514,11 +616,11 @@ export const FUNCTION_NAME_BY_ALIAS: Readonly<Record<ModelAliasKey, string>> =
514
616
  // ─── Provider Functions Index ────────────────────────────────────────────────
515
617
 
516
618
  export type ProviderFunctionsIndex = Readonly<
517
- Record<ProviderName, readonly ProviderFunctionEntry[]>
619
+ Record<ModelCatalogProviderName, readonly ProviderFunctionEntry[]>
518
620
  >;
519
621
 
520
622
  export function buildProviderFunctionsIndex(): ProviderFunctionsIndex {
521
- const index: Partial<Record<ProviderName, ProviderFunctionEntry[]>> = {};
623
+ const index: Partial<Record<ModelCatalogProviderName, ProviderFunctionEntry[]>> = {};
522
624
 
523
625
  for (const alias of Object.keys(MODEL_CONFIG) as ModelAliasKey[]) {
524
626
  const entry = (MODEL_CONFIG as Record<string, ModelConfigEntry>)[alias]!;
@@ -542,7 +644,7 @@ export function buildProviderFunctionsIndex(): ProviderFunctionsIndex {
542
644
  }
543
645
 
544
646
  // Freeze each provider array
545
- for (const provider of Object.keys(index) as ProviderName[]) {
647
+ for (const provider of Object.keys(index) as ModelCatalogProviderName[]) {
546
648
  Object.freeze(index[provider]);
547
649
  }
548
650
 
@@ -518,6 +518,58 @@ describe("runAgentStep", () => {
518
518
  expect(logCalls[0]).toBe("writeLog:test-agent-agent-debug.log");
519
519
  });
520
520
 
521
+ it("passes every observed event to a provided entry.onEvent", async () => {
522
+ const io = createFakeIO();
523
+ const events: HarnessEvent[] = [
524
+ { harness: "claude", seq: 0, at: 1, raw: { text: "hello" }, type: "assistant_message", text: "hello" },
525
+ { harness: "claude", seq: 1, at: 2, raw: { message: "done" }, type: "run_completed", result: makeSuccessResult() },
526
+ ];
527
+ const run = mock((opts: { onEvent?: (event: HarnessEvent) => void }) => {
528
+ for (const event of events) {
529
+ opts.onEvent?.(event);
530
+ }
531
+ return makeFakeHarnessRun(makeSuccessResult());
532
+ });
533
+ const startMcpIoServer = mock(async () => createFakeMcpHandle());
534
+
535
+ const relayed: HarnessEvent[] = [];
536
+ await executeAgent(
537
+ {
538
+ io,
539
+ entry: { name: "a", harness: "claude", prompt: "p", onEvent: (e) => relayed.push(e) },
540
+ },
541
+ { run, startMcpIoServer },
542
+ );
543
+
544
+ expect(relayed).toEqual(events);
545
+ const logCalls = io.calls.filter((c) => c.startsWith("writeLog:"));
546
+ expect(logCalls).toHaveLength(events.length);
547
+ });
548
+
549
+ it("with no entry.onEvent, buffering and debug-log writes are unchanged", async () => {
550
+ const io = createFakeIO();
551
+ const events: HarnessEvent[] = [
552
+ { harness: "claude", seq: 0, at: 1, raw: { text: "hello" }, type: "assistant_message", text: "hello" },
553
+ { harness: "claude", seq: 1, at: 2, raw: { message: "done" }, type: "run_completed", result: makeSuccessResult() },
554
+ ];
555
+ const run = mock((opts: { onEvent?: (event: HarnessEvent) => void }) => {
556
+ for (const event of events) {
557
+ opts.onEvent?.(event);
558
+ }
559
+ return makeFakeHarnessRun(makeSuccessResult());
560
+ });
561
+ const startMcpIoServer = mock(async () => createFakeMcpHandle());
562
+
563
+ const result = await executeAgent(
564
+ { io, entry: { name: "a", harness: "claude", prompt: "p" } },
565
+ { run, startMcpIoServer },
566
+ );
567
+
568
+ expect(result.ok).toBe(true);
569
+ const logCalls = io.calls.filter((c) => c.startsWith("writeLog:"));
570
+ expect(logCalls).toHaveLength(events.length);
571
+ });
572
+
521
573
  it("promptFrom reads the named artifact for the prompt", async () => {
522
574
  let capturedIO: ReturnType<typeof createFakeIO> | undefined;
523
575
  const createTaskFileIO = mock(() => {