Skip to content

Commit 80461bc

Browse files
committed
fix(threads): preserve auto-title matching after prompt formatting
1 parent 81c3c98 commit 80461bc

File tree

6 files changed

+100
-0
lines changed

6 files changed

+100
-0
lines changed

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,58 @@ describe("ProviderCommandReactor", () => {
425425
expect(thread?.title).toBe("Keep this custom title");
426426
});
427427

428+
it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => {
429+
const harness = await createHarness();
430+
const now = new Date().toISOString();
431+
const seededTitle = "Fix reconnect spinner on resume";
432+
harness.generateThreadTitle.mockReturnValue(
433+
Effect.succeed({
434+
title: "Reconnect spinner resume bug",
435+
}),
436+
);
437+
438+
await Effect.runPromise(
439+
harness.engine.dispatch({
440+
type: "thread.meta.update",
441+
commandId: CommandId.makeUnsafe("cmd-thread-title-formatted-seed"),
442+
threadId: ThreadId.makeUnsafe("thread-1"),
443+
title: seededTitle,
444+
}),
445+
);
446+
447+
await Effect.runPromise(
448+
harness.engine.dispatch({
449+
type: "thread.turn.start",
450+
commandId: CommandId.makeUnsafe("cmd-turn-start-title-formatted"),
451+
threadId: ThreadId.makeUnsafe("thread-1"),
452+
message: {
453+
messageId: asMessageId("user-message-title-formatted"),
454+
role: "user",
455+
text: "[effort:high]\\n\\nFix reconnect spinner on resume",
456+
attachments: [],
457+
},
458+
titleSeed: seededTitle,
459+
textGenerationModel: "gpt-5.4-mini",
460+
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
461+
runtimeMode: "approval-required",
462+
createdAt: now,
463+
}),
464+
);
465+
466+
await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
467+
await waitFor(async () => {
468+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
469+
return (
470+
readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title ===
471+
"Reconnect spinner resume bug"
472+
);
473+
});
474+
475+
const readModel = await Effect.runPromise(harness.engine.getReadModel());
476+
const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
477+
expect(thread?.title).toBe("Reconnect spinner resume bug");
478+
});
479+
428480
it("reuses the text generation model for automatic worktree branch naming", async () => {
429481
const harness = await createHarness();
430482
const now = new Date().toISOString();

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,16 @@ const DEFAULT_THREAD_TITLE = "New thread";
7979
function buildReplaceableThreadTitles(input: {
8080
readonly messageText: string;
8181
readonly attachments?: ReadonlyArray<ChatAttachment>;
82+
readonly titleSeed?: string;
8283
}): ReadonlySet<string> {
8384
const titles = new Set<string>([DEFAULT_THREAD_TITLE]);
85+
const trimmedTitleSeed = input.titleSeed?.trim();
86+
87+
if (trimmedTitleSeed) {
88+
titles.add(trimmedTitleSeed);
89+
return titles;
90+
}
91+
8492
const trimmedMessage = input.messageText.trim();
8593

8694
if (trimmedMessage.length > 0) {
@@ -101,6 +109,7 @@ function isReplaceableThreadTitle(
101109
input: {
102110
readonly messageText: string;
103111
readonly attachments?: ReadonlyArray<ChatAttachment>;
112+
readonly titleSeed?: string;
104113
},
105114
): boolean {
106115
return buildReplaceableThreadTitles(input).has(currentTitle.trim());
@@ -565,6 +574,7 @@ const make = Effect.gen(function* () {
565574
const generationInput = {
566575
messageText: message.text,
567576
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
577+
...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}),
568578
...(event.payload.textGenerationModel !== undefined
569579
? { textGenerationModel: event.payload.textGenerationModel }
570580
: {}),

apps/server/src/orchestration/decider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
377377
...(command.modelSelection !== undefined
378378
? { modelSelection: command.modelSelection }
379379
: {}),
380+
...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}),
380381
...(command.textGenerationModel !== undefined
381382
? { textGenerationModel: command.textGenerationModel }
382383
: {}),

apps/web/src/components/ChatView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2711,6 +2711,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27112711
attachments: turnAttachments,
27122712
},
27132713
modelSelection: selectedModelSelection,
2714+
titleSeed: title,
27142715
textGenerationModel: selectedTextGenerationModel,
27152716
assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
27162717
runtimeMode,
@@ -2994,6 +2995,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29942995
attachments: [],
29952996
},
29962997
modelSelection: selectedModelSelection,
2998+
titleSeed: activeThread.title,
29972999
textGenerationModel: selectedTextGenerationModel,
29983000
assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
29993001
runtimeMode,
@@ -3113,6 +3115,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
31133115
attachments: [],
31143116
},
31153117
modelSelection: selectedModelSelection,
3118+
titleSeed: nextThreadTitle,
31163119
textGenerationModel: selectedTextGenerationModel,
31173120
assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
31183121
runtimeMode,

packages/contracts/src/orchestration.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,25 @@ it.effect("accepts a text generation model in thread.turn.start", () =>
338338
}),
339339
);
340340

341+
it.effect("accepts a title seed in thread.turn.start", () =>
342+
Effect.gen(function* () {
343+
const parsed = yield* decodeThreadTurnStartCommand({
344+
type: "thread.turn.start",
345+
commandId: "cmd-turn-title-seed",
346+
threadId: "thread-1",
347+
message: {
348+
messageId: "msg-title-seed",
349+
role: "user",
350+
text: "hello",
351+
attachments: [],
352+
},
353+
titleSeed: "Investigate reconnect failures",
354+
createdAt: "2026-01-01T00:00:00.000Z",
355+
});
356+
assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures");
357+
}),
358+
);
359+
341360
it.effect("accepts a source proposed plan reference in thread.turn.start", () =>
342361
Effect.gen(function* () {
343362
const parsed = yield* decodeThreadTurnStartCommand({
@@ -409,6 +428,18 @@ it.effect("decodes thread.turn-start-requested text generation model when presen
409428
}),
410429
);
411430

431+
it.effect("decodes thread.turn-start-requested title seed when present", () =>
432+
Effect.gen(function* () {
433+
const parsed = yield* decodeThreadTurnStartRequestedPayload({
434+
threadId: "thread-2",
435+
messageId: "msg-2",
436+
titleSeed: "Investigate reconnect failures",
437+
createdAt: "2026-01-01T00:00:00.000Z",
438+
});
439+
assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures");
440+
}),
441+
);
442+
412443
it.effect("decodes latest turn source proposed plan metadata when present", () =>
413444
Effect.gen(function* () {
414445
const parsed = yield* decodeOrchestrationLatestTurn({

packages/contracts/src/orchestration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
399399
attachments: Schema.Array(ChatAttachment),
400400
}),
401401
modelSelection: Schema.optional(ModelSelection),
402+
titleSeed: Schema.optional(TrimmedNonEmptyString),
402403
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
403404
assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
404405
runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
@@ -420,6 +421,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
420421
attachments: Schema.Array(UploadChatAttachment),
421422
}),
422423
modelSelection: Schema.optional(ModelSelection),
424+
titleSeed: Schema.optional(TrimmedNonEmptyString),
423425
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
424426
assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
425427
runtimeMode: RuntimeMode,
@@ -716,6 +718,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
716718
threadId: ThreadId,
717719
messageId: MessageId,
718720
modelSelection: Schema.optional(ModelSelection),
721+
titleSeed: Schema.optional(TrimmedNonEmptyString),
719722
textGenerationModel: Schema.optional(TrimmedNonEmptyString),
720723
assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
721724
runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),

0 commit comments

Comments
 (0)