Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/openai-streamed-apply-patch-diff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@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.
53 changes: 32 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,14 @@ 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 has any other status, and then
* carries the complete input. An ended call emits nothing.
*/
const userToolChunks = (
item: unknown,
outputIndex: number,
Expand All @@ -1609,21 +1617,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 +1640,19 @@ 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') return chunks
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
75 changes: 75 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,81 @@ 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('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 @@ -150,6 +150,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 @@ -945,6 +946,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 @@ -1408,6 +1414,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 @@ -1611,6 +1618,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 @@ -1815,6 +1823,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 @@ -2020,6 +2029,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 @@ -2223,6 +2233,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 @@ -2426,6 +2437,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 @@ -2630,6 +2642,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 @@ -3677,6 +3690,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 @@ -4304,6 +4324,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute,
ApiNeonWireRoute: ApiNeonWireRoute,
ApiNonStreamingRunErrorRoute: ApiNonStreamingRunErrorRoute,
ApiOpenaiApplyPatchWireRoute: ApiOpenaiApplyPatchWireRoute,
ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute,
ApiOpenaiComputerUseWireRoute: ApiOpenaiComputerUseWireRoute,
ApiOpenaiImage25ModelsRoute: ApiOpenaiImage25ModelsRoute,
Expand Down
Loading