authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-08 07:55:02+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 21:55:02-07:00
logbc4b00666272188027d086361fa5ae01cda53bd2
tree1316cbce9f7b704d39a3caea3d82ea47749f3090
parent9d345fa957cf22806171c8037432eec8d9ed496d
signature Signed by PGP key B5690EEEBB952194

fix: generate thread titles with the selected model across connections (#10526)

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 <SettingsRow2718 <SettingsRow
2719 serverScoped2719 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 <SettingResetButton2724 <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 @@
1import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts";1import {
2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6} from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
38
4import {9import {
...@@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => {...@@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => {
38});43});
3944
40describe("splitSharedServerPatch", () => {45describe("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});
76123
77describe("filterSharedServerPatch", () => {124describe("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";
17import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings";
17import * as Equal from "effect/Equal";18import * as Equal from "effect/Equal";
18import * as Struct from "effect/Struct";19import * as Struct from "effect/Struct";
1920
...@@ -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>;
3032
31export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number];33export 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}
5456
55/** Omit restart recovery on servers that cannot persist its preference. */57/** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */
56export function filterSharedServerPatch(58export 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 === true82 return capabilities?.threadRestartContinuation === true
61 ? patch83 ? 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}
7298
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 }];