Skip to content

Commit cb2929e

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

File tree

12 files changed

+134
-34
lines changed

12 files changed

+134
-34
lines changed

apps/server/src/bootstrap.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => {
132132
const duplicatedFdPath = resolveFdPath(fd);
133133
assert.notStrictEqual(duplicatedFdPath, undefined);
134134
const closeSyncSpy = vi.spyOn(NFS, "closeSync");
135-
bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = duplicatedFdPath;
135+
bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = duplicatedFdPath ?? null;
136136

137137
try {
138138
const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,9 @@ it.layer(TestLayer)("git integration", (it) => {
415415

416416
it.effect("refreshes upstream behind count after checkout when remote branch advanced", () =>
417417
Effect.gen(function* () {
418-
const services = yield* Effect.services();
419-
const runPromise = Effect.runPromiseWith(services);
418+
const runPromise = yield* Effect.withFiber((fiber) =>
419+
Effect.succeed(Effect.runPromiseWith(fiber.services)),
420+
);
420421

421422
const remote = yield* makeTmpDir();
422423
const source = yield* makeTmpDir();

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/server/src/persistence/NodeSqliteClient.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import * as Stream from "effect/Stream";
2020
import * as Reactivity from "effect/unstable/reactivity/Reactivity";
2121
import * as Client from "effect/unstable/sql/SqlClient";
2222
import type { Connection } from "effect/unstable/sql/SqlConnection";
23-
import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError";
23+
import { SqlError } from "effect/unstable/sql/SqlError";
2424
import * as Statement from "effect/unstable/sql/Statement";
2525

2626
const ATTR_DB_SYSTEM_NAME = "db.system.name";
@@ -29,8 +29,11 @@ export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
2929

3030
export type TypeId = "~local/sqlite-node/SqliteClient";
3131

32-
const classifyError = (cause: unknown, message: string, operation: string) =>
33-
classifySqliteError(cause, { message, operation });
32+
const sqlError = (cause: unknown, message: string) =>
33+
new SqlError({
34+
cause,
35+
message,
36+
});
3437

3538
/**
3639
* SqliteClient - Effect service tag for the sqlite SQL client.
@@ -112,10 +115,7 @@ const makeWithDatabase = (
112115
lookup: (sql: string) =>
113116
Effect.try({
114117
try: () => db.prepare(sql),
115-
catch: (cause) =>
116-
new SqlError({
117-
reason: classifyError(cause, "Failed to prepare statement", "prepare"),
118-
}),
118+
catch: (cause) => sqlError(cause, "Failed to prepare statement"),
119119
}),
120120
});
121121

@@ -133,11 +133,7 @@ const makeWithDatabase = (
133133
const result = statement.run(...(params as any));
134134
return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []);
135135
} catch (cause) {
136-
return Effect.fail(
137-
new SqlError({
138-
reason: classifyError(cause, "Failed to execute statement", "execute"),
139-
}),
140-
);
136+
return Effect.fail(sqlError(cause, "Failed to execute statement"));
141137
}
142138
});
143139

@@ -160,10 +156,7 @@ const makeWithDatabase = (
160156
statement.run(...(params as any));
161157
return [];
162158
},
163-
catch: (cause) =>
164-
new SqlError({
165-
reason: classifyError(cause, "Failed to execute statement", "execute"),
166-
}),
159+
catch: (cause) => sqlError(cause, "Failed to execute statement"),
167160
}),
168161
(statement) =>
169162
Effect.sync(() => {

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,8 +1100,9 @@ describe("ClaudeAdapterLive", () => {
11001100
it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
11011101
const harness = makeHarness();
11021102
return Effect.gen(function* () {
1103-
const services = yield* Effect.services();
1104-
const runFork = Effect.runForkWith(services);
1103+
const runFork = yield* Effect.withFiber((fiber) =>
1104+
Effect.succeed(Effect.runForkWith(fiber.services)),
1105+
);
11051106

11061107
const adapter = yield* ClaudeAdapter;
11071108
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
@@ -1200,8 +1201,9 @@ describe("ClaudeAdapterLive", () => {
12001201
);
12011202

12021203
return Effect.gen(function* () {
1203-
const services = yield* Effect.services();
1204-
const runFork = Effect.runForkWith(services);
1204+
const runFork = yield* Effect.withFiber((fiber) =>
1205+
Effect.succeed(Effect.runForkWith(fiber.services)),
1206+
);
12051207

12061208
const adapter = yield* ClaudeAdapter;
12071209

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,9 +2380,12 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
23802380
existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined;
23812381
const sessionId = existingResumeSessionId ?? newSessionId;
23822382

2383-
const services = yield* Effect.services();
2384-
const runFork = Effect.runForkWith(services);
2385-
const runPromise = Effect.runPromiseWith(services);
2383+
const runFork = yield* Effect.withFiber((fiber) =>
2384+
Effect.succeed(Effect.runForkWith(fiber.services)),
2385+
);
2386+
const runPromise = yield* Effect.withFiber((fiber) =>
2387+
Effect.succeed(Effect.runPromiseWith(fiber.services)),
2388+
);
23862389

23872390
const promptQueue = yield* Queue.unbounded<PromptQueueItem>();
23882391
const prompt = Stream.fromQueue(promptQueue).pipe(

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({

0 commit comments

Comments
 (0)