| author | |
| committer | |
| log | bc4b00666272188027d086361fa5ae01cda53bd2 |
| tree | 1316cbce9f7b704d39a3caea3d82ea47749f3090 |
| parent | 9d345fa957cf22806171c8037432eec8d9ed496d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>7 files changed, 188 insertions(+), 7 deletions(-)
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+2| ... | @@ -669,6 +669,8 @@ function AutoSettleSettingsRows() { | ... | @@ -669,6 +669,8 @@ function AutoSettleSettingsRows() { |
| 669 | patch: filterSharedServerPatch( | 669 | patch: filterSharedServerPatch( |
| 670 | patch, | 670 | patch, |
| 671 | target?.serverConfig?.environment.capabilities, | 671 | target?.serverConfig?.environment.capabilities, |
| 672 | target?.serverConfig?.settings, | ||
| 673 | referenceSettings, | ||
| 672 | ), | 674 | ), |
| 673 | }, | 675 | }, |
| 674 | }); | 676 | }); |
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+4| ... | @@ -50,6 +50,10 @@ function makeFakeClaudeBinary(dir: string) { | ... | @@ -50,6 +50,10 @@ function makeFakeClaudeBinary(dir: string) { |
| 50 | " process.exit(code);", | 50 | " process.exit(code);", |
| 51 | "}", | 51 | "}", |
| 52 | "", | 52 | "", |
| 53 | 'const permissionIndex = argv.indexOf("--permission-mode");', | ||
| 54 | 'if (permissionIndex === -1 || argv[permissionIndex + 1] !== "dontAsk") {', | ||
| 55 | ' fail("text generation must deny permission prompts", 12);', | ||
| 56 | "}", | ||
| 53 | 'const toolsIndex = argv.indexOf("--tools");', | 57 | 'const toolsIndex = argv.indexOf("--tools");', |
| 54 | 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {', | 58 | 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {', |
| 55 | ' fail("text generation must receive an explicit empty tool set", 6);', | 59 | ' fail("text generation must receive an explicit empty tool set", 6);', |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+2| ... | @@ -214,6 +214,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -214,6 +214,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 214 | "", | 214 | "", |
| 215 | "--disable-slash-commands", | 215 | "--disable-slash-commands", |
| 216 | "--strict-mcp-config", | 216 | "--strict-mcp-config", |
| 217 | "--permission-mode", | ||
| 218 | "dontAsk", | ||
| 217 | ], | 219 | ], |
| 218 | { env: claudeEnvironment }, | 220 | { env: claudeEnvironment }, |
| 219 | ); | 221 | ); |
apps/web/src/components/settings/SettingsPanels.tsx+1-1| ... | @@ -2718,7 +2718,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2718,7 +2718,7 @@ export function GeneralSettingsPanel() { |
| 2718 | <SettingsRow | 2718 | <SettingsRow |
| 2719 | serverScoped | 2719 | serverScoped |
| 2720 | {...searchableSetting("text-generation-model")} | 2720 | {...searchableSetting("text-generation-model")} |
| 2721 | description="Used for thread titles and other generated text. Source control can override it." | 2721 | description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it." |
| 2722 | resetAction={ | 2722 | resetAction={ |
| 2723 | isTextGenerationModelDirty ? ( | 2723 | isTextGenerationModelDirty ? ( |
| 2724 | <SettingResetButton | 2724 | <SettingResetButton |
apps/web/src/hooks/useSettings.ts+12-1| ... | @@ -450,6 +450,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -450,6 +450,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 450 | } | 450 | } |
| 451 | } | 451 | } |
| 452 | if (Object.keys(sharedPatch).length > 0) { | 452 | if (Object.keys(sharedPatch).length > 0) { |
| 453 | const sourceSettings = environments.find( | ||
| 454 | (target) => target.environmentId === environmentId, | ||
| 455 | )?.serverConfig?.settings; | ||
| 453 | const targets = new Set( | 456 | const targets = new Set( |
| 454 | environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), | 457 | environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), |
| 455 | ); | 458 | ); |
| ... | @@ -462,6 +465,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { | ... | @@ -462,6 +465,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 462 | const targetPatch = filterSharedServerPatch( | 465 | const targetPatch = filterSharedServerPatch( |
| 463 | sharedPatch, | 466 | sharedPatch, |
| 464 | target?.serverConfig?.environment.capabilities, | 467 | target?.serverConfig?.environment.capabilities, |
| 468 | target?.serverConfig?.settings, | ||
| 469 | sourceSettings, | ||
| 470 | targetId === environmentId, | ||
| 465 | ); | 471 | ); |
| 466 | if (Object.keys(targetPatch).length === 0) continue; | 472 | if (Object.keys(targetPatch).length === 0) continue; |
| 467 | wroteToTarget = true; | 473 | wroteToTarget = true; |
| ... | @@ -540,7 +546,12 @@ export function useSharedSettingsSync() { | ... | @@ -540,7 +546,12 @@ export function useSharedSettingsSync() { |
| 540 | void persistServerSettings({ | 546 | void persistServerSettings({ |
| 541 | environmentId: mismatch.environmentId, | 547 | environmentId: mismatch.environmentId, |
| 542 | input: { | 548 | input: { |
| 543 | patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities), | 549 | patch: filterSharedServerPatch( |
| 550 | patch, | ||
| 551 | target?.serverConfig?.environment.capabilities, | ||
| 552 | target?.serverConfig?.settings, | ||
| 553 | primarySettings, | ||
| 554 | ), | ||
| 544 | }, | 555 | }, |
| 545 | }); | 556 | }); |
| 546 | } | 557 | } |
packages/client-runtime/src/state/sharedSettings.test.ts+128-1| ... | @@ -1,4 +1,9 @@ | ... | @@ -1,4 +1,9 @@ |
| 1 | import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; | 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | EnvironmentId, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 8 | ||
| 4 | import { | 9 | import { |
| ... | @@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => { | ... | @@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => { |
| 38 | }); | 43 | }); |
| 39 | 44 | ||
| 40 | describe("splitSharedServerPatch", () => { | 45 | describe("splitSharedServerPatch", () => { |
| 46 | it.each([ | ||
| 47 | { | ||
| 48 | instanceId: ProviderInstanceId.make("codex"), | ||
| 49 | model: "gpt-5.6-sol", | ||
| 50 | options: [{ id: "reasoningEffort", value: "low" }], | ||
| 51 | }, | ||
| 52 | { | ||
| 53 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 54 | model: "claude-sonnet-4-6", | ||
| 55 | options: [{ id: "effort", value: "high" }], | ||
| 56 | }, | ||
| 57 | DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, | ||
| 58 | ])("shares the text generation model and options, including reset (%j)", (selection) => { | ||
| 59 | const patch = { textGenerationModelSelection: selection }; | ||
| 60 | expect(splitSharedServerPatch(patch)).toEqual({ sharedPatch: patch, localPatch: {} }); | ||
| 61 | expect(pickSharedServerSettings({ ...DEFAULT_SERVER_SETTINGS, ...patch })).toMatchObject(patch); | ||
| 62 | const environment = { | ||
| 63 | environmentId: boxId, | ||
| 64 | label: "Remote Box", | ||
| 65 | syncEligible: true, | ||
| 66 | settings: { | ||
| 67 | ...DEFAULT_SERVER_SETTINGS, | ||
| 68 | textGenerationModelSelection: { ...selection, model: "different-model" }, | ||
| 69 | }, | ||
| 70 | }; | ||
| 71 | const input = { | ||
| 72 | primaryEnvironmentId: primaryId, | ||
| 73 | primarySettings: { ...DEFAULT_SERVER_SETTINGS, ...patch }, | ||
| 74 | environments: [environment], | ||
| 75 | }; | ||
| 76 | expect(findSharedSettingsMismatches(input)).toEqual([ | ||
| 77 | { environmentId: boxId, label: "Remote Box" }, | ||
| 78 | ]); | ||
| 79 | expect( | ||
| 80 | findSharedSettingsMismatches({ | ||
| 81 | ...input, | ||
| 82 | environments: [{ ...environment, settings: input.primarySettings }], | ||
| 83 | }), | ||
| 84 | ).toEqual([]); | ||
| 85 | }); | ||
| 86 | |||
| 41 | it("routes preference keys to the shared patch and machine keys to the local patch", () => { | 87 | it("routes preference keys to the shared patch and machine keys to the local patch", () => { |
| 42 | const { sharedPatch, localPatch } = splitSharedServerPatch({ | 88 | const { sharedPatch, localPatch } = splitSharedServerPatch({ |
| 43 | sidebarAutoSettleAfterDays: 7, | 89 | sidebarAutoSettleAfterDays: 7, |
| ... | @@ -70,11 +116,92 @@ describe("pickSharedServerSettings", () => { | ... | @@ -70,11 +116,92 @@ describe("pickSharedServerSettings", () => { |
| 70 | "sidebarAutoSettleAfterDays", | 116 | "sidebarAutoSettleAfterDays", |
| 71 | "sidebarAutoSettleOnMerge", | 117 | "sidebarAutoSettleOnMerge", |
| 72 | "sourceControlWritingStyle", | 118 | "sourceControlWritingStyle", |
| 119 | "textGenerationModelSelection", | ||
| 73 | ]); | 120 | ]); |
| 74 | }); | 121 | }); |
| 75 | }); | 122 | }); |
| 76 | 123 | ||
| 77 | describe("filterSharedServerPatch", () => { | 124 | describe("filterSharedServerPatch", () => { |
| 125 | it.each([true, false])( | ||
| 126 | "resets a disabled default provider only on the originating environment (%s)", | ||
| 127 | (targetIsSource) => { | ||
| 128 | const settings = { | ||
| 129 | ...DEFAULT_SERVER_SETTINGS, | ||
| 130 | providerInstances: { | ||
| 131 | codex: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} }, | ||
| 132 | claudeAgent: { | ||
| 133 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 134 | enabled: true, | ||
| 135 | config: {}, | ||
| 136 | }, | ||
| 137 | }, | ||
| 138 | textGenerationModelSelection: { | ||
| 139 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 140 | model: "claude-opus-4-6", | ||
| 141 | }, | ||
| 142 | }; | ||
| 143 | const patch = { | ||
| 144 | textGenerationModelSelection: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, | ||
| 145 | continueThreadsAfterServerUpdate: true, | ||
| 146 | sidebarAutoSettleAfterDays: 7, | ||
| 147 | }; | ||
| 148 | expect(filterSharedServerPatch(patch, undefined, settings, settings, targetIsSource)).toEqual( | ||
| 149 | { | ||
| 150 | ...(targetIsSource | ||
| 151 | ? { textGenerationModelSelection: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection } | ||
| 152 | : {}), | ||
| 153 | sidebarAutoSettleAfterDays: 7, | ||
| 154 | }, | ||
| 155 | ); | ||
| 156 | }, | ||
| 157 | ); | ||
| 158 | |||
| 159 | it.each(["missing", "disabled", "different-driver", "enabled"] as const)( | ||
| 160 | "shares a custom model only when its target provider is enabled (%s)", | ||
| 161 | (availability) => { | ||
| 162 | const instanceId = ProviderInstanceId.make("codex_personal"); | ||
| 163 | const selection = { | ||
| 164 | instanceId, | ||
| 165 | model: "gpt-5.6-luna", | ||
| 166 | options: [{ id: "reasoningEffort", value: "low" }], | ||
| 167 | }; | ||
| 168 | const instance = { | ||
| 169 | driver: ProviderDriverKind.make( | ||
| 170 | availability === "different-driver" ? "claudeAgent" : "codex", | ||
| 171 | ), | ||
| 172 | enabled: availability !== "disabled", | ||
| 173 | config: {}, | ||
| 174 | }; | ||
| 175 | const settings = { | ||
| 176 | ...DEFAULT_SERVER_SETTINGS, | ||
| 177 | providerInstances: availability === "missing" ? {} : { [instanceId]: instance }, | ||
| 178 | }; | ||
| 179 | const patch = { sidebarAutoSettleAfterDays: 7, textGenerationModelSelection: selection }; | ||
| 180 | const sourceSettings = { | ||
| 181 | ...settings, | ||
| 182 | providerInstances: { | ||
| 183 | [instanceId]: { ...instance, driver: ProviderDriverKind.make("codex"), enabled: true }, | ||
| 184 | }, | ||
| 185 | }; | ||
| 186 | expect(filterSharedServerPatch(patch, restartCapabilities, settings, sourceSettings)).toEqual( | ||
| 187 | availability === "enabled" ? patch : { sidebarAutoSettleAfterDays: 7 }, | ||
| 188 | ); | ||
| 189 | const primarySettings = { | ||
| 190 | ...sourceSettings, | ||
| 191 | textGenerationModelSelection: selection, | ||
| 192 | }; | ||
| 193 | expect( | ||
| 194 | findSharedSettingsMismatches({ | ||
| 195 | primaryEnvironmentId: primaryId, | ||
| 196 | primarySettings, | ||
| 197 | environments: [ | ||
| 198 | { environmentId: boxId, label: "Remote Box", syncEligible: true, settings }, | ||
| 199 | ], | ||
| 200 | }), | ||
| 201 | ).toEqual(availability === "enabled" ? [{ environmentId: boxId, label: "Remote Box" }] : []); | ||
| 202 | }, | ||
| 203 | ); | ||
| 204 | |||
| 78 | it.each([true, false])("preserves supported restart preference %s", (enabled) => { | 205 | it.each([true, false])("preserves supported restart preference %s", (enabled) => { |
| 79 | const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; | 206 | const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; |
| 80 | expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); | 207 | expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); |
packages/client-runtime/src/state/sharedSettings.ts+39-4| ... | @@ -14,6 +14,7 @@ import type { | ... | @@ -14,6 +14,7 @@ import type { |
| 14 | ServerSettings, | 14 | ServerSettings, |
| 15 | ServerSettingsPatch, | 15 | ServerSettingsPatch, |
| 16 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| 17 | import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings"; | ||
| 17 | import * as Equal from "effect/Equal"; | 18 | import * as Equal from "effect/Equal"; |
| 18 | import * as Struct from "effect/Struct"; | 19 | import * as Struct from "effect/Struct"; |
| 19 | 20 | ||
| ... | @@ -26,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [ | ... | @@ -26,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [ |
| 26 | "sidebarAutoSettleOnMerge", | 27 | "sidebarAutoSettleOnMerge", |
| 27 | "newWorktreesStartFromOrigin", | 28 | "newWorktreesStartFromOrigin", |
| 28 | "sourceControlWritingStyle", | 29 | "sourceControlWritingStyle", |
| 30 | "textGenerationModelSelection", | ||
| 29 | ] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>; | 31 | ] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>; |
| 30 | 32 | ||
| 31 | export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; | 33 | export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; |
| ... | @@ -52,11 +54,31 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { | ... | @@ -52,11 +54,31 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { |
| 52 | }; | 54 | }; |
| 53 | } | 55 | } |
| 54 | 56 | ||
| 55 | /** Omit restart recovery on servers that cannot persist its preference. */ | 57 | /** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */ |
| 56 | export function filterSharedServerPatch( | 58 | export function filterSharedServerPatch( |
| 57 | patch: ServerSettingsPatch, | 59 | patch: ServerSettingsPatch, |
| 58 | capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined, | 60 | capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined, |
| 61 | settings?: ServerSettings, | ||
| 62 | sourceSettings = settings, | ||
| 63 | targetIsSource = false, | ||
| 59 | ): ServerSettingsPatch { | 64 | ): ServerSettingsPatch { |
| 65 | const instanceId = | ||
| 66 | patch.textGenerationModelSelection?.instanceId ?? | ||
| 67 | sourceSettings?.textGenerationModelSelection.instanceId; | ||
| 68 | if ( | ||
| 69 | !targetIsSource && | ||
| 70 | patch.textGenerationModelSelection && | ||
| 71 | (!settings || | ||
| 72 | (instanceId !== undefined && | ||
| 73 | (sourceSettings?.providerInstances[instanceId]?.driver ?? instanceId) !== | ||
| 74 | (settings.providerInstances[instanceId]?.driver ?? instanceId)) || | ||
| 75 | !isModelSelectionProviderEnabled(settings, { | ||
| 76 | ...settings.textGenerationModelSelection, | ||
| 77 | ...patch.textGenerationModelSelection, | ||
| 78 | })) | ||
| 79 | ) { | ||
| 80 | patch = Struct.omit(patch, ["textGenerationModelSelection"]); | ||
| 81 | } | ||
| 60 | return capabilities?.threadRestartContinuation === true | 82 | return capabilities?.threadRestartContinuation === true |
| 61 | ? patch | 83 | ? patch |
| 62 | : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); | 84 | : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); |
| ... | @@ -67,7 +89,11 @@ export function pickSharedServerSettings( | ... | @@ -67,7 +89,11 @@ export function pickSharedServerSettings( |
| 67 | settings: ServerSettings, | 89 | settings: ServerSettings, |
| 68 | capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">, | 90 | capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">, |
| 69 | ): ServerSettingsPatch { | 91 | ): ServerSettingsPatch { |
| 70 | return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities); | 92 | return filterSharedServerPatch( |
| 93 | Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), | ||
| 94 | capabilities, | ||
| 95 | settings, | ||
| 96 | ); | ||
| 71 | } | 97 | } |
| 72 | 98 | ||
| 73 | /** | 99 | /** |
| ... | @@ -129,11 +155,20 @@ export function findSharedSettingsMismatches(input: { | ... | @@ -129,11 +155,20 @@ export function findSharedSettingsMismatches(input: { |
| 129 | ) { | 155 | ) { |
| 130 | return []; | 156 | return []; |
| 131 | } | 157 | } |
| 132 | const expected = filterSharedServerPatch(primarySettings, environment.capabilities); | 158 | const expected = filterSharedServerPatch( |
| 133 | const actual = filterSharedServerPatch( | 159 | primarySettings, |
| 160 | environment.capabilities, | ||
| 161 | environment.settings, | ||
| 162 | input.primarySettings ?? undefined, | ||
| 163 | ); | ||
| 164 | let actual = filterSharedServerPatch( | ||
| 134 | pickSharedServerSettings(environment.settings, environment.capabilities), | 165 | pickSharedServerSettings(environment.settings, environment.capabilities), |
| 135 | input.primaryCapabilities, | 166 | input.primaryCapabilities, |
| 167 | environment.settings, | ||
| 136 | ); | 168 | ); |
| 169 | if (!expected.textGenerationModelSelection) { | ||
| 170 | actual = Struct.omit(actual, ["textGenerationModelSelection"]); | ||
| 171 | } | ||
| 137 | return Equal.equals(actual, expected) | 172 | return Equal.equals(actual, expected) |
| 138 | ? [] | 173 | ? [] |
| 139 | : [{ environmentId: environment.environmentId, label: environment.label }]; | 174 | : [{ environmentId: environment.environmentId, label: environment.label }]; |