| 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 | 669 | patch: filterSharedServerPatch( |
| 670 | 670 | patch, |
| 671 | 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 | 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 | 57 | 'const toolsIndex = argv.indexOf("--tools");', |
| 54 | 58 | 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {', |
| 55 | 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 | 214 | "", |
| 215 | 215 | "--disable-slash-commands", |
| 216 | 216 | "--strict-mcp-config", |
| 217 | "--permission-mode", | |
| 218 | "dontAsk", | |
| 217 | 219 | ], |
| 218 | 220 | { env: claudeEnvironment }, |
| 219 | 221 | ); |
apps/web/src/components/settings/SettingsPanels.tsx+1-1| ... | ... | @@ -2718,7 +2718,7 @@ export function GeneralSettingsPanel() { |
| 2718 | 2718 | <SettingsRow |
| 2719 | 2719 | serverScoped |
| 2720 | 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 | 2722 | resetAction={ |
| 2723 | 2723 | isTextGenerationModelDirty ? ( |
| 2724 | 2724 | <SettingResetButton |
apps/web/src/hooks/useSettings.ts+12-1| ... | ... | @@ -450,6 +450,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 450 | 450 | } |
| 451 | 451 | } |
| 452 | 452 | if (Object.keys(sharedPatch).length > 0) { |
| 453 | const sourceSettings = environments.find( | |
| 454 | (target) => target.environmentId === environmentId, | |
| 455 | )?.serverConfig?.settings; | |
| 453 | 456 | const targets = new Set( |
| 454 | 457 | environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), |
| 455 | 458 | ); |
| ... | ... | @@ -462,6 +465,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 462 | 465 | const targetPatch = filterSharedServerPatch( |
| 463 | 466 | sharedPatch, |
| 464 | 467 | target?.serverConfig?.environment.capabilities, |
| 468 | target?.serverConfig?.settings, | |
| 469 | sourceSettings, | |
| 470 | targetId === environmentId, | |
| 465 | 471 | ); |
| 466 | 472 | if (Object.keys(targetPatch).length === 0) continue; |
| 467 | 473 | wroteToTarget = true; |
| ... | ... | @@ -540,7 +546,12 @@ export function useSharedSettingsSync() { |
| 540 | 546 | void persistServerSettings({ |
| 541 | 547 | environmentId: mismatch.environmentId, |
| 542 | 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 | 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 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 8 | |
| 4 | 9 | import { |
| ... | ... | @@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => { |
| 38 | 43 | }); |
| 39 | 44 | |
| 40 | 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 | 87 | it("routes preference keys to the shared patch and machine keys to the local patch", () => { |
| 42 | 88 | const { sharedPatch, localPatch } = splitSharedServerPatch({ |
| 43 | 89 | sidebarAutoSettleAfterDays: 7, |
| ... | ... | @@ -70,11 +116,92 @@ describe("pickSharedServerSettings", () => { |
| 70 | 116 | "sidebarAutoSettleAfterDays", |
| 71 | 117 | "sidebarAutoSettleOnMerge", |
| 72 | 118 | "sourceControlWritingStyle", |
| 119 | "textGenerationModelSelection", | |
| 73 | 120 | ]); |
| 74 | 121 | }); |
| 75 | 122 | }); |
| 76 | 123 | |
| 77 | 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 | 205 | it.each([true, false])("preserves supported restart preference %s", (enabled) => { |
| 79 | 206 | const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; |
| 80 | 207 | expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); |
packages/client-runtime/src/state/sharedSettings.ts+39-4| ... | ... | @@ -14,6 +14,7 @@ import type { |
| 14 | 14 | ServerSettings, |
| 15 | 15 | ServerSettingsPatch, |
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings"; | |
| 17 | 18 | import * as Equal from "effect/Equal"; |
| 18 | 19 | import * as Struct from "effect/Struct"; |
| 19 | 20 | |
| ... | ... | @@ -26,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [ |
| 26 | 27 | "sidebarAutoSettleOnMerge", |
| 27 | 28 | "newWorktreesStartFromOrigin", |
| 28 | 29 | "sourceControlWritingStyle", |
| 30 | "textGenerationModelSelection", | |
| 29 | 31 | ] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>; |
| 30 | 32 | |
| 31 | 33 | export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; |
| ... | ... | @@ -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 | 58 | export function filterSharedServerPatch( |
| 57 | 59 | patch: ServerSettingsPatch, |
| 58 | 60 | capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined, |
| 61 | settings?: ServerSettings, | |
| 62 | sourceSettings = settings, | |
| 63 | targetIsSource = false, | |
| 59 | 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 | 82 | return capabilities?.threadRestartContinuation === true |
| 61 | 83 | ? patch |
| 62 | 84 | : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); |
| ... | ... | @@ -67,7 +89,11 @@ export function pickSharedServerSettings( |
| 67 | 89 | settings: ServerSettings, |
| 68 | 90 | capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">, |
| 69 | 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 | 155 | ) { |
| 130 | 156 | return []; |
| 131 | 157 | } |
| 132 | const expected = filterSharedServerPatch(primarySettings, environment.capabilities); | |
| 133 | const actual = filterSharedServerPatch( | |
| 158 | const expected = filterSharedServerPatch( | |
| 159 | primarySettings, | |
| 160 | environment.capabilities, | |
| 161 | environment.settings, | |
| 162 | input.primarySettings ?? undefined, | |
| 163 | ); | |
| 164 | let actual = filterSharedServerPatch( | |
| 134 | 165 | pickSharedServerSettings(environment.settings, environment.capabilities), |
| 135 | 166 | input.primaryCapabilities, |
| 167 | environment.settings, | |
| 136 | 168 | ); |
| 169 | if (!expected.textGenerationModelSelection) { | |
| 170 | actual = Struct.omit(actual, ["textGenerationModelSelection"]); | |
| 171 | } | |
| 137 | 172 | return Equal.equals(actual, expected) |
| 138 | 173 | ? [] |
| 139 | 174 | : [{ environmentId: environment.environmentId, label: environment.label }]; |