authorgravatar for 45952970+repparw@users.noreply.github.comUlises Britos <45952970+repparw@users.noreply.github.com> 2026-08-15 08:31:36-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 13:31:36+02:00
logddee418a8d6d3e242ca26a8053a886ecc3b56b53
tree0cffe83bad79ecfdb8673cb1a233310c7e6e89db
parent2cb1a26f061fa9029ccbe2a614f02bb14b22dd45
signature Signed by PGP key B5690EEEBB952194

fix(server): stop the provider title mirror from overwriting real thread titles (#5941)


7 files changed, 164 insertions(+), 24 deletions(-)

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+2-13
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 type ProviderCommandReactorShape,39 type ProviderCommandReactorShape,
40} from "../Services/ProviderCommandReactor.ts";40} from "../Services/ProviderCommandReactor.ts";
41import { forkParked, ServerActivation } from "../../serverActivation.ts";41import { forkParked, ServerActivation } from "../../serverActivation.ts";
42import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts";
42import {43import {
43 resolveSourceControlWriterModelSelection,44 resolveSourceControlWriterModelSelection,
44 ServerSettingsService,45 ServerSettingsService,
...@@ -91,7 +92,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string =>...@@ -91,7 +92,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string =>
91const HANDLED_TURN_START_KEY_MAX = 10_000;92const HANDLED_TURN_START_KEY_MAX = 10_000;
92const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);93const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
93const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";94const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
94const DEFAULT_THREAD_TITLE = "New thread";
95const MAX_REGENERATION_ATTACHMENTS = 4;95const MAX_REGENERATION_ATTACHMENTS = 4;
96const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;96const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
97const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000;97const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000;
...@@ -227,18 +227,6 @@ export function providerErrorLabelFromInstanceHint(input: {...@@ -227,18 +227,6 @@ export function providerErrorLabelFromInstanceHint(input: {
227 );227 );
228}228}
229229
230function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
231 const trimmedCurrentTitle = currentTitle.trim();
232 if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) {
233 return true;
234 }
235
236 const trimmedTitleSeed = titleSeed?.trim();
237 return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0
238 ? trimmedCurrentTitle === trimmedTitleSeed
239 : false;
240}
241
242function findProviderAdapterRequestError(230function findProviderAdapterRequestError(
243 cause: Cause.Cause<ProviderServiceError>,231 cause: Cause.Cause<ProviderServiceError>,
244): ProviderAdapterRequestError | undefined {232): ProviderAdapterRequestError | undefined {
...@@ -626,6 +614,7 @@ const make = Effect.gen(function* () {...@@ -626,6 +614,7 @@ const make = Effect.gen(function* () {
626 ...(preferredProvider ? { provider: preferredProvider } : {}),614 ...(preferredProvider ? { provider: preferredProvider } : {}),
627 providerInstanceId: desiredInstanceId,615 providerInstanceId: desiredInstanceId,
628 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),616 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
617 ...(thread.title ? { title: thread.title } : {}),
629 modelSelection: desiredModelSelection,618 modelSelection: desiredModelSelection,
630 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),619 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
631 runtimeMode: desiredRuntimeMode,620 runtimeMode: desiredRuntimeMode,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+53-4
...@@ -48,6 +48,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu...@@ -48,6 +48,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQu
48import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";48import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
49import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";49import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
50import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";50import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
51import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts";
51import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";52import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
52import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";53import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
53import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";54import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
...@@ -221,7 +222,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -221,7 +222,10 @@ describe("ProviderRuntimeIngestion", () => {
221 }222 }
222 });223 });
223224
224 async function createHarness(options?: { serverSettings?: Partial<ServerSettings> }) {225 async function createHarness(options?: {
226 serverSettings?: Partial<ServerSettings>;
227 threadTitle?: string;
228 }) {
225 const workspaceRoot = makeTempDir("t3-provider-project-");229 const workspaceRoot = makeTempDir("t3-provider-project-");
226 NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git"));230 NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git"));
227 const provider = createProviderServiceHarness();231 const provider = createProviderServiceHarness();
...@@ -277,7 +281,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -277,7 +281,7 @@ describe("ProviderRuntimeIngestion", () => {
277 commandId: CommandId.make("cmd-thread-create"),281 commandId: CommandId.make("cmd-thread-create"),
278 threadId: ThreadId.make("thread-1"),282 threadId: ThreadId.make("thread-1"),
279 projectId: asProjectId("project-1"),283 projectId: asProjectId("project-1"),
280 title: "Thread",284 title: options?.threadTitle ?? "Thread",
281 modelSelection: {285 modelSelection: {
282 instanceId: ProviderInstanceId.make("codex"),286 instanceId: ProviderInstanceId.make("codex"),
283 model: "gpt-5-codex",287 model: "gpt-5-codex",
...@@ -2915,7 +2919,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2915,7 +2919,7 @@ describe("ProviderRuntimeIngestion", () => {
2915 const thread = await waitForThread(2919 const thread = await waitForThread(
2916 harness.readModel,2920 harness.readModel,
2917 (entry) =>2921 (entry) =>
2918 entry.title === "Renamed by provider" &&2922 entry.title === "Thread" &&
2919 entry.activities.some(2923 entry.activities.some(
2920 (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated",2924 (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated",
2921 ) &&2925 ) &&
...@@ -2930,7 +2934,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2930,7 +2934,7 @@ describe("ProviderRuntimeIngestion", () => {
2930 ),2934 ),
2931 );2935 );
29322936
2933 expect(thread.title).toBe("Renamed by provider");2937 expect(thread.title).toBe("Thread");
29342938
2935 const planActivity = thread.activities.find(2939 const planActivity = thread.activities.find(
2936 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated",2940 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated",
...@@ -2971,6 +2975,51 @@ describe("ProviderRuntimeIngestion", () => {...@@ -2971,6 +2975,51 @@ describe("ProviderRuntimeIngestion", () => {
2971 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");2975 expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated");
2972 });2976 });
29732977
2978 it("mirrors a provider title only while the thread still has the default title", async () => {
2979 const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE });
2980 const now = "2026-01-01T00:00:00.000Z";
2981
2982 harness.emit({
2983 type: "thread.metadata.updated",
2984 eventId: asEventId("evt-thread-metadata-default"),
2985 provider: ProviderDriverKind.make("codex"),
2986 createdAt: now,
2987 threadId: asThreadId("thread-1"),
2988 payload: {
2989 name: "Renamed by provider",
2990 metadata: { source: "provider" },
2991 },
2992 });
2993
2994 const thread = await waitForThread(
2995 harness.readModel,
2996 (entry) => entry.title === "Renamed by provider",
2997 );
2998 expect(thread.title).toBe("Renamed by provider");
2999 });
3000
3001 it("rejects a provider title once the thread has a real title", async () => {
3002 const harness = await createHarness({ threadTitle: "User-set title" });
3003 const now = "2026-01-01T00:00:00.000Z";
3004
3005 harness.emit({
3006 type: "thread.metadata.updated",
3007 eventId: asEventId("evt-thread-metadata-real"),
3008 provider: ProviderDriverKind.make("codex"),
3009 createdAt: now,
3010 threadId: asThreadId("thread-1"),
3011 payload: {
3012 name: "Renamed by provider",
3013 metadata: { source: "provider" },
3014 },
3015 });
3016
3017 await harness.drain();
3018 const readModel = await harness.readModel();
3019 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
3020 expect(thread?.title).toBe("User-set title");
3021 });
3022
2974 it("projects context window updates into normalized thread activities", async () => {3023 it("projects context window updates into normalized thread activities", async () => {
2975 const harness = await createHarness();3024 const harness = await createHarness();
2976 const now = "2026-01-01T00:00:00.000Z";3025 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9-6
...@@ -43,6 +43,7 @@ import {...@@ -43,6 +43,7 @@ import {
43} from "../Services/ProviderRuntimeIngestion.ts";43} from "../Services/ProviderRuntimeIngestion.ts";
44import { forkParked } from "../../serverActivation.ts";44import { forkParked } from "../../serverActivation.ts";
45import { ServerSettingsService } from "../../serverSettings.ts";45import { ServerSettingsService } from "../../serverSettings.ts";
46import { canReplaceThreadTitle } from "../threadTitles.ts";
4647
47const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;48const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
48const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;49const providerTaskKey = (threadId: ThreadId, taskId: string) => `${threadId}:${taskId}`;
...@@ -1892,12 +1893,14 @@ const make = Effect.gen(function* () {...@@ -1892,12 +1893,14 @@ const make = Effect.gen(function* () {
1892 }1893 }
18931894
1894 if (event.type === "thread.metadata.updated" && event.payload.name) {1895 if (event.type === "thread.metadata.updated" && event.payload.name) {
1895 yield* orchestrationEngine.dispatch({1896 if (canReplaceThreadTitle(thread.title)) {
1896 type: "thread.meta.update",1897 yield* orchestrationEngine.dispatch({
1897 commandId: yield* providerCommandId(event, "thread-meta-update"),1898 type: "thread.meta.update",
1898 threadId: thread.id,1899 commandId: yield* providerCommandId(event, "thread-meta-update"),
1899 title: event.payload.name,1900 threadId: thread.id,
1900 });1901 title: event.payload.name,
1902 });
1903 }
1901 }1904 }
19021905
1903 if (event.type === "turn.diff.updated") {1906 if (event.type === "turn.diff.updated") {
apps/server/src/orchestration/threadTitles.ts created+13
...@@ -0,0 +1,13 @@
1export const DEFAULT_THREAD_TITLE = "New thread";
2
3export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean {
4 const trimmedCurrentTitle = currentTitle.trim();
5 if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) {
6 return true;
7 }
8
9 const trimmedTitleSeed = titleSeed?.trim();
10 return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0
11 ? trimmedCurrentTitle === trimmedTitleSeed
12 : false;
13}
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+67
...@@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
1191 }),1191 }),
1192 );1192 );
11931193
1194 it.effect("passes the thread title to session.create when provided", () =>
1195 Effect.gen(function* () {
1196 const adapter = yield* OpenCodeAdapter;
1197 const threadId = asThreadId("thread-opencode-title-provided");
1198
1199 yield* adapter.startSession({
1200 provider: ProviderDriverKind.make("opencode"),
1201 threadId,
1202 runtimeMode: "full-access",
1203 title: "Investigate reconnect failures",
1204 });
1205
1206 NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1);
1207 NodeAssert.equal(
1208 runtimeMock.state.sessionCreateInputs[0]?.title,
1209 "Investigate reconnect failures",
1210 );
1211 }),
1212 );
1213
1214 it.effect("does not mirror OpenCode's default placeholder session titles", () =>
1215 Effect.gen(function* () {
1216 const adapter = yield* OpenCodeAdapter;
1217 const threadId = asThreadId("thread-opencode-placeholder-title");
1218 runtimeMock.state.subscribedEvents = [
1219 {
1220 type: "session.updated",
1221 properties: {
1222 info: {
1223 id: "http://127.0.0.1:9999/session",
1224 title: "New session - 2026-08-09T10:20:30.456Z",
1225 },
1226 },
1227 },
1228 {
1229 type: "session.updated",
1230 properties: {
1231 info: {
1232 id: "http://127.0.0.1:9999/session",
1233 title: "Investigate reconnect failures",
1234 },
1235 },
1236 },
1237 ];
1238
1239 const eventsFiber = yield* adapter.streamEvents.pipe(
1240 Stream.filter((event) => event.threadId === threadId),
1241 Stream.take(3),
1242 Stream.runCollect,
1243 Effect.forkChild,
1244 );
1245
1246 yield* adapter.startSession({
1247 provider: ProviderDriverKind.make("opencode"),
1248 threadId,
1249 runtimeMode: "full-access",
1250 });
1251
1252 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
1253 const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated");
1254 NodeAssert.equal(metadataUpdated.length, 1);
1255 if (metadataUpdated[0]?.type === "thread.metadata.updated") {
1256 NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures");
1257 }
1258 }),
1259 );
1260
1194 it.effect("writes provider-native observability records using the session thread id", () =>1261 it.effect("writes provider-native observability records using the session thread id", () =>
1195 Effect.gen(function* () {1262 Effect.gen(function* () {
1196 const nativeEvents: Array<{1263 const nativeEvents: Array<{
apps/server/src/provider/Layers/OpenCodeAdapter.ts+19-1
...@@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und...@@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und
201 return undefined;201 return undefined;
202 }202 }
203203
204 return trimText(event.properties.info.title);204 const title = trimText(event.properties.info.title);
205 // OpenCode mints a placeholder title at session.create when no title was
206 // provided, and re-emits it on every `session.updated`. Mirroring it would
207 // overwrite the thread's real title (openCodeEventSessionTitle feeds the
208 // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated
209 // placeholders so the thread isn't locked onto them.
210 if (!title || isOpenCodeDefaultTitle(title)) {
211 return undefined;
212 }
213
214 return title;
215}
216
217const OPENCODE_DEFAULT_TITLE_PATTERN =
218 /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
219
220function isOpenCodeDefaultTitle(title: string): boolean {
221 return OPENCODE_DEFAULT_TITLE_PATTERN.test(title);
205}222}
206223
207interface OpenCodeSessionContext {224interface OpenCodeSessionContext {
...@@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter(...@@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter(
1302 }1319 }
1303 const createdSession = yield* runOpenCodeSdk("session.create", () =>1320 const createdSession = yield* runOpenCodeSdk("session.create", () =>
1304 client.session.create({1321 client.session.create({
1322 ...(input.title ? { title: input.title } : {}),
1305 permission: buildOpenCodePermissionRules(input.runtimeMode),1323 permission: buildOpenCodePermissionRules(input.runtimeMode),
1306 }),1324 }),
1307 );1325 );
packages/contracts/src/provider.ts+1
...@@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({...@@ -56,6 +56,7 @@ export const ProviderSessionStartInput = Schema.Struct({
56 // See ProviderSession for the migration story.56 // See ProviderSession for the migration story.
57 providerInstanceId: Schema.optional(ProviderInstanceId),57 providerInstanceId: Schema.optional(ProviderInstanceId),
58 cwd: Schema.optional(TrimmedNonEmptyString),58 cwd: Schema.optional(TrimmedNonEmptyString),
59 title: Schema.optional(TrimmedNonEmptyString),
59 modelSelection: Schema.optional(ModelSelection),60 modelSelection: Schema.optional(ModelSelection),
60 resumeCursor: Schema.optional(Schema.Unknown),61 resumeCursor: Schema.optional(Schema.Unknown),
61 approvalPolicy: Schema.optional(ProviderApprovalPolicy),62 approvalPolicy: Schema.optional(ProviderApprovalPolicy),