@codehz/ai 0.1.0 → 0.1.2

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.
@@ -0,0 +1,721 @@
1
+ /**
2
+ * Mock Adapter
3
+ *
4
+ * 面向测试的脚本化 adapter:
5
+ * - 按 turn 顺序消费请求,验证调用方是否正确续接 replay / tool_result
6
+ * - 发出可控的 message / reasoning / tool_call 流
7
+ * - 注入 warning / auxiliary / content_filter / 中断 / provider error
8
+ *
9
+ * 这不是通用“假模型”,而是测试工具调用编排与错误路径的测试夹具。
10
+ */
11
+
12
+ import { AIRequestError } from "../core/errors.js";
13
+ import { AdapterBase } from "../helpers/adapter-base.js";
14
+ import { messageItem, reasoningItem, replayFromOutput, textBlock } from "../helpers/mapping.js";
15
+ import { CAPABILITY_MATRIX } from "../types/adapter.js";
16
+
17
+ import type {
18
+ AIStreamEvent,
19
+ AuxiliaryInfo,
20
+ BillingInfo,
21
+ ContentBlock,
22
+ EventFactory,
23
+ InputItem,
24
+ MessageItem,
25
+ NormalizedRequest,
26
+ OutputItem,
27
+ ReplayItem,
28
+ StopReason,
29
+ ToolCallItem,
30
+ ToolResultItem,
31
+ Usage,
32
+ } from "../index.js";
33
+
34
+ export type MockInputExpectation = {
35
+ type: InputItem["type"];
36
+ id?: string;
37
+ role?: MessageItem["role"];
38
+ name?: string;
39
+ toolName?: string;
40
+ callId?: string;
41
+ outcome?: ToolResultItem["outcome"];
42
+ visibility?: Extract<InputItem, { type: "reasoning" }>["visibility"];
43
+ source?: Extract<InputItem, { type: "opaque" }>["source"];
44
+ purpose?: Extract<InputItem, { type: "opaque" }>["purpose"];
45
+ textIncludes?: string;
46
+ };
47
+
48
+ export type MockRequestExpectation = {
49
+ minItems?: number;
50
+ maxItems?: number;
51
+ ordered?: boolean;
52
+ requireReplayFromPreviousTurn?: boolean;
53
+ requireToolResultsForPendingCalls?: boolean;
54
+ tools?: "ignore" | "present" | "absent";
55
+ toolChoice?: "ignore" | "present" | "absent";
56
+ items?: MockInputExpectation[];
57
+ };
58
+
59
+ export type MockTurnContext = {
60
+ turnIndex: number;
61
+ previousReplay: ReplayItem[];
62
+ pendingToolCalls: readonly ToolCallItem[];
63
+ history: readonly MockTurnRecord[];
64
+ };
65
+
66
+ export type MockTurnValidator = (
67
+ request: NormalizedRequest,
68
+ context: MockTurnContext,
69
+ ) => void | Promise<void>;
70
+
71
+ export type MockWarningStep = {
72
+ type: "warning";
73
+ message: string;
74
+ code?: string;
75
+ };
76
+
77
+ export type MockAuxiliaryStep = {
78
+ type: "auxiliary";
79
+ usage?: Usage;
80
+ billing?: BillingInfo;
81
+ auxiliary?: Partial<AuxiliaryInfo>;
82
+ };
83
+
84
+ export type MockMessageStep = {
85
+ type: "message";
86
+ id?: string;
87
+ content: string | ContentBlock[];
88
+ };
89
+
90
+ export type MockReasoningStep = {
91
+ type: "reasoning";
92
+ id?: string;
93
+ visibility?: Extract<OutputItem, { type: "reasoning" }>["visibility"];
94
+ content: string | ContentBlock[];
95
+ };
96
+
97
+ export type MockToolCallStep = {
98
+ type: "tool_call";
99
+ id: string;
100
+ name: string;
101
+ argumentsText: string;
102
+ argumentsJson?: unknown;
103
+ streamArguments?: boolean;
104
+ };
105
+
106
+ export type MockOutputStep = {
107
+ type: "output";
108
+ item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>;
109
+ };
110
+
111
+ export type MockCompleteStep = {
112
+ type: "complete";
113
+ stopReason?: StopReason;
114
+ replay?: ReplayItem[];
115
+ usage?: Usage;
116
+ billing?: BillingInfo;
117
+ auxiliary?: Partial<AuxiliaryInfo>;
118
+ providerMetadata?: Record<string, unknown>;
119
+ rawResponseId?: string;
120
+ warnings?: string[];
121
+ };
122
+
123
+ export type MockErrorStep = {
124
+ type: "error";
125
+ message: string;
126
+ code?: string;
127
+ stopReason?: StopReason;
128
+ providerMetadata?: Record<string, unknown>;
129
+ };
130
+
131
+ export type MockInterruptStep = {
132
+ type: "interrupt";
133
+ };
134
+
135
+ export type MockThrowStep = {
136
+ type: "throw";
137
+ error: string | Error;
138
+ };
139
+
140
+ export type MockStep =
141
+ | MockWarningStep
142
+ | MockAuxiliaryStep
143
+ | MockMessageStep
144
+ | MockReasoningStep
145
+ | MockToolCallStep
146
+ | MockOutputStep
147
+ | MockCompleteStep
148
+ | MockErrorStep
149
+ | MockInterruptStep
150
+ | MockThrowStep;
151
+
152
+ export type MockTurn = {
153
+ name?: string;
154
+ expect?: MockRequestExpectation | MockTurnValidator;
155
+ steps: MockStep[];
156
+ };
157
+
158
+ export type MockAdapterOptions = {
159
+ turns: MockTurn[];
160
+ onExhausted?: "throw" | "repeat-last" | "complete-empty";
161
+ providerMetadata?: Record<string, unknown>;
162
+ };
163
+
164
+ type MockTurnRecord = {
165
+ turnIndex: number;
166
+ turnName?: string;
167
+ requestId: string;
168
+ replay: ReplayItem[];
169
+ toolCalls: ToolCallItem[];
170
+ };
171
+
172
+ type MockProviderRequest = {
173
+ request: NormalizedRequest;
174
+ turn: MockTurn;
175
+ turnIndex: number;
176
+ turnName?: string;
177
+ remainingPendingToolCalls: ToolCallItem[];
178
+ };
179
+
180
+ export class MockAdapter extends AdapterBase {
181
+ readonly kind = "mock" as const;
182
+ readonly capabilities = CAPABILITY_MATRIX.mock;
183
+
184
+ private readonly turns: MockTurn[];
185
+ private readonly onExhausted: NonNullable<MockAdapterOptions["onExhausted"]>;
186
+ private readonly providerMetadata?: Record<string, unknown>;
187
+
188
+ private cursor = 0;
189
+ private previousReplay: ReplayItem[] = [];
190
+ private pendingToolCalls: ToolCallItem[] = [];
191
+ private history: MockTurnRecord[] = [];
192
+ private activeStream = false;
193
+
194
+ constructor(options: MockAdapterOptions) {
195
+ super();
196
+ this.turns = options.turns;
197
+ this.onExhausted = options.onExhausted ?? "throw";
198
+ this.providerMetadata = options.providerMetadata;
199
+ }
200
+
201
+ protected async buildRequest(request: NormalizedRequest): Promise<MockProviderRequest> {
202
+ 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
+
215
+ const remainingPendingToolCalls = consumePendingToolCalls(this.pendingToolCalls, request.input);
216
+
217
+ this.cursor += 1;
218
+
219
+ return {
220
+ request,
221
+ turn,
222
+ turnIndex,
223
+ turnName,
224
+ remainingPendingToolCalls,
225
+ };
226
+ }
227
+
228
+ protected async *runStream(
229
+ providerRequest: unknown,
230
+ factory: EventFactory,
231
+ request: NormalizedRequest,
232
+ ): AsyncIterable<AIStreamEvent> {
233
+ if (this.activeStream) {
234
+ throw new AIRequestError("MockAdapter does not support concurrent streams", "MOCK_CONCURRENT_STREAM");
235
+ }
236
+
237
+ this.activeStream = true;
238
+
239
+ try {
240
+ const mockRequest = providerRequest as MockProviderRequest;
241
+ const output: OutputItem[] = [];
242
+
243
+ for (const [stepIndex, step] of mockRequest.turn.steps.entries()) {
244
+ switch (step.type) {
245
+ case "warning":
246
+ yield factory.responseWarning(step.message, step.code);
247
+ break;
248
+ case "auxiliary":
249
+ yield factory.responseAuxiliary({
250
+ usage: step.usage,
251
+ billing: step.billing,
252
+ auxiliary: step.auxiliary,
253
+ });
254
+ break;
255
+ case "message": {
256
+ const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepIndex);
257
+ yield* emitMessage(factory, item);
258
+ output.push(item);
259
+ break;
260
+ }
261
+ case "reasoning": {
262
+ const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepIndex);
263
+ yield* emitReasoning(factory, item);
264
+ output.push(item);
265
+ break;
266
+ }
267
+ case "tool_call": {
268
+ const item = createToolCallFromStep(step);
269
+ yield* emitToolCall(factory, item, step.streamArguments ?? true);
270
+ output.push(item);
271
+ break;
272
+ }
273
+ case "output": {
274
+ assertSupportedOutputItem(step.item);
275
+ const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepIndex);
276
+ yield* emitOutputItem(factory, item);
277
+ output.push(item);
278
+ break;
279
+ }
280
+ case "complete": {
281
+ const response = this.finalizeTurn(request, factory, mockRequest, output, step);
282
+ yield factory.responseCompleted(response);
283
+ return;
284
+ }
285
+ case "error": {
286
+ 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
+ });
292
+ yield factory.responseCompleted(response);
293
+ return;
294
+ }
295
+ case "interrupt":
296
+ this.pendingToolCalls = mockRequest.remainingPendingToolCalls;
297
+ return;
298
+ case "throw":
299
+ throw typeof step.error === "string" ? new Error(step.error) : step.error;
300
+ }
301
+ }
302
+
303
+ const response = this.finalizeTurn(request, factory, mockRequest, output, {
304
+ type: "complete",
305
+ });
306
+ yield factory.responseCompleted(response);
307
+ } finally {
308
+ this.activeStream = false;
309
+ }
310
+ }
311
+
312
+ private finalizeTurn(
313
+ request: NormalizedRequest,
314
+ factory: EventFactory,
315
+ mockRequest: MockProviderRequest,
316
+ output: OutputItem[],
317
+ completion: MockCompleteStep,
318
+ ) {
319
+ const replay = completion.replay ?? replayFromOutput(output);
320
+ const toolCalls = output.filter((item): item is ToolCallItem => item.type === "tool_call");
321
+
322
+ this.previousReplay = replay;
323
+ this.pendingToolCalls = [...mockRequest.remainingPendingToolCalls, ...toolCalls];
324
+ this.history.push({
325
+ turnIndex: mockRequest.turnIndex,
326
+ turnName: mockRequest.turnName,
327
+ requestId: request.requestId,
328
+ replay,
329
+ toolCalls,
330
+ });
331
+
332
+ return this.buildResponse(
333
+ request,
334
+ {
335
+ output,
336
+ replay,
337
+ stopReason: completion.stopReason ?? resolveStopReason(output),
338
+ usage: completion.usage,
339
+ billing: completion.billing,
340
+ auxiliary: completion.auxiliary,
341
+ providerMetadata: {
342
+ turnIndex: mockRequest.turnIndex,
343
+ turnName: mockRequest.turnName,
344
+ scriptedSteps: mockRequest.turn.steps.length,
345
+ pendingToolCallIds: this.pendingToolCalls.map((item) => item.id),
346
+ historyLength: this.history.length,
347
+ ...this.providerMetadata,
348
+ ...completion.providerMetadata,
349
+ },
350
+ warnings: completion.warnings,
351
+ metadataSources: ["mock"],
352
+ rawResponseId: completion.rawResponseId,
353
+ },
354
+ factory,
355
+ );
356
+ }
357
+
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 {
380
+ return {
381
+ turnIndex,
382
+ previousReplay: this.previousReplay.map(cloneItem),
383
+ pendingToolCalls: this.pendingToolCalls.map(cloneItem),
384
+ history: this.history.map((record) => ({
385
+ ...record,
386
+ replay: record.replay.map(cloneItem),
387
+ toolCalls: record.toolCalls.map(cloneItem),
388
+ })),
389
+ };
390
+ }
391
+ }
392
+
393
+ function createMessageFromStep(
394
+ step: MockMessageStep,
395
+ request: NormalizedRequest,
396
+ turnIndex: number,
397
+ stepIndex: number,
398
+ ): MessageItem {
399
+ return {
400
+ ...messageItem(normalizeBlocks(step.content), {
401
+ id: step.id ?? `mock-msg-${request.requestId}-${turnIndex}-${stepIndex}`,
402
+ }),
403
+ role: "assistant",
404
+ };
405
+ }
406
+
407
+ function createReasoningFromStep(
408
+ step: MockReasoningStep,
409
+ request: NormalizedRequest,
410
+ turnIndex: number,
411
+ stepIndex: number,
412
+ ): Extract<OutputItem, { type: "reasoning" }> {
413
+ return reasoningItem(
414
+ normalizeBlocks(step.content),
415
+ step.visibility ?? "full",
416
+ step.id ?? `mock-reason-${request.requestId}-${turnIndex}-${stepIndex}`,
417
+ );
418
+ }
419
+
420
+ function createToolCallFromStep(step: MockToolCallStep): ToolCallItem {
421
+ return {
422
+ type: "tool_call",
423
+ id: step.id,
424
+ name: step.name,
425
+ argumentsText: step.argumentsText,
426
+ argumentsJson: step.argumentsJson,
427
+ };
428
+ }
429
+
430
+ function normalizeBlocks(content: string | ContentBlock[]): ContentBlock[] {
431
+ return typeof content === "string" ? [textBlock(content)] : content;
432
+ }
433
+
434
+ function assertSupportedOutputItem(item: OutputItem): void {
435
+ if (item.type === "opaque") {
436
+ throw new AIRequestError("MockAdapter does not stream opaque output items; use complete.replay if needed", "MOCK_OPAQUE_OUTPUT");
437
+ }
438
+ }
439
+
440
+ function attachSyntheticId(
441
+ item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>,
442
+ request: NormalizedRequest,
443
+ turnIndex: number,
444
+ stepIndex: number,
445
+ ): Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }> {
446
+ if (item.type === "message") {
447
+ return {
448
+ ...item,
449
+ id: item.id ?? `mock-msg-${request.requestId}-${turnIndex}-${stepIndex}`,
450
+ role: "assistant",
451
+ };
452
+ }
453
+
454
+ if (item.type === "reasoning") {
455
+ return {
456
+ ...item,
457
+ id: item.id ?? `mock-reason-${request.requestId}-${turnIndex}-${stepIndex}`,
458
+ };
459
+ }
460
+
461
+ return item;
462
+ }
463
+
464
+ async function* emitOutputItem(
465
+ factory: EventFactory,
466
+ item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>,
467
+ ): AsyncIterable<AIStreamEvent> {
468
+ if (item.type === "message") {
469
+ yield* emitMessage(factory, item);
470
+ return;
471
+ }
472
+
473
+ if (item.type === "reasoning") {
474
+ yield* emitReasoning(factory, item);
475
+ return;
476
+ }
477
+
478
+ yield* emitToolCall(factory, item, true);
479
+ }
480
+
481
+ async function* emitMessage(factory: EventFactory, item: MessageItem): AsyncIterable<AIStreamEvent> {
482
+ if (!item.id) {
483
+ throw new AIRequestError("Mock message output requires an id after normalization", "MOCK_MESSAGE_ID_MISSING");
484
+ }
485
+
486
+ yield factory.messageStarted(item.id);
487
+
488
+ for (const block of item.content) {
489
+ if (block.type === "text") {
490
+ yield factory.messageDelta(item.id, block.text);
491
+ }
492
+ }
493
+
494
+ yield factory.messageCompleted(item);
495
+ }
496
+
497
+ async function* emitReasoning(
498
+ factory: EventFactory,
499
+ item: Extract<OutputItem, { type: "reasoning" }>,
500
+ ): AsyncIterable<AIStreamEvent> {
501
+ if (!item.id) {
502
+ throw new AIRequestError("Mock reasoning output requires an id after normalization", "MOCK_REASONING_ID_MISSING");
503
+ }
504
+
505
+ yield factory.reasoningStarted(item.id, item.visibility);
506
+
507
+ for (const block of item.content) {
508
+ yield factory.reasoningDelta(item.id, block);
509
+ }
510
+
511
+ yield factory.reasoningCompleted(item);
512
+ }
513
+
514
+ async function* emitToolCall(
515
+ factory: EventFactory,
516
+ item: ToolCallItem,
517
+ streamArguments: boolean,
518
+ ): AsyncIterable<AIStreamEvent> {
519
+ yield factory.toolCallStarted(item.id, item.name);
520
+
521
+ if (streamArguments && item.argumentsText) {
522
+ yield factory.toolCallDelta(item.id, { argumentsText: item.argumentsText });
523
+ }
524
+
525
+ yield factory.toolCallCompleted(item);
526
+ }
527
+
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
+ );
538
+
539
+ return pending.filter((item) => !fulfilledIds.has(item.id)).map(cloneItem);
540
+ }
541
+
542
+ function assertRequestMatchesExpectation(
543
+ request: NormalizedRequest,
544
+ expectation: MockRequestExpectation,
545
+ context: MockTurnContext,
546
+ ): void {
547
+ const prefix = `MockAdapter turn ${context.turnIndex + 1} expectation failed`;
548
+
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");
551
+ }
552
+
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");
555
+ }
556
+
557
+ if (expectation.tools === "present" && (!request.tools || request.tools.length === 0)) {
558
+ throw new AIRequestError(`${prefix}: expected tools to be present`, "MOCK_EXPECTATION_FAILED");
559
+ }
560
+
561
+ if (expectation.tools === "absent" && request.tools && request.tools.length > 0) {
562
+ throw new AIRequestError(`${prefix}: expected tools to be absent`, "MOCK_EXPECTATION_FAILED");
563
+ }
564
+
565
+ if (expectation.toolChoice === "present" && request.toolChoice === undefined) {
566
+ throw new AIRequestError(`${prefix}: expected toolChoice to be present`, "MOCK_EXPECTATION_FAILED");
567
+ }
568
+
569
+ if (expectation.toolChoice === "absent" && request.toolChoice !== undefined) {
570
+ throw new AIRequestError(`${prefix}: expected toolChoice to be absent`, "MOCK_EXPECTATION_FAILED");
571
+ }
572
+
573
+ if (expectation.requireReplayFromPreviousTurn && context.previousReplay.length > 0) {
574
+ assertReplayIncluded(request.input, context.previousReplay, prefix);
575
+ }
576
+
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
+ );
583
+
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
+ }
592
+ }
593
+
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
+ }
600
+ }
601
+ }
602
+
603
+ function assertReplayIncluded(input: readonly InputItem[], replay: readonly ReplayItem[], prefix: string): void {
604
+ const fingerprints = input.map(fingerprintItem);
605
+ let cursor = 0;
606
+
607
+ for (const replayItem of replay) {
608
+ const target = fingerprintItem(replayItem);
609
+ const foundIndex = fingerprints.indexOf(target, cursor);
610
+ if (foundIndex === -1) {
611
+ throw new AIRequestError(`${prefix}: previous replay item was not carried into the next request`, "MOCK_EXPECTATION_FAILED");
612
+ }
613
+ cursor = foundIndex + 1;
614
+ }
615
+ }
616
+
617
+ function assertOrderedItems(input: readonly InputItem[], expectations: readonly MockInputExpectation[], prefix: string): void {
618
+ let cursor = 0;
619
+
620
+ for (const expected of expectations) {
621
+ let matched = false;
622
+ while (cursor < input.length) {
623
+ const item = input[cursor];
624
+ if (item !== undefined && matchesItemExpectation(item, expected)) {
625
+ matched = true;
626
+ cursor += 1;
627
+ break;
628
+ }
629
+ cursor += 1;
630
+ }
631
+
632
+ if (!matched) {
633
+ throw new AIRequestError(
634
+ `${prefix}: missing ordered input item ${describeExpectation(expected)}`,
635
+ "MOCK_EXPECTATION_FAILED",
636
+ );
637
+ }
638
+ }
639
+ }
640
+
641
+ function assertUnorderedItems(input: readonly InputItem[], expectations: readonly MockInputExpectation[], prefix: string): void {
642
+ for (const expected of expectations) {
643
+ const matched = input.some((item) => matchesItemExpectation(item, expected));
644
+ if (!matched) {
645
+ throw new AIRequestError(
646
+ `${prefix}: missing input item ${describeExpectation(expected)}`,
647
+ "MOCK_EXPECTATION_FAILED",
648
+ );
649
+ }
650
+ }
651
+ }
652
+
653
+ function matchesItemExpectation(item: InputItem, expected: MockInputExpectation): boolean {
654
+ if (item.type !== expected.type) {
655
+ return false;
656
+ }
657
+
658
+ if (expected.id !== undefined && "id" in item && item.id !== expected.id) {
659
+ return false;
660
+ }
661
+
662
+ switch (item.type) {
663
+ case "message":
664
+ return (
665
+ (expected.role === undefined || item.role === expected.role) &&
666
+ matchesText(item.content, expected.textIncludes)
667
+ );
668
+ case "reasoning":
669
+ return (
670
+ (expected.visibility === undefined || item.visibility === expected.visibility) &&
671
+ matchesText(item.content, expected.textIncludes)
672
+ );
673
+ case "tool_call":
674
+ return (
675
+ (expected.name === undefined || item.name === expected.name) &&
676
+ (expected.textIncludes === undefined || item.argumentsText.includes(expected.textIncludes))
677
+ );
678
+ case "tool_result":
679
+ return (
680
+ (expected.toolName === undefined || item.toolName === expected.toolName) &&
681
+ (expected.callId === undefined || item.callId === expected.callId) &&
682
+ (expected.outcome === undefined || item.outcome === expected.outcome) &&
683
+ matchesText(item.content, expected.textIncludes)
684
+ );
685
+ case "opaque":
686
+ return (
687
+ (expected.source === undefined || item.source === expected.source) &&
688
+ (expected.purpose === undefined || item.purpose === expected.purpose)
689
+ );
690
+ }
691
+ }
692
+
693
+ function matchesText(blocks: readonly ContentBlock[], textIncludes: string | undefined): boolean {
694
+ if (textIncludes === undefined) {
695
+ return true;
696
+ }
697
+
698
+ return blocks.some((block) => {
699
+ if (block.type === "text") return block.text.includes(textIncludes);
700
+ if (block.type === "json") return JSON.stringify(block.json).includes(textIncludes);
701
+ return false;
702
+ });
703
+ }
704
+
705
+ function fingerprintItem(item: InputItem): string {
706
+ return JSON.stringify(item);
707
+ }
708
+
709
+ function describeExpectation(expectation: MockInputExpectation): string {
710
+ const parts = [`type=${expectation.type}`];
711
+ if (expectation.role) parts.push(`role=${expectation.role}`);
712
+ if (expectation.name) parts.push(`name=${expectation.name}`);
713
+ if (expectation.toolName) parts.push(`toolName=${expectation.toolName}`);
714
+ if (expectation.callId) parts.push(`callId=${expectation.callId}`);
715
+ if (expectation.textIncludes) parts.push(`textIncludes=${JSON.stringify(expectation.textIncludes)}`);
716
+ return `{ ${parts.join(", ")} }`;
717
+ }
718
+
719
+ function cloneItem<T>(item: T): T {
720
+ return structuredClone(item);
721
+ }