@tanstack/openai-base 0.10.15 → 0.11.1

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,4 +1,10 @@
1
- import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
1
+ import {
2
+ EventType,
3
+ fileReferenceFor,
4
+ isFileSource,
5
+ normalizeSystemPrompts,
6
+ unsupportedFileSourceError,
7
+ } from '@tanstack/ai'
2
8
  import { BaseTextAdapter } from '@tanstack/ai/adapters'
3
9
  import {
4
10
  toRunErrorPayload,
@@ -11,6 +17,13 @@ import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
11
17
  import type { StructuredOutputCompatibility } from '../utils/schema-converter'
12
18
  import { buildResponsesUsage } from '../usage'
13
19
  import { convertToolsToResponsesFormat } from './responses-tool-converter'
20
+ import {
21
+ hostedShellCallIds,
22
+ readUserExecutedCall,
23
+ userToolRequestItem,
24
+ userToolResultItem,
25
+ } from './responses-user-tools'
26
+ import type { OpenAIUserToolName } from './responses-user-tools'
14
27
  import type OpenAI from 'openai'
15
28
  import type {
16
29
  StructuredOutputOptions,
@@ -98,7 +111,11 @@ function readReasoningItem(
98
111
  * item ID here so stateless follow-up requests can replay both values.
99
112
  */
100
113
  export interface OpenAIResponsesToolCallMetadata {
101
- itemId: string
114
+ itemId?: string
115
+ /** Set for shell, local_shell, and apply_patch calls the app must run. */
116
+ openaiUserTool?: OpenAIUserToolName
117
+ /** Shell `action.max_output_length`, echoed on `shell_call_output`. */
118
+ maxOutputLength?: number | null
102
119
  }
103
120
 
104
121
  interface StreamedFunctionCallMetadata {
@@ -979,6 +996,56 @@ export abstract class OpenAIBaseResponsesTextAdapter<
979
996
  accumulatedReasoning = ''
980
997
  }
981
998
 
999
+ const userToolChunks = (
1000
+ item: unknown,
1001
+ outputIndex: number,
1002
+ bareShell: boolean,
1003
+ ): Array<AdapterYieldChunk> => {
1004
+ const call = readUserExecutedCall(item, { bareShell })
1005
+ if (!call || toolCallMetadata.get(call.callId)?.ended) return []
1006
+ const tracked = toolCallMetadata.get(call.callId) ?? {
1007
+ callId: call.callId,
1008
+ index: outputIndex,
1009
+ name: call.name,
1010
+ started: false,
1011
+ }
1012
+ toolCallMetadata.set(call.callId, tracked)
1013
+ tracked.started = true
1014
+ tracked.ended = true
1015
+ tracked.name = call.name
1016
+ const timestamp = Date.now()
1017
+ const modelName = model || options.model
1018
+ const metadata: OpenAIResponsesToolCallMetadata = {
1019
+ openaiUserTool: call.name,
1020
+ ...(call.itemId ? { itemId: call.itemId } : {}),
1021
+ ...(call.maxOutputLength !== undefined
1022
+ ? { maxOutputLength: call.maxOutputLength }
1023
+ : {}),
1024
+ }
1025
+ return [
1026
+ {
1027
+ type: EventType.TOOL_CALL_START,
1028
+ toolCallId: call.callId,
1029
+ toolCallName: call.name,
1030
+ toolName: call.name,
1031
+ parentMessageId: aguiState.messageId,
1032
+ model: modelName,
1033
+ timestamp,
1034
+ index: outputIndex,
1035
+ metadata,
1036
+ },
1037
+ {
1038
+ type: EventType.TOOL_CALL_END,
1039
+ toolCallId: call.callId,
1040
+ toolCallName: call.name,
1041
+ toolName: call.name,
1042
+ model: modelName,
1043
+ timestamp,
1044
+ input: call.input,
1045
+ },
1046
+ ]
1047
+ }
1048
+
982
1049
  const emitReasoningDelta = function* (
983
1050
  text: string,
984
1051
  ): Generator<AdapterYieldChunk> {
@@ -1341,6 +1408,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1341
1408
  metadata.started = true
1342
1409
  }
1343
1410
  }
1411
+ yield* userToolChunks(item, chunk.output_index, false)
1344
1412
  }
1345
1413
 
1346
1414
  // Handle function call arguments delta (streaming). Drop the
@@ -1545,6 +1613,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1545
1613
  metadata.pendingArguments = undefined
1546
1614
  }
1547
1615
  }
1616
+ yield* userToolChunks(item, chunk.output_index, false)
1548
1617
  }
1549
1618
 
1550
1619
  if (chunk.type === 'response.completed') {
@@ -1621,11 +1690,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1621
1690
  // be silently dropped from the AG-UI stream while `hasFunctionCalls`
1622
1691
  // below still routes the run's finishReason to 'tool_calls' —
1623
1692
  // leaving consumers waiting for tool results they never saw start.
1624
- for (const item of chunk.response.output) {
1693
+ for (const [outputIndex, item] of chunk.response.output.entries()) {
1625
1694
  if (item.type !== 'function_call' || !item.id) continue
1626
1695
  const metadata = toolCallMetadata.get(item.id) ?? {
1627
1696
  callId: item.call_id || item.id,
1628
- index: 0,
1697
+ index: outputIndex,
1629
1698
  name: item.name || '',
1630
1699
  started: false,
1631
1700
  }
@@ -1697,6 +1766,19 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1697
1766
  }
1698
1767
  }
1699
1768
 
1769
+ const shellOutputs = hostedShellCallIds(chunk.response.output)
1770
+ for (const [outputIndex, item] of chunk.response.output.entries()) {
1771
+ if (
1772
+ isRecord(item) &&
1773
+ item.type === 'shell_call' &&
1774
+ typeof item.call_id === 'string' &&
1775
+ shellOutputs.has(item.call_id)
1776
+ ) {
1777
+ continue
1778
+ }
1779
+ yield* userToolChunks(item, outputIndex, true)
1780
+ }
1781
+
1700
1782
  yield* closeReasoning()
1701
1783
  // Emit TEXT_MESSAGE_END if we had text content
1702
1784
  if (hasEmittedTextMessageStart) {
@@ -1716,10 +1798,18 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1716
1798
  // The Responses API's incomplete_details.reason ('max_output_tokens'
1717
1799
  // | 'content_filter') maps to the AG-UI finishReason vocabulary:
1718
1800
  // max_output_tokens → 'length', content_filter → 'content_filter'.
1719
- const hasFunctionCalls = chunk.response.output.some(
1720
- (item: unknown) =>
1721
- (item as { type: string }).type === 'function_call',
1722
- )
1801
+ const hasFunctionCalls = chunk.response.output.some((item) => {
1802
+ if (!isRecord(item)) return false
1803
+ if (item.type === 'function_call') return true
1804
+ if (
1805
+ item.type === 'shell_call' &&
1806
+ typeof item.call_id === 'string' &&
1807
+ shellOutputs.has(item.call_id)
1808
+ ) {
1809
+ return false
1810
+ }
1811
+ return readUserExecutedCall(item, { bareShell: true }) !== null
1812
+ })
1723
1813
  const incompleteReason = chunk.response.incomplete_details?.reason
1724
1814
  const finishReason:
1725
1815
  | 'tool_calls'
@@ -1923,10 +2013,33 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1923
2013
  messages: Array<ModelMessage>,
1924
2014
  ): ResponseInput {
1925
2015
  const result: ResponseInput = []
2016
+ // A reasoning item id may only appear once in `input`; replaying the same
2017
+ // id twice fails with "Duplicate item found with id rs_...". Providers have
2018
+ // been observed minting one id for two separate reasoning items, and a
2019
+ // stored transcript can end up carrying it on two assistant messages.
2020
+ const seenReasoningIds = new Set<string>()
2021
+ const userToolCalls = new Map<
2022
+ string,
2023
+ {
2024
+ id: string
2025
+ function: { name: string; arguments: string }
2026
+ metadata?: unknown
2027
+ }
2028
+ >()
1926
2029
 
1927
2030
  for (const message of messages) {
1928
2031
  // Handle tool messages - convert to FunctionToolCallOutput
1929
2032
  if (message.role === 'tool') {
2033
+ const owner = message.toolCallId
2034
+ ? userToolCalls.get(message.toolCallId)
2035
+ : undefined
2036
+ const userOutput = owner
2037
+ ? userToolResultItem(owner, message.content)
2038
+ : null
2039
+ if (userOutput) {
2040
+ result.push(userOutput)
2041
+ continue
2042
+ }
1930
2043
  const toolContent = message.content
1931
2044
  const output: string | Array<ResponseFunctionCallOutputItem> =
1932
2045
  Array.isArray(toolContent)
@@ -1944,11 +2057,21 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1944
2057
 
1945
2058
  // Handle assistant messages
1946
2059
  if (message.role === 'assistant') {
2060
+ // Every reasoning item this message could contribute, and how many of
2061
+ // them actually made it into `input` after de-duplication. Both counts
2062
+ // are needed below to decide whether the function calls can still be
2063
+ // paired with their reasoning.
2064
+ let reasoningCandidates = 0
2065
+ let emittedReasoning = 0
1947
2066
  if (message.thinking) {
1948
2067
  for (const thinking of message.thinking) {
1949
2068
  if (!thinking.signature) continue
1950
2069
  const packed = unpackResponsesReasoningSignature(thinking.signature)
1951
2070
  if (!packed?.id) continue
2071
+ reasoningCandidates++
2072
+ if (seenReasoningIds.has(packed.id)) continue
2073
+ seenReasoningIds.add(packed.id)
2074
+ emittedReasoning++
1952
2075
  result.push({
1953
2076
  type: 'reasoning',
1954
2077
  id: packed.id,
@@ -1962,15 +2085,50 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1962
2085
  }
1963
2086
  }
1964
2087
 
2088
+ // The Responses API requires a persisted `function_call` item to sit
2089
+ // directly after the reasoning item that produced it. A ModelMessage
2090
+ // stores reasoning and tool calls as two flat arrays, so their original
2091
+ // interleaving is gone: a message holding two or more reasoning items
2092
+ // replays as reasoning A, reasoning B, call A, call B, and the request
2093
+ // is rejected with "Item 'fc_...' of type 'function_call' was provided
2094
+ // without its required 'reasoning' item: 'rs_...'". The same happens
2095
+ // when this message's reasoning was dropped above as a duplicate.
2096
+ //
2097
+ // Sending the calls without their item id makes them fresh items that
2098
+ // the API does not try to pair, which replays correctly. `call_id` is
2099
+ // untouched, so the matching `function_call_output` still resolves.
2100
+ // One reasoning item (or none at all) always lands adjacent to its
2101
+ // calls, so those keep their ids and their prompt-cache hits. If any
2102
+ // reasoning was dropped as a duplicate, a call may belong to it, so
2103
+ // the ids go too.
2104
+ const canPairReasoning =
2105
+ reasoningCandidates === 0 ||
2106
+ (reasoningCandidates === 1 && emittedReasoning === 1)
2107
+
1965
2108
  // If the assistant message has tool calls, add them as FunctionToolCall objects
1966
2109
  // Responses API expects arguments as a string (JSON string)
1967
2110
  if (message.toolCalls && message.toolCalls.length > 0) {
1968
2111
  for (const toolCall of message.toolCalls) {
1969
- // Keep arguments as string for Responses API
1970
2112
  const argumentsString =
1971
2113
  typeof toolCall.function.arguments === 'string'
1972
2114
  ? toolCall.function.arguments
1973
2115
  : JSON.stringify(toolCall.function.arguments)
2116
+ const replayCall = {
2117
+ id: toolCall.id,
2118
+ function: {
2119
+ name: toolCall.function.name,
2120
+ arguments: argumentsString,
2121
+ },
2122
+ ...(toolCall.metadata !== undefined
2123
+ ? { metadata: toolCall.metadata }
2124
+ : {}),
2125
+ }
2126
+ const userItem = userToolRequestItem(replayCall)
2127
+ if (userItem) {
2128
+ userToolCalls.set(toolCall.id, replayCall)
2129
+ result.push(userItem)
2130
+ continue
2131
+ }
1974
2132
  const itemId = (
1975
2133
  toolCall.metadata as OpenAIResponsesToolCallMetadata | undefined
1976
2134
  )?.itemId
@@ -1978,7 +2136,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1978
2136
  result.push({
1979
2137
  type: 'function_call',
1980
2138
  call_id: toolCall.id,
1981
- ...(itemId && { id: itemId }),
2139
+ ...(itemId && canPairReasoning && { id: itemId }),
1982
2140
  name: toolCall.function.name,
1983
2141
  arguments: argumentsString,
1984
2142
  })
@@ -2046,6 +2204,16 @@ export abstract class OpenAIBaseResponsesTextAdapter<
2046
2204
  const imageMetadata = part.metadata as
2047
2205
  | { detail?: 'auto' | 'low' | 'high' }
2048
2206
  | undefined
2207
+ if (isFileSource(part.source)) {
2208
+ if (this.supportsFileSources !== true) {
2209
+ throw unsupportedFileSourceError(this.name)
2210
+ }
2211
+ return {
2212
+ type: 'input_image',
2213
+ file_id: fileReferenceFor(part.source, this.name),
2214
+ detail: imageMetadata?.detail || 'auto',
2215
+ }
2216
+ }
2049
2217
  if (part.source.type === 'url') {
2050
2218
  return {
2051
2219
  type: 'input_image',
@@ -2069,6 +2237,15 @@ export abstract class OpenAIBaseResponsesTextAdapter<
2069
2237
  }
2070
2238
  }
2071
2239
  case 'audio': {
2240
+ if (isFileSource(part.source)) {
2241
+ if (this.supportsFileSources !== true) {
2242
+ throw unsupportedFileSourceError(this.name)
2243
+ }
2244
+ return {
2245
+ type: 'input_file',
2246
+ file_id: fileReferenceFor(part.source, this.name),
2247
+ }
2248
+ }
2072
2249
  if (part.source.type === 'url') {
2073
2250
  return {
2074
2251
  type: 'input_file',
@@ -2102,6 +2279,16 @@ export abstract class OpenAIBaseResponsesTextAdapter<
2102
2279
  documentMetadata?.detail !== undefined
2103
2280
  ? { detail: documentMetadata.detail as 'low' | 'high' }
2104
2281
  : {}
2282
+ if (isFileSource(part.source)) {
2283
+ if (this.supportsFileSources !== true) {
2284
+ throw unsupportedFileSourceError(this.name)
2285
+ }
2286
+ return {
2287
+ type: 'input_file',
2288
+ file_id: fileReferenceFor(part.source, this.name),
2289
+ ...documentDetail,
2290
+ }
2291
+ }
2105
2292
  if (part.source.type === 'url') {
2106
2293
  // The Responses API fetches the PDF itself; filename and MIME
2107
2294
  // type are inferred from the response.
@@ -2164,7 +2351,6 @@ export abstract class OpenAIBaseResponsesTextAdapter<
2164
2351
  ...documentDetail,
2165
2352
  }
2166
2353
  }
2167
-
2168
2354
  case 'video':
2169
2355
  default:
2170
2356
  // OpenAI Responses API doesn't accept native video parts on this
@@ -0,0 +1,355 @@
1
+ import type { ResponseInputItem } from 'openai/resources/responses/responses'
2
+
3
+ /**
4
+ * OpenAI Responses tools the app must run.
5
+ * A container `shell` runs on the provider. `apply_patch`, `local_shell`,
6
+ * and `shell` with `environment.type: "local"` (or no environment) do not.
7
+ */
8
+ export type OpenAIUserToolName = 'shell' | 'apply_patch' | 'local_shell'
9
+
10
+ export interface OpenAIUserExecutedCall {
11
+ name: OpenAIUserToolName
12
+ callId: string
13
+ itemId?: string
14
+ input: Record<string, unknown>
15
+ maxOutputLength?: number | null
16
+ }
17
+
18
+ interface ToolCallLike {
19
+ id: string
20
+ function: { name: string; arguments: string }
21
+ metadata?: unknown
22
+ }
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
26
+ }
27
+
28
+ function stringList(value: unknown): Array<string> | null {
29
+ if (
30
+ !Array.isArray(value) ||
31
+ !value.every((entry) => typeof entry === 'string')
32
+ ) {
33
+ return null
34
+ }
35
+ return value
36
+ }
37
+
38
+ function stringEnv(value: unknown): Record<string, string> {
39
+ if (!isRecord(value)) return {}
40
+ const env: Record<string, string> = {}
41
+ for (const [key, entry] of Object.entries(value)) {
42
+ if (typeof entry === 'string') env[key] = entry
43
+ }
44
+ return env
45
+ }
46
+
47
+ function nullableNumber(value: unknown): number | null {
48
+ return typeof value === 'number' || value === null ? value : null
49
+ }
50
+
51
+ /**
52
+ * Hosted shell calls already include a `shell_call_output` in the same
53
+ * response. Those call ids must not pause the app for another run.
54
+ */
55
+ export function hostedShellCallIds(
56
+ output: ReadonlyArray<unknown>,
57
+ ): Set<string> {
58
+ const ids = new Set<string>()
59
+ for (const item of output) {
60
+ if (
61
+ isRecord(item) &&
62
+ item.type === 'shell_call_output' &&
63
+ typeof item.call_id === 'string'
64
+ ) {
65
+ ids.add(item.call_id)
66
+ }
67
+ }
68
+ return ids
69
+ }
70
+
71
+ export function readUserToolName(metadata: unknown): OpenAIUserToolName | null {
72
+ if (!isRecord(metadata)) return null
73
+ const name = metadata.openaiUserTool
74
+ if (name === 'shell' || name === 'apply_patch' || name === 'local_shell') {
75
+ return name
76
+ }
77
+ return null
78
+ }
79
+
80
+ function readItemId(metadata: unknown): string | undefined {
81
+ if (!isRecord(metadata)) return undefined
82
+ return typeof metadata.itemId === 'string' && metadata.itemId.length > 0
83
+ ? metadata.itemId
84
+ : undefined
85
+ }
86
+
87
+ function readMaxOutputLength(metadata: unknown): number | null | undefined {
88
+ if (!isRecord(metadata)) return undefined
89
+ const value = metadata.maxOutputLength
90
+ return typeof value === 'number' || value === null ? value : undefined
91
+ }
92
+
93
+ function applyPatchOperation(
94
+ value: unknown,
95
+ ):
96
+ | { type: 'create_file'; path: string; diff: string }
97
+ | { type: 'update_file'; path: string; diff: string }
98
+ | { type: 'delete_file'; path: string }
99
+ | null {
100
+ if (!isRecord(value) || typeof value.path !== 'string') return null
101
+ if (value.type === 'delete_file') {
102
+ return { type: 'delete_file', path: value.path }
103
+ }
104
+ if (
105
+ (value.type === 'create_file' || value.type === 'update_file') &&
106
+ typeof value.diff === 'string'
107
+ ) {
108
+ return { type: value.type, path: value.path, diff: value.diff }
109
+ }
110
+ return null
111
+ }
112
+
113
+ /**
114
+ * Read a user-run Responses output item.
115
+ * `bareShell` is true only once the full response is known. A shell call
116
+ * with no environment waits for that pass, so a hosted call that later
117
+ * carries `shell_call_output` is not asked of the app.
118
+ */
119
+ export function readUserExecutedCall(
120
+ item: unknown,
121
+ options: { bareShell: boolean },
122
+ ): OpenAIUserExecutedCall | null {
123
+ if (!isRecord(item) || typeof item.type !== 'string') return null
124
+ const callId = typeof item.call_id === 'string' ? item.call_id : ''
125
+ const itemId = typeof item.id === 'string' ? item.id : undefined
126
+
127
+ if (item.type === 'apply_patch_call') {
128
+ const operation = applyPatchOperation(item.operation)
129
+ if (!callId || !operation) return null
130
+ return {
131
+ name: 'apply_patch',
132
+ callId,
133
+ ...(itemId ? { itemId } : {}),
134
+ input: { operation },
135
+ }
136
+ }
137
+
138
+ if (item.type === 'local_shell_call') {
139
+ if (!isRecord(item.action)) return null
140
+ const command = stringList(item.action.command)
141
+ if (!callId || !command || command.length === 0) return null
142
+ return {
143
+ name: 'local_shell',
144
+ callId,
145
+ ...(itemId ? { itemId } : {}),
146
+ input: item.action,
147
+ }
148
+ }
149
+
150
+ if (item.type !== 'shell_call') return null
151
+ const environment = item.environment
152
+ if (isRecord(environment)) {
153
+ if (environment.type !== 'local') return null
154
+ } else if (!options.bareShell) {
155
+ return null
156
+ }
157
+ if (!callId || !isRecord(item.action)) return null
158
+ const commands = stringList(item.action.commands)
159
+ if (!commands || commands.length === 0) return null
160
+ const maxOutputLength = nullableNumber(item.action.max_output_length)
161
+ return {
162
+ name: 'shell',
163
+ callId,
164
+ ...(itemId ? { itemId } : {}),
165
+ input: {
166
+ commands,
167
+ max_output_length: maxOutputLength,
168
+ timeout_ms: nullableNumber(item.action.timeout_ms),
169
+ },
170
+ maxOutputLength,
171
+ }
172
+ }
173
+
174
+ function parseArguments(argumentsString: string): Record<string, unknown> {
175
+ try {
176
+ const parsed: unknown = JSON.parse(argumentsString)
177
+ return isRecord(parsed) ? parsed : {}
178
+ } catch {
179
+ return {}
180
+ }
181
+ }
182
+
183
+ function parseContent(content: unknown): unknown {
184
+ if (typeof content !== 'string') return content
185
+ try {
186
+ return JSON.parse(content) as unknown
187
+ } catch {
188
+ return content
189
+ }
190
+ }
191
+
192
+ /** Replay a user-run tool call as the Responses input item OpenAI expects. */
193
+ export function userToolRequestItem(
194
+ toolCall: ToolCallLike,
195
+ ): ResponseInputItem | null {
196
+ const name = readUserToolName(toolCall.metadata)
197
+ if (!name) return null
198
+ const args = parseArguments(toolCall.function.arguments)
199
+ const itemId = readItemId(toolCall.metadata)
200
+
201
+ if (name === 'apply_patch') {
202
+ const operation = applyPatchOperation(args.operation)
203
+ if (!operation) return null
204
+ return {
205
+ type: 'apply_patch_call',
206
+ call_id: toolCall.id,
207
+ status: 'completed',
208
+ operation,
209
+ ...(itemId ? { id: itemId } : {}),
210
+ }
211
+ }
212
+
213
+ if (name === 'local_shell') {
214
+ const command = stringList(args.command)
215
+ if (!command) return null
216
+ return {
217
+ type: 'local_shell_call',
218
+ id: itemId ?? toolCall.id,
219
+ call_id: toolCall.id,
220
+ status: 'completed',
221
+ action: {
222
+ type: 'exec',
223
+ command,
224
+ env: stringEnv(args.env),
225
+ timeout_ms: nullableNumber(args.timeout_ms),
226
+ ...(typeof args.user === 'string' || args.user === null
227
+ ? { user: args.user }
228
+ : {}),
229
+ ...(typeof args.working_directory === 'string' ||
230
+ args.working_directory === null
231
+ ? { working_directory: args.working_directory }
232
+ : {}),
233
+ },
234
+ }
235
+ }
236
+
237
+ const commands = stringList(args.commands)
238
+ if (!commands) return null
239
+ return {
240
+ type: 'shell_call',
241
+ call_id: toolCall.id,
242
+ status: 'completed',
243
+ action: {
244
+ commands,
245
+ max_output_length: nullableNumber(args.max_output_length),
246
+ timeout_ms: nullableNumber(args.timeout_ms),
247
+ },
248
+ ...(itemId ? { id: itemId } : {}),
249
+ }
250
+ }
251
+
252
+ type ShellOutcome = { type: 'exit'; exit_code: number } | { type: 'timeout' }
253
+
254
+ function shellOutcome(value: unknown): ShellOutcome {
255
+ if (isRecord(value) && value.type === 'timeout') return { type: 'timeout' }
256
+ const exitCode =
257
+ isRecord(value) && typeof value.exit_code === 'number' ? value.exit_code : 0
258
+ return { type: 'exit', exit_code: exitCode }
259
+ }
260
+
261
+ function shellEntry(value: unknown): {
262
+ stdout: string
263
+ stderr: string
264
+ outcome: ShellOutcome
265
+ } {
266
+ if (typeof value === 'string') {
267
+ return {
268
+ stdout: value,
269
+ stderr: '',
270
+ outcome: { type: 'exit', exit_code: 0 },
271
+ }
272
+ }
273
+ const record = isRecord(value) ? value : {}
274
+ return {
275
+ stdout: typeof record.stdout === 'string' ? record.stdout : '',
276
+ stderr: typeof record.stderr === 'string' ? record.stderr : '',
277
+ outcome: shellOutcome(record.outcome),
278
+ }
279
+ }
280
+
281
+ function shellOutputList(content: unknown): Array<{
282
+ stdout: string
283
+ stderr: string
284
+ outcome: ShellOutcome
285
+ }> {
286
+ const parsed = parseContent(content)
287
+ if (isRecord(parsed) && Array.isArray(parsed.output)) {
288
+ return parsed.output.map((entry) => shellEntry(entry))
289
+ }
290
+ if (isRecord(parsed) && ('stdout' in parsed || 'outcome' in parsed)) {
291
+ return [shellEntry(parsed)]
292
+ }
293
+ if (typeof parsed === 'string') return [shellEntry(parsed)]
294
+ return [shellEntry(JSON.stringify(parsed ?? ''))]
295
+ }
296
+
297
+ /** Replay the app's tool result as the matching Responses output item. */
298
+ export function userToolResultItem(
299
+ toolCall: ToolCallLike,
300
+ content: unknown,
301
+ ): ResponseInputItem | null {
302
+ const name = readUserToolName(toolCall.metadata)
303
+ if (!name) return null
304
+ const parsed = parseContent(content)
305
+
306
+ if (name === 'apply_patch') {
307
+ const record = isRecord(parsed) ? parsed : {}
308
+ const failed =
309
+ record.status === 'failed' ||
310
+ (record.status !== 'completed' && typeof record.error === 'string')
311
+ const output =
312
+ typeof record.output === 'string'
313
+ ? record.output
314
+ : typeof record.error === 'string'
315
+ ? record.error
316
+ : typeof parsed === 'string'
317
+ ? parsed
318
+ : undefined
319
+ return {
320
+ type: 'apply_patch_call_output',
321
+ call_id: toolCall.id,
322
+ status: failed ? 'failed' : 'completed',
323
+ ...(output !== undefined ? { output } : {}),
324
+ }
325
+ }
326
+
327
+ if (name === 'local_shell') {
328
+ const output =
329
+ typeof parsed === 'string'
330
+ ? parsed
331
+ : isRecord(parsed) && typeof parsed.output === 'string'
332
+ ? parsed.output
333
+ : JSON.stringify(parsed ?? '')
334
+ return {
335
+ type: 'local_shell_call_output',
336
+ id: toolCall.id,
337
+ output,
338
+ status: 'completed',
339
+ }
340
+ }
341
+
342
+ const record = isRecord(parsed) ? parsed : {}
343
+ const fromResult = nullableNumber(record.max_output_length)
344
+ const fromCall = readMaxOutputLength(toolCall.metadata)
345
+ const maxOutputLength =
346
+ record.max_output_length !== undefined ? fromResult : fromCall
347
+ return {
348
+ type: 'shell_call_output',
349
+ call_id: toolCall.id,
350
+ output: shellOutputList(content),
351
+ ...(maxOutputLength !== undefined
352
+ ? { max_output_length: maxOutputLength }
353
+ : {}),
354
+ }
355
+ }