@codehz/ai 0.1.2 → 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.
@@ -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
  *
@@ -12,7 +13,6 @@
12
13
  import { AIRequestError } from "../core/errors.js";
13
14
  import { AdapterBase } from "../helpers/adapter-base.js";
14
15
  import { messageItem, reasoningItem, replayFromOutput, textBlock } from "../helpers/mapping.js";
15
- import { CAPABILITY_MATRIX } from "../types/adapter.js";
16
16
 
17
17
  import type {
18
18
  AIStreamEvent,
@@ -56,18 +56,20 @@ export type MockRequestExpectation = {
56
56
  items?: MockInputExpectation[];
57
57
  };
58
58
 
59
- 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 = {
60
67
  turnIndex: number;
61
68
  previousReplay: ReplayItem[];
62
69
  pendingToolCalls: readonly ToolCallItem[];
63
- history: readonly MockTurnRecord[];
70
+ history: readonly MockHistoryRecord[];
64
71
  };
65
72
 
66
- export type MockTurnValidator = (
67
- request: NormalizedRequest,
68
- context: MockTurnContext,
69
- ) => void | Promise<void>;
70
-
71
73
  export type MockWarningStep = {
72
74
  type: "warning";
73
75
  message: string;
@@ -81,10 +83,26 @@ export type MockAuxiliaryStep = {
81
83
  auxiliary?: Partial<AuxiliaryInfo>;
82
84
  };
83
85
 
86
+ export type MockTextStreamOptions = {
87
+ /**
88
+ * 每秒吐出的字符数。未设置时仍会按 chunk 拆分,但不会额外等待。
89
+ */
90
+ charsPerSecond?: number;
91
+ /**
92
+ * 每个 delta 最多包含多少个字符,默认 1。
93
+ */
94
+ chunkSize?: number;
95
+ /**
96
+ * 首个 delta 发出前的延迟。
97
+ */
98
+ initialDelayMs?: number;
99
+ };
100
+
84
101
  export type MockMessageStep = {
85
102
  type: "message";
86
103
  id?: string;
87
104
  content: string | ContentBlock[];
105
+ stream?: MockTextStreamOptions | false;
88
106
  };
89
107
 
90
108
  export type MockReasoningStep = {
@@ -92,6 +110,7 @@ export type MockReasoningStep = {
92
110
  id?: string;
93
111
  visibility?: Extract<OutputItem, { type: "reasoning" }>["visibility"];
94
112
  content: string | ContentBlock[];
113
+ stream?: MockTextStreamOptions | false;
95
114
  };
96
115
 
97
116
  export type MockToolCallStep = {
@@ -101,11 +120,13 @@ export type MockToolCallStep = {
101
120
  argumentsText: string;
102
121
  argumentsJson?: unknown;
103
122
  streamArguments?: boolean;
123
+ stream?: MockTextStreamOptions | false;
104
124
  };
105
125
 
106
126
  export type MockOutputStep = {
107
127
  type: "output";
108
128
  item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>;
129
+ stream?: MockTextStreamOptions | false;
109
130
  };
110
131
 
111
132
  export type MockCompleteStep = {
@@ -149,78 +170,129 @@ export type MockStep =
149
170
  | MockInterruptStep
150
171
  | MockThrowStep;
151
172
 
152
- export type MockTurn = {
153
- name?: string;
154
- expect?: MockRequestExpectation | MockTurnValidator;
155
- steps: MockStep[];
156
- };
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>;
157
181
 
158
182
  export type MockAdapterOptions = {
159
- turns: MockTurn[];
160
- onExhausted?: "throw" | "repeat-last" | "complete-empty";
183
+ handler: MockHandler;
161
184
  providerMetadata?: Record<string, unknown>;
162
185
  };
163
186
 
164
- type MockTurnRecord = {
165
- turnIndex: number;
166
- turnName?: string;
167
- requestId: string;
168
- replay: ReplayItem[];
169
- toolCalls: ToolCallItem[];
170
- };
171
-
172
187
  type MockProviderRequest = {
173
188
  request: NormalizedRequest;
174
- turn: MockTurn;
189
+ handlerResult: AsyncIterable<MockStep>;
175
190
  turnIndex: number;
176
- turnName?: string;
177
191
  remainingPendingToolCalls: ToolCallItem[];
178
192
  };
179
193
 
194
+ type ResolvedMockTextStreamOptions = {
195
+ charsPerSecond?: number;
196
+ chunkSize: number;
197
+ initialDelayMs: number;
198
+ };
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
+
180
265
  export class MockAdapter extends AdapterBase {
181
266
  readonly kind = "mock" as const;
182
- readonly capabilities = CAPABILITY_MATRIX.mock;
267
+ readonly nativeStreaming = false;
183
268
 
184
- private readonly turns: MockTurn[];
185
- private readonly onExhausted: NonNullable<MockAdapterOptions["onExhausted"]>;
269
+ private readonly handler: MockHandler;
186
270
  private readonly providerMetadata?: Record<string, unknown>;
187
271
 
188
272
  private cursor = 0;
189
273
  private previousReplay: ReplayItem[] = [];
190
274
  private pendingToolCalls: ToolCallItem[] = [];
191
- private history: MockTurnRecord[] = [];
275
+ private history: MockHistoryRecord[] = [];
192
276
  private activeStream = false;
193
277
 
194
278
  constructor(options: MockAdapterOptions) {
195
279
  super();
196
- this.turns = options.turns;
197
- this.onExhausted = options.onExhausted ?? "throw";
280
+ this.handler = options.handler;
198
281
  this.providerMetadata = options.providerMetadata;
199
282
  }
200
283
 
201
284
  protected async buildRequest(request: NormalizedRequest): Promise<MockProviderRequest> {
202
285
  const turnIndex = this.cursor;
203
- const turn = this.resolveTurn(turnIndex);
204
- const turnName = turn.name;
205
- const context = this.buildTurnContext(turnIndex);
206
-
207
- if (turn.expect) {
208
- if (typeof turn.expect === "function") {
209
- await turn.expect(request, context);
210
- } else {
211
- assertRequestMatchesExpectation(request, turn.expect, context);
212
- }
213
- }
214
-
286
+ const context = this.buildHandlerContext(turnIndex);
215
287
  const remainingPendingToolCalls = consumePendingToolCalls(this.pendingToolCalls, request.input);
288
+ const handlerResult = this.handler(request, context);
216
289
 
217
290
  this.cursor += 1;
218
291
 
219
292
  return {
220
293
  request,
221
- turn,
294
+ handlerResult,
222
295
  turnIndex,
223
- turnName,
224
296
  remainingPendingToolCalls,
225
297
  };
226
298
  }
@@ -239,8 +311,11 @@ export class MockAdapter extends AdapterBase {
239
311
  try {
240
312
  const mockRequest = providerRequest as MockProviderRequest;
241
313
  const output: OutputItem[] = [];
314
+ let stepCount = 0;
315
+
316
+ for await (const step of mockRequest.handlerResult) {
317
+ stepCount += 1;
242
318
 
243
- for (const [stepIndex, step] of mockRequest.turn.steps.entries()) {
244
319
  switch (step.type) {
245
320
  case "warning":
246
321
  yield factory.responseWarning(step.message, step.code);
@@ -253,42 +328,54 @@ export class MockAdapter extends AdapterBase {
253
328
  });
254
329
  break;
255
330
  case "message": {
256
- const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepIndex);
257
- yield* emitMessage(factory, item);
331
+ const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
332
+ yield* emitMessage(factory, item, resolveStepStreamOptions(undefined, step.stream, "message"));
258
333
  output.push(item);
259
334
  break;
260
335
  }
261
336
  case "reasoning": {
262
- const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepIndex);
263
- yield* emitReasoning(factory, item);
337
+ const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
338
+ yield* emitReasoning(factory, item, resolveStepStreamOptions(undefined, step.stream, "reasoning"));
264
339
  output.push(item);
265
340
  break;
266
341
  }
267
342
  case "tool_call": {
268
343
  const item = createToolCallFromStep(step);
269
- yield* emitToolCall(factory, item, step.streamArguments ?? true);
344
+ yield* emitToolCall(
345
+ factory,
346
+ item,
347
+ step.streamArguments ?? true,
348
+ resolveStepStreamOptions(undefined, step.stream, "tool_call"),
349
+ );
270
350
  output.push(item);
271
351
  break;
272
352
  }
273
353
  case "output": {
274
354
  assertSupportedOutputItem(step.item);
275
- const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepIndex);
276
- yield* emitOutputItem(factory, item);
355
+ const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepCount - 1);
356
+ yield* emitOutputItem(factory, item, resolveStepStreamOptions(undefined, step.stream, "output"));
277
357
  output.push(item);
278
358
  break;
279
359
  }
280
360
  case "complete": {
281
- const response = this.finalizeTurn(request, factory, mockRequest, output, step);
361
+ const response = this.finalizeTurn(request, factory, mockRequest, output, step, stepCount);
282
362
  yield factory.responseCompleted(response);
283
363
  return;
284
364
  }
285
365
  case "error": {
286
366
  yield factory.responseWarning(step.message, step.code);
287
- const response = this.finalizeTurn(request, factory, mockRequest, output, {
288
- type: "complete",
289
- stopReason: step.stopReason ?? "error",
290
- providerMetadata: step.providerMetadata,
291
- });
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
+ );
292
379
  yield factory.responseCompleted(response);
293
380
  return;
294
381
  }
@@ -300,9 +387,16 @@ export class MockAdapter extends AdapterBase {
300
387
  }
301
388
  }
302
389
 
303
- const response = this.finalizeTurn(request, factory, mockRequest, output, {
304
- type: "complete",
305
- });
390
+ const response = this.finalizeTurn(
391
+ request,
392
+ factory,
393
+ mockRequest,
394
+ output,
395
+ {
396
+ type: "complete",
397
+ },
398
+ stepCount,
399
+ );
306
400
  yield factory.responseCompleted(response);
307
401
  } finally {
308
402
  this.activeStream = false;
@@ -315,6 +409,7 @@ export class MockAdapter extends AdapterBase {
315
409
  mockRequest: MockProviderRequest,
316
410
  output: OutputItem[],
317
411
  completion: MockCompleteStep,
412
+ stepCount: number,
318
413
  ) {
319
414
  const replay = completion.replay ?? replayFromOutput(output);
320
415
  const toolCalls = output.filter((item): item is ToolCallItem => item.type === "tool_call");
@@ -323,7 +418,6 @@ export class MockAdapter extends AdapterBase {
323
418
  this.pendingToolCalls = [...mockRequest.remainingPendingToolCalls, ...toolCalls];
324
419
  this.history.push({
325
420
  turnIndex: mockRequest.turnIndex,
326
- turnName: mockRequest.turnName,
327
421
  requestId: request.requestId,
328
422
  replay,
329
423
  toolCalls,
@@ -340,8 +434,7 @@ export class MockAdapter extends AdapterBase {
340
434
  auxiliary: completion.auxiliary,
341
435
  providerMetadata: {
342
436
  turnIndex: mockRequest.turnIndex,
343
- turnName: mockRequest.turnName,
344
- scriptedSteps: mockRequest.turn.steps.length,
437
+ stepCount,
345
438
  pendingToolCallIds: this.pendingToolCalls.map((item) => item.id),
346
439
  historyLength: this.history.length,
347
440
  ...this.providerMetadata,
@@ -355,28 +448,7 @@ export class MockAdapter extends AdapterBase {
355
448
  );
356
449
  }
357
450
 
358
- private resolveTurn(turnIndex: number): MockTurn {
359
- const turn = this.turns[turnIndex];
360
- if (turn !== undefined) {
361
- return turn;
362
- }
363
-
364
- const lastTurn = this.turns.at(-1);
365
- if (this.onExhausted === "repeat-last" && lastTurn !== undefined) {
366
- return lastTurn;
367
- }
368
-
369
- if (this.onExhausted === "complete-empty") {
370
- return { name: "exhausted", steps: [] };
371
- }
372
-
373
- throw new AIRequestError(
374
- `MockAdapter turn ${turnIndex + 1} requested, but only ${this.turns.length} turn(s) were scripted`,
375
- "MOCK_TURN_EXHAUSTED",
376
- );
377
- }
378
-
379
- private buildTurnContext(turnIndex: number): MockTurnContext {
451
+ private buildHandlerContext(turnIndex: number): MockHandlerContext {
380
452
  return {
381
453
  turnIndex,
382
454
  previousReplay: this.previousReplay.map(cloneItem),
@@ -390,6 +462,46 @@ export class MockAdapter extends AdapterBase {
390
462
  }
391
463
  }
392
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
+
393
505
  function createMessageFromStep(
394
506
  step: MockMessageStep,
395
507
  request: NormalizedRequest,
@@ -433,7 +545,10 @@ function normalizeBlocks(content: string | ContentBlock[]): ContentBlock[] {
433
545
 
434
546
  function assertSupportedOutputItem(item: OutputItem): void {
435
547
  if (item.type === "opaque") {
436
- 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
+ );
437
552
  }
438
553
  }
439
554
 
@@ -464,30 +579,40 @@ function attachSyntheticId(
464
579
  async function* emitOutputItem(
465
580
  factory: EventFactory,
466
581
  item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>,
582
+ stream?: ResolvedMockTextStreamOptions,
467
583
  ): AsyncIterable<AIStreamEvent> {
468
584
  if (item.type === "message") {
469
- yield* emitMessage(factory, item);
585
+ yield* emitMessage(factory, item, stream);
470
586
  return;
471
587
  }
472
588
 
473
589
  if (item.type === "reasoning") {
474
- yield* emitReasoning(factory, item);
590
+ yield* emitReasoning(factory, item, stream);
475
591
  return;
476
592
  }
477
593
 
478
- yield* emitToolCall(factory, item, true);
594
+ yield* emitToolCall(factory, item, true, stream);
479
595
  }
480
596
 
481
- async function* emitMessage(factory: EventFactory, item: MessageItem): AsyncIterable<AIStreamEvent> {
597
+ async function* emitMessage(
598
+ factory: EventFactory,
599
+ item: MessageItem,
600
+ stream?: ResolvedMockTextStreamOptions,
601
+ ): AsyncIterable<AIStreamEvent> {
482
602
  if (!item.id) {
483
603
  throw new AIRequestError("Mock message output requires an id after normalization", "MOCK_MESSAGE_ID_MISSING");
484
604
  }
485
605
 
486
606
  yield factory.messageStarted(item.id);
487
607
 
608
+ let chunkIndex = 0;
488
609
  for (const block of item.content) {
489
610
  if (block.type === "text") {
490
- yield factory.messageDelta(item.id, block.text);
611
+ for (const chunk of chunkText(block.text, stream)) {
612
+ await delayForChunk(stream, chunkIndex, chunk.length);
613
+ yield factory.messageDelta(item.id, chunk);
614
+ chunkIndex += 1;
615
+ }
491
616
  }
492
617
  }
493
618
 
@@ -497,6 +622,7 @@ async function* emitMessage(factory: EventFactory, item: MessageItem): AsyncIter
497
622
  async function* emitReasoning(
498
623
  factory: EventFactory,
499
624
  item: Extract<OutputItem, { type: "reasoning" }>,
625
+ stream?: ResolvedMockTextStreamOptions,
500
626
  ): AsyncIterable<AIStreamEvent> {
501
627
  if (!item.id) {
502
628
  throw new AIRequestError("Mock reasoning output requires an id after normalization", "MOCK_REASONING_ID_MISSING");
@@ -504,8 +630,18 @@ async function* emitReasoning(
504
630
 
505
631
  yield factory.reasoningStarted(item.id, item.visibility);
506
632
 
633
+ let chunkIndex = 0;
507
634
  for (const block of item.content) {
508
- yield factory.reasoningDelta(item.id, block);
635
+ if (block.type !== "text") {
636
+ yield factory.reasoningDelta(item.id, block);
637
+ continue;
638
+ }
639
+
640
+ for (const chunk of chunkText(block.text, stream)) {
641
+ await delayForChunk(stream, chunkIndex, chunk.length);
642
+ yield factory.reasoningDelta(item.id, textBlock(chunk));
643
+ chunkIndex += 1;
644
+ }
509
645
  }
510
646
 
511
647
  yield factory.reasoningCompleted(item);
@@ -515,89 +651,122 @@ async function* emitToolCall(
515
651
  factory: EventFactory,
516
652
  item: ToolCallItem,
517
653
  streamArguments: boolean,
654
+ stream?: ResolvedMockTextStreamOptions,
518
655
  ): AsyncIterable<AIStreamEvent> {
519
656
  yield factory.toolCallStarted(item.id, item.name);
520
657
 
521
658
  if (streamArguments && item.argumentsText) {
522
- yield factory.toolCallDelta(item.id, { argumentsText: item.argumentsText });
659
+ let chunkIndex = 0;
660
+ for (const chunk of chunkText(item.argumentsText, stream)) {
661
+ await delayForChunk(stream, chunkIndex, chunk.length);
662
+ yield factory.toolCallDelta(item.id, { argumentsText: chunk });
663
+ chunkIndex += 1;
664
+ }
523
665
  }
524
666
 
525
667
  yield factory.toolCallCompleted(item);
526
668
  }
527
669
 
528
- function resolveStopReason(output: OutputItem[]): StopReason {
529
- return output.some((item) => item.type === "tool_call") ? "tool_call" : "end_turn";
530
- }
531
-
532
- function consumePendingToolCalls(pending: readonly ToolCallItem[], input: readonly InputItem[]): ToolCallItem[] {
533
- const fulfilledIds = new Set(
534
- input
535
- .filter((item): item is ToolResultItem => item.type === "tool_result")
536
- .map((item) => item.callId),
537
- );
670
+ function resolveStepStreamOptions(
671
+ defaults: ResolvedMockTextStreamOptions | undefined,
672
+ override: MockTextStreamOptions | false | undefined,
673
+ label: string,
674
+ ): ResolvedMockTextStreamOptions | undefined {
675
+ if (override === false) {
676
+ return undefined;
677
+ }
538
678
 
539
- return pending.filter((item) => !fulfilledIds.has(item.id)).map(cloneItem);
679
+ return resolveMockTextStreamOptions(override, `${label} stream`, defaults);
540
680
  }
541
681
 
542
- function assertRequestMatchesExpectation(
543
- request: NormalizedRequest,
544
- expectation: MockRequestExpectation,
545
- context: MockTurnContext,
546
- ): void {
547
- const prefix = `MockAdapter turn ${context.turnIndex + 1} expectation failed`;
682
+ function resolveMockTextStreamOptions(
683
+ options: MockTextStreamOptions | undefined,
684
+ label: string,
685
+ defaults?: ResolvedMockTextStreamOptions,
686
+ ): ResolvedMockTextStreamOptions | undefined {
687
+ if (options === undefined) {
688
+ return defaults;
689
+ }
548
690
 
549
- if (expectation.minItems !== undefined && request.input.length < expectation.minItems) {
550
- throw new AIRequestError(`${prefix}: expected at least ${expectation.minItems} input item(s)`, "MOCK_EXPECTATION_FAILED");
691
+ const chunkSize = options.chunkSize ?? defaults?.chunkSize ?? 1;
692
+ const initialDelayMs = options.initialDelayMs ?? defaults?.initialDelayMs ?? 0;
693
+ const charsPerSecond = options.charsPerSecond ?? defaults?.charsPerSecond;
694
+
695
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) {
696
+ throw new AIRequestError(`${label}: chunkSize must be a positive integer`, "MOCK_STREAM_CONFIG_INVALID");
551
697
  }
552
698
 
553
- if (expectation.maxItems !== undefined && request.input.length > expectation.maxItems) {
554
- throw new AIRequestError(`${prefix}: expected at most ${expectation.maxItems} input item(s)`, "MOCK_EXPECTATION_FAILED");
699
+ if (!Number.isFinite(initialDelayMs) || initialDelayMs < 0) {
700
+ throw new AIRequestError(`${label}: initialDelayMs must be a non-negative number`, "MOCK_STREAM_CONFIG_INVALID");
555
701
  }
556
702
 
557
- if (expectation.tools === "present" && (!request.tools || request.tools.length === 0)) {
558
- throw new AIRequestError(`${prefix}: expected tools to be present`, "MOCK_EXPECTATION_FAILED");
703
+ if (charsPerSecond !== undefined && (!Number.isFinite(charsPerSecond) || charsPerSecond <= 0)) {
704
+ throw new AIRequestError(`${label}: charsPerSecond must be a positive number`, "MOCK_STREAM_CONFIG_INVALID");
559
705
  }
560
706
 
561
- if (expectation.tools === "absent" && request.tools && request.tools.length > 0) {
562
- throw new AIRequestError(`${prefix}: expected tools to be absent`, "MOCK_EXPECTATION_FAILED");
707
+ return {
708
+ chunkSize,
709
+ initialDelayMs,
710
+ charsPerSecond,
711
+ };
712
+ }
713
+
714
+ function chunkText(text: string, stream?: ResolvedMockTextStreamOptions): string[] {
715
+ if (!text) {
716
+ return [];
563
717
  }
564
718
 
565
- if (expectation.toolChoice === "present" && request.toolChoice === undefined) {
566
- throw new AIRequestError(`${prefix}: expected toolChoice to be present`, "MOCK_EXPECTATION_FAILED");
719
+ if (!stream) {
720
+ return [text];
567
721
  }
568
722
 
569
- if (expectation.toolChoice === "absent" && request.toolChoice !== undefined) {
570
- throw new AIRequestError(`${prefix}: expected toolChoice to be absent`, "MOCK_EXPECTATION_FAILED");
723
+ const chars = Array.from(text);
724
+ const chunks: string[] = [];
725
+
726
+ for (let index = 0; index < chars.length; index += stream.chunkSize) {
727
+ chunks.push(chars.slice(index, index + stream.chunkSize).join(""));
571
728
  }
572
729
 
573
- if (expectation.requireReplayFromPreviousTurn && context.previousReplay.length > 0) {
574
- assertReplayIncluded(request.input, context.previousReplay, prefix);
730
+ return chunks;
731
+ }
732
+
733
+ async function delayForChunk(
734
+ stream: ResolvedMockTextStreamOptions | undefined,
735
+ chunkIndex: number,
736
+ chunkLength: number,
737
+ ): Promise<void> {
738
+ if (!stream) {
739
+ return;
575
740
  }
576
741
 
577
- if (expectation.requireToolResultsForPendingCalls && context.pendingToolCalls.length > 0) {
578
- const toolResultIds = new Set(
579
- request.input
580
- .filter((item): item is ToolResultItem => item.type === "tool_result")
581
- .map((item) => item.callId),
582
- );
742
+ if (chunkIndex === 0 && stream.initialDelayMs > 0) {
743
+ await sleep(stream.initialDelayMs);
744
+ return;
745
+ }
583
746
 
584
- for (const call of context.pendingToolCalls) {
585
- if (!toolResultIds.has(call.id)) {
586
- throw new AIRequestError(
587
- `${prefix}: expected tool_result for pending tool call "${call.id}"`,
588
- "MOCK_EXPECTATION_FAILED",
589
- );
590
- }
591
- }
747
+ if (chunkIndex > 0 && stream.charsPerSecond !== undefined) {
748
+ await sleep((chunkLength / stream.charsPerSecond) * 1000);
592
749
  }
750
+ }
593
751
 
594
- if (expectation.items && expectation.items.length > 0) {
595
- if (expectation.ordered) {
596
- assertOrderedItems(request.input, expectation.items, prefix);
597
- } else {
598
- assertUnorderedItems(request.input, expectation.items, prefix);
599
- }
752
+ async function sleep(ms: number): Promise<void> {
753
+ if (ms <= 0) {
754
+ return;
600
755
  }
756
+
757
+ await new Promise((resolve) => setTimeout(resolve, ms));
758
+ }
759
+
760
+ function resolveStopReason(output: OutputItem[]): StopReason {
761
+ return output.some((item) => item.type === "tool_call") ? "tool_call" : "end_turn";
762
+ }
763
+
764
+ function consumePendingToolCalls(pending: readonly ToolCallItem[], input: readonly InputItem[]): ToolCallItem[] {
765
+ const fulfilledIds = new Set(
766
+ input.filter((item): item is ToolResultItem => item.type === "tool_result").map((item) => item.callId),
767
+ );
768
+
769
+ return pending.filter((item) => !fulfilledIds.has(item.id)).map(cloneItem);
601
770
  }
602
771
 
603
772
  function assertReplayIncluded(input: readonly InputItem[], replay: readonly ReplayItem[], prefix: string): void {
@@ -608,13 +777,20 @@ function assertReplayIncluded(input: readonly InputItem[], replay: readonly Repl
608
777
  const target = fingerprintItem(replayItem);
609
778
  const foundIndex = fingerprints.indexOf(target, cursor);
610
779
  if (foundIndex === -1) {
611
- 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
+ );
612
784
  }
613
785
  cursor = foundIndex + 1;
614
786
  }
615
787
  }
616
788
 
617
- 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 {
618
794
  let cursor = 0;
619
795
 
620
796
  for (const expected of expectations) {
@@ -638,7 +814,11 @@ function assertOrderedItems(input: readonly InputItem[], expectations: readonly
638
814
  }
639
815
  }
640
816
 
641
- 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 {
642
822
  for (const expected of expectations) {
643
823
  const matched = input.some((item) => matchesItemExpectation(item, expected));
644
824
  if (!matched) {
@@ -662,8 +842,7 @@ function matchesItemExpectation(item: InputItem, expected: MockInputExpectation)
662
842
  switch (item.type) {
663
843
  case "message":
664
844
  return (
665
- (expected.role === undefined || item.role === expected.role) &&
666
- matchesText(item.content, expected.textIncludes)
845
+ (expected.role === undefined || item.role === expected.role) && matchesText(item.content, expected.textIncludes)
667
846
  );
668
847
  case "reasoning":
669
848
  return (