@positronic/core 0.0.1 → 0.0.3

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 (79) hide show
  1. package/CLAUDE.md +141 -0
  2. package/dist/src/adapters/types.js +1 -16
  3. package/dist/src/clients/types.js +4 -1
  4. package/dist/src/dsl/brain-runner.js +487 -0
  5. package/dist/src/dsl/brain-runner.test.js +733 -0
  6. package/dist/src/dsl/brain.js +1128 -0
  7. package/dist/src/dsl/brain.test.js +4225 -0
  8. package/dist/src/dsl/constants.js +6 -6
  9. package/dist/src/dsl/json-patch.js +37 -9
  10. package/dist/src/index.js +11 -10
  11. package/dist/src/resources/resources.js +371 -0
  12. package/dist/src/test-utils.js +474 -0
  13. package/dist/src/testing.js +3 -0
  14. package/dist/types/adapters/types.d.ts +3 -8
  15. package/dist/types/adapters/types.d.ts.map +1 -1
  16. package/dist/types/clients/types.d.ts +46 -6
  17. package/dist/types/clients/types.d.ts.map +1 -1
  18. package/dist/types/dsl/brain-runner.d.ts +24 -0
  19. package/dist/types/dsl/brain-runner.d.ts.map +1 -0
  20. package/dist/types/dsl/brain.d.ts +136 -0
  21. package/dist/types/dsl/brain.d.ts.map +1 -0
  22. package/dist/types/dsl/constants.d.ts +5 -5
  23. package/dist/types/dsl/constants.d.ts.map +1 -1
  24. package/dist/types/dsl/json-patch.d.ts +2 -1
  25. package/dist/types/dsl/json-patch.d.ts.map +1 -1
  26. package/dist/types/index.d.ts +13 -11
  27. package/dist/types/index.d.ts.map +1 -1
  28. package/dist/types/resources/resource-loader.d.ts +6 -0
  29. package/dist/types/resources/resource-loader.d.ts.map +1 -0
  30. package/dist/types/resources/resources.d.ts +23 -0
  31. package/dist/types/resources/resources.d.ts.map +1 -0
  32. package/dist/types/test-utils.d.ts +94 -0
  33. package/dist/types/test-utils.d.ts.map +1 -0
  34. package/dist/types/testing.d.ts +2 -0
  35. package/dist/types/testing.d.ts.map +1 -0
  36. package/docs/core-testing-guide.md +289 -0
  37. package/package.json +26 -7
  38. package/src/adapters/types.ts +3 -22
  39. package/src/clients/types.ts +50 -10
  40. package/src/dsl/brain-runner.test.ts +384 -0
  41. package/src/dsl/brain-runner.ts +111 -0
  42. package/src/dsl/brain.test.ts +1981 -0
  43. package/src/dsl/brain.ts +740 -0
  44. package/src/dsl/constants.ts +6 -6
  45. package/src/dsl/json-patch.ts +24 -9
  46. package/src/dsl/types.ts +1 -1
  47. package/src/index.ts +30 -16
  48. package/src/resources/resource-loader.ts +8 -0
  49. package/src/resources/resources.ts +267 -0
  50. package/src/test-utils.ts +254 -0
  51. package/test/resources.test.ts +248 -0
  52. package/tsconfig.json +2 -2
  53. package/.swcrc +0 -31
  54. package/dist/src/dsl/extensions.js +0 -19
  55. package/dist/src/dsl/workflow-runner.js +0 -93
  56. package/dist/src/dsl/workflow.js +0 -308
  57. package/dist/src/file-stores/local-file-store.js +0 -12
  58. package/dist/src/utils/temp-files.js +0 -27
  59. package/dist/types/dsl/extensions.d.ts +0 -18
  60. package/dist/types/dsl/extensions.d.ts.map +0 -1
  61. package/dist/types/dsl/workflow-runner.d.ts +0 -28
  62. package/dist/types/dsl/workflow-runner.d.ts.map +0 -1
  63. package/dist/types/dsl/workflow.d.ts +0 -118
  64. package/dist/types/dsl/workflow.d.ts.map +0 -1
  65. package/dist/types/file-stores/local-file-store.d.ts +0 -7
  66. package/dist/types/file-stores/local-file-store.d.ts.map +0 -1
  67. package/dist/types/file-stores/types.d.ts +0 -4
  68. package/dist/types/file-stores/types.d.ts.map +0 -1
  69. package/dist/types/utils/temp-files.d.ts +0 -12
  70. package/dist/types/utils/temp-files.d.ts.map +0 -1
  71. package/src/dsl/extensions.ts +0 -58
  72. package/src/dsl/workflow-runner.test.ts +0 -203
  73. package/src/dsl/workflow-runner.ts +0 -146
  74. package/src/dsl/workflow.test.ts +0 -1435
  75. package/src/dsl/workflow.ts +0 -554
  76. package/src/file-stores/local-file-store.ts +0 -11
  77. package/src/file-stores/types.ts +0 -3
  78. package/src/utils/temp-files.ts +0 -46
  79. /package/dist/src/{file-stores/types.js → resources/resource-loader.js} +0 -0
@@ -1,11 +1,11 @@
1
- export const WORKFLOW_EVENTS = {
2
- START: 'workflow:start',
3
- RESTART: 'workflow:restart',
1
+ export const BRAIN_EVENTS = {
2
+ START: 'brain:start',
3
+ RESTART: 'brain:restart',
4
4
  STEP_START: 'step:start',
5
5
  STEP_COMPLETE: 'step:complete',
6
6
  STEP_STATUS: 'step:status',
7
- ERROR: 'workflow:error',
8
- COMPLETE: 'workflow:complete',
7
+ ERROR: 'brain:error',
8
+ COMPLETE: 'brain:complete',
9
9
  } as const;
10
10
 
11
11
  export const STATUS = {
@@ -13,4 +13,4 @@ export const STATUS = {
13
13
  RUNNING: 'running',
14
14
  COMPLETE: 'complete',
15
15
  ERROR: 'error',
16
- } as const;
16
+ } as const;
@@ -1,13 +1,15 @@
1
1
  import pkg from 'fast-json-patch';
2
2
  const { compare, applyPatch } = pkg;
3
- import { JsonPatch, State } from './types';
3
+ import { JsonPatch, State } from './types.js';
4
+
5
+ export type { JsonPatch };
4
6
 
5
7
  /**
6
8
  * Creates a JSON Patch that describes the changes needed to transform prevState into nextState.
7
9
  */
8
10
  export function createPatch(prevState: State, nextState: State): JsonPatch {
9
11
  // Filter out non-standard operations and ensure type safety
10
- return compare(prevState, nextState).filter(op =>
12
+ return compare(prevState, nextState).filter((op) =>
11
13
  ['add', 'remove', 'replace', 'move', 'copy', 'test'].includes(op.op)
12
14
  ) as JsonPatch;
13
15
  }
@@ -16,12 +18,25 @@ export function createPatch(prevState: State, nextState: State): JsonPatch {
16
18
  * Applies one or more JSON Patches to a state object and returns the resulting state.
17
19
  * If multiple patches are provided, they are applied in sequence.
18
20
  */
19
- export function applyPatches(state: State, patches: JsonPatch | JsonPatch[]): State {
20
- const patchArray = Array.isArray(patches[0]) ? patches as JsonPatch[] : [patches as JsonPatch];
21
+ export function applyPatches(
22
+ state: State,
23
+ patches: JsonPatch | JsonPatch[]
24
+ ): State {
25
+ const patchArray = Array.isArray(patches[0])
26
+ ? (patches as JsonPatch[])
27
+ : [patches as JsonPatch];
21
28
 
22
29
  // Apply patches in sequence, creating a new state object each time
23
- return patchArray.reduce((currentState, patch) => {
24
- const { newDocument } = applyPatch(currentState, patch as any[], true, false);
25
- return newDocument;
26
- }, { ...state });
27
- }
30
+ return patchArray.reduce(
31
+ (currentState, patch) => {
32
+ const { newDocument } = applyPatch(
33
+ currentState,
34
+ patch as any[],
35
+ true,
36
+ false
37
+ );
38
+ return newDocument;
39
+ },
40
+ { ...state }
41
+ );
42
+ }
package/src/dsl/types.ts CHANGED
@@ -10,4 +10,4 @@ export type JsonPatch = {
10
10
  path: string;
11
11
  value?: JsonValue;
12
12
  from?: string;
13
- }[];
13
+ }[];
package/src/index.ts CHANGED
@@ -1,22 +1,36 @@
1
- export { Workflow, workflow } from "./dsl/workflow";
2
- export { WorkflowRunner } from "./dsl/workflow-runner";
3
- export { createExtension } from "./dsl/extensions";
4
- export { STATUS, WORKFLOW_EVENTS } from "./dsl/constants";
5
- export { Adapter } from "./adapters/types";
1
+ export { Brain, brain } from './dsl/brain.js';
2
+ export { BrainRunner } from './dsl/brain-runner.js';
3
+ export { STATUS, BRAIN_EVENTS } from './dsl/constants.js';
4
+ export type { Adapter } from './adapters/types.js';
6
5
  export type {
7
- WorkflowEvent,
6
+ BrainEvent,
8
7
  SerializedStep,
9
8
  InitialRunParams,
10
9
  RerunParams,
11
- WorkflowStartEvent,
12
- WorkflowCompleteEvent,
13
- WorkflowErrorEvent,
10
+ BrainStartEvent,
11
+ BrainCompleteEvent,
12
+ BrainErrorEvent,
14
13
  StepStatusEvent,
15
14
  StepStartedEvent,
16
- StepCompletedEvent
17
- } from "./dsl/workflow";
18
- export type { PromptClient, ResponseModel } from "./clients/types";
19
- export type { State } from "./dsl/types";
20
- export type { FileStore } from "./file-stores/types";
21
- export { createPatch, applyPatches } from "./dsl/json-patch";
22
- export { LocalFileStore } from "./file-stores/local-file-store";
15
+ StepCompletedEvent,
16
+ BrainStructure,
17
+ BrainFactory,
18
+ } from './dsl/brain.js';
19
+ export type { ObjectGenerator, Message } from './clients/types.js';
20
+ export type { State } from './dsl/types.js';
21
+ export { createPatch, applyPatches } from './dsl/json-patch.js';
22
+
23
+ // Only needed for development to ensure that zod version numbers are the same, it's a peer
24
+ // dependency so when not using file://..path/to/package links the version numbers
25
+ // will match just fine if the user has the same version of zod installed.
26
+ // NOTE: Not 100% sure this is still needed - worth re-evaluating if we can remove this.
27
+ export { z } from 'zod';
28
+
29
+ export type { ResourceLoader } from './resources/resource-loader.js';
30
+ export { createResources, type Resources } from './resources/resources.js';
31
+ export type {
32
+ Manifest as ResourceManifest,
33
+ Entry as ResourceEntry,
34
+ ResourceType,
35
+ } from './resources/resources.js';
36
+ export { RESOURCE_TYPES } from './resources/resources.js';
@@ -0,0 +1,8 @@
1
+ export interface ResourceLoader {
2
+ load(resourceName: string, type?: 'text'): Promise<string>;
3
+ load(resourceName: string, type: 'binary'): Promise<Buffer>;
4
+ load(
5
+ resourceName: string,
6
+ type?: 'text' | 'binary'
7
+ ): Promise<string | Buffer>;
8
+ }
@@ -0,0 +1,267 @@
1
+ import { ResourceLoader } from './resource-loader.js';
2
+
3
+ // Runtime array of valid resource types
4
+ export const RESOURCE_TYPES = ['text', 'binary'] as const;
5
+ export type ResourceType = (typeof RESOURCE_TYPES)[number];
6
+
7
+ export interface Entry {
8
+ type: ResourceType;
9
+ path: string; // File path - used during build process
10
+ key: string; // R2 object key (original filename with path)
11
+ }
12
+
13
+ export interface Manifest {
14
+ [key: string]: ManifestEntry;
15
+ }
16
+
17
+ type ManifestEntry = Entry | Manifest;
18
+
19
+ interface Resource {
20
+ load: () => Promise<string | Buffer>;
21
+ loadText: () => Promise<string>;
22
+ loadBinary: () => Promise<Buffer>;
23
+ }
24
+
25
+ export interface Resources {
26
+ [key: string]: Resource | Resources;
27
+ }
28
+
29
+ function isResourceEntry(entry: ManifestEntry): entry is Entry {
30
+ return typeof (entry as Entry).type === 'string';
31
+ }
32
+
33
+ export function createResources<M extends Manifest>(
34
+ loader: ResourceLoader,
35
+ initialManifest: M
36
+ ) {
37
+ // Helper function to find a resource entry by path in the manifest
38
+ function findResourceByPath(manifest: Manifest, path: string): Entry | null {
39
+ const parts = path.split('/');
40
+
41
+ let current: Manifest | Entry = manifest;
42
+ for (let i = 0; i < parts.length; i++) {
43
+ const part = parts[i];
44
+ if (isResourceEntry(current)) {
45
+ // We hit a resource entry before consuming all parts
46
+ return null;
47
+ }
48
+
49
+ const currentManifest = current as Manifest;
50
+ const next = currentManifest[part];
51
+ if (!next) {
52
+ // If exact match not found and this is the last part, try without extension
53
+ if (i === parts.length - 1) {
54
+ const partWithoutExt = part.replace(/\.[^/.]+$/, '');
55
+ const currentManifest = current as Manifest; // We know it's a Manifest since we checked isResourceEntry above
56
+ const matches = Object.keys(currentManifest).filter((key) => {
57
+ const keyWithoutExt = key.replace(/\.[^/.]+$/, '');
58
+ return (
59
+ keyWithoutExt === partWithoutExt &&
60
+ isResourceEntry(currentManifest[key])
61
+ );
62
+ });
63
+
64
+ if (matches.length === 1) {
65
+ return currentManifest[matches[0]] as Entry;
66
+ } else if (matches.length > 1) {
67
+ throw new Error(
68
+ `Ambiguous resource path '${path}': found ${matches.join(
69
+ ', '
70
+ )}. ` + `Please specify the full filename with extension.`
71
+ );
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ if (i === parts.length - 1 && isResourceEntry(next)) {
78
+ // Found the resource
79
+ return next;
80
+ }
81
+
82
+ current = next as Manifest;
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ function createProxiedResources(manifestNode: Manifest): Resources {
89
+ // Create methods that will be shared across all instances
90
+ const loadText = async (path: string): Promise<string> => {
91
+ const entry = findResourceByPath(manifestNode, path);
92
+ if (!entry) {
93
+ throw new Error(`Resource not found: ${path}`);
94
+ }
95
+ if (entry.type !== 'text') {
96
+ throw new Error(
97
+ `Resource "${path}" is of type "${entry.type}", but was accessed with loadText().`
98
+ );
99
+ }
100
+ return loader.load(entry.key, 'text');
101
+ };
102
+
103
+ const loadBinary = async (path: string) => {
104
+ const entry = findResourceByPath(manifestNode, path);
105
+ if (!entry) {
106
+ throw new Error(`Resource not found: ${path}`);
107
+ }
108
+ if (entry.type !== 'binary') {
109
+ throw new Error(
110
+ `Resource "${path}" is of type "${entry.type}", but was accessed with loadBinary().`
111
+ );
112
+ }
113
+ return loader.load(entry.key, 'binary');
114
+ };
115
+
116
+ const resultProxy: Resources = new Proxy({} as Resources, {
117
+ get: (target, prop, receiver): any => {
118
+ if (prop === 'loadText') {
119
+ return loadText;
120
+ }
121
+ if (prop === 'loadBinary') {
122
+ return loadBinary;
123
+ }
124
+
125
+ // Handle dynamic resource properties
126
+ if (typeof prop !== 'string') {
127
+ return Reflect.get(target, prop, receiver);
128
+ }
129
+
130
+ // Check if the property exists directly (with extension)
131
+ if (prop in manifestNode) {
132
+ const manifestEntry = manifestNode[prop];
133
+
134
+ if (isResourceEntry(manifestEntry)) {
135
+ const { key, type } = manifestEntry;
136
+ const apiObject: Resource = {
137
+ load: () =>
138
+ manifestEntry.type === 'text'
139
+ ? loader.load(key, 'text')
140
+ : loader.load(key, 'binary'),
141
+ loadText: () => {
142
+ if (type !== 'text') {
143
+ throw new Error(
144
+ `Resource "${prop}" is of type "${type}", but was accessed with loadText().`
145
+ );
146
+ }
147
+ return loader.load(key, 'text');
148
+ },
149
+ loadBinary: () => {
150
+ if (type !== 'binary') {
151
+ throw new Error(
152
+ `Resource "${prop}" is of type "${type}", but was accessed with loadBinary().`
153
+ );
154
+ }
155
+ return loader.load(key, 'binary');
156
+ },
157
+ };
158
+ return apiObject;
159
+ } else {
160
+ // manifestEntry is a nested Manifest
161
+ const nestedResources = createProxiedResources(
162
+ manifestEntry as Manifest
163
+ );
164
+ return nestedResources;
165
+ }
166
+ }
167
+
168
+ // If not found directly, check for files without extension
169
+ // Find all keys that match when extension is removed
170
+ const matches = Object.keys(manifestNode).filter((key) => {
171
+ const keyWithoutExt = key.replace(/\.[^/.]+$/, '');
172
+ return keyWithoutExt === prop && isResourceEntry(manifestNode[key]);
173
+ });
174
+
175
+ if (matches.length === 0) {
176
+ // No matches - might be a nested directory
177
+ if (prop in manifestNode && !isResourceEntry(manifestNode[prop])) {
178
+ return createProxiedResources(manifestNode[prop] as Manifest);
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ if (matches.length === 1) {
184
+ // Single match - return it
185
+ const manifestEntry = manifestNode[matches[0]] as Entry;
186
+ const { key, type } = manifestEntry;
187
+ const apiObject: Resource = {
188
+ load: () =>
189
+ type === 'text'
190
+ ? loader.load(key, 'text')
191
+ : loader.load(key, 'binary'),
192
+ loadText: () => {
193
+ if (type !== 'text') {
194
+ throw new Error(
195
+ `Resource "${matches[0]}" is of type "${type}", but was accessed with loadText().`
196
+ );
197
+ }
198
+ return loader.load(key, 'text');
199
+ },
200
+ loadBinary: () => {
201
+ if (type !== 'binary') {
202
+ throw new Error(
203
+ `Resource "${matches[0]}" is of type "${type}", but was accessed with loadBinary().`
204
+ );
205
+ }
206
+ return loader.load(key, 'binary');
207
+ },
208
+ };
209
+ return apiObject;
210
+ }
211
+
212
+ // Multiple matches - throw helpful error
213
+ throw new Error(
214
+ `Ambiguous resource name '${prop}': found ${matches.join(', ')}. ` +
215
+ `Please use resources.loadText('${matches[0]}') or resources.loadBinary('${matches[1]}') instead.`
216
+ );
217
+ },
218
+ has: (target, prop): boolean => {
219
+ // Check for special methods
220
+ if (prop === 'loadText' || prop === 'loadBinary') {
221
+ return true;
222
+ }
223
+ // Then check manifest
224
+ if (typeof prop === 'string') {
225
+ return prop in manifestNode;
226
+ }
227
+ return Reflect.has(target, prop);
228
+ },
229
+ ownKeys: (target): string[] => {
230
+ // Combine special methods with manifest keys
231
+ return ['loadText', 'loadBinary', ...Object.keys(manifestNode)];
232
+ },
233
+ getOwnPropertyDescriptor: (
234
+ target,
235
+ prop
236
+ ): PropertyDescriptor | undefined => {
237
+ if (prop === 'loadText' || prop === 'loadBinary') {
238
+ return {
239
+ value: prop === 'loadText' ? loadText : loadBinary,
240
+ writable: false,
241
+ enumerable: true,
242
+ configurable: true,
243
+ };
244
+ }
245
+
246
+ if (typeof prop === 'string' && prop in manifestNode) {
247
+ const value: Resource | Resources | undefined =
248
+ resultProxy[prop as keyof Resources];
249
+ if (value === undefined) {
250
+ return undefined;
251
+ }
252
+ return {
253
+ value,
254
+ writable: false,
255
+ enumerable: true,
256
+ configurable: true,
257
+ };
258
+ }
259
+ return Reflect.getOwnPropertyDescriptor(target, prop);
260
+ },
261
+ });
262
+
263
+ return resultProxy;
264
+ }
265
+
266
+ return createProxiedResources(initialManifest);
267
+ }
@@ -0,0 +1,254 @@
1
+ import type { ObjectGenerator, Message } from './clients/types.js';
2
+ import { z } from 'zod';
3
+ import { jest } from '@jest/globals';
4
+ import type { Adapter } from './adapters/types.js';
5
+ import type { BrainEvent, Brain } from './dsl/brain.js';
6
+ import type { State } from './dsl/types.js';
7
+ import { BRAIN_EVENTS } from './dsl/constants.js';
8
+ import { applyPatches } from './dsl/json-patch.js';
9
+ import { BrainRunner } from './dsl/brain-runner.js';
10
+ import type { Resources } from './resources/resources.js';
11
+
12
+ /**
13
+ * Mock implementation of ObjectGenerator for testing
14
+ */
15
+ export class MockObjectGenerator implements ObjectGenerator {
16
+ private generateObjectMock: jest.Mock<any>;
17
+ private calls: Array<{
18
+ params: Parameters<ObjectGenerator['generateObject']>[0];
19
+ timestamp: Date;
20
+ }> = [];
21
+
22
+ constructor() {
23
+ this.generateObjectMock = jest.fn();
24
+ }
25
+
26
+ async generateObject<T extends z.AnyZodObject>(
27
+ params: Parameters<ObjectGenerator['generateObject']>[0]
28
+ ): Promise<z.infer<T>> {
29
+ this.calls.push({ params, timestamp: new Date() });
30
+ return this.generateObjectMock(params) as Promise<z.infer<T>>;
31
+ }
32
+
33
+ /**
34
+ * Mock a response for the next generateObject call
35
+ */
36
+ mockNextResponse<T>(response: T): void {
37
+ this.generateObjectMock.mockResolvedValueOnce(response as any);
38
+ }
39
+
40
+ /**
41
+ * Mock multiple responses in sequence
42
+ */
43
+ mockResponses(...responses: any[]): void {
44
+ responses.forEach((response) => {
45
+ this.generateObjectMock.mockResolvedValueOnce(response as any);
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Mock an error for the next generateObject call
51
+ */
52
+ mockNextError(error: Error | string): void {
53
+ const errorObj = typeof error === 'string' ? new Error(error) : error;
54
+ this.generateObjectMock.mockRejectedValueOnce(errorObj as any);
55
+ }
56
+
57
+ /**
58
+ * Get the underlying jest.Mock for advanced mocking scenarios
59
+ */
60
+ get mock(): jest.Mock<any> {
61
+ return this.generateObjectMock;
62
+ }
63
+
64
+ /**
65
+ * Clear all mocks and call history
66
+ */
67
+ clear(): void {
68
+ this.generateObjectMock.mockClear();
69
+ this.calls = [];
70
+ }
71
+
72
+ /**
73
+ * Reset mock to initial state
74
+ */
75
+ reset(): void {
76
+ this.generateObjectMock.mockReset();
77
+ this.calls = [];
78
+ }
79
+
80
+ /**
81
+ * Get all calls made to generateObject
82
+ */
83
+ getCalls() {
84
+ return this.calls;
85
+ }
86
+
87
+ /**
88
+ * Get the last call made to generateObject
89
+ */
90
+ getLastCall() {
91
+ return this.calls[this.calls.length - 1];
92
+ }
93
+
94
+ /**
95
+ * Assert that generateObject was called with specific parameters
96
+ */
97
+ expectCalledWith(expected: {
98
+ prompt?: string | ((actual: string) => boolean);
99
+ schemaName?: string;
100
+ messages?: Message[];
101
+ system?: string;
102
+ }): void {
103
+ const lastCall = this.getLastCall();
104
+ if (!lastCall) {
105
+ throw new Error('No calls made to generateObject');
106
+ }
107
+
108
+ if (expected.prompt !== undefined) {
109
+ if (typeof expected.prompt === 'function') {
110
+ expect(expected.prompt(lastCall.params.prompt || '')).toBe(true);
111
+ } else {
112
+ expect(lastCall.params.prompt).toBe(expected.prompt);
113
+ }
114
+ }
115
+
116
+ if (expected.schemaName !== undefined) {
117
+ expect(lastCall.params.schemaName).toBe(expected.schemaName);
118
+ }
119
+
120
+ if (expected.messages !== undefined) {
121
+ expect(lastCall.params.messages).toEqual(expected.messages);
122
+ }
123
+
124
+ if (expected.system !== undefined) {
125
+ expect(lastCall.params.system).toBe(expected.system);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Assert that generateObject was called N times
131
+ */
132
+ expectCallCount(count: number): void {
133
+ expect(this.generateObjectMock).toHaveBeenCalledTimes(count);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Creates a mock client for testing brains
139
+ */
140
+ export function createMockClient(): MockObjectGenerator {
141
+ return new MockObjectGenerator();
142
+ }
143
+
144
+ /**
145
+ * Test adapter that collects brain execution results
146
+ */
147
+ class TestAdapter<TState extends State = State> implements Adapter {
148
+ private stepTitles: string[] = [];
149
+ private currentState: TState;
150
+ private completed = false;
151
+ private error: Error | null = null;
152
+
153
+ constructor(private initialState: TState) {
154
+ this.currentState = { ...initialState };
155
+ }
156
+
157
+ async dispatch(event: BrainEvent): Promise<void> {
158
+ switch (event.type) {
159
+ case BRAIN_EVENTS.STEP_COMPLETE:
160
+ this.stepTitles.push(event.stepTitle);
161
+ if (event.patch && event.patch.length > 0) {
162
+ this.currentState = applyPatches(this.currentState, event.patch) as TState;
163
+ }
164
+ break;
165
+ case BRAIN_EVENTS.COMPLETE:
166
+ this.completed = true;
167
+ break;
168
+ case BRAIN_EVENTS.ERROR:
169
+ this.error = event.error;
170
+ break;
171
+ }
172
+ }
173
+
174
+ getExecutedSteps(): string[] {
175
+ return [...this.stepTitles];
176
+ }
177
+
178
+ getFinalState(): TState {
179
+ return { ...this.currentState };
180
+ }
181
+
182
+ isCompleted(): boolean {
183
+ return this.completed;
184
+ }
185
+
186
+ getError(): Error | null {
187
+ return this.error;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Test runner options
193
+ */
194
+ export interface TestRunnerOptions<TState extends State = State> {
195
+ client?: ObjectGenerator;
196
+ resources?: Resources;
197
+ initialState?: TState;
198
+ }
199
+
200
+ /**
201
+ * Result of running a brain test
202
+ */
203
+ export interface TestRunResult<TState extends State = State> {
204
+ finalState: TState;
205
+ steps: string[];
206
+ error: Error | null;
207
+ completed: boolean;
208
+ }
209
+
210
+ /**
211
+ * Runs a brain with test utilities and returns collected data
212
+ */
213
+ export async function runBrainTest<
214
+ TOptions extends object = {},
215
+ TState extends State = {}
216
+ >(
217
+ brain: Brain<TOptions, TState, any>,
218
+ options: TestRunnerOptions<TState> & { brainOptions?: TOptions } = {}
219
+ ): Promise<TestRunResult<TState>> {
220
+ const {
221
+ client = createMockClient(),
222
+ resources,
223
+ initialState = {} as TState,
224
+ brainOptions,
225
+ } = options;
226
+
227
+ // Create test adapter
228
+ const testAdapter = new TestAdapter<TState>(initialState);
229
+
230
+ // Create brain runner with test adapter
231
+ const runner = new BrainRunner({
232
+ adapters: [testAdapter],
233
+ client,
234
+ resources,
235
+ });
236
+
237
+ try {
238
+ // Run the brain
239
+ await runner.run(brain, {
240
+ initialState,
241
+ options: brainOptions,
242
+ });
243
+ } catch (error) {
244
+ // Brain might throw after emitting ERROR event
245
+ // This is expected behavior, so we don't re-throw
246
+ }
247
+
248
+ return {
249
+ finalState: testAdapter.getFinalState(),
250
+ steps: testAdapter.getExecutedSteps(),
251
+ error: testAdapter.getError(),
252
+ completed: testAdapter.isCompleted(),
253
+ };
254
+ }