@tanstack/ai-claude-code 0.3.1 → 0.4.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,5 +1,8 @@
1
1
  import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
2
- import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
2
+ import {
3
+ appendOutputSchemaInstruction,
4
+ toRunErrorRawEvent,
5
+ } from '@tanstack/ai/adapter-internals'
3
6
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
4
7
  import {
5
8
  SandboxCapability,
@@ -26,6 +29,11 @@ import { buildPrompt } from '../messages/prompt'
26
29
  import { translateSdkStream } from '../stream/translate'
27
30
  import { mapPolicyToClaudeFlags } from './policy-map'
28
31
  import { projectClaudeWorkspace } from './projection'
32
+ import {
33
+ CLAUDE_JSON_SCHEMA_PLACEHOLDER,
34
+ CLAUDE_RUNNER_SOURCE,
35
+ } from './claude-run-source'
36
+
29
37
  import type { ClaudePolicyFlags } from './policy-map'
30
38
  import type {
31
39
  BridgeEventChannel,
@@ -89,6 +97,12 @@ export interface ClaudeCodeTextConfig {
89
97
  streamPartials?: boolean
90
98
  /** Extra environment variables for the claude process inside the sandbox. */
91
99
  env?: Record<string, string>
100
+ /**
101
+ * `'api-key'` (default) injects `ANTHROPIC_API_KEY`.
102
+ * `'host'` uses `claude login` and does not inject the key.
103
+ * Not inferred from the sandbox.
104
+ */
105
+ authMode?: 'host' | 'api-key'
92
106
  /** Emit a `file.changed` CUSTOM event with the git diff after the run (default true). */
93
107
  emitDiff?: boolean
94
108
  }
@@ -98,6 +112,27 @@ function q(value: string): string {
98
112
  return `'${value.replace(/'/g, `'\\''`)}'`
99
113
  }
100
114
 
115
+ /** Copy host Anthropic auth into the sandbox process. Docker `exec` Env replaces the container env, so a key set only at create time can vanish. */
116
+ function hostClaudeAuthEnv(): Record<string, string> {
117
+ const env: Record<string, string> = {}
118
+ const apiKey = process.env.ANTHROPIC_API_KEY
119
+ if (apiKey) env.ANTHROPIC_API_KEY = apiKey
120
+ const authToken = process.env.ANTHROPIC_AUTH_TOKEN
121
+ if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken
122
+ return env
123
+ }
124
+
125
+ /**
126
+ * Windows Node often has USERPROFILE but no HOME. Claude then cannot find
127
+ * `~/.claude.json` (the `claude login` file) and prints "Not logged in".
128
+ */
129
+ function localProcessHomeEnv(provider: string): Record<string, string> {
130
+ if (provider !== 'local-process') return {}
131
+ if (process.env.HOME) return {}
132
+ const home = process.env.USERPROFILE
133
+ return home ? { HOME: home } : {}
134
+ }
135
+
101
136
  /** Format a host tool-bridge as claude's `--mcp-config` JSON. */
102
137
  function bridgeToMcpConfig(bridge: HostToolBridge): string {
103
138
  return JSON.stringify({
@@ -153,56 +188,71 @@ export class ClaudeCodeTextAdapter<
153
188
  )
154
189
  }
155
190
 
191
+ supportsCombinedToolsAndSchema(): boolean {
192
+ return true
193
+ }
194
+
195
+ combinedStructuredOutputSource(): 'event' {
196
+ return 'event'
197
+ }
198
+
156
199
  /** Build the `claude` command line (prompt goes via stdin, not argv). */
157
- private buildCommand(
200
+ private buildArgv(
158
201
  options: TextOptions<ClaudeCodeTextProviderOptions>,
159
202
  resume: string | undefined,
160
203
  policyFlags: ClaudePolicyFlags,
161
204
  mcpConfigPath: string | undefined,
162
205
  permissionPromptTool: string | undefined,
163
- ): string {
206
+ hasJsonSchema: boolean,
207
+ ): Array<string> {
164
208
  const config = this.adapterConfig
165
209
  const modelOptions = options.modelOptions
166
- const exe = config.claudeExecutable ?? 'claude'
210
+ const exeParts = (config.claudeExecutable ?? 'claude').split(' ')
167
211
 
212
+ // `--setting-sources user` before `-p`. Do not pass `--bare`: that flag
213
+ // skips stored `claude login` credentials and prints
214
+ // "Not logged in · Please run /login" (claude-code#51047).
215
+ // `-p` can take the next token as the prompt, so these flags stay first.
168
216
  const args: Array<string> = [
217
+ ...exeParts,
218
+ '--setting-sources',
219
+ 'user',
169
220
  '-p',
170
221
  '--output-format',
171
222
  'stream-json',
172
223
  '--verbose',
173
224
  '--model',
174
- q(this.model),
225
+ this.model,
175
226
  ]
176
227
 
177
228
  if (config.streamPartials !== false) args.push('--include-partial-messages')
178
- if (resume !== undefined) args.push('--resume', q(resume))
229
+ if (resume !== undefined) args.push('--resume', resume)
179
230
 
180
- // Precedence: per-call modelOptions > adapter config > policy > sandbox default.
181
231
  const permissionMode =
182
232
  modelOptions?.permissionMode ??
183
233
  config.permissionMode ??
184
234
  policyFlags.permissionMode ??
185
235
  'bypassPermissions'
186
- args.push('--permission-mode', q(permissionMode))
236
+ args.push('--permission-mode', permissionMode)
187
237
 
188
238
  const maxTurns = modelOptions?.maxTurns ?? config.maxTurns
189
239
  if (maxTurns !== undefined) args.push('--max-turns', String(maxTurns))
190
240
 
191
- for (const dir of config.addDirs ?? []) args.push('--add-dir', q(dir))
241
+ for (const dir of config.addDirs ?? []) args.push('--add-dir', dir)
192
242
 
193
243
  const allowedTools = [
194
244
  ...(modelOptions?.allowedTools ?? config.allowedTools ?? []),
195
245
  ...policyFlags.allowedTools,
196
246
  ]
197
247
  if (allowedTools.length > 0) {
198
- args.push('--allowedTools', q([...new Set(allowedTools)].join(',')))
248
+ args.push('--allowedTools', [...new Set(allowedTools)].join(','))
199
249
  }
200
250
  const disallowedTools = [
201
251
  ...(modelOptions?.disallowedTools ?? config.disallowedTools ?? []),
202
252
  ...policyFlags.disallowedTools,
203
253
  ]
204
254
  if (disallowedTools.length > 0) {
205
- args.push('--disallowedTools', q([...new Set(disallowedTools)].join(',')))
255
+ args.push('--disallowedTools', [...new Set(disallowedTools)].join(','))
206
256
  }
207
257
 
208
258
  const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
@@ -214,15 +264,18 @@ export class ClaudeCodeTextAdapter<
214
264
  config.systemPromptMode === 'replace'
215
265
  ? '--system-prompt'
216
266
  : '--append-system-prompt'
217
- args.push(flag, q(joined))
267
+ args.push(flag, joined)
218
268
  }
219
269
 
220
- if (mcpConfigPath !== undefined) args.push('--mcp-config', q(mcpConfigPath))
270
+ if (mcpConfigPath !== undefined) args.push('--mcp-config', mcpConfigPath)
271
+ if (hasJsonSchema) {
272
+ args.push('--json-schema', CLAUDE_JSON_SCHEMA_PLACEHOLDER)
273
+ }
221
274
  if (permissionPromptTool !== undefined) {
222
- args.push('--permission-prompt-tool', q(permissionPromptTool))
275
+ args.push('--permission-prompt-tool', permissionPromptTool)
223
276
  }
224
277
 
225
- return `${exe} ${args.join(' ')}`
278
+ return args
226
279
  }
227
280
 
228
281
  /**
@@ -308,6 +361,7 @@ export class ClaudeCodeTextAdapter<
308
361
  const sandbox = this.sandboxFrom(options)
309
362
  cleanupSandbox = sandbox
310
363
  const cwd = this.workdir(options)
364
+
311
365
  // Durability comes from `withSandbox(sandbox, { runs, durability })`, read
312
366
  // back off the capability bus. Absent it, everything below resolves to
313
367
  // exactly today's behavior (no journal option, no alignment, and a
@@ -390,10 +444,15 @@ export class ClaudeCodeTextAdapter<
390
444
  })
391
445
  }
392
446
 
393
- const { prompt, resume } = buildPrompt(
447
+ const built = buildPrompt(
394
448
  options.messages,
395
449
  options.modelOptions?.sessionId,
396
450
  )
451
+ const resume = built.resume
452
+ const prompt =
453
+ options.outputSchema !== undefined
454
+ ? appendOutputSchemaInstruction(built.prompt, options.outputSchema)
455
+ : built.prompt
397
456
  // Both files below name themselves after `runId`, and durability makes
398
457
  // `runId` CALLER-chosen. Raw, a `/` in it would silently turn each basename
399
458
  // into a nested path (writing outside the intended directory, or failing on
@@ -421,7 +480,17 @@ export class ClaudeCodeTextAdapter<
421
480
  tempFiles.push(mcpConfigPath)
422
481
  mcpConfigArg = mcpConfigFile
423
482
  }
424
- const command = this.buildCommand(
483
+ let jsonSchemaFile: string | undefined
484
+ if (options.outputSchema !== undefined) {
485
+ jsonSchemaFile = `tanstack-output-schema-${runIdSegment}.json`
486
+ const schemaPath = `${cwd}/${jsonSchemaFile}`
487
+ await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema))
488
+ tempFiles.push(schemaPath)
489
+ }
490
+ const runnerFile = `tanstack-claude-run-${runIdSegment}.mjs`
491
+ await sandbox.fs.write(`${cwd}/${runnerFile}`, CLAUDE_RUNNER_SOURCE)
492
+ tempFiles.push(`${cwd}/${runnerFile}`)
493
+ const argv = this.buildArgv(
425
494
  options,
426
495
  resume,
427
496
  mapPolicyToClaudeFlags(policy),
@@ -429,7 +498,17 @@ export class ClaudeCodeTextAdapter<
429
498
  bridge && permission
430
499
  ? `mcp__${bridge.name}__${permission.toolName}`
431
500
  : undefined,
501
+ jsonSchemaFile !== undefined,
432
502
  )
503
+ // Filenames only on the shell line. JSON (schema, system prompt, MCP
504
+ // config path) lives in the argv file so git-bash cannot retokenize it.
505
+ const argvFile = `tanstack-claude-argv-${runIdSegment}.json`
506
+ await sandbox.fs.write(`${cwd}/${argvFile}`, JSON.stringify(argv))
507
+ tempFiles.push(`${cwd}/${argvFile}`)
508
+ const command =
509
+ jsonSchemaFile === undefined
510
+ ? `node ${q(runnerFile)} ${q(argvFile)}`
511
+ : `node ${q(runnerFile)} ${q(argvFile)} ${q(jsonSchemaFile)}`
433
512
 
434
513
  // Deliver the prompt. The default feeds it over stdin (keeps it out of
435
514
  // argv). Providers without a writable host→process stdin (e.g. Cloudflare)
@@ -450,17 +529,31 @@ export class ClaudeCodeTextAdapter<
450
529
  { provider: 'claude-code', model: this.model },
451
530
  )
452
531
 
532
+ const authMode =
533
+ options.modelOptions?.authMode ??
534
+ this.adapterConfig.authMode ??
535
+ 'api-key'
536
+ const injectApiKey = authMode === 'api-key'
537
+
453
538
  const journalOptions = journalOptionsFor(durability, runId)
454
539
  const rawEvents = spawnNdjson(sandbox, runCommand, {
455
540
  cwd,
456
541
  ...(stdinInput !== undefined ? { input: stdinInput } : {}),
457
- // claude maps `bypassPermissions` to `--dangerously-skip-permissions`,
458
- // which it refuses to run as root. Sandbox containers routinely run as
459
- // root (Docker / Cloudflare), so set `IS_SANDBOX=1` — claude's
460
- // documented escape hatch for skip-permissions in an isolated
461
- // environment — merged over the sandbox env (a caller-provided value
462
- // wins). Safe to set unconditionally; it is a no-op for stricter modes.
463
- env: { IS_SANDBOX: '1', ...this.adapterConfig.env },
542
+ // Isolated sandboxes often run as root. Claude refuses
543
+ // `--dangerously-skip-permissions` as root unless IS_SANDBOX=1.
544
+ // CLAUDE_CODE_SANDBOXED marks a real isolation boundary. Do not set
545
+ // either on local-process: that provider runs on the host.
546
+ env: {
547
+ ...(sandbox.provider === 'local-process'
548
+ ? {}
549
+ : {
550
+ IS_SANDBOX: '1',
551
+ CLAUDE_CODE_SANDBOXED: '1',
552
+ }),
553
+ ...(injectApiKey ? hostClaudeAuthEnv() : {}),
554
+ ...localProcessHomeEnv(sandbox.provider),
555
+ ...this.adapterConfig.env,
556
+ },
464
557
  ...(options.abortController?.signal
465
558
  ? { signal: options.abortController.signal }
466
559
  : options.request?.signal
@@ -509,6 +602,7 @@ export class ClaudeCodeTextAdapter<
509
602
  // which mixes in `Date.now()` / `Math.random()`). See
510
603
  // `createRunScopedIdGen` in `@tanstack/ai-sandbox`.
511
604
  genId: createRunScopedIdGen(runId),
605
+ ...(options.outputSchema ? { expectStructuredOutput: true } : {}),
512
606
  onSdkMessage: (message) =>
513
607
  logger.provider(`provider=claude-code type=${message.type}`, {
514
608
  chunk: message,
@@ -585,8 +679,8 @@ export class ClaudeCodeTextAdapter<
585
679
  ): Promise<StructuredOutputResult<unknown>> {
586
680
  return Promise.reject(
587
681
  new Error(
588
- 'Structured output is not yet supported by the in-sandbox Claude Code adapter. ' +
589
- 'Use a model adapter (e.g. anthropic) for structured output, or omit outputSchema.',
682
+ 'This harness honors outputSchema on chat() in the same turn. ' +
683
+ 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.',
590
684
  ),
591
685
  )
592
686
  }
@@ -27,4 +27,10 @@ export interface ClaudeCodeTextProviderOptions {
27
27
  disallowedTools?: Array<string>
28
28
  /** Per-call override of the harness working directory. */
29
29
  cwd?: string
30
+ /**
31
+ * `'api-key'` (default) injects `ANTHROPIC_API_KEY`.
32
+ * `'host'` uses `claude login`.
33
+ * Not inferred from the sandbox.
34
+ */
35
+ authMode?: 'host' | 'api-key'
30
36
  }
@@ -60,12 +60,22 @@ export type SdkRawStreamEvent =
60
60
  | {
61
61
  type: 'content_block_start'
62
62
  index: number
63
- content_block: { type: string }
63
+ content_block: {
64
+ type: string
65
+ id?: string
66
+ name?: string
67
+ input?: unknown
68
+ }
64
69
  }
65
70
  | {
66
71
  type: 'content_block_delta'
67
72
  index: number
68
- delta: { type: string; text?: string; thinking?: string }
73
+ delta: {
74
+ type: string
75
+ text?: string
76
+ thinking?: string
77
+ partial_json?: string
78
+ }
69
79
  }
70
80
  | { type: 'content_block_stop'; index: number }
71
81
  | { type: 'message_delta' }
@@ -1,4 +1,9 @@
1
1
  import { EventType, buildBaseUsage } from '@tanstack/ai'
2
+ import {
3
+ parseJsonFromAssistantText,
4
+ structuredOutputCompleteChunk,
5
+ structuredOutputStartChunk,
6
+ } from '@tanstack/ai/adapter-internals'
2
7
  import type { StreamChunk, TokenUsage } from '@tanstack/ai'
3
8
  import type {
4
9
  AgentSdkMessage,
@@ -18,6 +23,13 @@ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
18
23
 
19
24
  const BRIDGED_MCP_PREFIX = `mcp__${BRIDGED_MCP_SERVER_NAME}__`
20
25
 
26
+ /**
27
+ * Claude Code `--json-schema` injects a fake tool named StructuredOutput.
28
+ * The model "calls" it with the schema JSON. The result message may omit
29
+ * `structured_output`; harvest the tool input in that case.
30
+ */
31
+ export const SYNTHETIC_STRUCTURED_OUTPUT_TOOL = 'StructuredOutput'
32
+
21
33
  /** Claude Code-specific usage details attached to RUN_FINISHED usage. */
22
34
  export type ClaudeCodeProviderUsageDetails = {
23
35
  /** Total cost of the harness run in USD, as reported by Claude Code. */
@@ -34,6 +46,8 @@ export interface TranslateContext {
34
46
  onSessionId?: (sessionId: string) => void
35
47
  /** Called for each raw SDK message, for logging. */
36
48
  onSdkMessage?: (message: AgentSdkMessage) => void
49
+ /** Emit structured-output events from `result.structured_output`. */
50
+ expectStructuredOutput?: boolean
37
51
  }
38
52
 
39
53
  /**
@@ -47,6 +61,19 @@ export function stripMcpPrefix(name: string): string {
47
61
  : name
48
62
  }
49
63
 
64
+ function isUsefulStructuredObject(value: unknown): boolean {
65
+ return (
66
+ value !== null &&
67
+ typeof value === 'object' &&
68
+ !Array.isArray(value) &&
69
+ Object.keys(value).length > 0
70
+ )
71
+ }
72
+
73
+ function rememberStructuredOutput(current: unknown, next: unknown): unknown {
74
+ return isUsefulStructuredObject(next) ? next : current
75
+ }
76
+
50
77
  function stringifyToolResultContent(
51
78
  content: SdkToolResultContent | undefined,
52
79
  ): string {
@@ -109,6 +136,11 @@ export async function* translateSdkStream(
109
136
  let runStarted = false
110
137
  /** Tool calls started but with no result yet. */
111
138
  const unresolvedToolCalls = new Set<string>()
139
+ const syntheticOutputToolIds = new Set<string>()
140
+ let capturedStructuredOutput: unknown
141
+ let assistantTextForHarvest = ''
142
+ let partialStructuredJson = ''
143
+ let partialIsStructuredOutput = false
112
144
  /** Anthropic message ids whose text/thinking already streamed via partials. */
113
145
  const streamedMessageIds = new Set<string>()
114
146
 
@@ -179,12 +211,42 @@ export async function* translateSdkStream(
179
211
  partialReasoningId = null
180
212
  }
181
213
 
214
+ function* emitStructuredOutput(object: unknown): Generator<StreamChunk> {
215
+ const raw = JSON.stringify(object)
216
+ const messageId = genId()
217
+ yield structuredOutputStartChunk({
218
+ messageId,
219
+ model,
220
+ threadId,
221
+ runId,
222
+ })
223
+ yield structuredOutputCompleteChunk({
224
+ messageId,
225
+ model,
226
+ threadId,
227
+ runId,
228
+ object,
229
+ raw,
230
+ })
231
+ }
232
+
182
233
  function* emitToolUse(block: {
183
234
  id: string
184
235
  name: string
185
236
  input: unknown
186
237
  }): Generator<StreamChunk> {
187
238
  const toolCallName = stripMcpPrefix(block.name)
239
+ if (
240
+ ctx.expectStructuredOutput === true &&
241
+ toolCallName === SYNTHETIC_STRUCTURED_OUTPUT_TOOL
242
+ ) {
243
+ capturedStructuredOutput = rememberStructuredOutput(
244
+ capturedStructuredOutput,
245
+ block.input,
246
+ )
247
+ syntheticOutputToolIds.add(block.id)
248
+ return
249
+ }
188
250
  const args = JSON.stringify(block.input ?? {})
189
251
  yield {
190
252
  type: EventType.TOOL_CALL_START,
@@ -226,6 +288,7 @@ export async function* translateSdkStream(
226
288
  if (alreadyStreamed) continue
227
289
  const messageId = message.message.id ?? genId()
228
290
  const text = (block as { text: string }).text
291
+ if (!alreadyStreamed) assistantTextForHarvest += text
229
292
  yield {
230
293
  type: EventType.TEXT_MESSAGE_START,
231
294
  messageId,
@@ -301,6 +364,10 @@ export async function* translateSdkStream(
301
364
  content?: SdkToolResultContent
302
365
  is_error?: boolean
303
366
  }
367
+ if (syntheticOutputToolIds.has(toolResult.tool_use_id)) {
368
+ syntheticOutputToolIds.delete(toolResult.tool_use_id)
369
+ continue
370
+ }
304
371
  unresolvedToolCalls.delete(toolResult.tool_use_id)
305
372
  yield {
306
373
  type: EventType.TOOL_CALL_RESULT,
@@ -320,6 +387,29 @@ export async function* translateSdkStream(
320
387
  yield* synthesizeUnresolvedResults()
321
388
 
322
389
  const usage = buildUsage(message.usage, message.total_cost_usd)
390
+ if (ctx.expectStructuredOutput === true) {
391
+ const fromResult = isUsefulStructuredObject(message.structured_output)
392
+ ? message.structured_output
393
+ : undefined
394
+ const fromTool = isUsefulStructuredObject(capturedStructuredOutput)
395
+ ? capturedStructuredOutput
396
+ : undefined
397
+ let fromText: unknown
398
+ if (fromResult === undefined && fromTool === undefined) {
399
+ const raw = assistantTextForHarvest || message.result || ''
400
+ if (raw.trim() !== '') {
401
+ try {
402
+ fromText = parseJsonFromAssistantText(raw)
403
+ } catch {
404
+ fromText = undefined
405
+ }
406
+ }
407
+ }
408
+ const object = fromResult ?? fromTool ?? fromText
409
+ if (isUsefulStructuredObject(object)) {
410
+ yield* emitStructuredOutput(object)
411
+ }
412
+ }
323
413
  if (message.subtype === 'success') {
324
414
  yield {
325
415
  type: EventType.RUN_FINISHED,
@@ -365,6 +455,24 @@ export async function* translateSdkStream(
365
455
  streamedMessageIds.add(partialMessageId)
366
456
  } else if (event.type === 'content_block_start') {
367
457
  partialBlockType = event.content_block.type
458
+ const startedBlock = event.content_block
459
+ partialIsStructuredOutput =
460
+ ctx.expectStructuredOutput === true &&
461
+ startedBlock.type === 'tool_use' &&
462
+ 'name' in startedBlock &&
463
+ startedBlock.name === SYNTHETIC_STRUCTURED_OUTPUT_TOOL
464
+ if (partialIsStructuredOutput) {
465
+ partialStructuredJson = ''
466
+ if ('id' in startedBlock && typeof startedBlock.id === 'string') {
467
+ syntheticOutputToolIds.add(startedBlock.id)
468
+ }
469
+ if ('input' in startedBlock) {
470
+ capturedStructuredOutput = rememberStructuredOutput(
471
+ capturedStructuredOutput,
472
+ startedBlock.input,
473
+ )
474
+ }
475
+ }
368
476
  if (partialBlockType === 'text') {
369
477
  partialTextMessageId = partialMessageId ?? genId()
370
478
  partialTextContent = ''
@@ -402,6 +510,7 @@ export async function* translateSdkStream(
402
510
  typeof event.delta.text === 'string'
403
511
  ) {
404
512
  partialTextContent += event.delta.text
513
+ assistantTextForHarvest += event.delta.text
405
514
  yield {
406
515
  type: EventType.TEXT_MESSAGE_CONTENT,
407
516
  messageId: partialTextMessageId,
@@ -410,6 +519,12 @@ export async function* translateSdkStream(
410
519
  delta: event.delta.text,
411
520
  content: partialTextContent,
412
521
  }
522
+ } else if (
523
+ event.delta.type === 'input_json_delta' &&
524
+ partialIsStructuredOutput &&
525
+ typeof event.delta.partial_json === 'string'
526
+ ) {
527
+ partialStructuredJson += event.delta.partial_json
413
528
  } else if (
414
529
  event.delta.type === 'thinking_delta' &&
415
530
  partialReasoningId &&
@@ -424,12 +539,24 @@ export async function* translateSdkStream(
424
539
  }
425
540
  }
426
541
  } else if (event.type === 'content_block_stop') {
542
+ if (partialIsStructuredOutput && partialStructuredJson !== '') {
543
+ try {
544
+ capturedStructuredOutput = rememberStructuredOutput(
545
+ capturedStructuredOutput,
546
+ JSON.parse(partialStructuredJson),
547
+ )
548
+ } catch {
549
+ // Incomplete JSON; the complete assistant tool_use may still arrive.
550
+ }
551
+ }
427
552
  if (partialBlockType === 'text') {
428
553
  yield* closePartialText()
429
554
  } else if (partialBlockType === 'thinking') {
430
555
  yield* closePartialReasoning()
431
556
  }
432
557
  partialBlockType = null
558
+ partialIsStructuredOutput = false
559
+ partialStructuredJson = ''
433
560
  }
434
561
  }
435
562