@zocomputer/agent-sdk 0.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +673 -0
  3. package/dist/attachments.js +52 -0
  4. package/dist/gateway-fetch.js +67 -0
  5. package/dist/index.js +4169 -0
  6. package/dist/initiator-auth.js +49 -0
  7. package/dist/platform/agent-sandbox/index.js +691 -0
  8. package/dist/platform/cloud-tools/image.js +498 -0
  9. package/dist/platform/cloud-tools/index.js +507 -0
  10. package/dist/platform/cloud-tools/web-search.js +87 -0
  11. package/dist/platform/runtime-ai/gateway.js +87 -0
  12. package/dist/platform/runtime-ai/index.js +91 -0
  13. package/dist/platform/runtime-ai/register.js +88 -0
  14. package/dist/platform/runtime-ai/session-fetch.js +54 -0
  15. package/dist/platform/runtime-auth/index.js +141 -0
  16. package/dist/state-files.js +287 -0
  17. package/dist/state-sandbox.js +383 -0
  18. package/dist/state.js +29 -0
  19. package/dist/steer-inbox.js +84 -0
  20. package/dist/steer.js +83 -0
  21. package/package.json +143 -0
  22. package/platform/agent-sandbox/api-client.ts +196 -0
  23. package/platform/agent-sandbox/index.ts +27 -0
  24. package/platform/agent-sandbox/pure.ts +83 -0
  25. package/platform/agent-sandbox/sftp.ts +141 -0
  26. package/platform/agent-sandbox/ssh-connection.ts +165 -0
  27. package/platform/agent-sandbox/ssh-exec.ts +98 -0
  28. package/platform/agent-sandbox/ssh-session.ts +487 -0
  29. package/platform/agent-sandbox/zo-backend.ts +88 -0
  30. package/platform/agent-sandbox/zo-sandbox.ts +39 -0
  31. package/platform/cloud-tools/image-path.ts +44 -0
  32. package/platform/cloud-tools/image.ts +225 -0
  33. package/platform/cloud-tools/index.ts +22 -0
  34. package/platform/cloud-tools/state-files.ts +368 -0
  35. package/platform/cloud-tools/tool-meta.ts +32 -0
  36. package/platform/cloud-tools/web-search.ts +15 -0
  37. package/platform/runtime-ai/gateway.ts +76 -0
  38. package/platform/runtime-ai/index.ts +20 -0
  39. package/platform/runtime-ai/register.ts +26 -0
  40. package/platform/runtime-ai/session-fetch.ts +124 -0
  41. package/platform/runtime-auth/index.ts +331 -0
  42. package/src/async-tasks.ts +273 -0
  43. package/src/attachments.ts +109 -0
  44. package/src/backgroundable.ts +88 -0
  45. package/src/bounded-output.ts +159 -0
  46. package/src/dir-conventions.ts +238 -0
  47. package/src/extract/cache.ts +40 -0
  48. package/src/extract/docx.ts +18 -0
  49. package/src/extract/pdf.ts +54 -0
  50. package/src/extract/sheet.ts +56 -0
  51. package/src/file-kind.ts +258 -0
  52. package/src/file-view.ts +80 -0
  53. package/src/gateway-fetch.ts +115 -0
  54. package/src/glob-match.ts +13 -0
  55. package/src/hooks.ts +213 -0
  56. package/src/index.ts +419 -0
  57. package/src/initiator-auth.ts +81 -0
  58. package/src/instructions.ts +224 -0
  59. package/src/list-files.ts +41 -0
  60. package/src/mock-model.ts +572 -0
  61. package/src/park-delivery.ts +247 -0
  62. package/src/path-locks.ts +52 -0
  63. package/src/read-file-content.ts +142 -0
  64. package/src/read-text.ts +40 -0
  65. package/src/redeliver.ts +155 -0
  66. package/src/run.ts +159 -0
  67. package/src/sandbox-io.ts +414 -0
  68. package/src/state-files.ts +460 -0
  69. package/src/state-sandbox.ts +710 -0
  70. package/src/state.ts +96 -0
  71. package/src/steer-inbox.ts +105 -0
  72. package/src/steer-tool.ts +83 -0
  73. package/src/steer.ts +146 -0
  74. package/src/task.ts +320 -0
  75. package/src/tools/bash.ts +143 -0
  76. package/src/tools/edit.ts +58 -0
  77. package/src/tools/glob.ts +56 -0
  78. package/src/tools/grep.ts +188 -0
  79. package/src/tools/read.ts +319 -0
  80. package/src/tools/tasks.ts +241 -0
  81. package/src/tools/webfetch.ts +368 -0
  82. package/src/tools/write.ts +42 -0
  83. package/src/walk.ts +112 -0
  84. package/src/watch-output.ts +104 -0
  85. package/src/web-fetch.ts +324 -0
  86. package/src/web-page.ts +179 -0
  87. package/src/workspace-io.ts +225 -0
  88. package/src/workspace.ts +41 -0
@@ -0,0 +1,572 @@
1
+ // A slow-streaming mock LanguageModelV4 for testing an eve agent's clients
2
+ // without inference credentials: everything past the model call — session
3
+ // routes, harness, framework tools, subagents, durable streams — runs REAL;
4
+ // only inference is canned. Gate it behind an env flag in the consumer's
5
+ // agent.ts (rib: RIB_MOCK_MODEL=1); never active in a normal run. Two modes, picked per turn from the user prompt:
6
+ //
7
+ // - Default: streams a long deterministic "story" as text deltas with a
8
+ // per-chunk delay, so a turn stays in-flight for a configurable window —
9
+ // what exercising the rail's in-progress states and chat switching needs.
10
+ // - Scripted tool calls, via a `[mock:<scenario>]` directive in the message:
11
+ // `[mock:hitl]` calls eve's ask_question (options with styles + freeform),
12
+ // `[mock:parallel]` calls ask_question twice in one response (parallel HITL),
13
+ // `[mock:todo]` writes then updates a todo list, `[mock:delegate]` delegates
14
+ // to a declared subagent. Each scenario ends with a short wrap-up text, so
15
+ // the HITL prompt, todo checklist, and subagent card render end-to-end.
16
+ import type {
17
+ LanguageModelV4,
18
+ LanguageModelV4CallOptions,
19
+ LanguageModelV4Prompt,
20
+ LanguageModelV4StreamPart,
21
+ LanguageModelV4Usage,
22
+ } from "@ai-sdk/provider";
23
+
24
+ export interface MockStoryModelOptions {
25
+ /** Text deltas per story turn. Default 240 (~60s at the default delay). */
26
+ chunkCount?: number;
27
+ /** Delay between deltas in ms. Default 250. */
28
+ chunkDelayMs?: number;
29
+ /** Deltas for a `[mock:burst]` turn (no pacing). Default 600. */
30
+ burstChunks?: number;
31
+ /** The declared subagent tool `[mock:delegate]` delegates to. Default "task_fast". */
32
+ delegateToolName?: string;
33
+ /**
34
+ * Clock for response-metadata ids/timestamps. Default `Date.now`. Inject a
35
+ * fixed clock to make the full stream byte-deterministic across runs.
36
+ */
37
+ now?: () => number;
38
+ }
39
+
40
+ const STORY_SENTENCES = [
41
+ "The lighthouse keeper counted the waves as they broke against the rocks.",
42
+ "Every seventh wave carried a whisper from the old town beneath the sea.",
43
+ "Marisol had kept the light burning for forty-one years without a single dark night.",
44
+ "Tonight the fog rolled in thicker than she had ever seen it.",
45
+ "Somewhere out past the shoals, a bell rang that no ship had carried in decades.",
46
+ "She climbed the spiral stairs slowly, lantern in one hand, logbook in the other.",
47
+ "The glass at the top of the tower was cold and streaked with salt.",
48
+ "Below, the sea moved like a great animal turning in its sleep.",
49
+ "She wrote the date in the logbook and then paused, pen hovering.",
50
+ "The bell rang again, closer now, and the fog pressed against the windows.",
51
+ ];
52
+
53
+ function storyChunk(index: number): string {
54
+ const sentence = STORY_SENTENCES[index % STORY_SENTENCES.length] ?? "The story went on.";
55
+ const paragraphBreak = index > 0 && index % 8 === 0 ? "\n\n" : " ";
56
+ return `${paragraphBreak}${sentence}`;
57
+ }
58
+
59
+ const delay = (ms: number): Promise<void> =>
60
+ ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
61
+
62
+ function usageFor(outputTokens: number): LanguageModelV4Usage {
63
+ return {
64
+ inputTokens: { total: 100, noCache: 100, cacheRead: 0, cacheWrite: 0 },
65
+ outputTokens: { total: outputTokens, text: outputTokens, reasoning: 0 },
66
+ };
67
+ }
68
+
69
+ // --- Scripted scenarios (pure helpers, unit-tested) -------------------------
70
+
71
+ // Multi-step tool scripts plus four stream shapes: `fail` ends the stream in
72
+ // a terminal error part (the deterministic trigger for the failed-turn UX),
73
+ // `burst` streams RIB_MOCK_BURST_CHUNKS deltas with no pacing delay — a
74
+ // renderer-throughput probe for the cockpits' streaming pipelines —
75
+ // `markdown` streams structure-heavy markdown (fences, tables, nested lists)
76
+ // that opens blocks mid-delta (the streaming-renderer stability probe),
77
+ // `interleave` alternates reasoning and text blocks in one message (multiple
78
+ // reasoning/text parts per response — how extended-thinking models actually
79
+ // stream), and `empty` finishes with zero content parts (a real model edge
80
+ // case that has broken assistant-message rendering before).
81
+ export const MOCK_SCENARIOS = [
82
+ "hitl",
83
+ "parallel",
84
+ "todo",
85
+ "delegate",
86
+ "fail",
87
+ "burst",
88
+ "markdown",
89
+ "interleave",
90
+ "empty",
91
+ ] as const;
92
+ export type MockScenario = (typeof MOCK_SCENARIOS)[number];
93
+ export type MockScriptedScenario = Extract<
94
+ MockScenario,
95
+ "hitl" | "parallel" | "todo" | "delegate"
96
+ >;
97
+
98
+ /** One tool call a scripted step emits. */
99
+ export interface MockToolCall {
100
+ toolName: string;
101
+ input: Record<string, unknown>;
102
+ }
103
+
104
+ /**
105
+ * The scripted action for a scenario at a step: one or more tool calls emitted
106
+ * in a single response (plural = parallel tool calls), or a wrap-up text that
107
+ * ends the turn.
108
+ */
109
+ export type MockScriptAction =
110
+ | { kind: "tool-calls"; calls: readonly MockToolCall[] }
111
+ | { kind: "text"; text: string };
112
+
113
+ /**
114
+ * Markdown chunks that deliberately split structure across deltas: a fence
115
+ * opens in one delta and closes several later, table rows arrive one at a
116
+ * time, list nesting grows mid-stream. A streaming renderer must keep the
117
+ * trailing block stable through all of it.
118
+ */
119
+ export function markdownChunks(): readonly string[] {
120
+ return [
121
+ "## Streaming markdown stress\n\n",
122
+ "A paragraph with **bold**, _italic_, `inline code`, and a [link](https://example.com).\n\n",
123
+ "```ts\n",
124
+ "export function keeper(light: number): string {\n",
125
+ " // the fence stays open across deltas\n",
126
+ " return `burning for ${light} years`;\n",
127
+ "}\n",
128
+ "```\n\n",
129
+ "| tide | bell | fog |\n",
130
+ "| --- | --- | --- |\n",
131
+ "| low | quiet | thin |\n",
132
+ "| high | ringing | thick |\n\n",
133
+ "1. climb the stairs\n",
134
+ " - lantern in one hand\n",
135
+ " - logbook in the other\n",
136
+ "2. write the date\n",
137
+ " 1. pause, pen hovering\n",
138
+ " 2. listen for the bell\n\n",
139
+ "> The sea moved like a great animal turning in its sleep.\n\n",
140
+ "Unicode: emoji 🌊🔔, CJK 灯台守は波を数えた, RTL مرحبا, combining é ñ ü.\n\n",
141
+ "A very long unbroken token: " + "abcdefghij".repeat(40) + "\n\n",
142
+ "Done. ✅\n",
143
+ ];
144
+ }
145
+
146
+ /** The `[mock:<scenario>]` directive in the turn's user text, if any. */
147
+ export function mockScenarioFrom(text: string): MockScenario | null {
148
+ const match = /\[mock:([a-z]+)\]/.exec(text);
149
+ const name = match?.[1];
150
+ return (MOCK_SCENARIOS as readonly string[]).includes(name ?? "")
151
+ ? (name as MockScenario)
152
+ : null;
153
+ }
154
+
155
+ /**
156
+ * Which step of a scripted scenario this doStream call is: the number of tool
157
+ * RESULTS since the last user message. Step 0 emits the scenario's first tool
158
+ * call(s); each tool result advances the script — so a step that emitted two
159
+ * parallel calls advances by two once both resolve, regardless of whether the
160
+ * harness delivers the results as one tool message or several.
161
+ */
162
+ export function scriptStepFrom(prompt: LanguageModelV4Prompt): number {
163
+ let step = 0;
164
+ for (const message of prompt) {
165
+ if (message.role === "user") step = 0;
166
+ else if (message.role === "tool") {
167
+ step += message.content.filter((part) => part.type === "tool-result").length;
168
+ }
169
+ }
170
+ return step;
171
+ }
172
+
173
+ /** The last user message's plain text, for topic echo + directive parsing. */
174
+ export function lastUserTextFrom(prompt: LanguageModelV4Prompt): string | undefined {
175
+ return [...prompt]
176
+ .reverse()
177
+ .flatMap((message) =>
178
+ message.role === "user"
179
+ ? message.content.flatMap((part) => (part.type === "text" ? [part.text] : []))
180
+ : [],
181
+ )[0];
182
+ }
183
+
184
+ function askQuestionCall(prompt: string, topic: string): MockToolCall {
185
+ return {
186
+ toolName: "ask_question",
187
+ input: {
188
+ prompt,
189
+ options: [
190
+ {
191
+ id: "ship",
192
+ label: `Ship the ${topic}`,
193
+ style: "primary",
194
+ description: "Proceed with the happy path.",
195
+ },
196
+ { id: "hold", label: "Hold for review" },
197
+ { id: "abort", label: "Abort the run", style: "danger", description: "Stops everything." },
198
+ ],
199
+ allowFreeform: true,
200
+ },
201
+ };
202
+ }
203
+
204
+ /** The scripted action for a scenario at a step; `text` actions end the turn. */
205
+ export function scriptActionFor(
206
+ scenario: MockScriptedScenario,
207
+ step: number,
208
+ delegateToolName = "task_fast",
209
+ ): MockScriptAction {
210
+ switch (scenario) {
211
+ case "hitl":
212
+ if (step === 0) {
213
+ return {
214
+ kind: "tool-calls",
215
+ calls: [askQuestionCall("Mock HITL: how should this test proceed?", "change")],
216
+ };
217
+ }
218
+ return { kind: "text", text: "Answer received — the mock turn resumed and finished cleanly." };
219
+ case "parallel":
220
+ // Two ask_question calls in ONE response: both should pend on a single
221
+ // park, and one respond covering both should resume the turn (the
222
+ // batching contract rib's live parallel-questions eval pins). The step
223
+ // counts tool RESULTS, so the wrap-up must not claim both answers
224
+ // arrived until two results are actually in the prompt — a harness that
225
+ // resumes after a partial answer gets an honest early-exit text instead.
226
+ if (step === 0) {
227
+ return {
228
+ kind: "tool-calls",
229
+ calls: [
230
+ askQuestionCall("Mock parallel HITL (1 of 2): which color?", "color"),
231
+ askQuestionCall("Mock parallel HITL (2 of 2): which size?", "size"),
232
+ ],
233
+ };
234
+ }
235
+ if (step === 1) {
236
+ return {
237
+ kind: "text",
238
+ text: "Only one answer arrived — the parallel HITL scenario ended without the second.",
239
+ };
240
+ }
241
+ return {
242
+ kind: "text",
243
+ text: "Both answers received — the parallel HITL scenario finished cleanly.",
244
+ };
245
+ case "todo":
246
+ if (step === 0) {
247
+ return {
248
+ kind: "tool-calls",
249
+ calls: [
250
+ {
251
+ toolName: "todo",
252
+ input: {
253
+ todos: [
254
+ { content: "Survey the harbor charts", status: "completed", priority: "high" },
255
+ { content: "Polish the tower glass", status: "in_progress", priority: "medium" },
256
+ { content: "Refill the oil reserves", status: "pending", priority: "medium" },
257
+ { content: "Log the evening tide", status: "pending", priority: "low" },
258
+ ],
259
+ },
260
+ },
261
+ ],
262
+ };
263
+ }
264
+ if (step === 1) {
265
+ return {
266
+ kind: "tool-calls",
267
+ calls: [
268
+ {
269
+ toolName: "todo",
270
+ input: {
271
+ todos: [
272
+ { content: "Survey the harbor charts", status: "completed", priority: "high" },
273
+ { content: "Polish the tower glass", status: "completed", priority: "medium" },
274
+ { content: "Refill the oil reserves", status: "in_progress", priority: "medium" },
275
+ { content: "Log the evening tide", status: "cancelled", priority: "low" },
276
+ ],
277
+ },
278
+ },
279
+ ],
280
+ };
281
+ }
282
+ return { kind: "text", text: "Todo list written and updated — checklist scenario complete." };
283
+ case "delegate":
284
+ if (step === 0) {
285
+ return {
286
+ kind: "tool-calls",
287
+ calls: [
288
+ {
289
+ toolName: delegateToolName,
290
+ input: {
291
+ message:
292
+ "Mock delegation: describe the lighthouse keeper's routine. Reply with a short report.",
293
+ },
294
+ },
295
+ ],
296
+ };
297
+ }
298
+ return { kind: "text", text: "The delegate reported back — delegation scenario complete." };
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Split a tool call's JSON input into small fragments, the way real models
304
+ * stream tool arguments — so arg-streaming renderers see many partial-JSON
305
+ * deltas, not one complete blob.
306
+ */
307
+ export function toolInputFragments(inputJson: string, fragmentSize = 24): readonly string[] {
308
+ if (inputJson.length === 0) return [];
309
+ const fragments: string[] = [];
310
+ for (let i = 0; i < inputJson.length; i += fragmentSize) {
311
+ fragments.push(inputJson.slice(i, i + fragmentSize));
312
+ }
313
+ return fragments;
314
+ }
315
+
316
+ // --- The model ---------------------------------------------------------------
317
+
318
+ export function createMockStoryModel(options: MockStoryModelOptions = {}): LanguageModelV4 {
319
+ const chunkCount = options.chunkCount ?? 240;
320
+ const chunkDelayMs = options.chunkDelayMs ?? 250;
321
+ const burstChunks = options.burstChunks ?? 600;
322
+ const delegateToolName = options.delegateToolName ?? "task_fast";
323
+ const now = options.now ?? Date.now;
324
+ return {
325
+ specificationVersion: "v4",
326
+ // eve's compaction compiler requires context-window metadata, which it
327
+ // resolves for provider instances via `formatLanguageModelGatewayId`
328
+ // (provider + modelId → an AI Gateway catalog slug). Borrow a real
329
+ // catalog identity so the mock compiles; routing stays "external", so no
330
+ // request ever reaches Anthropic.
331
+ provider: "anthropic",
332
+ modelId: "claude-sonnet-4-6",
333
+ supportedUrls: {},
334
+ async doGenerate() {
335
+ return {
336
+ content: [{ type: "text" as const, text: "(mock model, non-streaming reply)" }],
337
+ finishReason: { unified: "stop" as const, raw: "stop" },
338
+ usage: usageFor(10),
339
+ warnings: [],
340
+ };
341
+ },
342
+ async doStream(callOptions: LanguageModelV4CallOptions) {
343
+ const abortSignal = callOptions.abortSignal;
344
+ const lastUserText = lastUserTextFrom(callOptions.prompt);
345
+ const scenario = mockScenarioFrom(lastUserText ?? "");
346
+ const step = scriptStepFrom(callOptions.prompt);
347
+ // Echo the asking prompt into the output so parallel chats render
348
+ // distinguishable transcripts (the canned sentences repeat otherwise).
349
+ const topic = (lastUserText ?? "an untitled request").slice(0, 60);
350
+ const stream = new ReadableStream<LanguageModelV4StreamPart>({
351
+ async start(controller) {
352
+ controller.enqueue({ type: "stream-start", warnings: [] });
353
+ controller.enqueue({
354
+ type: "response-metadata",
355
+ id: `mock-${now()}`,
356
+ modelId: "claude-sonnet-4-6",
357
+ timestamp: new Date(now()),
358
+ });
359
+
360
+ if (scenario === "fail") {
361
+ controller.enqueue({ type: "text-start", id: "t1" });
362
+ for (let i = 0; i < 6; i++) {
363
+ if (abortSignal?.aborted) break;
364
+ await delay(chunkDelayMs);
365
+ controller.enqueue({ type: "text-delta", id: "t1", delta: storyChunk(i) });
366
+ }
367
+ controller.enqueue({ type: "text-end", id: "t1" });
368
+ // A terminal stream failure mid-turn: the deterministic trigger
369
+ // for the failed-turn notice / error rail state in both cockpits.
370
+ controller.enqueue({
371
+ type: "error",
372
+ error: new Error("mock: injected mid-stream failure [mock:fail]"),
373
+ });
374
+ controller.close();
375
+ return;
376
+ }
377
+
378
+ if (scenario === "burst") {
379
+ controller.enqueue({ type: "text-start", id: "t1" });
380
+ controller.enqueue({
381
+ type: "text-delta",
382
+ id: "t1",
383
+ delta: `**Burst: ${burstChunks} deltas, no pacing.**\n\n`,
384
+ });
385
+ for (let i = 0; i < burstChunks; i++) {
386
+ if (abortSignal?.aborted) break;
387
+ controller.enqueue({
388
+ type: "text-delta",
389
+ id: "t1",
390
+ delta: i % 8 === 0 ? `${storyChunk(i)} [¶${i / 8 + 1}]` : storyChunk(i),
391
+ });
392
+ }
393
+ controller.enqueue({ type: "text-delta", id: "t1", delta: "\n\n**Burst done.**" });
394
+ controller.enqueue({ type: "text-end", id: "t1" });
395
+ controller.enqueue({
396
+ type: "finish",
397
+ finishReason: { unified: "stop", raw: "stop" },
398
+ usage: usageFor(burstChunks * 12),
399
+ });
400
+ controller.close();
401
+ return;
402
+ }
403
+
404
+ if (scenario === "markdown") {
405
+ controller.enqueue({ type: "text-start", id: "t1" });
406
+ for (const chunk of markdownChunks()) {
407
+ if (abortSignal?.aborted) break;
408
+ await delay(Math.min(chunkDelayMs, 120));
409
+ controller.enqueue({ type: "text-delta", id: "t1", delta: chunk });
410
+ }
411
+ controller.enqueue({ type: "text-end", id: "t1" });
412
+ controller.enqueue({
413
+ type: "finish",
414
+ finishReason: { unified: "stop", raw: "stop" },
415
+ usage: usageFor(400),
416
+ });
417
+ controller.close();
418
+ return;
419
+ }
420
+
421
+ if (scenario === "interleave") {
422
+ // Reasoning and text alternate as SEPARATE blocks in one message —
423
+ // the shape extended-thinking models actually stream. Renderers
424
+ // must segment multiple reasoning/text parts, not assume one each.
425
+ const blocks: readonly { kind: "reasoning" | "text"; id: string; text: string }[] = [
426
+ {
427
+ kind: "reasoning",
428
+ id: "r1",
429
+ text: "First thought: check the tide tables before anything else.",
430
+ },
431
+ {
432
+ kind: "text",
433
+ id: "t1",
434
+ text: "The tide tables say low water at dusk.\n\nThat changes the plan.",
435
+ },
436
+ {
437
+ kind: "reasoning",
438
+ id: "r2",
439
+ text: "Second thought: the bell only rings when the fog is thick.",
440
+ },
441
+ {
442
+ kind: "text",
443
+ id: "t2",
444
+ text: "So the keeper waits for the bell — interleave scenario complete.",
445
+ },
446
+ ];
447
+ for (const block of blocks) {
448
+ const startType = block.kind === "reasoning" ? "reasoning-start" : "text-start";
449
+ const deltaType = block.kind === "reasoning" ? "reasoning-delta" : "text-delta";
450
+ const endType = block.kind === "reasoning" ? "reasoning-end" : "text-end";
451
+ controller.enqueue({ type: startType, id: block.id });
452
+ for (const word of block.text.split(" ")) {
453
+ if (abortSignal?.aborted) break;
454
+ await delay(Math.min(chunkDelayMs, 80));
455
+ controller.enqueue({ type: deltaType, id: block.id, delta: `${word} ` });
456
+ }
457
+ controller.enqueue({ type: endType, id: block.id });
458
+ if (abortSignal?.aborted) break;
459
+ }
460
+ controller.enqueue({
461
+ type: "finish",
462
+ finishReason: { unified: "stop", raw: "stop" },
463
+ usage: usageFor(120),
464
+ });
465
+ controller.close();
466
+ return;
467
+ }
468
+
469
+ if (scenario === "empty") {
470
+ // A completion with zero content parts: models do occasionally
471
+ // return nothing, and empty assistant messages have broken
472
+ // renderers before. The stream is still grammatical.
473
+ controller.enqueue({
474
+ type: "finish",
475
+ finishReason: { unified: "stop", raw: "stop" },
476
+ usage: usageFor(0),
477
+ });
478
+ controller.close();
479
+ return;
480
+ }
481
+
482
+ if (scenario !== null) {
483
+ const action = scriptActionFor(scenario, step, delegateToolName);
484
+ // A short reasoning burst before each scripted step, so the
485
+ // Thinking… display renders too.
486
+ controller.enqueue({ type: "reasoning-start", id: "r1" });
487
+ for (const word of `Scripted ${scenario} scenario, step ${step}: deciding the next move.`.split(" ")) {
488
+ if (abortSignal?.aborted) break;
489
+ await delay(chunkDelayMs);
490
+ controller.enqueue({ type: "reasoning-delta", id: "r1", delta: `${word} ` });
491
+ }
492
+ controller.enqueue({ type: "reasoning-end", id: "r1" });
493
+
494
+ if (action.kind === "tool-calls") {
495
+ for (const [callIndex, call] of action.calls.entries()) {
496
+ const toolCallId = `mock-call-${scenario}-${step}-${callIndex}`;
497
+ const inputJson = JSON.stringify(call.input);
498
+ controller.enqueue({
499
+ type: "tool-input-start",
500
+ id: toolCallId,
501
+ toolName: call.toolName,
502
+ });
503
+ // Fragmented like a real model streams tool arguments —
504
+ // arg-streaming renderers see partial JSON, not one blob.
505
+ for (const fragment of toolInputFragments(inputJson)) {
506
+ if (abortSignal?.aborted) break;
507
+ await delay(Math.min(chunkDelayMs, 80));
508
+ controller.enqueue({ type: "tool-input-delta", id: toolCallId, delta: fragment });
509
+ }
510
+ controller.enqueue({ type: "tool-input-end", id: toolCallId });
511
+ controller.enqueue({
512
+ type: "tool-call",
513
+ toolCallId,
514
+ toolName: call.toolName,
515
+ input: inputJson,
516
+ });
517
+ }
518
+ controller.enqueue({
519
+ type: "finish",
520
+ finishReason: { unified: "tool-calls", raw: "tool_use" },
521
+ usage: usageFor(50 * action.calls.length),
522
+ });
523
+ controller.close();
524
+ return;
525
+ }
526
+ controller.enqueue({ type: "text-start", id: "t1" });
527
+ for (const word of action.text.split(" ")) {
528
+ if (abortSignal?.aborted) break;
529
+ await delay(chunkDelayMs);
530
+ controller.enqueue({ type: "text-delta", id: "t1", delta: `${word} ` });
531
+ }
532
+ controller.enqueue({ type: "text-end", id: "t1" });
533
+ controller.enqueue({
534
+ type: "finish",
535
+ finishReason: { unified: "stop", raw: "stop" },
536
+ usage: usageFor(action.text.length),
537
+ });
538
+ controller.close();
539
+ return;
540
+ }
541
+
542
+ controller.enqueue({ type: "text-start", id: "t1" });
543
+ controller.enqueue({
544
+ type: "text-delta",
545
+ id: "t1",
546
+ delta: `**Story for: "${topic}…"**\n\n`,
547
+ });
548
+ for (let i = 0; i < chunkCount; i++) {
549
+ if (abortSignal?.aborted) break;
550
+ await delay(chunkDelayMs);
551
+ controller.enqueue({
552
+ type: "text-delta",
553
+ id: "t1",
554
+ delta:
555
+ i % 8 === 0
556
+ ? `${storyChunk(i)} [${topic.slice(0, 12)}… ¶${i / 8 + 1}]`
557
+ : storyChunk(i),
558
+ });
559
+ }
560
+ controller.enqueue({ type: "text-end", id: "t1" });
561
+ controller.enqueue({
562
+ type: "finish",
563
+ finishReason: { unified: "stop", raw: "stop" },
564
+ usage: usageFor(chunkCount * 12),
565
+ });
566
+ controller.close();
567
+ },
568
+ });
569
+ return { stream };
570
+ },
571
+ };
572
+ }