tinker-agent 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,20 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFile } from "node:fs/promises";
1
3
  import type { AgentMessage, AssistantMessage } from "../agent/types";
2
4
  import { cancellationError } from "../agent/turn-cancellation";
5
+ import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
6
+ import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
7
+ import type { InputTokenEstimator } from "./input-token-estimator";
3
8
  import type { ModelContextBudget } from "./model-context-profile";
4
9
  import type {
10
+ MaterializedModelRequest,
5
11
  ModelClient,
12
+ ModelMaterializeOptions,
6
13
  ModelMessageProtocol,
7
14
  ModelRequestInput,
8
15
  ModelRequestOptions,
9
16
  ModelRequestOutput,
17
+ PreparedMediaDescriptor,
10
18
  PreparedModelRequest,
11
19
  PreparedPromptSegment,
12
20
  } from "./model-client";
@@ -14,21 +22,71 @@ import { sha256, stableJsonStringify } from "./model-request-preflight";
14
22
  import { estimatePromptSegments } from "./token-estimator";
15
23
 
16
24
  export class FakeModelClient implements ModelClient {
17
- readonly inputModalities = Object.freeze(["text"] as const);
25
+ readonly inputModalities: readonly ("text" | "image")[];
26
+ readonly inputTokenEstimator?: InputTokenEstimator;
18
27
  readonly messageProtocol: ModelMessageProtocol = Object.freeze({
19
28
  adapter: "fake",
20
29
  serializationVersion: "fake-v1",
21
30
  });
22
31
  private steps = 0;
23
32
  private readonly preparedInputs = new WeakMap<object, ModelRequestInput>();
33
+ private readonly materializedRequests = new WeakSet<object>();
24
34
 
25
35
  constructor(
26
36
  private readonly mode: string,
27
37
  private readonly options: {
28
38
  model: string;
29
39
  contextBudget: ModelContextBudget;
40
+ inputModalities?: readonly ("text" | "image")[];
41
+ requestLogPath?: string;
42
+ tokenEstimator?: {
43
+ kind: "moonshot-estimate-token-count-v1";
44
+ model: string;
45
+ apiBase: string;
46
+ timeoutMs: number;
47
+ maxRetries: 0;
48
+ };
30
49
  },
31
- ) {}
50
+ ) {
51
+ this.inputModalities = Object.freeze([
52
+ ...(options.inputModalities ?? (["text"] as const)),
53
+ ]);
54
+ if (!this.inputModalities.includes("text")) {
55
+ throw new Error('Fake model input modalities must include "text".');
56
+ }
57
+ if (
58
+ this.inputModalities.includes("image") &&
59
+ options.tokenEstimator === undefined
60
+ ) {
61
+ throw new Error("Image-capable fake model requires a token estimator.");
62
+ }
63
+ if (options.tokenEstimator !== undefined) {
64
+ const estimator = options.tokenEstimator;
65
+ const endpoint = tokenEstimatorEndpoint(estimator.apiBase);
66
+ this.inputTokenEstimator = Object.freeze({
67
+ kind: estimator.kind,
68
+ compatibility: Object.freeze({
69
+ kind: estimator.kind,
70
+ coverageVersion: "full-request-v1",
71
+ model: estimator.model,
72
+ endpoint,
73
+ timeoutMs: estimator.timeoutMs,
74
+ maxRetries: estimator.maxRetries,
75
+ }),
76
+ async estimate(
77
+ request: MaterializedModelRequest,
78
+ estimateOptions: { signal: AbortSignal },
79
+ ) {
80
+ estimateOptions.signal.throwIfAborted();
81
+ return Object.freeze({
82
+ inputTokens: estimatePromptSegments(request.promptSegments).totalTokens,
83
+ source: "provider_estimated" as const,
84
+ coverage: "full_request" as const,
85
+ });
86
+ },
87
+ });
88
+ }
89
+ }
32
90
 
33
91
  prepare(input: ModelRequestInput): PreparedModelRequest {
34
92
  const toolSegments = input.tools.map(
@@ -38,6 +96,10 @@ export class FakeModelClient implements ModelClient {
38
96
  }),
39
97
  );
40
98
  const messageSegments = input.messages.map(toPromptSegment);
99
+ const mediaOccurrenceCount = messageSegments.reduce(
100
+ (total, segment) => total + (segment.media?.length ?? 0),
101
+ 0,
102
+ );
41
103
  const requestConfigHash = sha256(
42
104
  stableJsonStringify({
43
105
  adapter: this.messageProtocol.adapter,
@@ -45,6 +107,10 @@ export class FakeModelClient implements ModelClient {
45
107
  mode: this.mode,
46
108
  model: this.options.model,
47
109
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
110
+ inputModalities: this.inputModalities,
111
+ ...(this.inputTokenEstimator === undefined
112
+ ? {}
113
+ : { tokenEstimator: this.inputTokenEstimator.compatibility }),
48
114
  }),
49
115
  );
50
116
  const prepared: PreparedModelRequest = Object.freeze({
@@ -61,7 +127,7 @@ export class FakeModelClient implements ModelClient {
61
127
  toolSegments.map((segment) => segment.normalizedText).join("\n"),
62
128
  ),
63
129
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
64
- mediaOccurrenceCount: 0,
130
+ mediaOccurrenceCount,
65
131
  assistantReplaySegments: (message: AssistantMessage) => [
66
132
  toPromptSegment(message),
67
133
  ],
@@ -73,6 +139,59 @@ export class FakeModelClient implements ModelClient {
73
139
  return prepared;
74
140
  }
75
141
 
142
+ async materialize(
143
+ prepared: PreparedModelRequest,
144
+ options: ModelMaterializeOptions,
145
+ ): Promise<MaterializedModelRequest> {
146
+ const input = this.preparedInputs.get(prepared);
147
+ if (input === undefined) {
148
+ throw new Error("Fake model request was not prepared by this client.");
149
+ }
150
+ options.signal.throwIfAborted();
151
+ if (prepared.mediaOccurrenceCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
152
+ throw new Error(
153
+ `Fake model request has ${prepared.mediaOccurrenceCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
154
+ );
155
+ }
156
+ if (prepared.mediaOccurrenceCount > 0 && !this.inputModalities.includes("image")) {
157
+ throw new Error("Current fake model profile does not support image input.");
158
+ }
159
+
160
+ const assets = distinctPreparedAssets(prepared.promptSegments);
161
+ const materializedAssets: Array<{
162
+ readonly assetId: ImageAssetId;
163
+ readonly byteLength: number;
164
+ readonly bytesSha256: string;
165
+ }> = [];
166
+ for (const asset of assets.values()) {
167
+ options.signal.throwIfAborted();
168
+ const bytes = await options.assetStore.readVerified(asset, {
169
+ signal: options.signal,
170
+ });
171
+ materializedAssets.push(
172
+ Object.freeze({
173
+ assetId: asset.assetId,
174
+ byteLength: bytes.byteLength,
175
+ bytesSha256: createHash("sha256").update(bytes).digest("hex"),
176
+ }),
177
+ );
178
+ }
179
+ options.signal.throwIfAborted();
180
+
181
+ const payload = Object.freeze({
182
+ ...(prepared.payload as Record<string, unknown>),
183
+ materializedAssets: Object.freeze(materializedAssets),
184
+ });
185
+ const materialized = Object.freeze({
186
+ ...prepared,
187
+ payload,
188
+ bodyBytes: Buffer.byteLength(stableJsonStringify(payload), "utf8"),
189
+ });
190
+ this.preparedInputs.set(materialized, input);
191
+ this.materializedRequests.add(materialized);
192
+ return materialized;
193
+ }
194
+
76
195
  async request(
77
196
  prepared: PreparedModelRequest,
78
197
  options: ModelRequestOptions,
@@ -82,6 +201,18 @@ export class FakeModelClient implements ModelClient {
82
201
  throw new Error("Fake model request was not prepared by this client.");
83
202
  }
84
203
  this.steps += 1;
204
+ if (this.options.requestLogPath !== undefined) {
205
+ await appendFile(
206
+ this.options.requestLogPath,
207
+ `${stableJsonStringify({
208
+ mode: this.mode,
209
+ model: this.options.model,
210
+ prompt: lastUserMessage(input.messages),
211
+ requestNumber: this.steps,
212
+ })}\n`,
213
+ "utf8",
214
+ );
215
+ }
85
216
 
86
217
  if (this.mode === "write-notes") {
87
218
  return this.writeNotes(input, prepared, options);
@@ -92,6 +223,63 @@ export class FakeModelClient implements ModelClient {
92
223
  if (this.mode === "recall-smoke") {
93
224
  return this.recallSmoke(input, prepared, options);
94
225
  }
226
+ if (this.mode === "pty-echo-history") {
227
+ return this.ptyEchoHistory(input, prepared);
228
+ }
229
+ if (this.mode === "pty-cancel-then-echo") {
230
+ return this.ptyCancelThenEcho(input, prepared, options);
231
+ }
232
+ if (this.mode === "pty-tool-chain") {
233
+ return this.ptyToolChain(input, prepared, options);
234
+ }
235
+ if (this.mode === "pty-background-task") {
236
+ return this.ptyBackgroundTask(input, prepared, options);
237
+ }
238
+ if (this.mode === "pty-resume") {
239
+ return this.ptyResume(input, prepared, options);
240
+ }
241
+ if (this.mode === "pty-interrupted-tool") {
242
+ return this.ptyInterruptedTool(input, prepared, options);
243
+ }
244
+ if (this.mode === "pty-fail-once") {
245
+ return this.ptyFailOnce(input, prepared);
246
+ }
247
+ if (this.mode === "pty-prompt-input") {
248
+ return this.ptyPromptInput(input, prepared);
249
+ }
250
+ if (this.mode === "pty-file-command") {
251
+ return this.ptyFileCommand(input, prepared);
252
+ }
253
+ if (this.mode === "pty-clear") {
254
+ return this.ptyClear(input, prepared);
255
+ }
256
+ if (this.mode === "pty-fork") {
257
+ return this.ptyFork(input, prepared, options);
258
+ }
259
+ if (this.mode === "pty-model-switch") {
260
+ return this.ptyModelSwitch(input, prepared);
261
+ }
262
+ if (this.mode === "pty-viewer") {
263
+ return this.ptyViewer(input, prepared);
264
+ }
265
+ if (this.mode === "pty-copy") {
266
+ return this.ptyCopy(input, prepared);
267
+ }
268
+ if (this.mode === "pty-context-heavy") {
269
+ return this.ptyContextHeavy(input, prepared, options);
270
+ }
271
+ if (this.mode === "pty-image") {
272
+ return this.ptyImage(input, prepared);
273
+ }
274
+ if (this.mode === "pty-local-panels") {
275
+ return this.ptyLocalPanels(input, prepared);
276
+ }
277
+ if (this.mode === "pty-skill-activate") {
278
+ return this.ptySkillActivate(input, prepared, options);
279
+ }
280
+ if (this.mode === "pty-mcp-call") {
281
+ return this.ptyMcpCall(input, prepared, options);
282
+ }
95
283
 
96
284
  return outputWithUsage(
97
285
  prepared,
@@ -103,6 +291,538 @@ export class FakeModelClient implements ModelClient {
103
291
  );
104
292
  }
105
293
 
294
+ private ptyEchoHistory(
295
+ input: ModelRequestInput,
296
+ prepared: PreparedModelRequest,
297
+ ): ModelRequestOutput {
298
+ const prompt = lastUserMessage(input.messages);
299
+ if (prompt === "PTY_FIRST") {
300
+ return textOutput(prepared, "PTY_TURN_ONE_DONE");
301
+ }
302
+ if (prompt === "PTY_SECOND") {
303
+ requireMessage(input.messages, "user", "PTY_FIRST");
304
+ requireMessage(input.messages, "assistant", "PTY_TURN_ONE_DONE");
305
+ return textOutput(prepared, "PTY_TURN_TWO_DONE");
306
+ }
307
+ throw new Error(`Unexpected pty-echo-history prompt: ${JSON.stringify(prompt)}.`);
308
+ }
309
+
310
+ private ptyCancelThenEcho(
311
+ input: ModelRequestInput,
312
+ prepared: PreparedModelRequest,
313
+ options: ModelRequestOptions,
314
+ ): Promise<ModelRequestOutput> | ModelRequestOutput {
315
+ const prompt = lastUserMessage(input.messages);
316
+ if (prompt === "PTY_CANCEL_BLOCK") {
317
+ return waitForCancellation(options.signal);
318
+ }
319
+ if (prompt === "PTY_AFTER_CANCEL") {
320
+ requireMessage(input.messages, "user", "PTY_CANCEL_BLOCK");
321
+ return textOutput(prepared, "PTY_AFTER_CANCEL_DONE");
322
+ }
323
+ throw new Error(
324
+ `Unexpected pty-cancel-then-echo prompt: ${JSON.stringify(prompt)}.`,
325
+ );
326
+ }
327
+
328
+ private ptyToolChain(
329
+ input: ModelRequestInput,
330
+ prepared: PreparedModelRequest,
331
+ options: ModelRequestOptions,
332
+ ): ModelRequestOutput {
333
+ requireTools(input, ["Write", "Edit", "Bash"]);
334
+ const prompt = lastUserMessage(input.messages);
335
+ if (prompt === "PTY_TOOL_FAILURE") {
336
+ const bash = toolMessagesAfterLastUser(input.messages).find(
337
+ (message) => message.name === "Bash",
338
+ );
339
+ if (bash === undefined) {
340
+ return toolCallOutput(prepared, options, "Bash", {
341
+ command: "printf 'PTY_TOOL_FAILURE_OUTPUT\\n' >&2; exit 7",
342
+ description: "Produce expected PTY failure",
343
+ });
344
+ }
345
+ if (
346
+ !bash.content.includes("Bash failed") ||
347
+ !bash.content.includes("exitCode=7") ||
348
+ !bash.content.includes("PTY_TOOL_FAILURE_OUTPUT")
349
+ ) {
350
+ throw new Error("PTY Bash failure branch returned an unexpected result.");
351
+ }
352
+ return textOutput(prepared, "PTY_TOOL_FAILURE_HANDLED");
353
+ }
354
+ if (prompt === "PTY_AFTER_TOOL_FAILURE") {
355
+ requireToolMessage(input.messages, "Bash", "exitCode=7");
356
+ requireMessage(input.messages, "assistant", "PTY_TOOL_FAILURE_HANDLED");
357
+ return textOutput(prepared, "PTY_AFTER_TOOL_FAILURE_DONE");
358
+ }
359
+ if (prompt === "PTY_TOOL_CHAIN_VERIFY") {
360
+ requireToolMessage(input.messages, "Write", "Write succeeded");
361
+ requireToolMessage(input.messages, "Edit", "Edit succeeded");
362
+ requireToolMessage(input.messages, "Bash", "PTY_BASH_OK:beta");
363
+ requireMessage(input.messages, "assistant", "PTY_TOOL_CHAIN_DONE");
364
+ return textOutput(prepared, "PTY_TOOL_CHAIN_VERIFIED");
365
+ }
366
+ if (prompt !== "PTY_TOOL_CHAIN_START") {
367
+ throw new Error(`Unexpected pty-tool-chain prompt: ${JSON.stringify(prompt)}.`);
368
+ }
369
+
370
+ const tools = toolMessagesAfterLastUser(input.messages);
371
+ const write = tools.find((message) => message.name === "Write");
372
+ if (write === undefined) {
373
+ return toolCallOutput(prepared, options, "Write", {
374
+ file_path: "pty-tool-chain.txt",
375
+ content: "alpha\n",
376
+ });
377
+ }
378
+ if (!write.content.includes("Write succeeded")) {
379
+ throw new Error("PTY Write tool did not succeed.");
380
+ }
381
+
382
+ const edit = tools.find((message) => message.name === "Edit");
383
+ if (edit === undefined) {
384
+ return toolCallOutput(prepared, options, "Edit", {
385
+ file_path: "pty-tool-chain.txt",
386
+ old_string: "alpha",
387
+ new_string: "beta",
388
+ });
389
+ }
390
+ if (!edit.content.includes("Edit succeeded")) {
391
+ throw new Error("PTY Edit tool did not succeed.");
392
+ }
393
+
394
+ const bash = tools.find((message) => message.name === "Bash");
395
+ if (bash === undefined) {
396
+ return toolCallOutput(prepared, options, "Bash", {
397
+ command: "printf 'PTY_BASH_OK:%s\\n' \"$(cat pty-tool-chain.txt)\"",
398
+ description: "Verify edited PTY fixture",
399
+ });
400
+ }
401
+ if (
402
+ !bash.content.includes("Bash completed") ||
403
+ !bash.content.includes("PTY_BASH_OK:beta")
404
+ ) {
405
+ throw new Error("PTY Bash tool did not verify the edited file.");
406
+ }
407
+ return textOutput(prepared, "PTY_TOOL_CHAIN_DONE");
408
+ }
409
+
410
+ private ptyBackgroundTask(
411
+ input: ModelRequestInput,
412
+ prepared: PreparedModelRequest,
413
+ options: ModelRequestOptions,
414
+ ): ModelRequestOutput {
415
+ requireTools(input, ["Bash", "TaskOutput", "TaskStop"]);
416
+ const prompt = lastUserMessage(input.messages);
417
+ if (prompt !== "PTY_BACKGROUND_STOP" && prompt !== "PTY_BACKGROUND_QUIT") {
418
+ throw new Error(
419
+ `Unexpected pty-background-task prompt: ${JSON.stringify(prompt)}.`,
420
+ );
421
+ }
422
+
423
+ const tools = toolMessagesAfterLastUser(input.messages);
424
+ const bash = tools.find((message) => message.name === "Bash");
425
+ if (bash === undefined) {
426
+ return toolCallOutput(prepared, options, "Bash", {
427
+ command:
428
+ "printf '%s\\n' \"$$\" > pty-background.pid; printf 'PTY_BACKGROUND_READY\\n'; while :; do sleep 1; done",
429
+ description: "Run PTY background fixture",
430
+ run_in_background: true,
431
+ });
432
+ }
433
+ if (!bash.content.includes("Bash command is running in background")) {
434
+ throw new Error("PTY Bash task did not enter the background.");
435
+ }
436
+ const taskId = requireObservationValue(bash.content, "taskId");
437
+
438
+ if (prompt === "PTY_BACKGROUND_QUIT") {
439
+ return textOutput(prepared, "PTY_BACKGROUND_RUNNING");
440
+ }
441
+
442
+ const outputs = tools.filter((message) => message.name === "TaskOutput");
443
+ const output = outputs.at(-1);
444
+ if (output === undefined || !output.content.includes("PTY_BACKGROUND_READY")) {
445
+ if (outputs.length >= 20) {
446
+ throw new Error("PTY background task did not produce its ready marker.");
447
+ }
448
+ return toolCallOutput(prepared, options, "TaskOutput", {
449
+ task_id: taskId,
450
+ });
451
+ }
452
+ if (!output.content.includes(`taskId=${taskId}`)) {
453
+ throw new Error("PTY TaskOutput returned the wrong task.");
454
+ }
455
+
456
+ const stop = tools.find((message) => message.name === "TaskStop");
457
+ if (stop === undefined) {
458
+ return toolCallOutput(prepared, options, "TaskStop", {
459
+ task_id: taskId,
460
+ });
461
+ }
462
+ if (
463
+ !stop.content.includes(`taskId=${taskId}`) ||
464
+ !stop.content.includes("status=killed")
465
+ ) {
466
+ throw new Error("PTY TaskStop did not kill the background task.");
467
+ }
468
+ return textOutput(prepared, "PTY_BACKGROUND_STOPPED");
469
+ }
470
+
471
+ private ptyResume(
472
+ input: ModelRequestInput,
473
+ prepared: PreparedModelRequest,
474
+ options: ModelRequestOptions,
475
+ ): ModelRequestOutput {
476
+ requireTools(input, ["Write"]);
477
+ const prompt = lastUserMessage(input.messages);
478
+ if (prompt === "PTY_RESUME_CONTINUE") {
479
+ requireMessage(input.messages, "user", "PTY_RESUME_SEED");
480
+ requireMessage(input.messages, "assistant", "PTY_RESUME_SEED_DONE");
481
+ requireToolMessage(input.messages, "Write", "Write succeeded");
482
+ return textOutput(prepared, "PTY_RESUME_CONTINUED");
483
+ }
484
+ if (prompt !== "PTY_RESUME_SEED") {
485
+ throw new Error(`Unexpected pty-resume prompt: ${JSON.stringify(prompt)}.`);
486
+ }
487
+ const write = toolMessagesAfterLastUser(input.messages).find(
488
+ (message) => message.name === "Write",
489
+ );
490
+ if (write === undefined) {
491
+ return toolCallOutput(prepared, options, "Write", {
492
+ file_path: "pty-resume.txt",
493
+ content: "PTY_RESUME_SIDE_EFFECT\n",
494
+ });
495
+ }
496
+ if (!write.content.includes("Write succeeded")) {
497
+ throw new Error("PTY resume seed Write did not succeed.");
498
+ }
499
+ return textOutput(prepared, "PTY_RESUME_SEED_DONE");
500
+ }
501
+
502
+ private ptyInterruptedTool(
503
+ input: ModelRequestInput,
504
+ prepared: PreparedModelRequest,
505
+ options: ModelRequestOptions,
506
+ ): Promise<ModelRequestOutput> | ModelRequestOutput {
507
+ requireTools(input, ["Write"]);
508
+ const prompt = lastUserMessage(input.messages);
509
+ if (prompt === "PTY_INTERRUPT_RECOVER") {
510
+ requireMessage(input.messages, "user", "PTY_INTERRUPT_START");
511
+ requireToolMessage(input.messages, "Write", "Write succeeded");
512
+ return textOutput(prepared, "PTY_INTERRUPT_RECOVERED");
513
+ }
514
+ if (prompt !== "PTY_INTERRUPT_START") {
515
+ throw new Error(
516
+ `Unexpected pty-interrupted-tool prompt: ${JSON.stringify(prompt)}.`,
517
+ );
518
+ }
519
+ const write = toolMessagesAfterLastUser(input.messages).find(
520
+ (message) => message.name === "Write",
521
+ );
522
+ if (write === undefined) {
523
+ return toolCallOutput(prepared, options, "Write", {
524
+ file_path: "pty-interrupted.txt",
525
+ content: "PTY_INTERRUPT_SIDE_EFFECT\n",
526
+ });
527
+ }
528
+ if (!write.content.includes("Write succeeded")) {
529
+ throw new Error("PTY interrupted Write did not succeed.");
530
+ }
531
+ return waitForCancellation(options.signal);
532
+ }
533
+
534
+ private ptyFailOnce(
535
+ input: ModelRequestInput,
536
+ prepared: PreparedModelRequest,
537
+ ): ModelRequestOutput {
538
+ const prompt = lastUserMessage(input.messages);
539
+ if (prompt === "PTY_FAIL_FIRST") {
540
+ throw new Error("PTY_FAKE_PROVIDER_FAILURE");
541
+ }
542
+ if (prompt === "PTY_FAIL_RECOVER") {
543
+ requireMessage(input.messages, "user", "PTY_FAIL_FIRST");
544
+ return textOutput(prepared, "PTY_FAIL_RECOVERED");
545
+ }
546
+ throw new Error(`Unexpected pty-fail-once prompt: ${JSON.stringify(prompt)}.`);
547
+ }
548
+
549
+ private ptyPromptInput(
550
+ input: ModelRequestInput,
551
+ prepared: PreparedModelRequest,
552
+ ): ModelRequestOutput {
553
+ const prompt = lastUserMessage(input.messages);
554
+ if (prompt === "first\n>second\n中文<") {
555
+ return textOutput(prepared, "PTY_PROMPT_FIRST_DONE");
556
+ }
557
+ if (prompt === "草稿-恢复") {
558
+ requireExactMessage(input.messages, "user", "first\n>second\n中文<");
559
+ requireExactMessage(input.messages, "assistant", "PTY_PROMPT_FIRST_DONE");
560
+ return textOutput(prepared, "PTY_PROMPT_DRAFT_DONE");
561
+ }
562
+ if (prompt === "草稿-恢复-重提") {
563
+ requireExactMessage(input.messages, "user", "草稿-恢复");
564
+ requireExactMessage(input.messages, "assistant", "PTY_PROMPT_DRAFT_DONE");
565
+ return textOutput(prepared, "PTY_PROMPT_HISTORY_DONE");
566
+ }
567
+ throw new Error(`Unexpected pty-prompt-input prompt: ${JSON.stringify(prompt)}.`);
568
+ }
569
+
570
+ private ptyFileCommand(
571
+ input: ModelRequestInput,
572
+ prepared: PreparedModelRequest,
573
+ ): ModelRequestOutput {
574
+ const prompt = lastUserMessage(input.messages);
575
+ if (prompt === "open src/index.ts now") {
576
+ return textOutput(prepared, "PTY_FILE_SELECTION_DONE");
577
+ }
578
+ if (prompt === "Review shallow and deep files.\nReturn exact marker.") {
579
+ requireExactMessage(input.messages, "user", "open src/index.ts now");
580
+ requireExactMessage(input.messages, "assistant", "PTY_FILE_SELECTION_DONE");
581
+ return textOutput(prepared, "PTY_PROJECT_COMMAND_DONE");
582
+ }
583
+ throw new Error(`Unexpected pty-file-command prompt: ${JSON.stringify(prompt)}.`);
584
+ }
585
+
586
+ private ptyClear(
587
+ input: ModelRequestInput,
588
+ prepared: PreparedModelRequest,
589
+ ): ModelRequestOutput {
590
+ const prompt = lastUserMessage(input.messages);
591
+ if (prompt === "PTY_CLEAR_SEED") {
592
+ return textOutput(prepared, "PTY_CLEAR_SEED_DONE");
593
+ }
594
+ if (prompt === "PTY_CLEAR_CONTINUE") {
595
+ requireExactMessage(input.messages, "user", "PTY_CLEAR_SEED");
596
+ requireExactMessage(input.messages, "assistant", "PTY_CLEAR_SEED_DONE");
597
+ return textOutput(prepared, "PTY_CLEAR_CONTINUED");
598
+ }
599
+ throw new Error(`Unexpected pty-clear prompt: ${JSON.stringify(prompt)}.`);
600
+ }
601
+
602
+ private ptyFork(
603
+ input: ModelRequestInput,
604
+ prepared: PreparedModelRequest,
605
+ options: ModelRequestOptions,
606
+ ): ModelRequestOutput {
607
+ requireTools(input, ["Write"]);
608
+ const prompt = lastUserMessage(input.messages);
609
+ if (prompt === "PTY_FORK_SEED") {
610
+ const write = toolMessagesAfterLastUser(input.messages).find(
611
+ (message) => message.name === "Write",
612
+ );
613
+ if (write === undefined) {
614
+ return toolCallOutput(prepared, options, "Write", {
615
+ file_path: "pty-fork-shared.txt",
616
+ content: "PTY_FORK_SHARED_HISTORY\n",
617
+ });
618
+ }
619
+ if (!write.content.includes("Write succeeded")) {
620
+ throw new Error("PTY fork seed Write did not succeed.");
621
+ }
622
+ return textOutput(prepared, "PTY_FORK_SEED_DONE");
623
+ }
624
+ if (prompt === "CLONE_ONLY") {
625
+ requireForkSeed(input.messages);
626
+ requireNoMessage(input.messages, "user", "SOURCE_ONLY");
627
+ return textOutput(prepared, "PTY_CLONE_ONLY_DONE");
628
+ }
629
+ if (prompt === "SOURCE_ONLY") {
630
+ requireForkSeed(input.messages);
631
+ requireNoMessage(input.messages, "user", "CLONE_ONLY");
632
+ return textOutput(prepared, "PTY_SOURCE_ONLY_DONE");
633
+ }
634
+ throw new Error(`Unexpected pty-fork prompt: ${JSON.stringify(prompt)}.`);
635
+ }
636
+
637
+ private ptyModelSwitch(
638
+ input: ModelRequestInput,
639
+ prepared: PreparedModelRequest,
640
+ ): ModelRequestOutput {
641
+ const prompt = lastUserMessage(input.messages);
642
+ if (prompt !== "PTY_MODEL_ALPHA_TURN") {
643
+ throw new Error(`Unexpected pty-model-switch prompt: ${JSON.stringify(prompt)}.`);
644
+ }
645
+ if (this.options.model !== "alpha-model") {
646
+ throw new Error(
647
+ `PTY model switch dispatched to ${JSON.stringify(this.options.model)}.`,
648
+ );
649
+ }
650
+ return textOutput(prepared, "PTY_MODEL_ALPHA_DONE");
651
+ }
652
+
653
+ private ptyViewer(
654
+ input: ModelRequestInput,
655
+ prepared: PreparedModelRequest,
656
+ ): ModelRequestOutput {
657
+ const prompt = lastUserMessage(input.messages);
658
+ if (prompt === "PTY_VIEW_SEED") {
659
+ return textOutput(prepared, "PTY_VIEW_SEED_DONE");
660
+ }
661
+ if (prompt === "PTY_VIEW_CONTINUE") {
662
+ requireExactMessage(input.messages, "user", "PTY_VIEW_SEED");
663
+ requireExactMessage(input.messages, "assistant", "PTY_VIEW_SEED_DONE");
664
+ return textOutput(prepared, "PTY_VIEW_CONTINUED");
665
+ }
666
+ throw new Error(`Unexpected pty-viewer prompt: ${JSON.stringify(prompt)}.`);
667
+ }
668
+
669
+ private ptyCopy(
670
+ input: ModelRequestInput,
671
+ prepared: PreparedModelRequest,
672
+ ): ModelRequestOutput {
673
+ const prompt = lastUserMessage(input.messages);
674
+ if (prompt !== "PTY_COPY_MARKDOWN") {
675
+ throw new Error(`Unexpected pty-copy prompt: ${JSON.stringify(prompt)}.`);
676
+ }
677
+ return textOutput(prepared, ptyCopyMarkdownResponse());
678
+ }
679
+
680
+ private ptyContextHeavy(
681
+ input: ModelRequestInput,
682
+ prepared: PreparedModelRequest,
683
+ options: ModelRequestOptions,
684
+ ): ModelRequestOutput {
685
+ requireTools(input, ["Read", "Recall"]);
686
+ const prompt = lastUserMessage(input.messages);
687
+ if (prompt === "PTY_CONTEXT_HEAVY") {
688
+ const read = toolMessagesAfterLastUser(input.messages).find(
689
+ (message) => message.name === "Read",
690
+ );
691
+ if (read === undefined) {
692
+ return toolCallOutput(prepared, options, "Read", {
693
+ file_path: "context-heavy.txt",
694
+ });
695
+ }
696
+ if (!read.content.includes("PTY_CONTEXT_ORIGINAL_MARKER")) {
697
+ throw new Error("PTY context Read did not return the original marker.");
698
+ }
699
+ return textOutput(prepared, "PTY_CONTEXT_HEAVY_DONE");
700
+ }
701
+ if (/^PTY_CONTEXT_PAD_[1-9]$/u.test(prompt)) {
702
+ return textOutput(prepared, `${prompt}_DONE`);
703
+ }
704
+ if (prompt === "PTY_CONTEXT_RECALL") {
705
+ return recallMarker(
706
+ input,
707
+ prepared,
708
+ options,
709
+ "PTY_CONTEXT_ORIGINAL_MARKER",
710
+ "PTY_CONTEXT_RECALLED",
711
+ );
712
+ }
713
+ throw new Error(`Unexpected pty-context-heavy prompt: ${JSON.stringify(prompt)}.`);
714
+ }
715
+
716
+ private ptyImage(
717
+ input: ModelRequestInput,
718
+ prepared: PreparedModelRequest,
719
+ ): ModelRequestOutput {
720
+ const prompt = lastUserMessage(input.messages);
721
+ if (prompt !== "[Image #1] describe fixture") {
722
+ throw new Error(`Unexpected pty-image prompt: ${JSON.stringify(prompt)}.`);
723
+ }
724
+ const user = [...input.messages]
725
+ .reverse()
726
+ .find(
727
+ (message): message is Extract<AgentMessage, { role: "user" }> =>
728
+ message.role === "user",
729
+ );
730
+ const attachment = user?.attachments?.[0];
731
+ if (
732
+ user?.attachments?.length !== 1 ||
733
+ attachment === undefined ||
734
+ attachment.label !== "[Image #1]" ||
735
+ attachment.originalName !== "fixture.png"
736
+ ) {
737
+ throw new Error("PTY image request has unexpected canonical attachment data.");
738
+ }
739
+ const payload = prepared.payload as {
740
+ readonly materializedAssets?: readonly {
741
+ readonly assetId: ImageAssetId;
742
+ readonly byteLength: number;
743
+ readonly bytesSha256: string;
744
+ }[];
745
+ };
746
+ const materialized = payload.materializedAssets?.[0];
747
+ if (
748
+ !this.materializedRequests.has(prepared) ||
749
+ prepared.mediaOccurrenceCount !== 1 ||
750
+ payload.materializedAssets?.length !== 1 ||
751
+ materialized?.assetId !== attachment.assetId ||
752
+ materialized.byteLength !== attachment.byteLength ||
753
+ !/^[0-9a-f]{64}$/u.test(materialized.bytesSha256)
754
+ ) {
755
+ throw new Error("PTY image request was not materialized from the asset store.");
756
+ }
757
+ return textOutput(prepared, "PTY_IMAGE_DONE");
758
+ }
759
+
760
+ private ptyLocalPanels(
761
+ input: ModelRequestInput,
762
+ prepared: PreparedModelRequest,
763
+ ): ModelRequestOutput {
764
+ const prompt = lastUserMessage(input.messages);
765
+ if (prompt !== "PTY_LOCAL_AFTER_PANELS") {
766
+ throw new Error(`Unexpected pty-local-panels prompt: ${JSON.stringify(prompt)}.`);
767
+ }
768
+ requireTools(input, ["Skill", "mcp__fixture__echo"]);
769
+ return textOutput(prepared, "PTY_LOCAL_AFTER_PANELS_DONE");
770
+ }
771
+
772
+ private ptySkillActivate(
773
+ input: ModelRequestInput,
774
+ prepared: PreparedModelRequest,
775
+ options: ModelRequestOptions,
776
+ ): ModelRequestOutput {
777
+ requireTools(input, ["Skill"]);
778
+ const prompt = lastUserMessage(input.messages);
779
+ if (prompt === "PTY_SKILL_START") {
780
+ const skill = toolMessagesAfterLastUser(input.messages).find(
781
+ (message) => message.name === "Skill",
782
+ );
783
+ if (skill === undefined) {
784
+ return toolCallOutput(prepared, options, "Skill", {
785
+ name: "pty-review",
786
+ });
787
+ }
788
+ if (!skill.content.includes("PTY_SKILL_INSTRUCTIONS")) {
789
+ throw new Error("PTY Skill result did not contain the fixture instructions.");
790
+ }
791
+ return textOutput(prepared, "PTY_SKILL_DONE");
792
+ }
793
+ if (prompt === "PTY_SKILL_AFTER_RESUME") {
794
+ requireExactMessage(input.messages, "user", "PTY_SKILL_START");
795
+ requireExactMessage(input.messages, "assistant", "PTY_SKILL_DONE");
796
+ requireSystemContent(input.messages, "PTY_SKILL_INSTRUCTIONS");
797
+ return textOutput(prepared, "PTY_SKILL_RESUMED");
798
+ }
799
+ throw new Error(`Unexpected pty-skill-activate prompt: ${JSON.stringify(prompt)}.`);
800
+ }
801
+
802
+ private ptyMcpCall(
803
+ input: ModelRequestInput,
804
+ prepared: PreparedModelRequest,
805
+ options: ModelRequestOptions,
806
+ ): ModelRequestOutput {
807
+ requireTools(input, ["mcp__fixture__echo"]);
808
+ const prompt = lastUserMessage(input.messages);
809
+ if (prompt !== "PTY_MCP_START") {
810
+ throw new Error(`Unexpected pty-mcp-call prompt: ${JSON.stringify(prompt)}.`);
811
+ }
812
+ const echo = toolMessagesAfterLastUser(input.messages).find(
813
+ (message) => message.name === "mcp__fixture__echo",
814
+ );
815
+ if (echo === undefined) {
816
+ return toolCallOutput(prepared, options, "mcp__fixture__echo", {
817
+ message: "PTY_MCP_PAYLOAD",
818
+ });
819
+ }
820
+ if (!echo.content.includes("echo: PTY_MCP_PAYLOAD")) {
821
+ throw new Error("PTY MCP echo returned unexpected content.");
822
+ }
823
+ return textOutput(prepared, "PTY_MCP_DONE\n\necho: PTY_MCP_PAYLOAD");
824
+ }
825
+
106
826
  private writeNotes(
107
827
  input: ModelRequestInput,
108
828
  prepared: PreparedModelRequest,
@@ -245,6 +965,47 @@ function outputWithUsage(
245
965
  };
246
966
  }
247
967
 
968
+ function textOutput(
969
+ prepared: PreparedModelRequest,
970
+ content: string,
971
+ ): ModelRequestOutput {
972
+ return outputWithUsage(
973
+ prepared,
974
+ {
975
+ role: "assistant",
976
+ content,
977
+ },
978
+ "stop",
979
+ );
980
+ }
981
+
982
+ function toolCallOutput(
983
+ prepared: PreparedModelRequest,
984
+ options: ModelRequestOptions,
985
+ name: string,
986
+ args: Readonly<Record<string, unknown>>,
987
+ ): ModelRequestOutput {
988
+ if (options.identity === undefined) {
989
+ throw new Error(`Fake ${name} call requires an iteration identity context.`);
990
+ }
991
+ const identity = options.identity;
992
+ return outputWithUsage(
993
+ prepared,
994
+ {
995
+ role: "assistant",
996
+ toolCalls: [
997
+ {
998
+ ...identity.runtimeSession.createToolCall(identity.iteration, 1),
999
+ providerToolCallId: `fake-${name.toLowerCase()}-${identity.iteration.iterationNumber}`,
1000
+ name,
1001
+ args,
1002
+ },
1003
+ ],
1004
+ },
1005
+ "tool_calls",
1006
+ );
1007
+ }
1008
+
248
1009
  function waitForCancellation(signal: AbortSignal): Promise<ModelRequestOutput> {
249
1010
  return new Promise((_resolve, reject) => {
250
1011
  const abort = () => reject(cancellationError(signal));
@@ -263,6 +1024,110 @@ function lastUserMessage(messages: AgentMessage[]): string {
263
1024
  return users.at(-1)?.content ?? "";
264
1025
  }
265
1026
 
1027
+ function toolMessagesAfterLastUser(
1028
+ messages: AgentMessage[],
1029
+ ): Array<Extract<AgentMessage, { role: "tool" }>> {
1030
+ return messages
1031
+ .slice(lastMessageIndex(messages, "user") + 1)
1032
+ .filter(
1033
+ (message): message is Extract<AgentMessage, { role: "tool" }> =>
1034
+ message.role === "tool",
1035
+ );
1036
+ }
1037
+
1038
+ function requireMessage(
1039
+ messages: AgentMessage[],
1040
+ role: "user" | "assistant",
1041
+ content: string,
1042
+ ): void {
1043
+ const found = messages.some(
1044
+ (message) =>
1045
+ message.role === role &&
1046
+ typeof message.content === "string" &&
1047
+ message.content.includes(content),
1048
+ );
1049
+ if (!found) {
1050
+ throw new Error(`Fake PTY context is missing ${role} content ${content}.`);
1051
+ }
1052
+ }
1053
+
1054
+ function requireExactMessage(
1055
+ messages: AgentMessage[],
1056
+ role: "user" | "assistant",
1057
+ content: string,
1058
+ ): void {
1059
+ const found = messages.some(
1060
+ (message) => message.role === role && message.content === content,
1061
+ );
1062
+ if (!found) {
1063
+ throw new Error(
1064
+ `Fake PTY context is missing exact ${role} content ${JSON.stringify(content)}.`,
1065
+ );
1066
+ }
1067
+ }
1068
+
1069
+ function requireNoMessage(
1070
+ messages: AgentMessage[],
1071
+ role: "user" | "assistant",
1072
+ content: string,
1073
+ ): void {
1074
+ const found = messages.some(
1075
+ (message) => message.role === role && message.content === content,
1076
+ );
1077
+ if (found) {
1078
+ throw new Error(
1079
+ `Fake PTY context unexpectedly contains ${role} content ${JSON.stringify(content)}.`,
1080
+ );
1081
+ }
1082
+ }
1083
+
1084
+ function requireSystemContent(messages: AgentMessage[], content: string): void {
1085
+ const found = messages.some(
1086
+ (message) => message.role === "system" && message.content.includes(content),
1087
+ );
1088
+ if (!found) {
1089
+ throw new Error(`Fake PTY system surface is missing ${content}.`);
1090
+ }
1091
+ }
1092
+
1093
+ function requireForkSeed(messages: AgentMessage[]): void {
1094
+ requireExactMessage(messages, "user", "PTY_FORK_SEED");
1095
+ requireExactMessage(messages, "assistant", "PTY_FORK_SEED_DONE");
1096
+ requireToolMessage(messages, "Write", "Write succeeded");
1097
+ }
1098
+
1099
+ function requireToolMessage(
1100
+ messages: AgentMessage[],
1101
+ name: string,
1102
+ content: string,
1103
+ ): void {
1104
+ const found = messages.some(
1105
+ (message) =>
1106
+ message.role === "tool" &&
1107
+ message.name === name &&
1108
+ message.content.includes(content),
1109
+ );
1110
+ if (!found) {
1111
+ throw new Error(`Fake PTY context is missing ${name} tool content ${content}.`);
1112
+ }
1113
+ }
1114
+
1115
+ function requireTools(input: ModelRequestInput, names: readonly string[]): void {
1116
+ const available = new Set(input.tools.map((tool) => tool.name));
1117
+ const missing = names.filter((name) => !available.has(name));
1118
+ if (missing.length > 0) {
1119
+ throw new Error(`Fake PTY model is missing tools: ${missing.join(", ")}.`);
1120
+ }
1121
+ }
1122
+
1123
+ function requireObservationValue(content: string, name: string): string {
1124
+ const value = content.match(new RegExp(`^${name}=(.+)$`, "m"))?.[1]?.trim();
1125
+ if (value === undefined || value === "") {
1126
+ throw new Error(`Fake PTY tool observation is missing ${name}.`);
1127
+ }
1128
+ return value;
1129
+ }
1130
+
266
1131
  function lastMessageIndex(
267
1132
  messages: AgentMessage[],
268
1133
  role: AgentMessage["role"],
@@ -276,6 +1141,26 @@ function lastMessageIndex(
276
1141
  }
277
1142
 
278
1143
  function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
1144
+ if (message.role === "user" && message.attachments !== undefined) {
1145
+ const media = message.attachments.map(
1146
+ (attachment): PreparedMediaDescriptor =>
1147
+ Object.freeze({
1148
+ assetId: attachment.assetId,
1149
+ label: attachment.label,
1150
+ range: Object.freeze({ ...attachment.range }),
1151
+ mimeType: attachment.mimeType,
1152
+ byteLength: attachment.byteLength,
1153
+ width: attachment.width,
1154
+ height: attachment.height,
1155
+ planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
1156
+ }),
1157
+ );
1158
+ return Object.freeze({
1159
+ kind: "user",
1160
+ normalizedText: message.content,
1161
+ media: Object.freeze(media),
1162
+ });
1163
+ }
279
1164
  return {
280
1165
  kind:
281
1166
  message.role === "system"
@@ -286,3 +1171,86 @@ function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
286
1171
  normalizedText: stableJsonStringify(message),
287
1172
  };
288
1173
  }
1174
+
1175
+ function distinctPreparedAssets(
1176
+ segments: readonly PreparedPromptSegment[],
1177
+ ): Map<ImageAssetId, ImageAssetRef> {
1178
+ const assets = new Map<ImageAssetId, ImageAssetRef>();
1179
+ for (const segment of segments) {
1180
+ for (const media of segment.media ?? []) {
1181
+ const asset = Object.freeze({
1182
+ assetId: media.assetId,
1183
+ mimeType: media.mimeType,
1184
+ byteLength: media.byteLength,
1185
+ width: media.width,
1186
+ height: media.height,
1187
+ });
1188
+ const existing = assets.get(media.assetId);
1189
+ if (
1190
+ existing !== undefined &&
1191
+ stableJsonStringify(existing) !== stableJsonStringify(asset)
1192
+ ) {
1193
+ throw new Error(`Conflicting fake image descriptors for ${media.assetId}.`);
1194
+ }
1195
+ assets.set(media.assetId, asset);
1196
+ }
1197
+ }
1198
+ return assets;
1199
+ }
1200
+
1201
+ function tokenEstimatorEndpoint(apiBase: string): string {
1202
+ const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
1203
+ base.username = "";
1204
+ base.password = "";
1205
+ base.search = "";
1206
+ base.hash = "";
1207
+ return new URL("tokenizers/estimate-token-count", base).toString();
1208
+ }
1209
+
1210
+ function recallMarker(
1211
+ input: ModelRequestInput,
1212
+ prepared: PreparedModelRequest,
1213
+ options: ModelRequestOptions,
1214
+ marker: string,
1215
+ finalText: string,
1216
+ ): ModelRequestOutput {
1217
+ const latestRecallResult = toolMessagesAfterLastUser(input.messages)
1218
+ .filter((message) => message.name === "Recall")
1219
+ .at(-1);
1220
+ if (latestRecallResult === undefined) {
1221
+ return toolCallOutput(prepared, options, "Recall", {
1222
+ mode: "search",
1223
+ query: marker,
1224
+ });
1225
+ }
1226
+ if (latestRecallResult.content.startsWith("Recall searched")) {
1227
+ const source = latestRecallResult.content.match(
1228
+ /^source=(ctx:\/\/message\/[0-9a-f-]+)$/m,
1229
+ )?.[1];
1230
+ if (source === undefined) {
1231
+ throw new Error("Fake PTY Recall search did not return a source.");
1232
+ }
1233
+ return toolCallOutput(prepared, options, "Recall", {
1234
+ mode: "get",
1235
+ source,
1236
+ });
1237
+ }
1238
+ if (!latestRecallResult.content.includes(marker)) {
1239
+ throw new Error(`Fake PTY Recall get did not recover ${marker}.`);
1240
+ }
1241
+ return textOutput(prepared, finalText);
1242
+ }
1243
+
1244
+ export function ptyCopyMarkdownResponse(): string {
1245
+ return [
1246
+ "# PTY canonical Markdown",
1247
+ "",
1248
+ "```ts",
1249
+ 'export const marker = "PTY_COPY_CODE";',
1250
+ "```",
1251
+ "",
1252
+ Array.from({ length: 240 }, (_, index) => `long-${index}`).join(" "),
1253
+ "",
1254
+ "PTY_COPY_END",
1255
+ ].join("\n");
1256
+ }