@codehz/ai 0.1.3 → 0.1.4

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.
package/package.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "name": "@codehz/ai",
3
+ "version": "0.1.4",
3
4
  "type": "module",
4
5
  "module": "dist/index.mjs",
5
6
  "exports": {
@@ -26,6 +27,5 @@
26
27
  "oxlint": "^1.73.0",
27
28
  "tsdown": "^0.22.3",
28
29
  "typescript": "^6"
29
- },
30
- "version": "0.1.3"
30
+ }
31
31
  }
@@ -283,7 +283,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
283
283
  item.role === "developer"
284
284
  ? "system"
285
285
  : item.role === "system"
286
- ? "system"
286
+ ? "system"
287
287
  : item.role === "user"
288
288
  ? "user"
289
289
  : "assistant";
@@ -293,9 +293,10 @@ export class ChatCompletionsAdapter extends AdapterBase {
293
293
  }
294
294
  case "tool_call": {
295
295
  // 只允许附着到尾部 assistant turn,否则新建一个
296
- const lastAssistant = messages.length > 0 && messages[messages.length - 1]?.role === "assistant"
297
- ? messages[messages.length - 1]
298
- : null;
296
+ const lastAssistant =
297
+ messages.length > 0 && messages[messages.length - 1]?.role === "assistant"
298
+ ? messages[messages.length - 1]
299
+ : null;
299
300
  const tc: ChatToolCall = {
300
301
  id: item.id,
301
302
  type: "function",
@@ -22,11 +22,13 @@ export type { OllamaAdapterOptions } from "./ollama.js";
22
22
  export { MockAdapter } from "./mock.js";
23
23
  export type {
24
24
  MockAdapterOptions,
25
+ MockHistoryRecord,
25
26
  MockTextStreamOptions,
26
27
  MockInputExpectation,
27
28
  MockRequestExpectation,
28
- MockTurnContext,
29
- MockTurnValidator,
29
+ MockHandlerContext,
30
+ MockHandler,
31
+ MockStaticHandler,
30
32
  MockWarningStep,
31
33
  MockAuxiliaryStep,
32
34
  MockMessageStep,
@@ -38,5 +40,5 @@ export type {
38
40
  MockInterruptStep,
39
41
  MockThrowStep,
40
42
  MockStep,
41
- MockTurn,
42
43
  } from "./mock.js";
44
+ export { assertMockRequest, withMockStreaming } from "./mock.js";
@@ -275,7 +275,9 @@ export class MessagesAdapter extends AdapterBase {
275
275
  if (item.role === "system" || item.role === "developer") {
276
276
  // Anthropic 不支持 system/developer role 在 messages 中
277
277
  // 合并到 system prompt
278
- const text = contentBlocksToText(ensureMessagesTextBlocks(item.content, `input message (${item.role}) content`));
278
+ const text = contentBlocksToText(
279
+ ensureMessagesTextBlocks(item.content, `input message (${item.role}) content`),
280
+ );
279
281
  systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
280
282
  break;
281
283
  }
@@ -296,9 +298,7 @@ export class MessagesAdapter extends AdapterBase {
296
298
  type: "tool_use",
297
299
  id: item.id,
298
300
  name: item.name,
299
- input:
300
- (item.argumentsJson as Record<string, unknown> | undefined) ??
301
- parseToolUseInput(item.argumentsText),
301
+ input: (item.argumentsJson as Record<string, unknown> | undefined) ?? parseToolUseInput(item.argumentsText),
302
302
  };
303
303
 
304
304
  if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content !== "string") {
@@ -345,7 +345,11 @@ export class MessagesAdapter extends AdapterBase {
345
345
  typeof b === "object" &&
346
346
  b !== null &&
347
347
  "type" in b &&
348
- (b.type === "text" || b.type === "thinking" || b.type === "redacted_thinking" || b.type === "tool_use" || b.type === "tool_result"),
348
+ (b.type === "text" ||
349
+ b.type === "thinking" ||
350
+ b.type === "redacted_thinking" ||
351
+ b.type === "tool_use" ||
352
+ b.type === "tool_result"),
349
353
  );
350
354
  if (isValidContent) {
351
355
  rollbackTrailingAssistantMessages(messages);
@@ -404,7 +408,10 @@ export class MessagesAdapter extends AdapterBase {
404
408
  const auxiliary = this.createAuxiliaryState(request);
405
409
 
406
410
  if (request.metadata) {
407
- yield factory.responseWarning("Request metadata is not supported by the Messages adapter", "UNSUPPORTED_METADATA");
411
+ yield factory.responseWarning(
412
+ "Request metadata is not supported by the Messages adapter",
413
+ "UNSUPPORTED_METADATA",
414
+ );
408
415
  }
409
416
 
410
417
  const response = await this.fetchFn(`${this.baseUrl}/messages`, {
@@ -453,7 +460,10 @@ export class MessagesAdapter extends AdapterBase {
453
460
 
454
461
  if (request.include?.providerMetadata !== "off") {
455
462
  const headerMetadata = pickProviderHeaders(response.headers);
456
- auxiliary.recordProviderMetadata("header", Object.keys(headerMetadata).length > 0 ? { headers: headerMetadata } : undefined);
463
+ auxiliary.recordProviderMetadata(
464
+ "header",
465
+ Object.keys(headerMetadata).length > 0 ? { headers: headerMetadata } : undefined,
466
+ );
457
467
  }
458
468
 
459
469
  try {
@@ -613,9 +623,9 @@ export class MessagesAdapter extends AdapterBase {
613
623
  if (u) {
614
624
  auxiliary.recordUsage(
615
625
  {
616
- inputTokens: u.input_tokens,
617
- outputTokens: u.output_tokens,
618
- totalTokens: u.input_tokens + u.output_tokens,
626
+ inputTokens: u.input_tokens,
627
+ outputTokens: u.output_tokens,
628
+ totalTokens: u.input_tokens + u.output_tokens,
619
629
  },
620
630
  "stream",
621
631
  u,
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Mock Adapter
3
3
  *
4
- * 面向测试的脚本化 adapter:
5
- * - turn 顺序消费请求,验证调用方是否正确续接 replay / tool_result
4
+ * 面向测试的回调驱动 adapter:
5
+ * - 每次请求执行用户提供的 handler
6
+ * - 验证调用方是否正确续接 replay / tool_result
6
7
  * - 发出可控的 message / reasoning / tool_call 流
7
8
  * - 注入 warning / auxiliary / content_filter / 中断 / provider error
8
9
  *
@@ -55,18 +56,20 @@ export type MockRequestExpectation = {
55
56
  items?: MockInputExpectation[];
56
57
  };
57
58
 
58
- export type MockTurnContext = {
59
+ export type MockHistoryRecord = {
60
+ turnIndex: number;
61
+ requestId: string;
62
+ replay: ReplayItem[];
63
+ toolCalls: ToolCallItem[];
64
+ };
65
+
66
+ export type MockHandlerContext = {
59
67
  turnIndex: number;
60
68
  previousReplay: ReplayItem[];
61
69
  pendingToolCalls: readonly ToolCallItem[];
62
- history: readonly MockTurnRecord[];
70
+ history: readonly MockHistoryRecord[];
63
71
  };
64
72
 
65
- export type MockTurnValidator = (
66
- request: NormalizedRequest,
67
- context: MockTurnContext,
68
- ) => void | Promise<void>;
69
-
70
73
  export type MockWarningStep = {
71
74
  type: "warning";
72
75
  message: string;
@@ -167,32 +170,24 @@ export type MockStep =
167
170
  | MockInterruptStep
168
171
  | MockThrowStep;
169
172
 
170
- export type MockTurn = {
171
- name?: string;
172
- expect?: MockRequestExpectation | MockTurnValidator;
173
- steps: MockStep[];
174
- };
173
+ export type MockHandler = (request: NormalizedRequest, context: MockHandlerContext) => AsyncIterable<MockStep>;
174
+
175
+ type MockHandlerSource = Iterable<MockStep> | AsyncIterable<MockStep>;
176
+
177
+ export type MockStaticHandler = (
178
+ request: NormalizedRequest,
179
+ context: MockHandlerContext,
180
+ ) => MockHandlerSource | Promise<MockHandlerSource>;
175
181
 
176
182
  export type MockAdapterOptions = {
177
- turns: MockTurn[];
178
- onExhausted?: "throw" | "repeat-last" | "complete-empty";
183
+ handler: MockHandler;
179
184
  providerMetadata?: Record<string, unknown>;
180
- stream?: MockTextStreamOptions;
181
- };
182
-
183
- type MockTurnRecord = {
184
- turnIndex: number;
185
- turnName?: string;
186
- requestId: string;
187
- replay: ReplayItem[];
188
- toolCalls: ToolCallItem[];
189
185
  };
190
186
 
191
187
  type MockProviderRequest = {
192
188
  request: NormalizedRequest;
193
- turn: MockTurn;
189
+ handlerResult: AsyncIterable<MockStep>;
194
190
  turnIndex: number;
195
- turnName?: string;
196
191
  remainingPendingToolCalls: ToolCallItem[];
197
192
  };
198
193
 
@@ -202,52 +197,102 @@ type ResolvedMockTextStreamOptions = {
202
197
  initialDelayMs: number;
203
198
  };
204
199
 
200
+ export function assertMockRequest(
201
+ request: NormalizedRequest,
202
+ expectation: MockRequestExpectation,
203
+ context: MockHandlerContext,
204
+ ): void {
205
+ const prefix = `MockAdapter turn ${context.turnIndex + 1} expectation failed`;
206
+
207
+ if (expectation.minItems !== undefined && request.input.length < expectation.minItems) {
208
+ throw new AIRequestError(
209
+ `${prefix}: expected at least ${expectation.minItems} input item(s)`,
210
+ "MOCK_EXPECTATION_FAILED",
211
+ );
212
+ }
213
+
214
+ if (expectation.maxItems !== undefined && request.input.length > expectation.maxItems) {
215
+ throw new AIRequestError(
216
+ `${prefix}: expected at most ${expectation.maxItems} input item(s)`,
217
+ "MOCK_EXPECTATION_FAILED",
218
+ );
219
+ }
220
+
221
+ if (expectation.tools === "present" && (!request.tools || request.tools.length === 0)) {
222
+ throw new AIRequestError(`${prefix}: expected tools to be present`, "MOCK_EXPECTATION_FAILED");
223
+ }
224
+
225
+ if (expectation.tools === "absent" && request.tools && request.tools.length > 0) {
226
+ throw new AIRequestError(`${prefix}: expected tools to be absent`, "MOCK_EXPECTATION_FAILED");
227
+ }
228
+
229
+ if (expectation.toolChoice === "present" && request.toolChoice === undefined) {
230
+ throw new AIRequestError(`${prefix}: expected toolChoice to be present`, "MOCK_EXPECTATION_FAILED");
231
+ }
232
+
233
+ if (expectation.toolChoice === "absent" && request.toolChoice !== undefined) {
234
+ throw new AIRequestError(`${prefix}: expected toolChoice to be absent`, "MOCK_EXPECTATION_FAILED");
235
+ }
236
+
237
+ if (expectation.requireReplayFromPreviousTurn && context.previousReplay.length > 0) {
238
+ assertReplayIncluded(request.input, context.previousReplay, prefix);
239
+ }
240
+
241
+ if (expectation.requireToolResultsForPendingCalls && context.pendingToolCalls.length > 0) {
242
+ const toolResultIds = new Set(
243
+ request.input.filter((item): item is ToolResultItem => item.type === "tool_result").map((item) => item.callId),
244
+ );
245
+
246
+ for (const call of context.pendingToolCalls) {
247
+ if (!toolResultIds.has(call.id)) {
248
+ throw new AIRequestError(
249
+ `${prefix}: expected tool_result for pending tool call "${call.id}"`,
250
+ "MOCK_EXPECTATION_FAILED",
251
+ );
252
+ }
253
+ }
254
+ }
255
+
256
+ if (expectation.items && expectation.items.length > 0) {
257
+ if (expectation.ordered) {
258
+ assertOrderedItems(request.input, expectation.items, prefix);
259
+ } else {
260
+ assertUnorderedItems(request.input, expectation.items, prefix);
261
+ }
262
+ }
263
+ }
264
+
205
265
  export class MockAdapter extends AdapterBase {
206
266
  readonly kind = "mock" as const;
207
267
  readonly nativeStreaming = false;
208
268
 
209
- private readonly turns: MockTurn[];
210
- private readonly onExhausted: NonNullable<MockAdapterOptions["onExhausted"]>;
269
+ private readonly handler: MockHandler;
211
270
  private readonly providerMetadata?: Record<string, unknown>;
212
- private readonly defaultStream?: ResolvedMockTextStreamOptions;
213
271
 
214
272
  private cursor = 0;
215
273
  private previousReplay: ReplayItem[] = [];
216
274
  private pendingToolCalls: ToolCallItem[] = [];
217
- private history: MockTurnRecord[] = [];
275
+ private history: MockHistoryRecord[] = [];
218
276
  private activeStream = false;
219
277
 
220
278
  constructor(options: MockAdapterOptions) {
221
279
  super();
222
- this.turns = options.turns;
223
- this.onExhausted = options.onExhausted ?? "throw";
280
+ this.handler = options.handler;
224
281
  this.providerMetadata = options.providerMetadata;
225
- this.defaultStream = resolveMockTextStreamOptions(options.stream, "adapter stream");
226
282
  }
227
283
 
228
284
  protected async buildRequest(request: NormalizedRequest): Promise<MockProviderRequest> {
229
285
  const turnIndex = this.cursor;
230
- const turn = this.resolveTurn(turnIndex);
231
- const turnName = turn.name;
232
- const context = this.buildTurnContext(turnIndex);
233
-
234
- if (turn.expect) {
235
- if (typeof turn.expect === "function") {
236
- await turn.expect(request, context);
237
- } else {
238
- assertRequestMatchesExpectation(request, turn.expect, context);
239
- }
240
- }
241
-
286
+ const context = this.buildHandlerContext(turnIndex);
242
287
  const remainingPendingToolCalls = consumePendingToolCalls(this.pendingToolCalls, request.input);
288
+ const handlerResult = this.handler(request, context);
243
289
 
244
290
  this.cursor += 1;
245
291
 
246
292
  return {
247
293
  request,
248
- turn,
294
+ handlerResult,
249
295
  turnIndex,
250
- turnName,
251
296
  remainingPendingToolCalls,
252
297
  };
253
298
  }
@@ -266,8 +311,11 @@ export class MockAdapter extends AdapterBase {
266
311
  try {
267
312
  const mockRequest = providerRequest as MockProviderRequest;
268
313
  const output: OutputItem[] = [];
314
+ let stepCount = 0;
315
+
316
+ for await (const step of mockRequest.handlerResult) {
317
+ stepCount += 1;
269
318
 
270
- for (const [stepIndex, step] of mockRequest.turn.steps.entries()) {
271
319
  switch (step.type) {
272
320
  case "warning":
273
321
  yield factory.responseWarning(step.message, step.code);
@@ -280,14 +328,14 @@ export class MockAdapter extends AdapterBase {
280
328
  });
281
329
  break;
282
330
  case "message": {
283
- const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepIndex);
284
- yield* emitMessage(factory, item, resolveStepStreamOptions(this.defaultStream, step.stream, "message"));
331
+ const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
332
+ yield* emitMessage(factory, item, resolveStepStreamOptions(undefined, step.stream, "message"));
285
333
  output.push(item);
286
334
  break;
287
335
  }
288
336
  case "reasoning": {
289
- const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepIndex);
290
- yield* emitReasoning(factory, item, resolveStepStreamOptions(this.defaultStream, step.stream, "reasoning"));
337
+ const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
338
+ yield* emitReasoning(factory, item, resolveStepStreamOptions(undefined, step.stream, "reasoning"));
291
339
  output.push(item);
292
340
  break;
293
341
  }
@@ -297,30 +345,37 @@ export class MockAdapter extends AdapterBase {
297
345
  factory,
298
346
  item,
299
347
  step.streamArguments ?? true,
300
- resolveStepStreamOptions(this.defaultStream, step.stream, "tool_call"),
348
+ resolveStepStreamOptions(undefined, step.stream, "tool_call"),
301
349
  );
302
350
  output.push(item);
303
351
  break;
304
352
  }
305
353
  case "output": {
306
354
  assertSupportedOutputItem(step.item);
307
- const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepIndex);
308
- yield* emitOutputItem(factory, item, resolveStepStreamOptions(this.defaultStream, step.stream, "output"));
355
+ const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepCount - 1);
356
+ yield* emitOutputItem(factory, item, resolveStepStreamOptions(undefined, step.stream, "output"));
309
357
  output.push(item);
310
358
  break;
311
359
  }
312
360
  case "complete": {
313
- const response = this.finalizeTurn(request, factory, mockRequest, output, step);
361
+ const response = this.finalizeTurn(request, factory, mockRequest, output, step, stepCount);
314
362
  yield factory.responseCompleted(response);
315
363
  return;
316
364
  }
317
365
  case "error": {
318
366
  yield factory.responseWarning(step.message, step.code);
319
- const response = this.finalizeTurn(request, factory, mockRequest, output, {
320
- type: "complete",
321
- stopReason: step.stopReason ?? "error",
322
- providerMetadata: step.providerMetadata,
323
- });
367
+ const response = this.finalizeTurn(
368
+ request,
369
+ factory,
370
+ mockRequest,
371
+ output,
372
+ {
373
+ type: "complete",
374
+ stopReason: step.stopReason ?? "error",
375
+ providerMetadata: step.providerMetadata,
376
+ },
377
+ stepCount,
378
+ );
324
379
  yield factory.responseCompleted(response);
325
380
  return;
326
381
  }
@@ -332,9 +387,16 @@ export class MockAdapter extends AdapterBase {
332
387
  }
333
388
  }
334
389
 
335
- const response = this.finalizeTurn(request, factory, mockRequest, output, {
336
- type: "complete",
337
- });
390
+ const response = this.finalizeTurn(
391
+ request,
392
+ factory,
393
+ mockRequest,
394
+ output,
395
+ {
396
+ type: "complete",
397
+ },
398
+ stepCount,
399
+ );
338
400
  yield factory.responseCompleted(response);
339
401
  } finally {
340
402
  this.activeStream = false;
@@ -347,6 +409,7 @@ export class MockAdapter extends AdapterBase {
347
409
  mockRequest: MockProviderRequest,
348
410
  output: OutputItem[],
349
411
  completion: MockCompleteStep,
412
+ stepCount: number,
350
413
  ) {
351
414
  const replay = completion.replay ?? replayFromOutput(output);
352
415
  const toolCalls = output.filter((item): item is ToolCallItem => item.type === "tool_call");
@@ -355,7 +418,6 @@ export class MockAdapter extends AdapterBase {
355
418
  this.pendingToolCalls = [...mockRequest.remainingPendingToolCalls, ...toolCalls];
356
419
  this.history.push({
357
420
  turnIndex: mockRequest.turnIndex,
358
- turnName: mockRequest.turnName,
359
421
  requestId: request.requestId,
360
422
  replay,
361
423
  toolCalls,
@@ -372,8 +434,7 @@ export class MockAdapter extends AdapterBase {
372
434
  auxiliary: completion.auxiliary,
373
435
  providerMetadata: {
374
436
  turnIndex: mockRequest.turnIndex,
375
- turnName: mockRequest.turnName,
376
- scriptedSteps: mockRequest.turn.steps.length,
437
+ stepCount,
377
438
  pendingToolCallIds: this.pendingToolCalls.map((item) => item.id),
378
439
  historyLength: this.history.length,
379
440
  ...this.providerMetadata,
@@ -387,28 +448,7 @@ export class MockAdapter extends AdapterBase {
387
448
  );
388
449
  }
389
450
 
390
- private resolveTurn(turnIndex: number): MockTurn {
391
- const turn = this.turns[turnIndex];
392
- if (turn !== undefined) {
393
- return turn;
394
- }
395
-
396
- const lastTurn = this.turns.at(-1);
397
- if (this.onExhausted === "repeat-last" && lastTurn !== undefined) {
398
- return lastTurn;
399
- }
400
-
401
- if (this.onExhausted === "complete-empty") {
402
- return { name: "exhausted", steps: [] };
403
- }
404
-
405
- throw new AIRequestError(
406
- `MockAdapter turn ${turnIndex + 1} requested, but only ${this.turns.length} turn(s) were scripted`,
407
- "MOCK_TURN_EXHAUSTED",
408
- );
409
- }
410
-
411
- private buildTurnContext(turnIndex: number): MockTurnContext {
451
+ private buildHandlerContext(turnIndex: number): MockHandlerContext {
412
452
  return {
413
453
  turnIndex,
414
454
  previousReplay: this.previousReplay.map(cloneItem),
@@ -422,6 +462,46 @@ export class MockAdapter extends AdapterBase {
422
462
  }
423
463
  }
424
464
 
465
+ export function withMockStreaming(handler: MockStaticHandler, options: MockTextStreamOptions): MockHandler {
466
+ const defaults = resolveMockTextStreamOptions(options, "mock stream wrapper");
467
+ if (!defaults) {
468
+ throw new AIRequestError("mock stream wrapper requires streaming options", "MOCK_STREAM_CONFIG_INVALID");
469
+ }
470
+
471
+ return async function* streamWrappedHandler(
472
+ request: NormalizedRequest,
473
+ context: MockHandlerContext,
474
+ ): AsyncIterable<MockStep> {
475
+ const source = await handler(request, context);
476
+
477
+ for await (const step of source) {
478
+ yield applyDefaultStreaming(step, defaults);
479
+ }
480
+ };
481
+ }
482
+
483
+ function applyDefaultStreaming(step: MockStep, defaults: ResolvedMockTextStreamOptions): MockStep {
484
+ switch (step.type) {
485
+ case "message":
486
+ case "reasoning":
487
+ case "tool_call":
488
+ case "output":
489
+ if (step.stream !== undefined) {
490
+ return step;
491
+ }
492
+ return {
493
+ ...step,
494
+ stream: {
495
+ charsPerSecond: defaults.charsPerSecond,
496
+ chunkSize: defaults.chunkSize,
497
+ initialDelayMs: defaults.initialDelayMs,
498
+ },
499
+ };
500
+ default:
501
+ return step;
502
+ }
503
+ }
504
+
425
505
  function createMessageFromStep(
426
506
  step: MockMessageStep,
427
507
  request: NormalizedRequest,
@@ -465,7 +545,10 @@ function normalizeBlocks(content: string | ContentBlock[]): ContentBlock[] {
465
545
 
466
546
  function assertSupportedOutputItem(item: OutputItem): void {
467
547
  if (item.type === "opaque") {
468
- throw new AIRequestError("MockAdapter does not stream opaque output items; use complete.replay if needed", "MOCK_OPAQUE_OUTPUT");
548
+ throw new AIRequestError(
549
+ "MockAdapter does not stream opaque output items; use complete.replay if needed",
550
+ "MOCK_OPAQUE_OUTPUT",
551
+ );
469
552
  }
470
553
  }
471
554
 
@@ -680,75 +763,12 @@ function resolveStopReason(output: OutputItem[]): StopReason {
680
763
 
681
764
  function consumePendingToolCalls(pending: readonly ToolCallItem[], input: readonly InputItem[]): ToolCallItem[] {
682
765
  const fulfilledIds = new Set(
683
- input
684
- .filter((item): item is ToolResultItem => item.type === "tool_result")
685
- .map((item) => item.callId),
766
+ input.filter((item): item is ToolResultItem => item.type === "tool_result").map((item) => item.callId),
686
767
  );
687
768
 
688
769
  return pending.filter((item) => !fulfilledIds.has(item.id)).map(cloneItem);
689
770
  }
690
771
 
691
- function assertRequestMatchesExpectation(
692
- request: NormalizedRequest,
693
- expectation: MockRequestExpectation,
694
- context: MockTurnContext,
695
- ): void {
696
- const prefix = `MockAdapter turn ${context.turnIndex + 1} expectation failed`;
697
-
698
- if (expectation.minItems !== undefined && request.input.length < expectation.minItems) {
699
- throw new AIRequestError(`${prefix}: expected at least ${expectation.minItems} input item(s)`, "MOCK_EXPECTATION_FAILED");
700
- }
701
-
702
- if (expectation.maxItems !== undefined && request.input.length > expectation.maxItems) {
703
- throw new AIRequestError(`${prefix}: expected at most ${expectation.maxItems} input item(s)`, "MOCK_EXPECTATION_FAILED");
704
- }
705
-
706
- if (expectation.tools === "present" && (!request.tools || request.tools.length === 0)) {
707
- throw new AIRequestError(`${prefix}: expected tools to be present`, "MOCK_EXPECTATION_FAILED");
708
- }
709
-
710
- if (expectation.tools === "absent" && request.tools && request.tools.length > 0) {
711
- throw new AIRequestError(`${prefix}: expected tools to be absent`, "MOCK_EXPECTATION_FAILED");
712
- }
713
-
714
- if (expectation.toolChoice === "present" && request.toolChoice === undefined) {
715
- throw new AIRequestError(`${prefix}: expected toolChoice to be present`, "MOCK_EXPECTATION_FAILED");
716
- }
717
-
718
- if (expectation.toolChoice === "absent" && request.toolChoice !== undefined) {
719
- throw new AIRequestError(`${prefix}: expected toolChoice to be absent`, "MOCK_EXPECTATION_FAILED");
720
- }
721
-
722
- if (expectation.requireReplayFromPreviousTurn && context.previousReplay.length > 0) {
723
- assertReplayIncluded(request.input, context.previousReplay, prefix);
724
- }
725
-
726
- if (expectation.requireToolResultsForPendingCalls && context.pendingToolCalls.length > 0) {
727
- const toolResultIds = new Set(
728
- request.input
729
- .filter((item): item is ToolResultItem => item.type === "tool_result")
730
- .map((item) => item.callId),
731
- );
732
-
733
- for (const call of context.pendingToolCalls) {
734
- if (!toolResultIds.has(call.id)) {
735
- throw new AIRequestError(
736
- `${prefix}: expected tool_result for pending tool call "${call.id}"`,
737
- "MOCK_EXPECTATION_FAILED",
738
- );
739
- }
740
- }
741
- }
742
-
743
- if (expectation.items && expectation.items.length > 0) {
744
- if (expectation.ordered) {
745
- assertOrderedItems(request.input, expectation.items, prefix);
746
- } else {
747
- assertUnorderedItems(request.input, expectation.items, prefix);
748
- }
749
- }
750
- }
751
-
752
772
  function assertReplayIncluded(input: readonly InputItem[], replay: readonly ReplayItem[], prefix: string): void {
753
773
  const fingerprints = input.map(fingerprintItem);
754
774
  let cursor = 0;
@@ -757,13 +777,20 @@ function assertReplayIncluded(input: readonly InputItem[], replay: readonly Repl
757
777
  const target = fingerprintItem(replayItem);
758
778
  const foundIndex = fingerprints.indexOf(target, cursor);
759
779
  if (foundIndex === -1) {
760
- throw new AIRequestError(`${prefix}: previous replay item was not carried into the next request`, "MOCK_EXPECTATION_FAILED");
780
+ throw new AIRequestError(
781
+ `${prefix}: previous replay item was not carried into the next request`,
782
+ "MOCK_EXPECTATION_FAILED",
783
+ );
761
784
  }
762
785
  cursor = foundIndex + 1;
763
786
  }
764
787
  }
765
788
 
766
- function assertOrderedItems(input: readonly InputItem[], expectations: readonly MockInputExpectation[], prefix: string): void {
789
+ function assertOrderedItems(
790
+ input: readonly InputItem[],
791
+ expectations: readonly MockInputExpectation[],
792
+ prefix: string,
793
+ ): void {
767
794
  let cursor = 0;
768
795
 
769
796
  for (const expected of expectations) {
@@ -787,7 +814,11 @@ function assertOrderedItems(input: readonly InputItem[], expectations: readonly
787
814
  }
788
815
  }
789
816
 
790
- function assertUnorderedItems(input: readonly InputItem[], expectations: readonly MockInputExpectation[], prefix: string): void {
817
+ function assertUnorderedItems(
818
+ input: readonly InputItem[],
819
+ expectations: readonly MockInputExpectation[],
820
+ prefix: string,
821
+ ): void {
791
822
  for (const expected of expectations) {
792
823
  const matched = input.some((item) => matchesItemExpectation(item, expected));
793
824
  if (!matched) {
@@ -811,8 +842,7 @@ function matchesItemExpectation(item: InputItem, expected: MockInputExpectation)
811
842
  switch (item.type) {
812
843
  case "message":
813
844
  return (
814
- (expected.role === undefined || item.role === expected.role) &&
815
- matchesText(item.content, expected.textIncludes)
845
+ (expected.role === undefined || item.role === expected.role) && matchesText(item.content, expected.textIncludes)
816
846
  );
817
847
  case "reasoning":
818
848
  return (