@funkai/agents 0.1.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 (153) hide show
  1. package/.generated/req.txt +1 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/.turbo/turbo-test$colon$coverage.log +109 -0
  4. package/.turbo/turbo-test.log +141 -0
  5. package/.turbo/turbo-typecheck.log +4 -0
  6. package/CHANGELOG.md +16 -0
  7. package/ISSUES.md +540 -0
  8. package/LICENSE +21 -0
  9. package/README.md +128 -0
  10. package/banner.svg +97 -0
  11. package/coverage/lcov-report/base.css +224 -0
  12. package/coverage/lcov-report/block-navigation.js +87 -0
  13. package/coverage/lcov-report/core/agents/base/agent.ts.html +1705 -0
  14. package/coverage/lcov-report/core/agents/base/index.html +146 -0
  15. package/coverage/lcov-report/core/agents/base/output.ts.html +256 -0
  16. package/coverage/lcov-report/core/agents/base/utils.ts.html +694 -0
  17. package/coverage/lcov-report/core/agents/flow/engine.ts.html +928 -0
  18. package/coverage/lcov-report/core/agents/flow/flow-agent.ts.html +1462 -0
  19. package/coverage/lcov-report/core/agents/flow/index.html +146 -0
  20. package/coverage/lcov-report/core/agents/flow/messages.ts.html +508 -0
  21. package/coverage/lcov-report/core/agents/flow/steps/factory.ts.html +1975 -0
  22. package/coverage/lcov-report/core/agents/flow/steps/index.html +116 -0
  23. package/coverage/lcov-report/core/index.html +131 -0
  24. package/coverage/lcov-report/core/logger.ts.html +541 -0
  25. package/coverage/lcov-report/core/models/providers/index.html +116 -0
  26. package/coverage/lcov-report/core/models/providers/openai.ts.html +337 -0
  27. package/coverage/lcov-report/core/provider/index.html +131 -0
  28. package/coverage/lcov-report/core/provider/provider.ts.html +346 -0
  29. package/coverage/lcov-report/core/provider/usage.ts.html +376 -0
  30. package/coverage/lcov-report/core/tool.ts.html +577 -0
  31. package/coverage/lcov-report/favicon.png +0 -0
  32. package/coverage/lcov-report/index.html +221 -0
  33. package/coverage/lcov-report/lib/hooks.ts.html +262 -0
  34. package/coverage/lcov-report/lib/index.html +161 -0
  35. package/coverage/lcov-report/lib/middleware.ts.html +274 -0
  36. package/coverage/lcov-report/lib/runnable.ts.html +151 -0
  37. package/coverage/lcov-report/lib/trace.ts.html +520 -0
  38. package/coverage/lcov-report/prettify.css +1 -0
  39. package/coverage/lcov-report/prettify.js +2 -0
  40. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  41. package/coverage/lcov-report/sorter.js +210 -0
  42. package/coverage/lcov-report/utils/attempt.ts.html +199 -0
  43. package/coverage/lcov-report/utils/error.ts.html +421 -0
  44. package/coverage/lcov-report/utils/index.html +176 -0
  45. package/coverage/lcov-report/utils/resolve.ts.html +208 -0
  46. package/coverage/lcov-report/utils/result.ts.html +538 -0
  47. package/coverage/lcov-report/utils/zod.ts.html +178 -0
  48. package/coverage/lcov.info +1566 -0
  49. package/dist/index.d.mts +2883 -0
  50. package/dist/index.d.mts.map +1 -0
  51. package/dist/index.mjs +2312 -0
  52. package/dist/index.mjs.map +1 -0
  53. package/docs/core/agent.md +231 -0
  54. package/docs/core/hooks.md +95 -0
  55. package/docs/core/overview.md +87 -0
  56. package/docs/core/step.md +279 -0
  57. package/docs/core/tools.md +98 -0
  58. package/docs/core/workflow.md +235 -0
  59. package/docs/guides/create-agent.md +224 -0
  60. package/docs/guides/create-tool.md +137 -0
  61. package/docs/guides/create-workflow.md +374 -0
  62. package/docs/overview.md +244 -0
  63. package/docs/provider/models.md +55 -0
  64. package/docs/provider/overview.md +106 -0
  65. package/docs/provider/usage.md +100 -0
  66. package/docs/research/experimental-context.md +167 -0
  67. package/docs/research/gap-analysis.md +86 -0
  68. package/docs/research/prepare-step-and-active-tools.md +138 -0
  69. package/docs/research/sub-agent-model.md +249 -0
  70. package/docs/troubleshooting.md +60 -0
  71. package/logo.svg +17 -0
  72. package/models.config.json +18 -0
  73. package/package.json +60 -0
  74. package/scripts/generate-models.ts +324 -0
  75. package/src/core/agents/base/agent.test.ts +1522 -0
  76. package/src/core/agents/base/agent.ts +547 -0
  77. package/src/core/agents/base/output.test.ts +93 -0
  78. package/src/core/agents/base/output.ts +57 -0
  79. package/src/core/agents/base/types.test-d.ts +69 -0
  80. package/src/core/agents/base/types.ts +503 -0
  81. package/src/core/agents/base/utils.test.ts +397 -0
  82. package/src/core/agents/base/utils.ts +197 -0
  83. package/src/core/agents/flow/engine.test.ts +452 -0
  84. package/src/core/agents/flow/engine.ts +281 -0
  85. package/src/core/agents/flow/flow-agent.test.ts +1027 -0
  86. package/src/core/agents/flow/flow-agent.ts +473 -0
  87. package/src/core/agents/flow/messages.test.ts +198 -0
  88. package/src/core/agents/flow/messages.ts +141 -0
  89. package/src/core/agents/flow/steps/agent.test.ts +280 -0
  90. package/src/core/agents/flow/steps/agent.ts +87 -0
  91. package/src/core/agents/flow/steps/all.test.ts +300 -0
  92. package/src/core/agents/flow/steps/all.ts +73 -0
  93. package/src/core/agents/flow/steps/builder.ts +124 -0
  94. package/src/core/agents/flow/steps/each.test.ts +257 -0
  95. package/src/core/agents/flow/steps/each.ts +61 -0
  96. package/src/core/agents/flow/steps/factory.test-d.ts +50 -0
  97. package/src/core/agents/flow/steps/factory.test.ts +1025 -0
  98. package/src/core/agents/flow/steps/factory.ts +645 -0
  99. package/src/core/agents/flow/steps/map.test.ts +273 -0
  100. package/src/core/agents/flow/steps/map.ts +75 -0
  101. package/src/core/agents/flow/steps/race.test.ts +290 -0
  102. package/src/core/agents/flow/steps/race.ts +59 -0
  103. package/src/core/agents/flow/steps/reduce.test.ts +310 -0
  104. package/src/core/agents/flow/steps/reduce.ts +73 -0
  105. package/src/core/agents/flow/steps/result.ts +27 -0
  106. package/src/core/agents/flow/steps/step.test.ts +402 -0
  107. package/src/core/agents/flow/steps/step.ts +51 -0
  108. package/src/core/agents/flow/steps/while.test.ts +283 -0
  109. package/src/core/agents/flow/steps/while.ts +75 -0
  110. package/src/core/agents/flow/types.ts +348 -0
  111. package/src/core/logger.test.ts +163 -0
  112. package/src/core/logger.ts +152 -0
  113. package/src/core/models/index.test.ts +137 -0
  114. package/src/core/models/index.ts +152 -0
  115. package/src/core/models/providers/openai.ts +84 -0
  116. package/src/core/provider/provider.test.ts +128 -0
  117. package/src/core/provider/provider.ts +99 -0
  118. package/src/core/provider/types.ts +98 -0
  119. package/src/core/provider/usage.test.ts +304 -0
  120. package/src/core/provider/usage.ts +97 -0
  121. package/src/core/tool.test.ts +65 -0
  122. package/src/core/tool.ts +164 -0
  123. package/src/core/types.ts +66 -0
  124. package/src/index.ts +95 -0
  125. package/src/lib/context.test.ts +86 -0
  126. package/src/lib/context.ts +49 -0
  127. package/src/lib/hooks.test.ts +102 -0
  128. package/src/lib/hooks.ts +59 -0
  129. package/src/lib/middleware.test.ts +122 -0
  130. package/src/lib/middleware.ts +63 -0
  131. package/src/lib/runnable.test.ts +41 -0
  132. package/src/lib/runnable.ts +22 -0
  133. package/src/lib/trace.test.ts +291 -0
  134. package/src/lib/trace.ts +145 -0
  135. package/src/models/index.ts +123 -0
  136. package/src/models/providers/index.ts +15 -0
  137. package/src/models/providers/openai.ts +84 -0
  138. package/src/testing/context.ts +32 -0
  139. package/src/testing/index.ts +2 -0
  140. package/src/testing/logger.ts +19 -0
  141. package/src/utils/attempt.test.ts +127 -0
  142. package/src/utils/attempt.ts +38 -0
  143. package/src/utils/error.test.ts +179 -0
  144. package/src/utils/error.ts +112 -0
  145. package/src/utils/resolve.test.ts +38 -0
  146. package/src/utils/resolve.ts +41 -0
  147. package/src/utils/result.test.ts +79 -0
  148. package/src/utils/result.ts +151 -0
  149. package/src/utils/zod.test.ts +69 -0
  150. package/src/utils/zod.ts +31 -0
  151. package/tsconfig.json +25 -0
  152. package/tsdown.config.ts +15 -0
  153. package/vitest.config.ts +46 -0
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import { fireHooks } from "@/lib/hooks.js";
4
+ import { createMockLogger } from "@/testing/logger.js";
5
+
6
+ describe("fireHooks", () => {
7
+ it("runs handlers sequentially", async () => {
8
+ const log = createMockLogger();
9
+ const order: number[] = [];
10
+
11
+ await fireHooks(
12
+ log,
13
+ () => {
14
+ order.push(1);
15
+ },
16
+ () => {
17
+ order.push(2);
18
+ },
19
+ () => {
20
+ order.push(3);
21
+ },
22
+ );
23
+
24
+ expect(order).toEqual([1, 2, 3]);
25
+ });
26
+
27
+ it("skips undefined handlers", async () => {
28
+ const log = createMockLogger();
29
+ const called = vi.fn();
30
+
31
+ await fireHooks(log, undefined, called, undefined);
32
+
33
+ expect(called).toHaveBeenCalledOnce();
34
+ });
35
+
36
+ it("swallows errors and logs them at warn level", async () => {
37
+ const log = createMockLogger();
38
+ const after = vi.fn();
39
+
40
+ await fireHooks(
41
+ log,
42
+ () => {
43
+ throw new Error("boom");
44
+ },
45
+ after,
46
+ );
47
+
48
+ expect(log.warn).toHaveBeenCalledWith("hook error", { error: "boom" });
49
+ expect(after).toHaveBeenCalledOnce();
50
+ });
51
+
52
+ it("logs non-Error thrown values as strings", async () => {
53
+ const log = createMockLogger();
54
+
55
+ await fireHooks(log, () => {
56
+ throw "string error";
57
+ });
58
+
59
+ expect(log.warn).toHaveBeenCalledWith("hook error", { error: "string error" });
60
+ });
61
+
62
+ it("handles async handlers", async () => {
63
+ const log = createMockLogger();
64
+ const order: number[] = [];
65
+
66
+ await fireHooks(
67
+ log,
68
+ async () => {
69
+ await Promise.resolve();
70
+ order.push(1);
71
+ },
72
+ async () => {
73
+ await Promise.resolve();
74
+ order.push(2);
75
+ },
76
+ );
77
+
78
+ expect(order).toEqual([1, 2]);
79
+ });
80
+
81
+ it("swallows async errors and continues", async () => {
82
+ const log = createMockLogger();
83
+ const after = vi.fn();
84
+
85
+ await fireHooks(
86
+ log,
87
+ async () => {
88
+ throw new Error("async boom");
89
+ },
90
+ after,
91
+ );
92
+
93
+ expect(log.warn).toHaveBeenCalledWith("hook error", { error: "async boom" });
94
+ expect(after).toHaveBeenCalledOnce();
95
+ });
96
+
97
+ it("does nothing with no handlers", async () => {
98
+ const log = createMockLogger();
99
+ await fireHooks(log);
100
+ expect(log.warn).not.toHaveBeenCalled();
101
+ });
102
+ });
@@ -0,0 +1,59 @@
1
+ import { match } from "ts-pattern";
2
+
3
+ import type { Logger } from "@/core/logger.js";
4
+
5
+ const formatHookError = (err: unknown): string =>
6
+ match(err)
7
+ .when(
8
+ (e): e is Error => e instanceof Error,
9
+ (e) => e.message,
10
+ )
11
+ .otherwise((e) => String(e));
12
+
13
+ /**
14
+ * Wrap a nullable hook into a callback for `fireHooks`, avoiding
15
+ * optional chaining, ternaries, and non-null assertions.
16
+ *
17
+ * @param hookFn - The hook callback, or undefined if not configured.
18
+ * @param event - The event payload to pass to the hook.
19
+ * @returns A thunk that calls the hook with the event, or undefined.
20
+ *
21
+ * @private
22
+ */
23
+ export function wrapHook<T>(
24
+ hookFn: ((event: T) => void | Promise<void>) | undefined,
25
+ event: T,
26
+ ): (() => void | Promise<void>) | undefined {
27
+ if (hookFn !== undefined) {
28
+ return () => hookFn(event);
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ /**
34
+ * Run hook callbacks sequentially, logging errors at warn level.
35
+ *
36
+ * Unlike `attemptEachAsync`, this function surfaces errors via the
37
+ * logger so hook failures are visible in diagnostic output.
38
+ *
39
+ * @param log - Logger for warning about hook errors.
40
+ * @param handlers - Callbacks to execute in order. `undefined` entries are skipped.
41
+ */
42
+ export async function fireHooks(
43
+ log: Logger,
44
+ ...handlers: Array<(() => void | Promise<void>) | undefined>
45
+ ): Promise<void> {
46
+ for (const h of handlers) {
47
+ if (h != null) {
48
+ try {
49
+ // oxlint-disable-next-line no-await-in-loop - sequential by design
50
+ await h();
51
+ } catch (err) {
52
+ const errorMessage = formatHookError(err);
53
+ log.warn("hook error", {
54
+ error: errorMessage,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,122 @@
1
+ import { type LanguageModelMiddleware } from "ai";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { withModelMiddleware } from "@/lib/middleware.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers — minimal model stubs
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function createStubModel() {
11
+ return {
12
+ specificationVersion: "v3" as const,
13
+ provider: "test",
14
+ modelId: "test-model",
15
+ defaultObjectGenerationMode: "json" as const,
16
+ supportsImageUrls: false,
17
+ doGenerate: vi.fn(),
18
+ doStream: vi.fn(),
19
+ };
20
+ }
21
+
22
+ function createStubMiddleware(
23
+ overrides?: Partial<LanguageModelMiddleware>,
24
+ ): LanguageModelMiddleware {
25
+ return {
26
+ specificationVersion: "v2",
27
+ ...overrides,
28
+ } as LanguageModelMiddleware;
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Tests
33
+ // ---------------------------------------------------------------------------
34
+
35
+ describe("withModelMiddleware", () => {
36
+ it("returns the model unchanged when there is no middleware and devtools is off", async () => {
37
+ const model = createStubModel();
38
+
39
+ const result = await withModelMiddleware({
40
+ model: model as never,
41
+ devtools: false,
42
+ });
43
+
44
+ expect(result).toBe(model);
45
+ });
46
+
47
+ it("wraps the model when custom middleware is provided", async () => {
48
+ const model = createStubModel();
49
+ const middleware = createStubMiddleware({ wrapGenerate: vi.fn() });
50
+
51
+ const result = await withModelMiddleware({
52
+ model: model as never,
53
+ middleware: [middleware],
54
+ devtools: false,
55
+ });
56
+
57
+ // The wrapped model should be a different object
58
+ expect(result).not.toBe(model);
59
+ // It should still expose the same model ID
60
+ expect(result.modelId).toBe("test-model");
61
+ });
62
+
63
+ it("applies multiple middleware in order", async () => {
64
+ const model = createStubModel();
65
+ const mw1 = createStubMiddleware({ wrapGenerate: vi.fn() });
66
+ const mw2 = createStubMiddleware({ wrapGenerate: vi.fn() });
67
+
68
+ const result = await withModelMiddleware({
69
+ model: model as never,
70
+ middleware: [mw1, mw2],
71
+ devtools: false,
72
+ });
73
+
74
+ expect(result).not.toBe(model);
75
+ expect(result.modelId).toBe("test-model");
76
+ });
77
+
78
+ it("returns a wrapped model when devtools is explicitly enabled", async () => {
79
+ const model = createStubModel();
80
+
81
+ const result = await withModelMiddleware({
82
+ model: model as never,
83
+ devtools: true,
84
+ });
85
+
86
+ // devtools middleware should wrap the model
87
+ expect(result).not.toBe(model);
88
+ });
89
+
90
+ it("respects devtools=false even in development", async () => {
91
+ const original = process.env.NODE_ENV;
92
+ process.env.NODE_ENV = "development";
93
+
94
+ const model = createStubModel();
95
+
96
+ const result = await withModelMiddleware({
97
+ model: model as never,
98
+ devtools: false,
99
+ });
100
+
101
+ expect(result).toBe(model);
102
+
103
+ process.env.NODE_ENV = original;
104
+ });
105
+
106
+ it("enables devtools automatically when NODE_ENV is development and devtools is not set", async () => {
107
+ const original = process.env.NODE_ENV;
108
+ process.env.NODE_ENV = "development";
109
+
110
+ const model = createStubModel();
111
+
112
+ const result = await withModelMiddleware({
113
+ model: model as never,
114
+ });
115
+
116
+ // devtools middleware should wrap the model automatically
117
+ expect(result).not.toBe(model);
118
+ expect(result.modelId).toBe("test-model");
119
+
120
+ process.env.NODE_ENV = original;
121
+ });
122
+ });
@@ -0,0 +1,63 @@
1
+ import { wrapLanguageModel, type LanguageModelMiddleware } from "ai";
2
+
3
+ import { type LanguageModel } from "@/core/provider/types.js";
4
+
5
+ /**
6
+ * Options for {@link withModelMiddleware}.
7
+ */
8
+ interface WrapModelOptions {
9
+ /** The base language model to wrap. */
10
+ model: LanguageModel;
11
+
12
+ /**
13
+ * Additional middleware to apply before defaults (outermost).
14
+ * Middleware runs in array order — first entry wraps outermost.
15
+ */
16
+ middleware?: LanguageModelMiddleware[];
17
+
18
+ /**
19
+ * Whether to include the AI SDK devtools middleware.
20
+ *
21
+ * Defaults to `true` when `NODE_ENV === 'development'`.
22
+ * Set to `false` to disable in development, or `true` to force-enable.
23
+ */
24
+ devtools?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Wrap a language model with middleware.
29
+ *
30
+ * In development (`NODE_ENV === 'development'`), the AI SDK devtools
31
+ * middleware is appended automatically. Any additional middleware
32
+ * provided in the options is applied first (outermost).
33
+ *
34
+ * @param options - The model and optional middleware configuration.
35
+ * @returns A wrapped language model with middleware applied.
36
+ */
37
+ export async function withModelMiddleware(options: WrapModelOptions): Promise<LanguageModel> {
38
+ const useDevtools =
39
+ options.devtools === true ||
40
+ (options.devtools !== false && process.env.NODE_ENV === "development");
41
+
42
+ const defaultMiddleware: LanguageModelMiddleware[] = [];
43
+ if (useDevtools) {
44
+ const { devToolsMiddleware } = await import("@ai-sdk/devtools");
45
+ defaultMiddleware.push(devToolsMiddleware());
46
+ }
47
+
48
+ const middleware: LanguageModelMiddleware[] = [];
49
+ if (options.middleware) {
50
+ middleware.push(...options.middleware, ...defaultMiddleware);
51
+ } else {
52
+ middleware.push(...defaultMiddleware);
53
+ }
54
+
55
+ if (middleware.length === 0) {
56
+ return options.model;
57
+ }
58
+
59
+ return wrapLanguageModel({
60
+ model: options.model,
61
+ middleware,
62
+ });
63
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ import { RUNNABLE_META, type RunnableMeta } from "@/lib/runnable.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // RUNNABLE_META symbol
8
+ // ---------------------------------------------------------------------------
9
+
10
+ describe("RUNNABLE_META", () => {
11
+ it("is a symbol", () => {
12
+ expect(typeof RUNNABLE_META).toBe("symbol");
13
+ });
14
+
15
+ it("is globally registered via Symbol.for", () => {
16
+ expect(RUNNABLE_META).toBe(Symbol.for("agent-sdk:runnable-meta"));
17
+ });
18
+
19
+ it("can be used as a property key on plain objects", () => {
20
+ const meta: RunnableMeta = { name: "test-agent" };
21
+ const obj: Record<symbol, RunnableMeta> = { [RUNNABLE_META]: meta };
22
+ // eslint-disable-next-line security/detect-object-injection -- Symbol-keyed property access; symbols cannot be user-controlled
23
+ expect(obj[RUNNABLE_META]).toBe(meta);
24
+ });
25
+
26
+ it("stores name and inputSchema", () => {
27
+ const schema = z.object({ query: z.string() });
28
+ const meta: RunnableMeta = { name: "search-agent", inputSchema: schema };
29
+ const obj: Record<symbol, RunnableMeta> = { [RUNNABLE_META]: meta };
30
+
31
+ // eslint-disable-next-line security/detect-object-injection -- Symbol-keyed property access; symbols cannot be user-controlled
32
+ const stored = obj[RUNNABLE_META] as RunnableMeta;
33
+ expect(stored.name).toBe("search-agent");
34
+ expect(stored.inputSchema).toBe(schema);
35
+ });
36
+
37
+ it("allows inputSchema to be omitted", () => {
38
+ const meta: RunnableMeta = { name: "simple-agent" };
39
+ expect(meta.inputSchema).toBeUndefined();
40
+ });
41
+ });
@@ -0,0 +1,22 @@
1
+ import type { ZodType } from "zod";
2
+
3
+ /**
4
+ * Symbol key for internal runnable metadata.
5
+ *
6
+ * Stored on Agent and Workflow objects to enable composition:
7
+ * `buildAITools()` reads this to wrap a Runnable as a delegatable
8
+ * tool in parent agents.
9
+ *
10
+ * @internal
11
+ */
12
+ export const RUNNABLE_META: unique symbol = Symbol.for("agent-sdk:runnable-meta");
13
+
14
+ /**
15
+ * Metadata stored on Agent and Workflow objects via {@link RUNNABLE_META}.
16
+ *
17
+ * @internal
18
+ */
19
+ export interface RunnableMeta {
20
+ name: string;
21
+ inputSchema?: ZodType;
22
+ }
@@ -0,0 +1,291 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { TokenUsage } from "@/core/provider/types.js";
4
+ import { collectUsages, snapshotTrace, type TraceEntry } from "@/lib/trace.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function createEntry(overrides?: Partial<TraceEntry>): TraceEntry {
11
+ return {
12
+ id: "entry-1",
13
+ type: "step",
14
+ startedAt: 1000,
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // snapshotTrace
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe("snapshotTrace", () => {
24
+ it("returns a frozen array", () => {
25
+ const trace = [createEntry()];
26
+ const snapshot = snapshotTrace(trace);
27
+ expect(Object.isFrozen(snapshot)).toBe(true);
28
+ });
29
+
30
+ it("freezes each entry in the array", () => {
31
+ const trace = [createEntry(), createEntry({ id: "entry-2" })];
32
+ const snapshot = snapshotTrace(trace);
33
+ expect(Object.isFrozen(snapshot[0])).toBe(true);
34
+ expect(Object.isFrozen(snapshot[1])).toBe(true);
35
+ });
36
+
37
+ it("returns a structural clone, not the same references", () => {
38
+ const original = createEntry({ output: { value: 42 } });
39
+ const trace = [original];
40
+ const snapshot = snapshotTrace(trace);
41
+
42
+ const entry = snapshot[0] as TraceEntry;
43
+ expect(entry).not.toBe(original);
44
+ expect(entry.id).toBe(original.id);
45
+ expect(entry.output).toEqual({ value: 42 });
46
+ });
47
+
48
+ it("deep-freezes nested children", () => {
49
+ const child: TraceEntry = createEntry({ id: "child-1", type: "agent" });
50
+ const parent: TraceEntry = createEntry({
51
+ id: "parent-1",
52
+ type: "map",
53
+ children: [child],
54
+ });
55
+
56
+ const snapshot = snapshotTrace([parent]);
57
+
58
+ const snapped = snapshot[0] as TraceEntry;
59
+ const snappedChildren = snapped.children as readonly TraceEntry[];
60
+ expect(Object.isFrozen(snapped)).toBe(true);
61
+ expect(Object.isFrozen(snappedChildren)).toBe(true);
62
+ expect(Object.isFrozen(snappedChildren[0])).toBe(true);
63
+ });
64
+
65
+ it("handles deeply nested children (3 levels)", () => {
66
+ const grandchild = createEntry({ id: "grandchild", type: "step" });
67
+ const child = createEntry({ id: "child", type: "agent", children: [grandchild] });
68
+ const root = createEntry({ id: "root", type: "map", children: [child] });
69
+
70
+ const snapshot = snapshotTrace([root]);
71
+
72
+ const root0 = snapshot[0] as TraceEntry;
73
+ const root0Children = root0.children as readonly TraceEntry[];
74
+ const mid = root0Children[0] as TraceEntry;
75
+ const midChildren = mid.children as readonly TraceEntry[];
76
+ const deep = midChildren[0] as TraceEntry;
77
+ expect(Object.isFrozen(deep)).toBe(true);
78
+ expect(deep.id).toBe("grandchild");
79
+ });
80
+
81
+ it("handles an empty trace array", () => {
82
+ const snapshot = snapshotTrace([]);
83
+ expect(snapshot).toEqual([]);
84
+ expect(Object.isFrozen(snapshot)).toBe(true);
85
+ });
86
+
87
+ it("preserves all entry fields", () => {
88
+ const error = new Error("test failure");
89
+ const usage = {
90
+ inputTokens: 100,
91
+ outputTokens: 50,
92
+ totalTokens: 150,
93
+ cacheReadTokens: 0,
94
+ cacheWriteTokens: 0,
95
+ reasoningTokens: 0,
96
+ };
97
+ const entry = createEntry({
98
+ id: "full-entry",
99
+ type: "reduce",
100
+ input: { items: [1, 2, 3] },
101
+ output: { total: 6 },
102
+ startedAt: 1000,
103
+ finishedAt: 2000,
104
+ error,
105
+ usage,
106
+ });
107
+
108
+ const snapshot = snapshotTrace([entry]);
109
+ const snapped = snapshot[0] as TraceEntry;
110
+
111
+ expect(snapped.id).toBe("full-entry");
112
+ expect(snapped.type).toBe("reduce");
113
+ expect(snapped.input).toEqual({ items: [1, 2, 3] });
114
+ expect(snapped.output).toEqual({ total: 6 });
115
+ expect(snapped.startedAt).toBe(1000);
116
+ expect(snapped.finishedAt).toBe(2000);
117
+ expect(snapped.error).toBe(error);
118
+ expect(snapped.usage).toEqual(usage);
119
+ });
120
+
121
+ it("does not modify the original trace", () => {
122
+ const trace = [createEntry({ id: "original" })];
123
+ snapshotTrace(trace);
124
+
125
+ // Original should remain unfrozen and mutable
126
+ const original = trace[0] as TraceEntry;
127
+ expect(Object.isFrozen(trace)).toBe(false);
128
+ expect(Object.isFrozen(original)).toBe(false);
129
+ original.id = "mutated";
130
+ expect(original.id).toBe("mutated");
131
+ });
132
+
133
+ it("handles entries without children property", () => {
134
+ const entry = createEntry({ id: "no-children" });
135
+ const snapshot = snapshotTrace([entry]);
136
+ const snapped = snapshot[0] as TraceEntry;
137
+ expect(snapped.children).toBeUndefined();
138
+ });
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // TraceEntry shape
143
+ // ---------------------------------------------------------------------------
144
+
145
+ describe("TraceEntry", () => {
146
+ it("supports all operation types", () => {
147
+ const types = ["step", "agent", "map", "each", "reduce", "while", "all", "race"] as const;
148
+ const entries: TraceEntry[] = types.map((type) => createEntry({ id: type, type }));
149
+
150
+ expect(entries).toHaveLength(8);
151
+ entries.forEach((entry, i) => {
152
+ // eslint-disable-next-line security/detect-object-injection -- Array index from forEach callback, not user input
153
+ expect(entry.type).toBe(types[i]);
154
+ });
155
+ });
156
+
157
+ it("allows optional fields to be undefined", () => {
158
+ const entry = createEntry();
159
+ expect(entry.input).toBeUndefined();
160
+ expect(entry.output).toBeUndefined();
161
+ expect(entry.finishedAt).toBeUndefined();
162
+ expect(entry.error).toBeUndefined();
163
+ expect(entry.usage).toBeUndefined();
164
+ expect(entry.children).toBeUndefined();
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Helpers for collectUsages
170
+ // ---------------------------------------------------------------------------
171
+
172
+ const ZERO_USAGE: TokenUsage = {
173
+ inputTokens: 0,
174
+ outputTokens: 0,
175
+ totalTokens: 0,
176
+ cacheReadTokens: 0,
177
+ cacheWriteTokens: 0,
178
+ reasoningTokens: 0,
179
+ };
180
+
181
+ function createUsage(overrides?: Partial<TokenUsage>): TokenUsage {
182
+ return { ...ZERO_USAGE, ...overrides };
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // collectUsages()
187
+ // ---------------------------------------------------------------------------
188
+
189
+ describe("collectUsages()", () => {
190
+ it("returns empty array for an empty trace", () => {
191
+ const result = collectUsages([]);
192
+
193
+ expect(result).toEqual([]);
194
+ });
195
+
196
+ it("returns empty array when no entries have usage", () => {
197
+ const trace = [createEntry({ id: "step-1" }), createEntry({ id: "step-2" })];
198
+
199
+ const result = collectUsages(trace);
200
+
201
+ expect(result).toEqual([]);
202
+ });
203
+
204
+ it("collects usage from a single entry", () => {
205
+ const usage = createUsage({ inputTokens: 100, outputTokens: 50, totalTokens: 150 });
206
+ const trace = [createEntry({ id: "agent-1", type: "agent", usage })];
207
+
208
+ const result = collectUsages(trace);
209
+
210
+ expect(result).toEqual([usage]);
211
+ });
212
+
213
+ it("collects usage across multiple entries", () => {
214
+ const usageA = createUsage({ inputTokens: 100 });
215
+ const usageB = createUsage({ inputTokens: 200 });
216
+ const trace = [
217
+ createEntry({ id: "agent-1", type: "agent", usage: usageA }),
218
+ createEntry({ id: "agent-2", type: "agent", usage: usageB }),
219
+ ];
220
+
221
+ const result = collectUsages(trace);
222
+
223
+ expect(result).toEqual([usageA, usageB]);
224
+ });
225
+
226
+ it("skips entries without usage", () => {
227
+ const usage = createUsage({ inputTokens: 100 });
228
+ const trace = [
229
+ createEntry({ id: "step-1", type: "step" }),
230
+ createEntry({ id: "agent-1", type: "agent", usage }),
231
+ createEntry({ id: "step-2", type: "step" }),
232
+ ];
233
+
234
+ const result = collectUsages(trace);
235
+
236
+ expect(result).toEqual([usage]);
237
+ });
238
+
239
+ it("recursively collects usage from children", () => {
240
+ const childUsage = createUsage({ inputTokens: 50 });
241
+ const trace = [
242
+ createEntry({
243
+ id: "map-1",
244
+ type: "map",
245
+ children: [createEntry({ id: "agent-child", type: "agent", usage: childUsage })],
246
+ }),
247
+ ];
248
+
249
+ const result = collectUsages(trace);
250
+
251
+ expect(result).toEqual([childUsage]);
252
+ });
253
+
254
+ it("collects usage from parent and nested children", () => {
255
+ const parentUsage = createUsage({ inputTokens: 100 });
256
+ const childUsage = createUsage({ inputTokens: 200 });
257
+ const trace = [
258
+ createEntry({ id: "agent-top", type: "agent", usage: parentUsage }),
259
+ createEntry({
260
+ id: "map-1",
261
+ type: "map",
262
+ children: [createEntry({ id: "agent-nested", type: "agent", usage: childUsage })],
263
+ }),
264
+ ];
265
+
266
+ const result = collectUsages(trace);
267
+
268
+ expect(result).toEqual([parentUsage, childUsage]);
269
+ });
270
+
271
+ it("handles deeply nested children (3 levels)", () => {
272
+ const deepUsage = createUsage({ inputTokens: 42 });
273
+ const trace = [
274
+ createEntry({
275
+ id: "root",
276
+ type: "map",
277
+ children: [
278
+ createEntry({
279
+ id: "mid",
280
+ type: "step",
281
+ children: [createEntry({ id: "deep-agent", type: "agent", usage: deepUsage })],
282
+ }),
283
+ ],
284
+ }),
285
+ ];
286
+
287
+ const result = collectUsages(trace);
288
+
289
+ expect(result).toEqual([deepUsage]);
290
+ });
291
+ });