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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,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
Expand Down Expand Up @@ -96,6 +103,26 @@ export function CapabilityEditor(props: {
isDisabled={props.disabled}
/>

<Selector
label={copy.applyPatch}
labelTooltip={copy.applyPatchHelp}
size="sm"
width="100%"
options={[
{
value: 'auto',
label: copy.applyPatchDefaultOption(modelApplyPatchEnabled(modelId)),
},
{ value: 'enabled', label: copy.applyPatchEnabled },
{ value: 'disabled', label: copy.applyPatchDisabled },
]}
value={applyPatchValue}
onChange={(value) =>
props.onChange({ applyPatch: value === 'auto' ? undefined : value === 'enabled' })
}
isDisabled={props.disabled}
/>

<TextInput
size="sm"
width="100%"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ const zhCapabilitiesCopy = {
supported === undefined ? '自动' : supported ? '自动 · 支持' : '自动 · 不支持',
visionEnabledOption: '支持',
visionDisabledOption: '不支持',
applyPatch: 'ApplyPatch 文件编辑',
applyPatchHelp: '自动跟随模型默认设置;手动启用或关闭仅影响此连接中的当前模型。启用时使用 ApplyPatch 编辑文件,关闭时使用 Write/Edit。选择自动可恢复默认设置。',
applyPatchDefaultOption: (enabled: boolean) => (enabled ? '自动 · 启用' : '自动 · 关闭'),
applyPatchEnabled: '启用',
applyPatchDisabled: '关闭',
contextWindow: '上下文窗口',
inputLimit: '输入上限',
inputLimitHelp: '单次请求可输入的 token 数。留空时按模型资料设置。',
Expand Down Expand Up @@ -80,6 +85,11 @@ const zhTwCapabilitiesCopy = {
supported === undefined ? '自動' : supported ? '自動 · 支援' : '自動 · 不支援',
visionEnabledOption: '支援',
visionDisabledOption: '不支援',
applyPatch: 'ApplyPatch 檔案編輯',
applyPatchHelp: '自動依模型預設設定;手動啟用或關閉僅影響此連線中的目前模型。啟用時使用 ApplyPatch 編輯檔案,關閉時使用 Write/Edit。選擇自動可恢復預設設定。',
applyPatchDefaultOption: (enabled: boolean) => (enabled ? '自動 · 啟用' : '自動 · 關閉'),
applyPatchEnabled: '啟用',
applyPatchDisabled: '關閉',
contextWindow: '上下文視窗',
inputLimit: '輸入上限',
inputLimitHelp: '單次請求可輸入的 token 數。留空時依模型資料設定。',
Expand Down Expand Up @@ -108,6 +118,12 @@ const enCapabilitiesCopy = {
supported === undefined ? 'Use model information' : supported ? 'Model information: allow images' : 'Model information: no images',
visionEnabledOption: 'Allow images',
visionDisabledOption: 'Do not send images',
applyPatch: 'ApplyPatch file editing',
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',
applyPatchDisabled: 'Disabled',
contextWindow: 'Context window',
inputLimit: 'Input limit',
inputLimitHelp: 'Maximum input tokens per request. Leave empty to use model information.',
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/stories/settings/provider-settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,11 @@ export const ModelCapabilities: Story = {
const configure = await within(canvasElement).findByRole('button', { name: /(?:参数|參數|parameters).*gpt-5.6-luna/i });
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(/^(自动|自動|Automatic)/);
await userEvent.click(patch);
expect(within(document.body).getAllByRole('option')).toHaveLength(3);
await userEvent.keyboard('{Escape}');
const pane = canvasElement.querySelector('.settingsMainPane');
if (pane) pane.scrollTop = 0;
},
Expand All @@ -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(/^(自动|自動|Automatic)/);
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');
Expand All @@ -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: /^(自动|自動|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(/^(自动|自動|Automatic)/);
expect(body.getByRole('textbox', { name: /^(输入上限|輸入上限|Input limit)$/i })).toHaveValue('64000');
expect(enable).not.toBeChecked();
const editable = body.getByRole('textbox', { name: /^(上下文窗口|上下文視窗|Context window)$/i });
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/__tests__/model-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { test } from 'node:test';
import {
type ConnectionThinkingContext,
normalizeModelOverrides,
modelApplyPatchEnabled,
modelOverride,
resolveThinkingLevel,
thinkingOptionsForModel,
Expand Down Expand Up @@ -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);
}
});
13 changes: 13 additions & 0 deletions packages/core/src/__tests__/runtime-policy-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
47 changes: 47 additions & 0 deletions packages/core/src/model-thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -138,6 +140,48 @@ export interface ModelOverride {

export type ModelOverrides = Readonly<Record<string, ModelOverride>>;

/** Known patch-capable models. Unknown models stay off until explicitly enabled. */
const APPLY_PATCH_MODELS: ReadonlySet<string> = 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<ModelOverride, 'applyPatch'>,
): 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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -364,6 +410,7 @@ export function applyModelOverride(
serviceTier: _tier,
compactionThreshold: _threshold,
maxOutputTokens: _outputBudget,
applyPatch: _applyPatch,
vision,
capabilities,
...facts
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/runtime-policy/connection-catalog-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export function decodeModelOverridesTable(value: unknown): Readonly<Record<strin
[
'thinkingLevels',
'vision',
'applyPatch',
'contextWindow',
'serviceTier',
'compactionThreshold',
Expand Down Expand Up @@ -305,6 +306,9 @@ export function decodeModelOverridesTable(value: unknown): Readonly<Record<strin
if (entry.vision !== undefined) {
declared.vision = booleanValue(entry.vision, `declared vision for ${modelId}`);
}
if (entry.applyPatch !== undefined) {
declared.applyPatch = booleanValue(entry.applyPatch, `declared ApplyPatch for ${modelId}`);
}
for (const field of [
'contextWindow',
'compactionThreshold',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ 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:9ef90b13f64829ae5baba777e929177838b59c9ed73e12a8c0b24c418ea2e473';
'sha256:4bb0eb9897640ff723301f274e2b5c91ff704c65672036d7583bc2e846ed30a2';
const execFileAsync = promisify(execFile);
test('backend creation resolves a bound Session by immutable Connection identity', async () => {
let observedRef: unknown;
Expand Down Expand Up @@ -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<Record<string, unknown>>).find((tool) => tool.name === 'Bash');
assert.ok(bash);
// The Eval session runs with Full access: the product Bash, minus the
Expand Down Expand Up @@ -4840,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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
11 changes: 6 additions & 5 deletions packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ describe('AiSdkBackend ApplyPatch routing', () => {
}
});

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: {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
);
Expand Down
Loading