From 7311f2cc009466e76ae0943f5f758926a0545174 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:16:43 +0800 Subject: [PATCH 1/5] feat(models): configure ApplyPatch editing per model Generated-by: OpenAI Codex --- .../provider-capability-editor.tsx | 15 ++ .../settings-provider-copy.ts | 12 ++ .../settings/provider-settings.stories.tsx | 13 ++ .../core/src/__tests__/model-thinking.test.ts | 30 ++++ .../__tests__/runtime-policy-codec.test.ts | 13 ++ packages/core/src/model-thinking.ts | 47 ++++++ packages/core/src/provider-registry.ts | 1 + .../connection-catalog-codec.ts | 4 + .../src/__tests__/ai-sdk-backend.test.ts | 11 +- .../src/__tests__/apply-patch-profile.test.ts | 142 +++++++++++++----- .../src/__tests__/builtin-tools.test.ts | 6 + .../__tests__/responses-wire-contract.test.ts | 115 ++++++++++++++ packages/runtime/src/apply-patch-profile.ts | 77 ++++++++-- packages/runtime/src/builtin-tools.ts | 5 +- packages/runtime/src/codex-v4a-patch.ts | 20 +++ packages/runtime/src/model-adapter.ts | 4 +- packages/runtime/src/model-runtime.ts | 8 + packages/runtime/src/openai-apply-patch.ts | 6 + packages/runtime/src/tool-runtime.ts | 6 +- 19 files changed, 472 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx index 082bf55aee..ee90439ccd 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx +++ b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx @@ -23,6 +23,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, Field, FormLayout } from '@astr import { DECLARABLE_RELAY_THINKING_LEVELS, THINKING_LEVELS, + modelApplyPatchEnabled, type ModelOverride, type ThinkingLevel, } from '@maka/core/model-thinking'; @@ -96,6 +97,20 @@ export function CapabilityEditor(props: { isDisabled={props.disabled} /> + props.onChange({ applyPatch: value === 'enabled' })} + isDisabled={props.disabled} + /> + expect(document.querySelector('dialog[open] .astryx-form-layout')).not.toBeNull()); + const patch = within(document.body).getByRole('combobox', { name: /^ApplyPatch/ }); + expect(patch).toHaveTextContent(/^(启用|啟用|Enabled)$/); + await userEvent.click(patch); + expect(within(document.body).getAllByRole('option')).toHaveLength(2); + await userEvent.keyboard('{Escape}'); const pane = canvasElement.querySelector('.settingsMainPane'); if (pane) pane.scrollTop = 0; }, @@ -932,6 +937,10 @@ export const ModelParameterSave: Story = { const vision = () => body.getByRole('combobox', { name: /^(图片识别|圖片辨識|Send images to the model)$/i }); await userEvent.click(vision()); await userEvent.click(await body.findByRole('option', { name: /^(支持|支援|Allow images)$/i })); + const patch = () => body.getByRole('combobox', { name: /^ApplyPatch/ }); + expect(patch()).toHaveTextContent(/^(关闭|關閉|Disabled)$/); + await userEvent.click(patch()); + await userEvent.click(await body.findByRole('option', { name: /^(启用|啟用|Enabled)$/ })); const save = body.getByRole('button', { name: /^(保存|儲存|Save)$/i }); await userEvent.clear(field); await userEvent.type(field, '1MB'); @@ -952,12 +961,16 @@ export const ModelParameterSave: Story = { expect(reopened).toHaveValue('128000'); expect(body.getByRole('textbox', { name: /^(输入上限|輸入上限|Input limit)$/i })).toHaveValue('64000'); expect(vision()).toHaveTextContent(/^(支持|支援|Allow images)$/i); + expect(patch()).toHaveTextContent(/^(启用|啟用|Enabled)$/); + await userEvent.click(patch()); + await userEvent.click(await body.findByRole('option', { name: /^(关闭|關閉|Disabled)$/ })); await userEvent.click(vision()); await userEvent.click(await body.findByRole('option', { name: /^(自动|自動|Model information)/i })); await userEvent.click(body.getByRole('button', { name: /^(保存|儲存|Save)$/i })); await waitFor(() => expect(configure).toHaveFocus()); await userEvent.click(configure); expect(vision()).toHaveTextContent(/^(自动|自動|Model information)/i); + expect(patch()).toHaveTextContent(/^(关闭|關閉|Disabled)$/); expect(body.getByRole('textbox', { name: /^(输入上限|輸入上限|Input limit)$/i })).toHaveValue('64000'); expect(enable).not.toBeChecked(); const editable = body.getByRole('textbox', { name: /^(上下文窗口|上下文視窗|Context window)$/i }); diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index 034995cd4d..f2b9164678 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -22,6 +22,7 @@ import { test } from 'node:test'; import { type ConnectionThinkingContext, normalizeModelOverrides, + modelApplyPatchEnabled, modelOverride, resolveThinkingLevel, thinkingOptionsForModel, @@ -212,3 +213,32 @@ test('Alibaba Token Plan exposes the formal Qwen3.8 effort and disable contract' // reasoning_content in tool-call history (400 otherwise), and other relays // ignore it, so the runtime replays unconditionally. That contract is // enforced per provider by the runtime provider-contract matrix, not here. + +test('normalizes per-model ApplyPatch without confusing false with automatic', () => { + assert.deepEqual( + normalizeModelOverrides({ + on: { applyPatch: true }, + off: { applyPatch: false }, + auto: {}, + invalid: { applyPatch: 'true' }, + }), + { on: { applyPatch: true }, off: { applyPatch: false }, auto: {}, invalid: {} }, + ); +}); + +test('ApplyPatch defaults are model-specific and explicit choices win', () => { + for (const model of [ + 'gpt-5.6-luna', + 'gpt-6-astra', + 'gpt-5.4-2026-03-05', + 'deepseek-v4-flash', + 'deepseek-v4-pro', + ]) { + assert.equal(modelApplyPatchEnabled(model), true, model); + assert.equal(modelApplyPatchEnabled(model, { applyPatch: false }), false, model); + } + for (const model of ['unknown', 'future-model', 'deepseek-v99', 'gpt-99', 'gemini-3.8-flash']) { + assert.equal(modelApplyPatchEnabled(model), false, model); + assert.equal(modelApplyPatchEnabled(model, { applyPatch: true }), true, model); + } +}); diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index 2fd2e91173..d36fe16ef2 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -596,3 +596,16 @@ test('credential domain validation requires material but leaves capacity to call RuntimePolicyDomainDecodeError, ); }); + +test('per-model ApplyPatch overrides survive persistence and reject non-booleans', () => { + const profiles = { + enabled: { applyPatch: true }, + disabled: { applyPatch: false }, + automatic: {}, + }; + assert.deepEqual(decodeModelOverridesTable(JSON.parse(JSON.stringify(profiles))), profiles); + assert.throws( + () => decodeModelOverridesTable({ model: { applyPatch: 'true' } }), + RuntimePolicyDomainDecodeError, + ); +}); diff --git a/packages/core/src/model-thinking.ts b/packages/core/src/model-thinking.ts index 27beaabb63..486a383ae7 100644 --- a/packages/core/src/model-thinking.ts +++ b/packages/core/src/model-thinking.ts @@ -124,6 +124,8 @@ export interface ModelOverride { readonly modalities?: ModelInfo['modalities']; readonly thinkingLevels?: readonly ThinkingLevel[]; readonly vision?: boolean; + /** Override ApplyPatch file editing. Omit to use this model's known support default. */ + readonly applyPatch?: boolean; readonly contextWindow?: number; readonly compactionThreshold?: number; readonly inputLimit?: number; @@ -138,6 +140,48 @@ export interface ModelOverride { export type ModelOverrides = Readonly>; +/** Known patch-capable models. Unknown models stay off until explicitly enabled. */ +const APPLY_PATCH_MODELS: ReadonlySet = new Set([ + 'gpt-5-codex', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex-max', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.4-nano', + 'gpt-5.4-pro', + 'gpt-5.5', + 'gpt-5.6', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-6-astra', + 'deepseek-v4-flash', + 'deepseek-v4-flash-vision-exp', + 'deepseek-v4-pro', +]); + +/** Shared by model settings and tool routing so the displayed switch matches execution. */ +export function modelApplyPatchEnabled( + modelId: string, + override?: Pick, +): boolean { + return ( + override?.applyPatch ?? + APPLY_PATCH_MODELS.has( + modelId + .trim() + .toLowerCase() + .replace(/-\d{4}-\d{2}-\d{2}$/, ''), + ) + ); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -150,6 +194,7 @@ function normalizeModelOverride(entry: unknown): ModelOverride | undefined { modalities?: ModelOverride['modalities']; thinkingLevels?: readonly ThinkingLevel[]; vision?: boolean; + applyPatch?: boolean; contextWindow?: number; compactionThreshold?: number; inputLimit?: number; @@ -180,6 +225,7 @@ function normalizeModelOverride(entry: unknown): ModelOverride | undefined { } } if (typeof entry.vision === 'boolean') declared.vision = entry.vision; + if (typeof entry.applyPatch === 'boolean') declared.applyPatch = entry.applyPatch; for (const field of [ 'contextWindow', 'compactionThreshold', @@ -364,6 +410,7 @@ export function applyModelOverride( serviceTier: _tier, compactionThreshold: _threshold, maxOutputTokens: _outputBudget, + applyPatch: _applyPatch, vision, capabilities, ...facts diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 1fa3d8ce71..e4d65e4e82 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1740,6 +1740,7 @@ const providerRegistry = { status: 'phase3-experimental', runtimeAdapter: { kind: 'openai-codex', + applyPatchProtocol: 'codex-v4a-freeform', responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { kind: 'protocol', auth: 'openai-codex' }, diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 813dce30d2..421a429fd4 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -267,6 +267,7 @@ export function decodeModelOverridesTable(value: unknown): Readonly { } }); - test('keeps Write and Edit when DeepSeek cannot carry custom apply_patch', async () => { + test('uses portable ApplyPatch for DeepSeek', async () => { const model = completionModel(); const backend = createBackend({ connection: { @@ -267,9 +267,9 @@ describe('AiSdkBackend ApplyPatch routing', () => { await drain(backend.send({ turnId: 'turn-1', text: 'edit', context: [] })); const names = modelToolNames(model); - assert.equal(names.includes('apply_patch'), false); - assert.equal(names.includes('Write'), true); - assert.equal(names.includes('Edit'), true); + assert.equal(names.includes('apply_patch'), true); + assert.equal(names.includes('Write'), false); + assert.equal(names.includes('Edit'), false); }); test('replays a durable apply_patch failure as native provider JSON', async () => { @@ -416,13 +416,14 @@ describe('AiSdkBackend ApplyPatch routing', () => { ); }; - test('downgrades durable DeepSeek freeform apply_patch history to a fact', async () => { + test('downgrades disabled DeepSeek freeform apply_patch history to a fact', async () => { await assertApplyPatchHistoryDowngraded( { ...connection(), slug: 'deepseek', providerType: 'deepseek', defaultModel: 'deepseek-v4-flash', + modelOverrides: { 'deepseek-v4-flash': { applyPatch: false } }, }, 'deepseek-v4-flash', ); diff --git a/packages/runtime/src/__tests__/apply-patch-profile.test.ts b/packages/runtime/src/__tests__/apply-patch-profile.test.ts index 820bb327dc..1fc96beac3 100644 --- a/packages/runtime/src/__tests__/apply-patch-profile.test.ts +++ b/packages/runtime/src/__tests__/apply-patch-profile.test.ts @@ -28,28 +28,86 @@ import type { MakaTool } from '../tool-runtime.js'; import { resolveModelRuntime } from '../model-runtime.js'; describe('ApplyPatch profile routing', () => { - test('derives the effective profile from the provider adapter contract', () => { + test('routes supported Codex and DeepSeek models through their supported tool transports', () => { + assert.deepEqual( + resolveModelRuntime({ providerType: 'openai-codex' }, 'gpt-6-astra').applyPatchProfile, + { kind: 'codex-v4a-freeform' }, + ); + assert.deepEqual( + resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash').applyPatchProfile, + { kind: 'portable-v4a' }, + ); + assert.equal(resolveModelRuntime({ providerType: 'xai' }, 'unknown').applyPatchProfile, null); + assert.equal( + resolveModelRuntime({ providerType: 'openai-codex' }, 'unknown').applyPatchProfile, + null, + ); assert.equal( + resolveModelRuntime({ providerType: 'deepseek' }, 'unknown').applyPatchProfile, + null, + ); + assert.deepEqual( + resolveModelRuntime({ providerType: 'openai-compatible' }, 'gpt-5.6-luna').applyPatchProfile, + { kind: 'portable-v4a' }, + ); + }); + + test('overrides are per model and work for future models without a name whitelist', () => { + const connection = { + providerType: 'openai-codex' as const, + modelOverrides: { disabled: { applyPatch: false }, future: { applyPatch: true } }, + }; + assert.equal(resolveModelRuntime(connection, 'disabled').applyPatchProfile, null); + assert.deepEqual(resolveModelRuntime(connection, 'future').applyPatchProfile, { + kind: 'codex-v4a-freeform', + }); + assert.deepEqual( + resolveModelRuntime( + { providerType: 'anthropic', modelOverrides: { future: { applyPatch: true } } }, + 'future', + ).applyPatchProfile, + { kind: 'portable-v4a' }, + ); + assert.equal( + resolveModelRuntime( + { providerType: 'anthropic', modelOverrides: { future: { applyPatch: true } } }, + 'other', + ).applyPatchProfile, + null, + ); + }); + + test('explicit enablement uses ordinary functions on relays and explicit disablement wins on OpenAI', () => { + assert.deepEqual( resolveModelRuntime( { - providerType: 'deepseek', - baseUrl: 'https://gateway.example/v1', + providerType: 'openai-responses-compatible', + modelOverrides: { future: { applyPatch: true } }, }, - 'deepseek-v4-flash', + 'future', ).applyPatchProfile, - null, + { kind: 'portable-v4a' }, ); assert.equal( - resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-pro').applyPatchProfile, + resolveModelRuntime( + { providerType: 'openai', modelOverrides: { 'gpt-5.4': { applyPatch: false } } }, + 'gpt-5.4', + ).applyPatchProfile, null, ); assert.equal( - resolveModelRuntime({ providerType: 'xai' }, 'deepseek-v4-flash').applyPatchProfile, + resolveModelRuntime( + { + providerType: 'deepseek', + modelOverrides: { 'deepseek-v4-flash': { applyPatch: false } }, + }, + 'deepseek-v4-flash', + ).applyPatchProfile, null, ); }); - test('keeps portable Write/Edit when DeepSeek cannot carry custom ApplyPatch', () => { + test('projects the editing surface and the matching input schema', () => { const tool = (name: string, providerTool?: MakaTool['providerTool']): MakaTool => ({ name, description: name, @@ -57,46 +115,50 @@ describe('ApplyPatch profile routing', () => { providerTool, impl: async () => undefined, }); - const routed = routeApplyPatchTools( - [tool('Write'), tool('Edit'), tool('apply_patch', { kind: 'openai-apply-patch' })], - resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash').applyPatchProfile, + const tools = [ + tool('Read'), + tool('Write'), + tool('Edit'), + tool('apply_patch', { kind: 'openai-apply-patch' }), + ]; + assert.deepEqual( + routeApplyPatchTools(tools, null).map((t) => t.name), + ['Read', 'Write', 'Edit'], ); - + const portable = routeApplyPatchTools(tools, { kind: 'portable-v4a' }); assert.deepEqual( - routed.map(({ name }) => name), - ['Write', 'Edit'], + portable.map((t) => t.name), + ['Read', 'apply_patch'], ); + assert.equal(portable[1]?.providerTool, undefined); + const custom = routeApplyPatchTools(tools, { kind: 'codex-v4a-freeform' }); + assert.equal(custom[1]?.providerTool?.kind, 'codex-apply-patch'); + assert.equal(tools[3]?.providerTool?.kind, 'openai-apply-patch'); }); - test('does not expose the dormant Codex V4A freeform target path', () => { + test('preserves multi-file history in custom and portable forms', () => { + const patch = '*** Begin Patch\n*** Delete File: a.txt\n*** Delete File: b.txt\n*** End Patch'; + const portable = { patch }; assert.equal( - resolveApplyPatchProfile( - { - wire: 'openai-responses', - applyPatchProtocol: 'codex-v4a-freeform', - }, - 'deepseek-v4-flash', - ), - null, + normalizeApplyPatchReplayInput({ kind: 'codex-v4a-freeform' }, 'c', portable), + patch, ); + assert.deepEqual( + normalizeApplyPatchReplayInput({ kind: 'portable-v4a' }, 'c', patch), + portable, + ); + assert.equal(normalizeApplyPatchReplayInput({ kind: 'portable-v4a' }, 'c', portable), portable); assert.equal( - resolveApplyPatchProfile( - { wire: 'openai-chat', applyPatchProtocol: 'codex-v4a-freeform' }, - 'deepseek-v4-flash', - ), + normalizeApplyPatchReplayInput({ kind: 'openai-structured' }, 'c', portable), null, ); + assert.equal(normalizeApplyPatchReplayInput(null, 'c', portable), null); assert.equal( - resolveApplyPatchProfile( - { - wire: 'openai-responses', - applyPatchProtocol: 'codex-v4a-freeform', - }, - 'deepseek-v4-pro', - ), - null, + normalizeApplyPatchReplayInput({ kind: 'codex-v4a-freeform' }, 'c', { + operation: { type: 'delete_file', path: 'old.txt' }, + }), + '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch', ); - assert.equal(resolveApplyPatchProfile({ wire: 'openai-responses' }, 'deepseek-v4-flash'), null); }); test('preserves structured routing for documented native OpenAI models', () => { @@ -107,12 +169,12 @@ describe('ApplyPatch profile routing', () => { ), { kind: 'openai-structured' }, ); - assert.equal( + assert.deepEqual( resolveApplyPatchProfile( { wire: 'openai-chat', applyPatchProtocol: 'openai-structured' }, 'gpt-5.6', ), - null, + { kind: 'portable-v4a' }, ); assert.equal( resolveApplyPatchProfile( @@ -121,7 +183,9 @@ describe('ApplyPatch profile routing', () => { ), null, ); - assert.equal(resolveApplyPatchProfile({ wire: 'openai-responses' }, 'gpt-5.6'), null); + assert.deepEqual(resolveApplyPatchProfile({ wire: 'openai-responses' }, 'gpt-5.6'), { + kind: 'portable-v4a', + }); }); test('normalizes portable single-operation history', () => { diff --git a/packages/runtime/src/__tests__/builtin-tools.test.ts b/packages/runtime/src/__tests__/builtin-tools.test.ts index 1c919748cf..61ff04b15f 100644 --- a/packages/runtime/src/__tests__/builtin-tools.test.ts +++ b/packages/runtime/src/__tests__/builtin-tools.test.ts @@ -209,6 +209,12 @@ describe('builtin apply_patch', () => { assert.equal(await readFile(join(cwd, 'added.txt'), 'utf8'), 'hello\n'); assert.equal(await readFile(join(cwd, 'changed.txt'), 'utf8'), 'after\n'); + await runTool( + applyPatch, + { patch: '*** Begin Patch\n*** Delete File: added.txt\n*** End Patch' }, + cwd, + ); + await assert.rejects(access(join(cwd, 'added.txt'))); }); }); diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index bdf98b796f..86857319a0 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -818,3 +818,118 @@ describe('responses wire request body', () => { }); }); }); + +test('Codex custom ApplyPatch streams raw input and replays custom tool results', async () => { + const patch = '*** Begin Patch\n*** Delete File: old.txt\n*** End Patch'; + const item = { + type: 'custom_tool_call', + status: 'completed', + id: 'custom-1', + call_id: 'patch-1', + name: 'apply_patch', + input: patch, + }; + const response = { + id: 'response-patch', + object: 'response', + created_at: 0, + model: 'future-model', + status: 'completed', + output: [item], + usage: { input_tokens: 1, output_tokens: 1 }, + }; + const requests: Array> = []; + const fetch = (async (_url: unknown, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + requests.push(body); + if (!body.stream) return Response.json(response); + const events = [ + { type: 'response.created', response: { ...response, status: 'in_progress', output: [] } }, + { type: 'response.output_item.added', output_index: 0, item: { ...item, input: '' } }, + { + type: 'response.custom_tool_call_input.delta', + output_index: 0, + item_id: item.id, + delta: patch, + }, + { type: 'response.output_item.done', output_index: 0, item }, + { type: 'response.completed', response }, + ]; + return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), { + headers: { 'Content-Type': 'text/event-stream' }, + }); + }) as typeof globalThis.fetch; + const connection = conn('openai-codex'); + const model = getAIModel({ connection, apiKey: 'test-token', modelId: 'future-model', fetch }); + const adapter = new ModelAdapter({ + connection, + apiKey: 'test-token', + modelId: 'future-model', + modelFactory: () => model, + newId: () => 'test-id', + now: () => 0, + }); + const toolSet = { + apply_patch: { + kind: 'provider' as const, + providerTool: { kind: 'codex-apply-patch' as const }, + }, + }; + const stream = await adapter.startStream({ + model: adapter.resolveModel(), + messages: [{ role: 'user', content: 'edit' }], + tools: toolSet, + activeTools: ['apply_patch'], + system: 'Edit files', + onStreamActivity: () => {}, + abortSignal: new AbortController().signal, + repairToolCall: async () => null, + }); + const events = []; + for await (const event of stream.events) events.push(event); + assert.equal( + events.find((event) => event.kind === 'error'), + undefined, + ); + const call = events.find((event) => event.kind === 'tool-call'); + assert.equal(call?.toolCall.input, patch); + assert.notEqual(call?.toolCall.providerExecuted, true); + const declarations = requests[0]?.tools; + assert.ok(Array.isArray(declarations)); + const declaration = declarations[0]!; + assert.equal(declaration.type, 'custom'); + assert.equal(declaration.name, 'apply_patch'); + assert.equal((declaration.format as { syntax: string }).syntax, 'lark'); + assert.doesNotMatch(JSON.stringify(declaration.format), /Move to/); + + const tools = lowerModelTools(toolSet); + await model.doGenerate({ + prompt: [ + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'patch-1', toolName: 'apply_patch', input: patch }, + ], + }, + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: 'patch-1', + toolName: 'apply_patch', + output: { type: 'json', value: { status: 'failed', output: 'file not found' } }, + }, + ], + }, + ], + tools: [{ ...(tools.apply_patch as object), name: 'apply_patch' } as never], + providerOptions: { openai: { store: false } }, + }); + const replay = requests[1]?.input as Array>; + assert.equal(replay[0]?.type, 'custom_tool_call'); + assert.equal(replay[0]?.input, patch); + assert.equal(replay[1]?.type, 'custom_tool_call_output'); + assert.equal(replay[1]?.call_id, 'patch-1'); + assert.match(String(replay[1]?.output), /file not found/); +}); diff --git a/packages/runtime/src/apply-patch-profile.ts b/packages/runtime/src/apply-patch-profile.ts index d4d524b4ee..11a931dada 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -18,30 +18,38 @@ */ import type { ApplyPatchProtocol } from '@maka/core/llm-connections'; -import { parseCodexV4aPatch } from './codex-v4a-patch.js'; +import { modelApplyPatchEnabled } from '@maka/core/model-thinking'; +import { z } from 'zod'; +import { CODEX_PATCH_DESCRIPTION, parseCodexV4aPatch } from './codex-v4a-patch.js'; import type { ApplyPatchOperation } from './filesystem-executor.js'; import type { ModelRuntimeWire } from './model-runtime.js'; import { openAiModelSupportsApplyPatch } from './openai-apply-patch.js'; import type { MakaTool } from './tool-runtime.js'; -export type ApplyPatchProfile = { readonly kind: 'openai-structured' }; +export type ApplyPatchProfile = + | { readonly kind: 'openai-structured' } + | { readonly kind: 'codex-v4a-freeform' } + | { readonly kind: 'portable-v4a' }; export interface ApplyPatchProfileRuntime { readonly wire: ModelRuntimeWire; readonly applyPatchProtocol?: ApplyPatchProtocol; + readonly enabled?: boolean; + readonly customTools?: boolean; } -/** Resolve the exact provider/model/wire contract; unknown combinations fail closed. */ +/** User overrides take precedence; new models can opt in through ordinary function calling. */ export function resolveApplyPatchProfile( runtime: ApplyPatchProfileRuntime, modelId: string, ): ApplyPatchProfile | null { - if (runtime.wire !== 'openai-responses' || !runtime.applyPatchProtocol) return null; - const id = modelId.trim().toLowerCase(); - if (runtime.applyPatchProtocol === 'openai-structured' && openAiModelSupportsApplyPatch(id)) { - return { kind: 'openai-structured' }; - } - return null; + if (!modelApplyPatchEnabled(modelId, { applyPatch: runtime.enabled })) return null; + const structured = + runtime.wire === 'openai-responses' && + runtime.applyPatchProtocol === 'openai-structured' && + openAiModelSupportsApplyPatch(modelId.trim().toLowerCase()); + if (structured) return { kind: 'openai-structured' }; + return runtime.customTools ? { kind: 'codex-v4a-freeform' } : { kind: 'portable-v4a' }; } /** Project one verified profile into an exclusive model-facing editing surface. */ @@ -53,10 +61,25 @@ export function routeApplyPatchTools( if (!applyPatchTool) return [...tools]; if (!profile) return tools.filter((tool) => tool !== applyPatchTool); - return tools.filter((tool) => tool.name !== 'Write' && tool.name !== 'Edit'); + const routed = tools.filter((tool) => tool.name !== 'Write' && tool.name !== 'Edit'); + if (profile.kind === 'openai-structured') return routed; + return routed.map((tool) => + tool !== applyPatchTool + ? tool + : { + ...tool, + description: CODEX_PATCH_DESCRIPTION, + parameters: + profile.kind === 'codex-v4a-freeform' ? z.string() : z.object({ patch: z.string() }), + providerTool: + profile.kind === 'codex-v4a-freeform' + ? { kind: 'codex-apply-patch' as const } + : undefined, + }, + ); } -/** Convert historical freeform calls for a structured target, or reject an undeclared target. */ +/** Re-encode history for the current tool transport, or preserve it as facts when disabled. */ export function normalizeApplyPatchReplayInput( profile: ApplyPatchProfile | null, toolCallId: string, @@ -66,9 +89,16 @@ export function normalizeApplyPatchReplayInput( // Returning the historical input would serialize a call to an undeclared // tool; route it through the durable-fact downgrade instead. if (!profile) return null; - if (typeof input !== 'string') return input; + const patch = patchText(input); + if (profile.kind !== 'openai-structured') { + const text = patch ?? structuredPatchText(input); + if (text === null) return null; + if (profile.kind === 'codex-v4a-freeform') return text; + return patch !== null && typeof input === 'object' ? input : { patch: text }; + } + if (patch === null) return structuredApplyPatchOperation(input) ? input : null; try { - const operations = parseCodexV4aPatch(input); + const operations = parseCodexV4aPatch(patch); return operations.length === 1 ? { callId: toolCallId, operation: operations[0] } : null; } catch { return null; @@ -82,9 +112,10 @@ export function applyPatchReplayFactText( isError: boolean, ): string | null { let operations: ApplyPatchOperation[]; - if (typeof input === 'string') { + const patch = patchText(input); + if (patch !== null) { try { - operations = parseCodexV4aPatch(input); + operations = parseCodexV4aPatch(patch); } catch { return null; } @@ -156,3 +187,19 @@ function structuredApplyPatchOperation(input: unknown): ApplyPatchOperation | nu } return null; } + +function patchText(input: unknown): string | null { + if (typeof input === 'string') return input; + if (input && typeof input === 'object' && 'patch' in input && typeof input.patch === 'string') + return input.patch; + return null; +} + +function structuredPatchText(input: unknown): string | null { + const op = structuredApplyPatchOperation(input); + if (!op) return null; + const action = + op.type === 'create_file' ? 'Add' : op.type === 'delete_file' ? 'Delete' : 'Update'; + const body = op.type === 'delete_file' ? '' : op.diff.endsWith('\n') ? op.diff : `${op.diff}\n`; + return `*** Begin Patch\n*** ${action} File: ${op.path}\n${body}*** End Patch`; +} diff --git a/packages/runtime/src/builtin-tools.ts b/packages/runtime/src/builtin-tools.ts index 83343d6b0d..39ddafcf0e 100644 --- a/packages/runtime/src/builtin-tools.ts +++ b/packages/runtime/src/builtin-tools.ts @@ -278,10 +278,11 @@ export function buildBuiltinTools(options: BuildBuiltinToolsOptions = {}): MakaT providerTool: { kind: 'openai-apply-patch' }, executionFacts, impl: async (input, ctx) => { - if (typeof input !== 'string') { + const patch = typeof input === 'string' ? input : input.patch; + if (typeof patch !== 'string') { return await filesystem.applyPatch({ operation: input.operation, ...filesystemCall(ctx) }); } - const operations = parseCodexV4aPatch(input); + const operations = parseCodexV4aPatch(patch); return await executeApplyPatchOperations( operations, async (operation) => { diff --git a/packages/runtime/src/codex-v4a-patch.ts b/packages/runtime/src/codex-v4a-patch.ts index 0e414956f2..fb51d092ed 100644 --- a/packages/runtime/src/codex-v4a-patch.ts +++ b/packages/runtime/src/codex-v4a-patch.ts @@ -19,6 +19,26 @@ import type { ApplyPatchOperation } from './filesystem-executor.js'; +export const CODEX_PATCH_DESCRIPTION = `Edit files using a patch enclosed in *** Begin Patch and *** End Patch. +Use *** Add File: path with each content line prefixed by +; *** Delete File: path; +or *** Update File: path with @@ context markers and lines prefixed by space (unchanged), +- (removed), or + (added). Include enough unchanged context to locate each edit. +One patch may contain multiple file operations. Moves are not supported: add the destination +and delete the source instead. Read existing files before patching them.`; + +// Match the executor's supported subset: intentionally no Move to production. +export const CODEX_PATCH_GRAMMAR = String.raw`start: begin_patch hunk+ end_patch +begin_patch: "*** Begin Patch" LF +end_patch: "*** End Patch" LF? +hunk: add_hunk | delete_hunk | update_hunk +add_hunk: "*** Add File: " filename LF add_line+ +delete_hunk: "*** Delete File: " filename LF +update_hunk: "*** Update File: " filename LF change+ +filename: /[^\r\n]+/ +add_line: "+" /[^\r\n]+/? LF +change: ("@@" | "@@ " /[^\r\n]+/) LF | ("+" | "-" | " ") /[^\r\n]+/? LF | "*** End of File" LF +%import common.LF`; + export class CodexV4aPatchError extends Error { constructor(message: string) { super(message); diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index d9ed62b3e1..bfa33ccfe9 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -80,7 +80,7 @@ import { OPENAI_RESPONSES_LANE_HEADER, type OpenAiResponsesTransportState, } from './openai-responses-websocket.js'; -import { openAiApplyPatchProviderTool } from './openai-apply-patch.js'; +import { openAiApplyPatchProviderTool, codexApplyPatchProviderTool } from './openai-apply-patch.js'; import { TOOL_SEARCH_NAME, TOOL_SEARCH_PROVIDER_NAME } from './tool-availability.js'; /** @@ -1256,6 +1256,8 @@ function compileProviderTool( switch (tool.kind) { case 'openai-apply-patch': return openAiApplyPatchProviderTool; + case 'codex-apply-patch': + return codexApplyPatchProviderTool; case 'openai-web-search': return openai.tools.webSearch({ ...(tool.searchContextSize ? { searchContextSize: tool.searchContextSize } : {}), diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 644e954424..5aad2c5816 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -31,6 +31,7 @@ import { openAiAdapterApiProtocol, } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; +import { modelOverride, type ModelOverrides } from '@maka/core/model-thinking'; import { anthropicV1BaseUrl, googleV1BetaBaseUrl, @@ -103,6 +104,7 @@ export type ResolvedModelRuntime = ModelRuntimeCall & { }; export interface ModelRuntimeConnection { + readonly modelOverrides?: ModelOverrides; readonly slug?: string; readonly providerType: ProviderType; readonly baseUrl?: string; @@ -174,6 +176,12 @@ export function resolveModelRuntime( { wire, applyPatchProtocol: adapter.applyPatchProtocol, + enabled: modelOverride(connection, modelId)?.applyPatch, + customTools: + wire === 'openai-responses' && + (connection.providerType === 'openai' || connection.providerType === 'openai-codex') && + replay.kind === 'responses' && + replay.contract.adapter === 'openai', }, modelId, ), diff --git a/packages/runtime/src/openai-apply-patch.ts b/packages/runtime/src/openai-apply-patch.ts index 2fc22d6f9b..a1f44532f9 100644 --- a/packages/runtime/src/openai-apply-patch.ts +++ b/packages/runtime/src/openai-apply-patch.ts @@ -18,6 +18,12 @@ */ import { openai } from '@ai-sdk/openai'; +import { CODEX_PATCH_DESCRIPTION, CODEX_PATCH_GRAMMAR } from './codex-v4a-patch.js'; + +export const codexApplyPatchProviderTool = openai.tools.customTool({ + description: `${CODEX_PATCH_DESCRIPTION}\nPass raw patch text, not JSON.`, + format: { type: 'grammar', syntax: 'lark', definition: CODEX_PATCH_GRAMMAR }, +}); export const openAiApplyPatchProviderTool = openai.tools.applyPatch({}); const inputSchema = openAiApplyPatchProviderTool.inputSchema; diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index e7dc056cd4..cc67a6062c 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -193,7 +193,11 @@ export interface MakaTool

{ * client-executed tools such as ApplyPatch still settle through ToolRuntime. */ providerTool?: { - readonly kind: 'openai-apply-patch' | 'openai-web-search' | 'anthropic-web-search-20250305'; + readonly kind: + | 'openai-apply-patch' + | 'codex-apply-patch' + | 'openai-web-search' + | 'anthropic-web-search-20250305'; readonly searchContextSize?: 'low' | 'medium' | 'high'; readonly maxUses?: number; }; From e64ead11a3ffdf7d93a7339c16d4235c58316055 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:31:16 +0800 Subject: [PATCH 2/5] fix(runtime): align hosted patch tool contract Remove the unused Codex ApplyPatch protocol declaration and update the headless DeepSeek wire contract for default-enabled portable patch editing. Generated-by: OpenAI Codex --- packages/core/src/provider-registry.ts | 1 - .../execution-model-composition.test.ts | 17 ++++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index e4d65e4e82..1fa3d8ce71 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1740,7 +1740,6 @@ const providerRegistry = { status: 'phase3-experimental', runtimeAdapter: { kind: 'openai-codex', - applyPatchProtocol: 'codex-v4a-freeform', responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, }, modelDiscovery: { kind: 'protocol', auth: 'openai-codex' }, diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 21949b0bfc..a8b7cd3398 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -159,7 +159,7 @@ const MAX_IMPLEMENTATION_CHILD_REQUESTS = const HEADLESS_CODING_V1_PROMPT_HASH = 'sha256:b2773282ac4755dc8d8a663eafdec68c3fa6f5680ec8557d261b5f723672b467'; const HEADLESS_CODING_V1_TOOLS_HASH = - 'sha256:9ef90b13f64829ae5baba777e929177838b59c9ed73e12a8c0b24c418ea2e473'; + 'sha256:4bb0eb9897640ff723301f274e2b5c91ff704c65672036d7583bc2e846ed30a2'; const execFileAsync = promisify(execFile); test('backend creation resolves a bound Session by immutable Connection identity', async () => { let observedRef: unknown; @@ -2587,14 +2587,25 @@ test('hosted execution freezes the headless coding provider wire contract', asyn assert.equal(stableHash(tools), HEADLESS_CODING_V1_TOOLS_HASH); assert.deepEqual(responsesToolNames(request?.body), [ 'Bash', - 'Edit', 'Glob', 'Grep', 'Read', 'StopBackgroundTask', - 'Write', 'WriteStdin', + 'apply_patch', ]); + // DeepSeek defaults to portable ApplyPatch instead of Write/Edit, including + // hosted headless sessions. Freeze its actual function-call wire format. + const patch = tools.find((tool) => tool.name === 'apply_patch'); + assert.ok(patch); + assert.equal(patch.type, 'function'); + assert.deepEqual(patch.parameters, { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { patch: { type: 'string' } }, + required: ['patch'], + additionalProperties: false, + }); const bash = (tools as Array>).find((tool) => tool.name === 'Bash'); assert.ok(bash); // The Eval session runs with Full access: the product Bash, minus the From 6401e1297d5b6478883008cde7e54b50ed3aab8a Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:45:38 +0800 Subject: [PATCH 3/5] ci: rerun checks after transient desktop failure The hosted patch contract passes. The unrelated Side Chat cleanup scenario and its five disposal tests pass locally; trigger another CI run because upstream rerun permissions are unavailable. Generated-by: OpenAI Codex From b5712c28c4b2724b8b10c20cc8c2c9059a3fcbe1 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:22:48 +0800 Subject: [PATCH 4/5] fix(runtime): address ApplyPatch review feedback Keep ApplyPatch available inside Code Mode through a portable nested projection, align the headless prompt with routed tools, remove the dead DeepSeek protocol declaration, and restore the model setting's automatic state. --- .../provider-capability-editor.tsx | 16 +++++- .../settings-provider-copy.ts | 4 ++ .../settings/provider-settings.stories.tsx | 10 ++-- packages/core/src/provider-registry.ts | 1 - .../execution-model-composition.test.ts | 4 +- .../hosted-execution-tool-profile.test.ts | 2 +- .../server/hosted-execution-tool-profile.ts | 2 +- .../src/__tests__/code-mode-backend.test.ts | 56 +++++++++++++++++++ packages/runtime/src/ai-sdk-turn.ts | 7 +++ packages/runtime/src/apply-patch-profile.ts | 30 ++++++---- 10 files changed, 110 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx index ee90439ccd..5dfc27573e 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx +++ b/apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx @@ -54,6 +54,12 @@ export function CapabilityEditor(props: { const thinkingId = useId(); const visionValue = declared?.vision === true ? 'enabled' : declared?.vision === false ? 'disabled' : 'auto'; + const applyPatchValue = + declared?.applyPatch === true + ? 'enabled' + : declared?.applyPatch === false + ? 'disabled' + : 'auto'; const draftLevels = declared?.thinkingLevels ?? []; // The menu offers the five declarable levels PLUS anything the stored table // already claims — a level saved while it was still declarable (or @@ -103,11 +109,17 @@ export function CapabilityEditor(props: { size="sm" width="100%" options={[ + { + value: 'auto', + label: copy.applyPatchDefaultOption(modelApplyPatchEnabled(modelId)), + }, { value: 'enabled', label: copy.applyPatchEnabled }, { value: 'disabled', label: copy.applyPatchDisabled }, ]} - value={modelApplyPatchEnabled(modelId, declared) ? 'enabled' : 'disabled'} - onChange={(value) => props.onChange({ applyPatch: value === 'enabled' })} + value={applyPatchValue} + onChange={(value) => + props.onChange({ applyPatch: value === 'auto' ? undefined : value === 'enabled' }) + } isDisabled={props.disabled} /> diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index 4b14a59402..ae8f9cab5e 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -53,6 +53,7 @@ const zhCapabilitiesCopy = { visionDisabledOption: '不支持', applyPatch: 'ApplyPatch 文件编辑', applyPatchHelp: '已知支持补丁编辑的模型默认启用,其余默认关闭。可为当前模型单独修改;关闭后使用 Write/Edit。', + applyPatchDefaultOption: (enabled: boolean) => (enabled ? '自动 · 启用' : '自动 · 关闭'), applyPatchEnabled: '启用', applyPatchDisabled: '关闭', contextWindow: '上下文窗口', @@ -86,6 +87,7 @@ const zhTwCapabilitiesCopy = { visionDisabledOption: '不支援', applyPatch: 'ApplyPatch 檔案編輯', applyPatchHelp: '已知支援補丁編輯的模型預設啟用,其餘預設關閉。可為目前模型單獨修改;關閉後使用 Write/Edit。', + applyPatchDefaultOption: (enabled: boolean) => (enabled ? '自動 · 啟用' : '自動 · 關閉'), applyPatchEnabled: '啟用', applyPatchDisabled: '關閉', contextWindow: '上下文視窗', @@ -118,6 +120,8 @@ const enCapabilitiesCopy = { visionDisabledOption: 'Do not send images', applyPatch: 'ApplyPatch file editing', applyPatchHelp: 'Enabled by default for models known to support patch editing, disabled for other models. Change this setting for the current model; disabling uses Write/Edit.', + applyPatchDefaultOption: (enabled: boolean) => + enabled ? 'Automatic: enabled' : 'Automatic: disabled', applyPatchEnabled: 'Enabled', applyPatchDisabled: 'Disabled', contextWindow: 'Context window', diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index d14ead0efa..ff868903fb 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -912,9 +912,9 @@ export const ModelCapabilities: Story = { configure.click(); await waitFor(() => expect(document.querySelector('dialog[open] .astryx-form-layout')).not.toBeNull()); const patch = within(document.body).getByRole('combobox', { name: /^ApplyPatch/ }); - expect(patch).toHaveTextContent(/^(启用|啟用|Enabled)$/); + expect(patch).toHaveTextContent(/^(自动|自動|Automatic)/); await userEvent.click(patch); - expect(within(document.body).getAllByRole('option')).toHaveLength(2); + expect(within(document.body).getAllByRole('option')).toHaveLength(3); await userEvent.keyboard('{Escape}'); const pane = canvasElement.querySelector('.settingsMainPane'); if (pane) pane.scrollTop = 0; @@ -938,7 +938,7 @@ export const ModelParameterSave: Story = { await userEvent.click(vision()); await userEvent.click(await body.findByRole('option', { name: /^(支持|支援|Allow images)$/i })); const patch = () => body.getByRole('combobox', { name: /^ApplyPatch/ }); - expect(patch()).toHaveTextContent(/^(关闭|關閉|Disabled)$/); + expect(patch()).toHaveTextContent(/^(自动|自動|Automatic)/); await userEvent.click(patch()); await userEvent.click(await body.findByRole('option', { name: /^(启用|啟用|Enabled)$/ })); const save = body.getByRole('button', { name: /^(保存|儲存|Save)$/i }); @@ -963,14 +963,14 @@ export const ModelParameterSave: Story = { expect(vision()).toHaveTextContent(/^(支持|支援|Allow images)$/i); expect(patch()).toHaveTextContent(/^(启用|啟用|Enabled)$/); await userEvent.click(patch()); - await userEvent.click(await body.findByRole('option', { name: /^(关闭|關閉|Disabled)$/ })); + await userEvent.click(await body.findByRole('option', { name: /^(自动|自動|Automatic)/ })); await userEvent.click(vision()); await userEvent.click(await body.findByRole('option', { name: /^(自动|自動|Model information)/i })); await userEvent.click(body.getByRole('button', { name: /^(保存|儲存|Save)$/i })); await waitFor(() => expect(configure).toHaveFocus()); await userEvent.click(configure); expect(vision()).toHaveTextContent(/^(自动|自動|Model information)/i); - expect(patch()).toHaveTextContent(/^(关闭|關閉|Disabled)$/); + expect(patch()).toHaveTextContent(/^(自动|自動|Automatic)/); expect(body.getByRole('textbox', { name: /^(输入上限|輸入上限|Input limit)$/i })).toHaveValue('64000'); expect(enable).not.toBeChecked(); const editable = body.getByRole('textbox', { name: /^(上下文窗口|上下文視窗|Context window)$/i }); diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 1fa3d8ce71..db638c9d3f 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -942,7 +942,6 @@ const providerRegistry = { runtimeAdapter: { kind: 'openai-compatible', name: 'provider', - applyPatchProtocol: 'codex-v4a-freeform', responses: { adapter: 'open-responses', reasoningReplay: 'plaintext-content' }, }, modelDiscovery: { kind: 'protocol' }, diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index a8b7cd3398..acaaf2029c 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -157,7 +157,7 @@ const MIN_IMPLEMENTATION_CHILD_REQUESTS = 6; const MAX_IMPLEMENTATION_CHILD_REQUESTS = MIN_IMPLEMENTATION_CHILD_REQUESTS + MAX_IMPLEMENTATION_CHILD_PTY_READS - 1; const HEADLESS_CODING_V1_PROMPT_HASH = - 'sha256:b2773282ac4755dc8d8a663eafdec68c3fa6f5680ec8557d261b5f723672b467'; + 'sha256:e490f6055478bf8cdcef1aa85217de623f0954120a692358dbba2065ba6710fc'; const HEADLESS_CODING_V1_TOOLS_HASH = 'sha256:4bb0eb9897640ff723301f274e2b5c91ff704c65672036d7583bc2e846ed30a2'; const execFileAsync = promisify(execFile); @@ -4851,7 +4851,7 @@ test('the headless coding profile freezes the Eval prompt and tool ceiling', asy ).text, [ 'Complete the task by acting with the available tools, not by narrating.', - 'Prefer Read, Glob, and Grep for inspection, Edit and Write for file changes, and Bash for shell commands and tests.', + 'Prefer Read, Glob, and Grep for inspection, the available file-editing tool for file changes, and Bash for shell commands and tests.', 'Verify the result when practical.', 'Stop when the task is complete.', ].join('\n'), diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts index e704df0e5f..7b8405bfd9 100644 --- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts +++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts @@ -78,7 +78,7 @@ test('the headless coding profile freezes prompt, tools, and memory and passes p profile.systemPrompt, [ 'Complete the task by acting with the available tools, not by narrating.', - 'Prefer Read, Glob, and Grep for inspection, Edit and Write for file changes, and Bash for shell commands and tests.', + 'Prefer Read, Glob, and Grep for inspection, the available file-editing tool for file changes, and Bash for shell commands and tests.', 'Verify the result when practical.', 'Stop when the task is complete.', ].join('\n'), diff --git a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts index 166f00891b..1221eee20f 100644 --- a/packages/runtime-host/src/server/hosted-execution-tool-profile.ts +++ b/packages/runtime-host/src/server/hosted-execution-tool-profile.ts @@ -37,7 +37,7 @@ const HEADLESS_CODING_V1_TOOL_NAMES = [ const HEADLESS_CODING_V1_SYSTEM_PROMPT = [ 'Complete the task by acting with the available tools, not by narrating.', - 'Prefer Read, Glob, and Grep for inspection, Edit and Write for file changes, and Bash for shell commands and tests.', + 'Prefer Read, Glob, and Grep for inspection, the available file-editing tool for file changes, and Bash for shell commands and tests.', 'Verify the result when practical.', 'Stop when the task is complete.', ].join('\n'); diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts index ece8d83949..6e3df79cd2 100644 --- a/packages/runtime/src/__tests__/code-mode-backend.test.ts +++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts @@ -715,6 +715,60 @@ test('keeps provider-native tools out of the cell snapshot', async () => { assert.match(JSON.stringify(execResult?.content), /execution_error/); }); +test('projects provider-native ApplyPatch as a portable Code Mode tool', async (t) => { + const cases = [ + { + name: 'OpenAI structured', + connection: { ...connection(), providerType: 'openai' as const }, + modelId: 'gpt-5.4', + }, + { + name: 'Codex freeform', + connection: { ...connection(), providerType: 'openai-codex' as const }, + modelId: 'gpt-6-astra', + }, + ]; + for (const testCase of cases) { + await t.test(testCase.name, async () => { + const inputs: unknown[] = []; + const tools: MakaTool[] = [ + { + name: 'apply_patch', + description: 'Provider-native patch tool', + parameters: z.string(), + providerTool: { kind: 'openai-apply-patch' }, + impl: (input) => { + inputs.push(input); + return { ok: true }; + }, + }, + ]; + const events = await collect( + backend( + execThenStopModel('return await tools.apply_patch({ patch: "portable patch" })'), + [], + undefined, + { + tools, + connection: testCase.connection, + modelId: testCase.modelId, + }, + ).send({ + turnId: `turn-code-${testCase.modelId}`, + text: 'edit through ApplyPatch', + context: [], + toolMode: 'code_mode', + }), + ); + + assert.deepEqual(inputs, [{ patch: 'portable patch' }], JSON.stringify(events)); + assert.ok( + events.some((event) => event.type === 'tool_start' && event.toolName === 'apply_patch'), + ); + }); + } +}); + test('validates nested arguments before ToolRuntime implementation dispatch', async () => { let implementationCalls = 0; const tools: MakaTool[] = [ @@ -1194,6 +1248,8 @@ function backend( Pick< AiSdkBackendInput, | 'tools' + | 'connection' + | 'modelId' | 'header' | 'maxSteps' | 'toolAvailability' diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index 9edb1a0d1d..8b8d5905b5 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -121,6 +121,7 @@ import { type ContextDiagnosticsCompaction, } from './context-diagnostics.js'; import { AiSdkCompaction, hasBlockingReplayDiagnostics } from './ai-sdk-compaction.js'; +import { portableApplyPatchTool } from './apply-patch-profile.js'; import { RunTrace } from './run-trace.js'; import { REQUEST_SANDBOX_BOUNDARY_TOOL_NAME, @@ -453,6 +454,12 @@ function nestableToolSnapshot( const active = new Set(activeToolNames); return new Map( providerTools + .map((tool) => + tool.providerTool?.kind === 'openai-apply-patch' || + tool.providerTool?.kind === 'codex-apply-patch' + ? portableApplyPatchTool(tool) + : tool, + ) .filter( (tool) => active.has(tool.name) && diff --git a/packages/runtime/src/apply-patch-profile.ts b/packages/runtime/src/apply-patch-profile.ts index 11a931dada..2dc0b6cf07 100644 --- a/packages/runtime/src/apply-patch-profile.ts +++ b/packages/runtime/src/apply-patch-profile.ts @@ -38,6 +38,18 @@ export interface ApplyPatchProfileRuntime { readonly customTools?: boolean; } +const portableApplyPatchParameters = z.object({ patch: z.string() }); + +/** Project a provider-native ApplyPatch tool into the portable client-executed shape. */ +export function portableApplyPatchTool(tool: MakaTool): MakaTool { + return { + ...tool, + description: CODEX_PATCH_DESCRIPTION, + parameters: portableApplyPatchParameters, + providerTool: undefined, + }; +} + /** User overrides take precedence; new models can opt in through ordinary function calling. */ export function resolveApplyPatchProfile( runtime: ApplyPatchProfileRuntime, @@ -66,16 +78,14 @@ export function routeApplyPatchTools( return routed.map((tool) => tool !== applyPatchTool ? tool - : { - ...tool, - description: CODEX_PATCH_DESCRIPTION, - parameters: - profile.kind === 'codex-v4a-freeform' ? z.string() : z.object({ patch: z.string() }), - providerTool: - profile.kind === 'codex-v4a-freeform' - ? { kind: 'codex-apply-patch' as const } - : undefined, - }, + : profile.kind === 'codex-v4a-freeform' + ? { + ...tool, + description: CODEX_PATCH_DESCRIPTION, + parameters: z.string(), + providerTool: { kind: 'codex-apply-patch' as const }, + } + : portableApplyPatchTool(tool), ); } From bbac1b3270e8c68d1d001fe9b9de25cf9e935e31 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:33:22 +0800 Subject: [PATCH 5/5] fix(settings): clarify automatic ApplyPatch behavior Explain the model default, per-connection override scope, editing tools, and how to restore Automatic in Simplified Chinese, Traditional Chinese, and English. Generated-by: OpenAI Codex --- .../features/connection-settings/settings-provider-copy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index ae8f9cab5e..07211d70cd 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -52,7 +52,7 @@ const zhCapabilitiesCopy = { visionEnabledOption: '支持', visionDisabledOption: '不支持', applyPatch: 'ApplyPatch 文件编辑', - applyPatchHelp: '已知支持补丁编辑的模型默认启用,其余默认关闭。可为当前模型单独修改;关闭后使用 Write/Edit。', + applyPatchHelp: '自动跟随模型默认设置;手动启用或关闭仅影响此连接中的当前模型。启用时使用 ApplyPatch 编辑文件,关闭时使用 Write/Edit。选择自动可恢复默认设置。', applyPatchDefaultOption: (enabled: boolean) => (enabled ? '自动 · 启用' : '自动 · 关闭'), applyPatchEnabled: '启用', applyPatchDisabled: '关闭', @@ -86,7 +86,7 @@ const zhTwCapabilitiesCopy = { visionEnabledOption: '支援', visionDisabledOption: '不支援', applyPatch: 'ApplyPatch 檔案編輯', - applyPatchHelp: '已知支援補丁編輯的模型預設啟用,其餘預設關閉。可為目前模型單獨修改;關閉後使用 Write/Edit。', + applyPatchHelp: '自動依模型預設設定;手動啟用或關閉僅影響此連線中的目前模型。啟用時使用 ApplyPatch 編輯檔案,關閉時使用 Write/Edit。選擇自動可恢復預設設定。', applyPatchDefaultOption: (enabled: boolean) => (enabled ? '自動 · 啟用' : '自動 · 關閉'), applyPatchEnabled: '啟用', applyPatchDisabled: '關閉', @@ -119,7 +119,7 @@ const enCapabilitiesCopy = { visionEnabledOption: 'Allow images', visionDisabledOption: 'Do not send images', applyPatch: 'ApplyPatch file editing', - applyPatchHelp: 'Enabled by default for models known to support patch editing, disabled for other models. Change this setting for the current model; disabling uses Write/Edit.', + applyPatchHelp: 'Automatic follows the model default. Enabled uses ApplyPatch to edit files; Disabled uses Write/Edit. Manual choices apply only to this model on this connection. Select Automatic to restore the default.', applyPatchDefaultOption: (enabled: boolean) => enabled ? 'Automatic: enabled' : 'Automatic: disabled', applyPatchEnabled: 'Enabled',