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() {
669669 patch: filterSharedServerPatch(
670670 patch,
671671 target?.serverConfig?.environment.capabilities,
672 target?.serverConfig?.settings,
673 referenceSettings,
672674 ),
673675 },
674676 });
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+4
......@@ -50,6 +50,10 @@ function makeFakeClaudeBinary(dir: string) {
5050 " process.exit(code);",
5151 "}",
5252 "",
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 "}",
5357 'const toolsIndex = argv.indexOf("--tools");',
5458 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {',
5559 ' 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
214214 "",
215215 "--disable-slash-commands",
216216 "--strict-mcp-config",
217 "--permission-mode",
218 "dontAsk",
217219 ],
218220 { env: claudeEnvironment },
219221 );
apps/web/src/components/settings/SettingsPanels.tsx+1-1
......@@ -2718,7 +2718,7 @@ export function GeneralSettingsPanel() {
27182718 <SettingsRow
27192719 serverScoped
27202720 {...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."
27222722 resetAction={
27232723 isTextGenerationModelDirty ? (
27242724 <SettingResetButton
apps/web/src/hooks/useSettings.ts+12-1
......@@ -450,6 +450,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
450450 }
451451 }
452452 if (Object.keys(sharedPatch).length > 0) {
453 const sourceSettings = environments.find(
454 (target) => target.environmentId === environmentId,
455 )?.serverConfig?.settings;
453456 const targets = new Set(
454457 environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId),
455458 );
......@@ -462,6 +465,9 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
462465 const targetPatch = filterSharedServerPatch(
463466 sharedPatch,
464467 target?.serverConfig?.environment.capabilities,
468 target?.serverConfig?.settings,
469 sourceSettings,
470 targetId === environmentId,
465471 );
466472 if (Object.keys(targetPatch).length === 0) continue;
467473 wroteToTarget = true;
......@@ -540,7 +546,12 @@ export function useSharedSettingsSync() {
540546 void persistServerSettings({
541547 environmentId: mismatch.environmentId,
542548 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 ),
544555 },
545556 });
546557 }
packages/client-runtime/src/state/sharedSettings.test.ts+128-1
......@@ -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";
27import { describe, expect, it } from "@effect/vitest";
38
49import {
......@@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => {
3843});
3944
4045describe("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
4187 it("routes preference keys to the shared patch and machine keys to the local patch", () => {
4288 const { sharedPatch, localPatch } = splitSharedServerPatch({
4389 sidebarAutoSettleAfterDays: 7,
......@@ -70,11 +116,92 @@ describe("pickSharedServerSettings", () => {
70116 "sidebarAutoSettleAfterDays",
71117 "sidebarAutoSettleOnMerge",
72118 "sourceControlWritingStyle",
119 "textGenerationModelSelection",
73120 ]);
74121 });
75122});
76123
77124describe("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
78205 it.each([true, false])("preserves supported restart preference %s", (enabled) => {
79206 const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 };
80207 expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch);
packages/client-runtime/src/state/sharedSettings.ts+39-4
......@@ -14,6 +14,7 @@ import type {
1414 ServerSettings,
1515 ServerSettingsPatch,
1616} from "@t3tools/contracts";
17import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings";
1718import * as Equal from "effect/Equal";
1819import * as Struct from "effect/Struct";
1920
......@@ -26,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [
2627 "sidebarAutoSettleOnMerge",
2728 "newWorktreesStartFromOrigin",
2829 "sourceControlWritingStyle",
30 "textGenerationModelSelection",
2931] as const satisfies ReadonlyArray<keyof ServerSettings & keyof ServerSettingsPatch>;
3032
3133export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number];
......@@ -52,11 +54,31 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): {
5254 };
5355}
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. */
5658export function filterSharedServerPatch(
5759 patch: ServerSettingsPatch,
5860 capabilities: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation"> | undefined,
61 settings?: ServerSettings,
62 sourceSettings = settings,
63 targetIsSource = false,
5964): 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 }
6082 return capabilities?.threadRestartContinuation === true
6183 ? patch
6284 : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]);
......@@ -67,7 +89,11 @@ export function pickSharedServerSettings(
6789 settings: ServerSettings,
6890 capabilities?: Pick<ExecutionEnvironmentCapabilities, "threadRestartContinuation">,
6991): 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 );
7197}
7298
7399/**
......@@ -129,11 +155,20 @@ export function findSharedSettingsMismatches(input: {
129155 ) {
130156 return [];
131157 }
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(
134165 pickSharedServerSettings(environment.settings, environment.capabilities),
135166 input.primaryCapabilities,
167 environment.settings,
136168 );
169 if (!expected.textGenerationModelSelection) {
170 actual = Struct.omit(actual, ["textGenerationModelSelection"]);
171 }
137172 return Equal.equals(actual, expected)
138173 ? []
139174 : [{ environmentId: environment.environmentId, label: environment.label }];