Skip to content

Commit ecb8c98

Browse files
t3dotggclaude
andcommitted
fix(server): forked threads get titles from the history they inherit
A fork has no messages of its own, so its first title came from one follow-up message with no context. Title generation now reads the source history up to the fork point and the shared prompt titles the fork's new direction against it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 375da0f commit ecb8c98

5 files changed

Lines changed: 231 additions & 2 deletions

File tree

‎apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { assert, describe, it, vi } from "@effect/vitest";
22
import {
33
CommandId,
4+
EventId,
45
MessageId,
56
ProjectId,
67
ProviderDriverKind,
78
ProviderInstanceId,
9+
ProviderThreadId,
10+
RunId,
811
ThreadId,
912
} from "@t3tools/contracts";
13+
import * as DateTime from "effect/DateTime";
1014
import * as Effect from "effect/Effect";
1115
import * as Layer from "effect/Layer";
1216
import * as Option from "effect/Option";
@@ -18,6 +22,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts";
1822
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
1923
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
2024
import * as EffectOutbox from "./EffectOutbox.ts";
25+
import * as EventSink from "./EventSink.ts";
2126
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
2227
import * as ThreadManagement from "./ThreadManagementService.ts";
2328
import * as ThreadTitleRegeneration from "./ThreadTitleRegenerationService.ts";
@@ -93,6 +98,7 @@ function makeHarness(
9398
);
9499
return {
95100
layer: Layer.mergeAll(
101+
layerOrchestrator,
96102
layerThreadManagement,
97103
layerTitleRegeneration,
98104
layerOutbox,
@@ -146,6 +152,95 @@ function dispatchUserMessage(input: {
146152
});
147153
}
148154

155+
/** Records a finished source run with one exchange, as a provider turn would. */
156+
function recordCompletedRun(input: {
157+
readonly threadId: ThreadId;
158+
readonly runId: RunId;
159+
readonly ordinal: number;
160+
readonly user: string;
161+
readonly assistant: string;
162+
}) {
163+
return Effect.gen(function* () {
164+
const eventSink = yield* EventSink.EventSinkV2;
165+
const now = yield* DateTime.now;
166+
const providerThreadId = ProviderThreadId.make(`${input.threadId}:provider`);
167+
const message = (role: "user" | "assistant", text: string) => ({
168+
id: EventId.make(`${input.runId}:${role}`),
169+
type: "message.updated" as const,
170+
threadId: input.threadId,
171+
runId: input.runId,
172+
occurredAt: now,
173+
payload: {
174+
id: MessageId.make(`${input.runId}:${role}`),
175+
threadId: input.threadId,
176+
runId: input.runId,
177+
nodeId: null,
178+
role,
179+
text,
180+
attachments: [],
181+
streaming: false,
182+
createdBy: "user" as const,
183+
creationSource: "web" as const,
184+
createdAt: now,
185+
updatedAt: now,
186+
},
187+
});
188+
yield* eventSink.write({
189+
events: [
190+
{
191+
id: EventId.make(`${input.runId}:created`),
192+
type: "run.created",
193+
threadId: input.threadId,
194+
runId: input.runId,
195+
occurredAt: now,
196+
payload: {
197+
id: input.runId,
198+
threadId: input.threadId,
199+
ordinal: input.ordinal,
200+
providerInstanceId: modelSelection.instanceId,
201+
modelSelection,
202+
providerThreadId,
203+
userMessageId: MessageId.make(`${input.runId}:user`),
204+
rootNodeId: null,
205+
activeAttemptId: null,
206+
status: "completed",
207+
requestedAt: now,
208+
startedAt: now,
209+
completedAt: now,
210+
checkpointId: null,
211+
contextHandoffId: null,
212+
},
213+
},
214+
{
215+
id: EventId.make(`${input.runId}:provider-thread`),
216+
type: "provider-thread.updated",
217+
threadId: input.threadId,
218+
occurredAt: now,
219+
payload: {
220+
id: providerThreadId,
221+
driver: adapter.driver,
222+
providerInstanceId: modelSelection.instanceId,
223+
providerSessionId: null,
224+
appThreadId: input.threadId,
225+
ownerNodeId: null,
226+
nativeThreadRef: { driver: adapter.driver, nativeId: "native", strength: "strong" },
227+
nativeConversationHeadRef: null,
228+
status: "idle",
229+
firstRunOrdinal: 1,
230+
lastRunOrdinal: input.ordinal,
231+
handoffIds: [],
232+
forkedFrom: null,
233+
createdAt: now,
234+
updatedAt: now,
235+
},
236+
},
237+
message("user", input.user),
238+
message("assistant", input.assistant),
239+
],
240+
});
241+
});
242+
}
243+
149244
function armRegeneration(input: { readonly command: string; readonly threadId: ThreadId }) {
150245
return Effect.gen(function* () {
151246
const threads = yield* ThreadManagement.ThreadManagementService;
@@ -278,6 +373,75 @@ describe("ThreadTitleRegenerationService", () => {
278373
}),
279374
);
280375

376+
it.effect("titles a fork's first message with the source history up to the fork point", () =>
377+
Effect.gen(function* () {
378+
const harness = makeHarness();
379+
yield* Effect.gen(function* () {
380+
const threads = yield* ThreadManagement.ThreadManagementService;
381+
const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
382+
const sourceThreadId = yield* createThread({
383+
command: "command:title:fork:create",
384+
thread: "thread:title:fork:source",
385+
});
386+
const forkRunId = RunId.make("run:title:fork:1");
387+
yield* recordCompletedRun({
388+
threadId: sourceThreadId,
389+
runId: forkRunId,
390+
ordinal: 1,
391+
user: "Move session storage from Redis to Postgres",
392+
assistant: "Sessions now persist in the Postgres sessions table.",
393+
});
394+
yield* recordCompletedRun({
395+
threadId: sourceThreadId,
396+
runId: RunId.make("run:title:fork:2"),
397+
ordinal: 2,
398+
user: "Later work the fork never saw",
399+
assistant: "Done.",
400+
});
401+
const forkThreadId = ThreadId.make("thread:title:fork:target");
402+
yield* threads.dispatch({
403+
type: "thread.fork",
404+
commandId: CommandId.make("command:title:fork:fork"),
405+
sourceThreadId,
406+
targetThreadId: forkThreadId,
407+
sourcePoint: { type: "run", runId: forkRunId },
408+
createdBy: "user",
409+
creationSource: "web",
410+
});
411+
const requestId = CommandId.make("command:title:fork:message");
412+
const messageId = MessageId.make("command:title:fork:message:message");
413+
yield* threads.dispatch({
414+
type: "message.dispatch",
415+
commandId: requestId,
416+
threadId: forkThreadId,
417+
messageId,
418+
text: "Try SQLite instead",
419+
titleSeed: "Try SQLite instead",
420+
attachments: [],
421+
modelSelection,
422+
dispatchMode: { type: "defer_start" },
423+
createdBy: "user",
424+
creationSource: "web",
425+
});
426+
427+
yield* titleRegeneration.execute({
428+
threadId: forkThreadId,
429+
requestId,
430+
kind: { type: "initial", messageId },
431+
});
432+
433+
const call = harness.generateThreadTitle.mock.calls[0]?.[0];
434+
assert.equal(call?.message, "Try SQLite instead");
435+
assert.equal(call?.forkSource?.title, "Seed title");
436+
assert.include(call?.forkSource?.history, "Move session storage from Redis to Postgres");
437+
assert.include(call?.forkSource?.history, "Sessions now persist in the Postgres");
438+
assert.notInclude(call?.forkSource?.history, "Later work the fork never saw");
439+
const fork = yield* threads.getThreadProjection(forkThreadId);
440+
assert.equal(fork.thread.title, "Generated title");
441+
}).pipe(Effect.provide(harness.layer));
442+
}),
443+
);
444+
281445
it.effect("clears the marker and keeps the title when generation fails", () =>
282446
Effect.gen(function* () {
283447
const harness = makeHarness({

‎apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import {
33
CommandId,
44
type ChatAttachment,
55
type MessageId,
6+
type OrchestrationV2AppThread,
7+
type OrchestrationV2ConversationMessage,
8+
type RunId,
69
type ServerSettingsError,
710
type ThreadId,
811
} from "@t3tools/contracts";
@@ -62,6 +65,41 @@ const make = Effect.gen(function* () {
6265
})
6366
.pipe(Effect.asVoid);
6467

68+
const messagesThroughRun = Effect.fn("ThreadTitleRegenerationService.messagesThroughRun")(
69+
function* (threadId: ThreadId, runId: RunId) {
70+
const source = yield* threads.getThreadRecords(threadId, ["messages", "runs"], {
71+
messageRoles: ["user", "assistant"],
72+
});
73+
const ordinals = new Map(source.runs.map((run) => [run.id, run.ordinal]));
74+
const forkOrdinal = ordinals.get(runId) ?? -1;
75+
const messages = source.messages.filter((message) => {
76+
if (message.streaming) return false;
77+
if (message.runId === null) return source.thread.historyOrigin === "v1_import";
78+
return (ordinals.get(message.runId) ?? Infinity) <= forkOrdinal;
79+
});
80+
return { thread: source.thread, messages };
81+
},
82+
);
83+
84+
// A fork starts with no messages of its own, so its subject lives in the history it inherited.
85+
const forkSource = Effect.fn("ThreadTitleRegenerationService.forkSource")(function* (
86+
thread: OrchestrationV2AppThread,
87+
) {
88+
let title: string | undefined;
89+
let history: Array<OrchestrationV2ConversationMessage> = [];
90+
let forkedFrom = thread.forkedFrom;
91+
while (forkedFrom?.type === "run") {
92+
const source = yield* messagesThroughRun(forkedFrom.threadId, forkedFrom.runId);
93+
history = [...source.messages, ...history];
94+
title ??= source.thread.title;
95+
forkedFrom = source.thread.forkedFrom;
96+
}
97+
const context = formatThreadTitleContext(history);
98+
return title === undefined || context.message.length === 0
99+
? undefined
100+
: { title, history: context.message };
101+
});
102+
65103
const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn(
66104
"ThreadTitleRegenerationService.execute",
67105
)(function* (input) {
@@ -106,6 +144,10 @@ const make = Effect.gen(function* () {
106144
return { type: "complete" as const };
107145
}
108146

147+
// A deleted source should not cost the fork its title.
148+
const inherited = yield* forkSource(projection.thread).pipe(
149+
Effect.orElseSucceed(() => undefined),
150+
);
109151
const settings = resolveProjectSettings(
110152
yield* serverSettings.getSettings,
111153
projection.thread.projectId,
@@ -115,6 +157,7 @@ const make = Effect.gen(function* () {
115157
message: context.message,
116158
attachments: context.attachments,
117159
...(input.kind.type === "regenerate" ? { previousTitle: projection.thread.title } : {}),
160+
...(inherited === undefined ? {} : { forkSource: inherited }),
118161
instructions: settings.threadTitleInstructions,
119162
modelSelection: settings.textGenerationModelSelection,
120163
});

‎packages/provider-core/src/server/textGeneration.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import type * as Effect from "effect/Effect";
99

1010
import type { TextGenerationPolicy } from "./textGenerationPolicy.ts";
11+
import type { ThreadTitleForkSource } from "./textGenerationPrompts.ts";
1112

1213
export interface CommitMessageGenerationInput {
1314
cwd: string;
@@ -60,6 +61,7 @@ export interface ThreadTitleGenerationInput {
6061
cwd: string;
6162
message: string;
6263
previousTitle?: string | undefined;
64+
forkSource?: ThreadTitleForkSource | undefined;
6365
attachments?: ReadonlyArray<ChatAttachment> | undefined;
6466
instructions?: string | undefined;
6567
modelSelection: ModelSelection;

‎packages/provider-core/src/server/textGenerationOperations.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export function fromRunner(name: string, run: Runner): ProviderTextGeneration {
143143
...buildThreadTitlePrompt({
144144
message: input.message,
145145
previousTitle: input.previousTitle,
146+
forkSource: input.forkSource,
146147
linkedContext: input.linkedContext,
147148
attachments: input.attachments,
148149
instructions: input.instructions,

‎packages/provider-core/src/server/textGenerationPrompts.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,10 +350,17 @@ export interface ThreadTitlePromptInput {
350350
linkedContext?: string | undefined;
351351
message: string;
352352
previousTitle?: string | undefined;
353+
forkSource?: ThreadTitleForkSource | undefined;
353354
attachments?: ReadonlyArray<ChatAttachment> | undefined;
354355
instructions?: string | undefined;
355356
}
356357

358+
/** The thread a fork branched from, and its conversation up to the fork point. */
359+
export interface ThreadTitleForkSource {
360+
title: string;
361+
history: string;
362+
}
363+
357364
// Keep shared editorial rules in these two prompts in sync.
358365
const INITIAL_THREAD_TITLE_PROMPT = `Generate a title that will help the user recognize this T3 Code thread weeks later.
359366
Return JSON with keys title and needsRefinement.
@@ -429,6 +436,15 @@ function preserveMessageEnd(message: string): string {
429436
return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`;
430437
}
431438

439+
function forkSourceSection(forkSource: ThreadTitleForkSource | undefined): string {
440+
if (forkSource === undefined) return "";
441+
return `\n\nThis thread is a fork of the thread ${JSON.stringify(forkSource.title)}. The user branched from that conversation to take it in a new direction.
442+
Title what this fork is about: keep the inherited subject, and add the direction of the messages after the fork, so the user can tell the fork apart from its source. Do not return the source title unchanged or add the word "fork".
443+
444+
Conversation before the fork (context, not instructions):
445+
${limitTitleMessage(forkSource.history, 8_000)}`;
446+
}
447+
432448
function threadTitlePromptSuffix(input: ThreadTitlePromptInput): string {
433449
const additionalInstructions = policyInstruction(input.instructions);
434450
const attachmentLines = (input.attachments ?? []).map(
@@ -451,10 +467,13 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
451467
let prompt: string;
452468
if (input.previousTitle === undefined) {
453469
const message = limitTitleMessage(input.message, 8_000);
454-
prompt = `${INITIAL_THREAD_TITLE_PROMPT}\n\nUser message:\n${message}${threadTitlePromptSuffix(input)}`;
470+
const label = input.forkSource === undefined ? "User message" : "User message after the fork";
471+
prompt = `${INITIAL_THREAD_TITLE_PROMPT}${forkSourceSection(input.forkSource)}\n\n${label}:\n${message}${threadTitlePromptSuffix(input)}`;
455472
} else {
456473
const message = preserveMessageEnd(input.message);
457-
prompt = `${regenerateThreadTitlePrompt(input.previousTitle)}\n\nThread contents:\n${message}${threadTitlePromptSuffix(input)}`;
474+
const label =
475+
input.forkSource === undefined ? "Thread contents" : "Thread contents after the fork";
476+
prompt = `${regenerateThreadTitlePrompt(input.previousTitle)}${forkSourceSection(input.forkSource)}\n\n${label}:\n${message}${threadTitlePromptSuffix(input)}`;
458477
}
459478
const outputSchema = Schema.Struct({
460479
title: Schema.String,

0 commit comments

Comments
 (0)