Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/openai-streamed-apply-patch-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/openai-base': patch
---

Pass the full diff of a streamed `apply_patch` call to the app. The Responses API adds an `apply_patch_call` with `status: "in_progress"` and an empty `diff`, and streams the diff in before the item is done. The adapter ended the call when the item was added, so `execute` always got `diff: ""`, and an agent loop sent empty patches until it hit its iteration limit. A user-run call (`apply_patch`, `local_shell`, `shell`, `computer`) now starts when its item is added and ends, with its input, once the item is no longer in progress, as function calls already do.

If the response completes while a user-run call is still in progress, the run now ends with a `RUN_ERROR` (code `incomplete-tool-call`) instead of running the tool with incomplete input.
79 changes: 58 additions & 21 deletions packages/openai-base/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,17 @@ export abstract class OpenAIBaseResponsesTextAdapter<
return tracked
}

/**
* The tool call chunks for a user-run item (`apply_patch_call`,
* `shell_call`, `local_shell_call`, `computer_call`). The first time a call
* is seen it emits `TOOL_CALL_START`. While the item is `in_progress` its
* input may be incomplete (an `apply_patch_call` has an empty diff), so
* `TOOL_CALL_END` waits until the item is no longer `in_progress`, and then
* carries the complete input. An ended call emits nothing.
*/
// The call ids of user-run calls that are still `in_progress`.
const unendedUserCalls = new Set<string>()

const userToolChunks = (
item: unknown,
outputIndex: number,
Expand All @@ -1609,21 +1620,21 @@ export abstract class OpenAIBaseResponsesTextAdapter<
started: false,
}
toolCallMetadata.set(call.callId, tracked)
tracked.started = true
tracked.ended = true
tracked.name = call.name
streamedToolCall = true
const timestamp = Date.now()
const modelName = model || options.model
const metadata: OpenAIResponsesToolCallMetadata = {
openaiUserTool: call.name,
...(call.itemId ? { itemId: call.itemId } : {}),
...(call.maxOutputLength !== undefined
? { maxOutputLength: call.maxOutputLength }
: {}),
}
return [
{
const chunks: Array<AdapterYieldChunk> = []
if (!tracked.started) {
tracked.started = true
const metadata: OpenAIResponsesToolCallMetadata = {
openaiUserTool: call.name,
...(call.itemId ? { itemId: call.itemId } : {}),
...(call.maxOutputLength !== undefined
? { maxOutputLength: call.maxOutputLength }
: {}),
}
chunks.push({
type: EventType.TOOL_CALL_START,
toolCallId: call.callId,
toolCallName: call.name,
Expand All @@ -1632,16 +1643,23 @@ export abstract class OpenAIBaseResponsesTextAdapter<
timestamp,
index: outputIndex,
metadata,
},
{
type: EventType.TOOL_CALL_END,
toolCallId: call.callId,
toolCallName: call.name,
model: modelName,
timestamp,
input: call.input,
},
]
})
}
if (isRecord(item) && item.status === 'in_progress') {
unendedUserCalls.add(call.callId)
return chunks
}
unendedUserCalls.delete(call.callId)
tracked.ended = true
chunks.push({
type: EventType.TOOL_CALL_END,
toolCallId: call.callId,
toolCallName: call.name,
model: modelName,
timestamp,
input: call.input,
})
return chunks
}

const emitReasoningDelta = function* (
Expand Down Expand Up @@ -2438,6 +2456,25 @@ export abstract class OpenAIBaseResponsesTextAdapter<
hasEmittedTextMessageStart = false
}

// A stateless stream can skip `output_item.done` and send an empty
// `output`. A user-run call that never left `in_progress` has
// incomplete input (an `apply_patch_call` has an empty diff), so
// the run fails instead of running the tool with it.
if (unendedUserCalls.size > 0) {
const message = `Response completed with user tool calls still in progress: ${[...unendedUserCalls].join(', ')}`
yield {
type: EventType.RUN_ERROR,
metadata: this.sourceMetadata(options.model),
model: model || options.model,
timestamp: Date.now(),
message,
code: 'incomplete-tool-call',
error: { message, code: 'incomplete-tool-call' },
}
runFinishedEmitted = true
return
}

// Determine finish reason. Function-call output → tool_calls.
// Otherwise surface incomplete_details.reason when present so
// callers can distinguish length-limit / content-filter cutoffs
Expand Down
129 changes: 129 additions & 0 deletions packages/openai-base/tests/responses-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4765,6 +4765,135 @@ describe('OpenAIBaseResponsesTextAdapter', () => {
expect(finished).toMatchObject({ finishReason: 'tool_calls' })
})

it('passes the streamed diff of an apply_patch_call to the app', async () => {
// The diff is empty when the item is added and streams in afterwards.
const diff = '@@\n-def fib\n+def fibonacci\n'
const item = (status: string, itemDiff: string) => ({
type: 'apply_patch_call',
id: 'apc_1',
call_id: 'call_patch',
status,
operation: { type: 'update_file', path: 'lib/fib.py', diff: itemDiff },
})
setupMockResponsesClient([
{
type: 'response.created',
response: {
id: 'resp-user-tool',
model: 'test-model',
status: 'in_progress',
},
},
{
type: 'response.output_item.added',
output_index: 0,
item: item('in_progress', ''),
},
{
type: 'response.apply_patch_call_operation_diff.delta',
output_index: 0,
item_id: 'apc_1',
delta: diff,
},
{
type: 'response.apply_patch_call_operation_diff.done',
output_index: 0,
item_id: 'apc_1',
},
{
type: 'response.output_item.done',
output_index: 0,
item: item('completed', diff),
},
{
type: 'response.completed',
response: {
id: 'resp-user-tool',
model: 'test-model',
status: 'completed',
output: [item('completed', diff)],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
},
])
const adapter = new TestResponsesAdapter(testConfig, 'test-model')
const chunks: Array<AdapterYieldChunk> = []
for await (const chunk of adapter.chatStream({
threadId: 'thread-1',
logger: testLogger,
model: 'test-model',
messages: [{ role: 'user', content: 'edit the file' }],
tools: [applyPatchTool()],
})) {
chunks.push(chunk)
}

const starts = chunks.filter((chunk) => chunk.type === 'TOOL_CALL_START')
const ends = chunks.filter((chunk) => chunk.type === 'TOOL_CALL_END')
expect(starts).toHaveLength(1)
expect(ends).toHaveLength(1)
expect(ends[0]).toMatchObject({
toolCallId: 'call_patch',
input: {
operation: { type: 'update_file', path: 'lib/fib.py', diff },
},
})
})

it('fails the run when a user tool call is still in_progress at completion', async () => {
// A stateless stream can skip output_item.done and send an empty output.
setupMockResponsesClient([
{
type: 'response.created',
response: {
id: 'resp-open',
model: 'test-model',
status: 'in_progress',
},
},
{
type: 'response.output_item.added',
output_index: 0,
item: {
type: 'apply_patch_call',
id: 'apc_open',
call_id: 'call_open',
status: 'in_progress',
operation: { type: 'update_file', path: 'lib/fib.py', diff: '' },
},
},
{
type: 'response.completed',
response: {
id: 'resp-open',
model: 'test-model',
status: 'completed',
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
},
])
const adapter = new TestResponsesAdapter(testConfig, 'test-model')
const chunks: Array<AdapterYieldChunk> = []
for await (const chunk of adapter.chatStream({
threadId: 'thread-1',
logger: testLogger,
model: 'test-model',
messages: [{ role: 'user', content: 'edit the file' }],
tools: [applyPatchTool()],
})) {
chunks.push(chunk)
}

const types = chunks.map((chunk) => chunk.type)
expect(types).not.toContain('TOOL_CALL_END')
expect(types).not.toContain('RUN_FINISHED')
expect(chunks.at(-1)).toMatchObject({
type: 'RUN_ERROR',
code: 'incomplete-tool-call',
})
})

it('surfaces a local shell call and ignores a hosted container shell', async () => {
const chunks = await collect([
{
Expand Down
21 changes: 21 additions & 0 deletions testing/e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ import { Route as ApiMoonshotUsageDetailsRouteImport } from './routes/api.moonsh
import { Route as ApiMultimodalToolResultWireRouteImport } from './routes/api.multimodal-tool-result-wire'
import { Route as ApiNeonWireRouteImport } from './routes/api.neon-wire'
import { Route as ApiNonStreamingRunErrorRouteImport } from './routes/api.non-streaming-run-error'
import { Route as ApiOpenaiApplyPatchWireRouteImport } from './routes/api.openai-apply-patch-wire'
import { Route as ApiOpenaiCompletedResponseTextRouteImport } from './routes/api.openai-completed-response-text'
import { Route as ApiOpenaiComputerUseWireRouteImport } from './routes/api.openai-computer-use-wire'
import { Route as ApiOpenaiImage25ModelsRouteImport } from './routes/api.openai-image-2-5-models'
Expand Down Expand Up @@ -971,6 +972,11 @@ const ApiNonStreamingRunErrorRoute = ApiNonStreamingRunErrorRouteImport.update({
path: '/api/non-streaming-run-error',
getParentRoute: () => rootRouteImport,
} as any)
const ApiOpenaiApplyPatchWireRoute = ApiOpenaiApplyPatchWireRouteImport.update({
id: '/api/openai-apply-patch-wire',
path: '/api/openai-apply-patch-wire',
getParentRoute: () => rootRouteImport,
} as any)
const ApiOpenaiCompletedResponseTextRoute =
ApiOpenaiCompletedResponseTextRouteImport.update({
id: '/api/openai-completed-response-text',
Expand Down Expand Up @@ -1443,6 +1449,7 @@ export interface FileRoutesByFullPath {
'/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute
'/api/neon-wire': typeof ApiNeonWireRoute
'/api/non-streaming-run-error': typeof ApiNonStreamingRunErrorRoute
'/api/openai-apply-patch-wire': typeof ApiOpenaiApplyPatchWireRoute
'/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute
'/api/openai-computer-use-wire': typeof ApiOpenaiComputerUseWireRoute
'/api/openai-image-2-5-models': typeof ApiOpenaiImage25ModelsRoute
Expand Down Expand Up @@ -1651,6 +1658,7 @@ export interface FileRoutesByTo {
'/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute
'/api/neon-wire': typeof ApiNeonWireRoute
'/api/non-streaming-run-error': typeof ApiNonStreamingRunErrorRoute
'/api/openai-apply-patch-wire': typeof ApiOpenaiApplyPatchWireRoute
'/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute
'/api/openai-computer-use-wire': typeof ApiOpenaiComputerUseWireRoute
'/api/openai-image-2-5-models': typeof ApiOpenaiImage25ModelsRoute
Expand Down Expand Up @@ -1860,6 +1868,7 @@ export interface FileRoutesById {
'/api/multimodal-tool-result-wire': typeof ApiMultimodalToolResultWireRoute
'/api/neon-wire': typeof ApiNeonWireRoute
'/api/non-streaming-run-error': typeof ApiNonStreamingRunErrorRoute
'/api/openai-apply-patch-wire': typeof ApiOpenaiApplyPatchWireRoute
'/api/openai-completed-response-text': typeof ApiOpenaiCompletedResponseTextRoute
'/api/openai-computer-use-wire': typeof ApiOpenaiComputerUseWireRoute
'/api/openai-image-2-5-models': typeof ApiOpenaiImage25ModelsRoute
Expand Down Expand Up @@ -2070,6 +2079,7 @@ export interface FileRouteTypes {
| '/api/multimodal-tool-result-wire'
| '/api/neon-wire'
| '/api/non-streaming-run-error'
| '/api/openai-apply-patch-wire'
| '/api/openai-completed-response-text'
| '/api/openai-computer-use-wire'
| '/api/openai-image-2-5-models'
Expand Down Expand Up @@ -2278,6 +2288,7 @@ export interface FileRouteTypes {
| '/api/multimodal-tool-result-wire'
| '/api/neon-wire'
| '/api/non-streaming-run-error'
| '/api/openai-apply-patch-wire'
| '/api/openai-completed-response-text'
| '/api/openai-computer-use-wire'
| '/api/openai-image-2-5-models'
Expand Down Expand Up @@ -2486,6 +2497,7 @@ export interface FileRouteTypes {
| '/api/multimodal-tool-result-wire'
| '/api/neon-wire'
| '/api/non-streaming-run-error'
| '/api/openai-apply-patch-wire'
| '/api/openai-completed-response-text'
| '/api/openai-computer-use-wire'
| '/api/openai-image-2-5-models'
Expand Down Expand Up @@ -2695,6 +2707,7 @@ export interface RootRouteChildren {
ApiMultimodalToolResultWireRoute: typeof ApiMultimodalToolResultWireRoute
ApiNeonWireRoute: typeof ApiNeonWireRoute
ApiNonStreamingRunErrorRoute: typeof ApiNonStreamingRunErrorRoute
ApiOpenaiApplyPatchWireRoute: typeof ApiOpenaiApplyPatchWireRoute
ApiOpenaiCompletedResponseTextRoute: typeof ApiOpenaiCompletedResponseTextRoute
ApiOpenaiComputerUseWireRoute: typeof ApiOpenaiComputerUseWireRoute
ApiOpenaiImage25ModelsRoute: typeof ApiOpenaiImage25ModelsRoute
Expand Down Expand Up @@ -3771,6 +3784,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiNonStreamingRunErrorRouteImport
parentRoute: typeof rootRouteImport
}
'/api/openai-apply-patch-wire': {
id: '/api/openai-apply-patch-wire'
path: '/api/openai-apply-patch-wire'
fullPath: '/api/openai-apply-patch-wire'
preLoaderRoute: typeof ApiOpenaiApplyPatchWireRouteImport
parentRoute: typeof rootRouteImport
}
'/api/openai-completed-response-text': {
id: '/api/openai-completed-response-text'
path: '/api/openai-completed-response-text'
Expand Down Expand Up @@ -4409,6 +4429,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute,
ApiNeonWireRoute: ApiNeonWireRoute,
ApiNonStreamingRunErrorRoute: ApiNonStreamingRunErrorRoute,
ApiOpenaiApplyPatchWireRoute: ApiOpenaiApplyPatchWireRoute,
ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute,
ApiOpenaiComputerUseWireRoute: ApiOpenaiComputerUseWireRoute,
ApiOpenaiImage25ModelsRoute: ApiOpenaiImage25ModelsRoute,
Expand Down
Loading
Loading