diff --git a/.changeset/openai-streamed-apply-patch-diff.md b/.changeset/openai-streamed-apply-patch-diff.md new file mode 100644 index 0000000000..a8f1f4c04d --- /dev/null +++ b/.changeset/openai-streamed-apply-patch-diff.md @@ -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. diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index 8dedd805f7..2078074cc7 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -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, @@ -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 = [] + 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, @@ -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* ( diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index f5e668f9dc..16cf8230e2 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -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 = [] + 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([ { diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 445df11af5..cf24518581 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 @@ -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 @@ -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 @@ -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' @@ -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' @@ -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' @@ -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 @@ -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' @@ -4304,6 +4324,7 @@ const rootRouteChildren: RootRouteChildren = { ApiMultimodalToolResultWireRoute: ApiMultimodalToolResultWireRoute, ApiNeonWireRoute: ApiNeonWireRoute, ApiNonStreamingRunErrorRoute: ApiNonStreamingRunErrorRoute, + ApiOpenaiApplyPatchWireRoute: ApiOpenaiApplyPatchWireRoute, ApiOpenaiCompletedResponseTextRoute: ApiOpenaiCompletedResponseTextRoute, ApiOpenaiComputerUseWireRoute: ApiOpenaiComputerUseWireRoute, ApiOpenaiImage25ModelsRoute: ApiOpenaiImage25ModelsRoute, diff --git a/testing/e2e/src/routes/api.openai-apply-patch-wire.ts b/testing/e2e/src/routes/api.openai-apply-patch-wire.ts new file mode 100644 index 0000000000..036a1e80d2 --- /dev/null +++ b/testing/e2e/src/routes/api.openai-apply-patch-wire.ts @@ -0,0 +1,139 @@ +import { createFileRoute } from '@tanstack/react-router' +import { chat, createChatOptions } from '@tanstack/ai' +import { createOpenaiChat } from '@tanstack/ai-openai' +import { applyPatchTool } from '@tanstack/ai-openai/tools' + +const DUMMY_KEY = 'sk-e2e-test-dummy-key' +const DIFF = '@@\n-def fib\n+def fibonacci\n' + +const patchCall = (status: string, diff: string) => ({ + type: 'apply_patch_call', + id: 'apc_e2e', + call_id: 'call_apc_e2e', + status, + operation: { type: 'update_file', path: 'lib/fib.py', diff }, +}) + +const answer = { + type: 'message', + id: 'msg_apc_e2e', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'Done.', annotations: [] }], +} + +function responseStream( + id: string, + output: Array, + itemEvents: Array = [], +): ReadableStream { + const encoder = new TextEncoder() + const events = [ + { + type: 'response.created', + response: { id, object: 'response', status: 'in_progress', output: [] }, + }, + ...itemEvents, + { + type: 'response.completed', + response: { + id, + object: 'response', + status: 'completed', + output, + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }, + }, + ] + return new ReadableStream({ + start(controller) { + for (const event of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) + } + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) +} + +/** + * Drives the OpenAI Responses adapter through one streamed `apply_patch` + * call. As the API does, the item is added with an empty diff, the diff + * streams in, and the item is done with the full diff. A custom `fetch` + * answers the first request with that call and the second with text. aimock + * cannot send an `apply_patch_call`, so this route serves the stream itself. + */ +export const Route = createFileRoute('/api/openai-apply-patch-wire')({ + server: { + handlers: { + POST: async () => { + let requests = 0 + const mockFetch: typeof fetch = async () => { + requests += 1 + const stream = + requests === 1 + ? responseStream( + 'resp_apc_e2e_1', + [patchCall('completed', DIFF)], + [ + { + type: 'response.output_item.added', + output_index: 0, + item: patchCall('in_progress', ''), + }, + { + type: 'response.apply_patch_call_operation_diff.delta', + output_index: 0, + item_id: 'apc_e2e', + delta: DIFF, + }, + { + type: 'response.apply_patch_call_operation_diff.done', + output_index: 0, + item_id: 'apc_e2e', + }, + { + type: 'response.output_item.done', + output_index: 0, + item: patchCall('completed', DIFF), + }, + ], + ) + : responseStream('resp_apc_e2e_2', [answer]) + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + + const toolInputs: Array = [] + const execute = async (input: unknown) => { + toolInputs.push(input) + return { status: 'completed', output: 'Applied.' } + } + try { + const stream = chat({ + threadId: 'openai-apply-patch-wire', + ...createChatOptions({ + adapter: createOpenaiChat('gpt-6.1-sol', DUMMY_KEY, { + fetch: mockFetch, + }), + }), + messages: [{ role: 'user', content: 'Rename fib.' }], + tools: [{ ...applyPatchTool(), execute }], + }) + for await (const _ of stream) { + // Drain the stream. + } + } catch (error) { + return Response.json({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + } + + return Response.json({ ok: true, toolInputs }) + }, + }, + }, +}) diff --git a/testing/e2e/tests/openai-apply-patch-wire.spec.ts b/testing/e2e/tests/openai-apply-patch-wire.spec.ts new file mode 100644 index 0000000000..1a3adee8eb --- /dev/null +++ b/testing/e2e/tests/openai-apply-patch-wire.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from './fixtures' + +/** + * A streamed `apply_patch_call` is added with an empty diff, and the diff + * streams in before the item is done. The app must get the full diff. + * + * `/api/openai-apply-patch-wire` serves the model responses with a custom + * `fetch` and returns the tool inputs as JSON. + */ +test.describe('openai — apply_patch wire format', () => { + test('runs apply_patch with the streamed diff', async ({ request }) => { + const res = await request.post('/api/openai-apply-patch-wire') + expect(res.ok()).toBe(true) + const { ok, error, toolInputs } = (await res.json()) as { + ok: boolean + error?: string + toolInputs: Array + } + + if (!ok) { + throw new Error(`Route failed: ${error}`) + } + + expect(toolInputs).toEqual([ + { + operation: { + type: 'update_file', + path: 'lib/fib.py', + diff: '@@\n-def fib\n+def fibonacci\n', + }, + }, + ]) + }) +})