From a8204e1ee30cf595013104e909abfd56dc0a0209 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 15:54:49 -0300 Subject: [PATCH 01/27] feat(orchestration): plumb text generation model through turn starts --- apps/server/src/orchestration/decider.ts | 7 +++++ apps/web/src/components/ChatView.tsx | 3 ++ packages/contracts/src/orchestration.test.ts | 31 ++++++++++++++++++++ packages/contracts/src/orchestration.ts | 8 +++++ 4 files changed, 49 insertions(+) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c70194befa..c2a6e4c077 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -376,6 +376,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), + ...(command.textGenerationModel !== undefined + ? { textGenerationModel: command.textGenerationModel } + : {}), + ...(command.providerOptions !== undefined + ? { providerOptions: command.providerOptions } + : {}), + assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308..237a71eeb7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2710,6 +2710,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, + textGenerationModel: settings.textGenerationModelSelection.model, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 59c023e62a..a4fa3554bc 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -319,6 +319,25 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("accepts a text generation model in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-text-model", + threadId: "thread-1", + message: { + messageId: "msg-text-model", + role: "user", + text: "hello", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.textGenerationModel, "gpt-5.4-mini"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ @@ -378,6 +397,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); +it.effect("decodes thread.turn-start-requested text generation model when present", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-2", + messageId: "msg-2", + textGenerationModel: "gpt-5.4-mini", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.textGenerationModel, "gpt-5.4-mini"); + }), +); + it.effect("decodes latest turn source proposed plan metadata when present", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationLatestTurn({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 31631666e2..fb2a60dacb 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -398,6 +398,9 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), + providerOptions: Schema.optional(ProviderStartOptions), + assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -417,6 +420,9 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), + providerOptions: Schema.optional(ProviderStartOptions), + assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -711,6 +717,8 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), + providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( From fc6e4439cc8ad1c913da0de8f0dfd45b8354f377 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 15:55:04 -0300 Subject: [PATCH 02/27] feat(server): generate first-turn thread titles --- .../OrchestrationEngineHarness.integration.ts | 3 +- .../git/Layers/CodexTextGeneration.test.ts | 21 ++++ .../src/git/Layers/CodexTextGeneration.ts | 82 ++++++++++++- apps/server/src/git/Layers/GitManager.test.ts | 19 +++ .../server/src/git/Services/TextGeneration.ts | 20 ++++ .../Layers/ProviderCommandReactor.test.ts | 111 +++++++++++++++++- .../Layers/ProviderCommandReactor.ts | 100 ++++++++++++---- 7 files changed, 330 insertions(+), 26 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 115d18d02b..1a8f802d73 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -306,7 +306,8 @@ export const makeOrchestrationIntegrationHarness = ( Effect.succeed({ branch: input.newBranch }), } as unknown as GitCoreShape); const textGenerationLayer = Layer.succeed(TextGeneration, { - generateBranchName: () => Effect.succeed({ branch: null }), + generateBranchName: () => Effect.succeed({ branch: "update" }), + generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1b07d87d90..7c8a57e82f 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -358,6 +358,27 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("generates thread titles and trims them for sidebar use", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: + ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate websocket reconnect regressions after a worktree restore.", + }); + + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 8f332bf13e..007b6b5319 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -11,6 +11,10 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, + type BranchNameGenerationResult, + type CommitMessageGenerationResult, + type PrContentGenerationResult, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -31,6 +35,22 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; +function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + if (normalized.length <= 50) { + return normalized; + } + return `${normalized.slice(0, 47).trimEnd()}...`; +} const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -83,7 +103,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); const materializeImageAttachments = ( - _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + _operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", attachments: BranchNameGenerationInput["attachments"], ): Effect.Effect => Effect.gen(function* () { @@ -124,7 +148,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { cleanupPaths = [], modelSelection, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -363,10 +391,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { }; }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = (input) => { + return Effect.gen(function* () { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + "You write concise thread titles for coding conversations.", + "Return a JSON object with key: title.", + "Rules:", + "- Title should summarize the user's request, not restate it verbatim.", + "- Keep it short and specific (3-8 words).", + "- Avoid quotes, filler, prefixes, and trailing punctuation.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + const prompt = promptSections.join("\n"); + + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: Schema.Struct({ + title: Schema.String, + }), + imagePaths, + ...(input.model ? { model: input.model } : {}), + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + }; + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 57d6853c4a..3a22dae673 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -65,6 +65,10 @@ interface FakeGitTextGeneration { cwd: string; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; + generateThreadTitle: (input: { + cwd: string; + message: string; + }) => Effect.Effect<{ title: string }, TextGenerationError>; } type FakePullRequest = NonNullable; @@ -168,6 +172,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ branch: "update-workflow", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), ...overrides, }; @@ -205,6 +213,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateThreadTitle: (input) => + implementation.generateThreadTitle(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index e9f2230f43..e8e2955aca 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -61,12 +61,25 @@ export interface BranchNameGenerationResult { branch: string; } +export interface ThreadTitleGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + model?: string; +} + +export interface ThreadTitleGenerationResult { + title: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, ): Promise; generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; + generateThreadTitle(input: ThreadTitleGenerationInput): Promise; } /** @@ -93,6 +106,13 @@ export interface TextGenerationShape { readonly generateBranchName: ( input: BranchNameGenerationInput, ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 834ab9be9e..f32921659a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -176,7 +176,7 @@ describe("ProviderCommandReactor", () => { : "renamed-branch", }), ); - const generateBranchName = vi.fn(() => + const generateBranchName = vi.fn((_) => Effect.fail( new TextGenerationError({ operation: "generateBranchName", @@ -184,6 +184,14 @@ describe("ProviderCommandReactor", () => { }), ), ); + const generateThreadTitle = vi.fn((_) => + Effect.fail( + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "disabled in test harness", + }), + ), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -213,7 +221,10 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), + Layer.succeed(TextGeneration, { + generateBranchName, + generateThreadTitle, + } as unknown as TextGenerationShape), ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -264,6 +275,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, generateBranchName, + generateThreadTitle, stateDir, drain, }; @@ -308,6 +320,101 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("generates a thread title on the first turn using the text generation model", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateThreadTitle.mockImplementation((input: unknown) => + Effect.succeed({ + title: + typeof input === "object" && + input !== null && + "model" in input && + typeof input.model === "string" + ? `Title via ${input.model}` + : "Generated title", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ + model: "gpt-5.4-mini", + message: "Please investigate reconnect failures after restarting the session.", + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Title via gpt-5.4-mini"); + }); + + it("reuses the text generation model for automatic worktree branch naming", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + harness.generateBranchName.mockImplementation((input: unknown) => + Effect.succeed({ + branch: + typeof input === "object" && + input !== null && + "model" in input && + typeof input.model === "string" + ? `feature/${input.model}` + : "feature/generated", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-branch-model"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-branch-model"), + role: "user", + text: "Add a safer reconnect backoff.", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ + model: "gpt-5.4-mini", + message: "Add a safer reconnect backoff.", + }); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7c522e5799..32339a765d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -400,9 +400,9 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; - readonly messageId: string; readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly textGenerationModel?: string; }) { if (!input.branch || !input.worktreePath) { return; @@ -411,28 +411,21 @@ const make = Effect.gen(function* () { return; } - const thread = yield* resolveThread(input.threadId); - if (!thread) { - return; - } - - const userMessages = thread.messages.filter((message) => message.role === "user"); - if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { - return; - } - const oldBranch = input.branch; const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection: modelSelection } = + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, + modelSelection: { + ...textGenerationModelSelection, + model: input.textGenerationModel ?? textGenerationModelSelection.model, + }, }); if (!generated) return; @@ -459,6 +452,50 @@ const make = Effect.gen(function* () { ); }); + const maybeGenerateThreadTitleForFirstTurn = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + readonly textGenerationModel?: string; + }) { + const attachments = input.attachments ?? []; + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; + yield* textGeneration + .generateThreadTitle({ + cwd: input.cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + model: input.textGenerationModel ?? textGenerationModelSelection.model, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("provider command reactor failed to generate thread title", { + threadId: input.threadId, + cwd: input.cwd, + reason: error.message, + }), + ), + Effect.flatMap((generated) => { + if (!generated) return Effect.void; + + return orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("thread-title-rename"), + threadId: input.threadId, + title: generated.title, + }); + }), + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to rename thread title", { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), + }), + ), + ); + }); + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -485,14 +522,35 @@ const make = Effect.gen(function* () { return; } - yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ - threadId: event.payload.threadId, - branch: thread.branch, - worktreePath: thread.worktreePath, - messageId: message.id, - messageText: message.text, - ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), - }).pipe(Effect.forkScoped); + const isFirstUserMessageTurn = + thread.messages.filter((entry) => entry.role === "user").length === 1; + if (isFirstUserMessageTurn) { + const generationCwd = + resolveThreadWorkspaceCwd({ + thread, + projects: (yield* orchestrationEngine.getReadModel()).projects, + }) ?? process.cwd(); + const generationInput = { + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.textGenerationModel !== undefined + ? { textGenerationModel: event.payload.textGenerationModel } + : {}), + }; + + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + ...generationInput, + }).pipe(Effect.forkScoped); + + yield* maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: generationCwd, + ...generationInput, + }).pipe(Effect.forkScoped); + } yield* sendTurnForThread({ threadId: event.payload.threadId, From 22cd903b0539438bfff4204618c944a83090387d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 17:03:47 -0300 Subject: [PATCH 03/27] test(server): fix thread title expectation --- apps/server/src/git/Layers/CodexTextGeneration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 7c8a57e82f..0c055c3018 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -374,7 +374,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { message: "Please investigate websocket reconnect regressions after a worktree restore.", }); - expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + expect(generated.title).toBe("Investigate websocket reconnect regressions after..."); }), ), ); From 84d83c81f65955af1845ec520c0ca35ea491118f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 17:18:56 -0300 Subject: [PATCH 04/27] fix(threads): address review follow-ups --- .../src/git/Layers/CodexTextGeneration.test.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 7 +++++++ apps/web/src/components/ChatView.tsx | 14 ++++++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 0c055c3018..7c8a57e82f 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -374,7 +374,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { message: "Please investigate websocket reconnect regressions after a worktree restore.", }); - expect(generated.title).toBe("Investigate websocket reconnect regressions after..."); + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f32921659a..eb9a8af14f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -359,6 +359,13 @@ describe("ProviderCommandReactor", () => { message: "Please investigate reconnect failures after restarting the session.", }); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + "Title via gpt-5.4-mini" + ); + }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); expect(thread?.title).toBe("Title via gpt-5.4-mini"); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 237a71eeb7..2f93fb3906 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2994,6 +2994,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: settings.textGenerationModelSelection.model, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -3041,8 +3044,11 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, + providerOptionsForDispatch, setComposerDraftInteractionMode, setThreadError, + settings.enableAssistantStreaming, + settings.textGenerationModelSelection.model, selectedModel, ], ); @@ -3069,7 +3075,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, - models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3109,6 +3114,9 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + textGenerationModel: settings.textGenerationModelSelection.model, + ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), + assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3160,7 +3168,9 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedPromptEffort, selectedModelSelection, selectedProvider, - selectedProviderModels, + settings.enableAssistantStreaming, + providerOptionsForDispatch, + settings.textGenerationModelSelection.model, syncServerReadModel, selectedModel, ]); From afd3ab5ba8a42e9ee3ed8a0fa3d0d7095a51e433 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 24 Mar 2026 17:38:56 -0300 Subject: [PATCH 05/27] fix(server): handle blank normalized thread titles --- .../git/Layers/CodexTextGeneration.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 7c8a57e82f..cbad60d157 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -379,6 +379,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("falls back when thread title normalization becomes whitespace-only", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ' """ """ ', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { From e3fd8163ff1e06da4caf1f29db6f24be0745b8cc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 25 Mar 2026 10:16:52 -0300 Subject: [PATCH 06/27] fix(server): trim thread titles after quote removal --- .../git/Layers/CodexTextGeneration.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index cbad60d157..8ed41a96eb 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -399,6 +399,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("trims whitespace exposed after quote removal in thread titles", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ` "' hello world '" `, + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + }); + + expect(generated.title).toBe("hello world"); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { From b2f39a21fa3b4875cc30141878f50a3876d4f294 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 25 Mar 2026 10:16:54 -0300 Subject: [PATCH 07/27] refactor(web): centralize text generation model selection --- apps/web/src/components/ChatView.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2f93fb3906..aceb3384c4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -662,6 +662,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); + const selectedTextGenerationModel = settings.textGenerationModelSelection.model; + const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -2710,7 +2712,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, - textGenerationModel: settings.textGenerationModelSelection.model, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2994,7 +2996,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - textGenerationModel: settings.textGenerationModelSelection.model, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -3048,8 +3050,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, - settings.textGenerationModelSelection.model, selectedModel, + selectedTextGenerationModel, ], ); @@ -3114,7 +3116,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, - textGenerationModel: settings.textGenerationModelSelection.model, + textGenerationModel: selectedTextGenerationModel, ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -3170,7 +3172,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, settings.enableAssistantStreaming, providerOptionsForDispatch, - settings.textGenerationModelSelection.model, + selectedTextGenerationModel, syncServerReadModel, selectedModel, ]); From 942b7368bb25a2a3d548dab0074b9f4a892dd452 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 00:04:15 -0300 Subject: [PATCH 08/27] chore(rebase): resolve main conflicts --- apps/server/src/git/Layers/ClaudeTextGeneration.ts | 10 ++++++++++ apps/server/src/git/Layers/CodexTextGeneration.ts | 14 +++++++++----- .../server/src/git/Layers/RoutingTextGeneration.ts | 1 + 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 919c3a323d..53452275c9 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -299,10 +299,20 @@ const makeClaudeTextGeneration = Effect.gen(function* () { }; }); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "ClaudeTextGeneration.generateThreadTitle", + )(function* () { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Thread title generation is only supported through Codex.", + }); + }); + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 007b6b5319..6326232ff8 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,7 +3,10 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { CodexModelSelection } from "@t3tools/contracts"; +import { + CodexModelSelection, + DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, +} from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -11,9 +14,6 @@ import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { type BranchNameGenerationInput, - type BranchNameGenerationResult, - type CommitMessageGenerationResult, - type PrContentGenerationResult, type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, @@ -24,6 +24,7 @@ import { buildPrContentPrompt, } from "../Prompts.ts"; import { + limitSection, normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, @@ -431,7 +432,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { title: Schema.String, }), imagePaths, - ...(input.model ? { model: input.model } : {}), + modelSelection: { + provider: "codex", + model: input.model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, + }, }); return { diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 7915131385..c3602458a0 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -47,6 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () { route(input.modelSelection.provider).generateCommitMessage(input), generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), + generateThreadTitle: (input) => codex.generateThreadTitle(input), } satisfies TextGenerationShape; }); From a7a8fa528671fdfc6ea1ecfff951c83e1d6ec967 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 00:34:42 -0300 Subject: [PATCH 09/27] test(server): fix branch naming model assertion --- .../Layers/ProviderCommandReactor.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index eb9a8af14f..58aa0c39ea 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -390,9 +390,12 @@ describe("ProviderCommandReactor", () => { branch: typeof input === "object" && input !== null && - "model" in input && - typeof input.model === "string" - ? `feature/${input.model}` + "modelSelection" in input && + typeof input.modelSelection === "object" && + input.modelSelection !== null && + "model" in input.modelSelection && + typeof input.modelSelection.model === "string" + ? `feature/${input.modelSelection.model}` : "feature/generated", }), ); @@ -417,7 +420,9 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.generateBranchName.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ - model: "gpt-5.4-mini", + modelSelection: { + model: "gpt-5.4-mini", + }, message: "Add a safer reconnect backoff.", }); }); From 69dfef818e16007066d5017a589c87c63254f356 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 19:49:48 -0300 Subject: [PATCH 10/27] chore(rebase): refresh main conflict resolution --- .../Layers/ProviderCommandReactor.ts | 3 +-- apps/server/src/orchestration/decider.ts | 1 + apps/web/src/components/ChatView.tsx | 24 ++++++++++++++++++- packages/contracts/src/orchestration.ts | 16 +++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 32339a765d..58cc8bc124 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -415,8 +415,7 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection } = - yield* serverSettingsService.getSettings; + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ cwd, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c2a6e4c077..4060bbb627 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -16,6 +16,7 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); +const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aceb3384c4..d38d6dd8a0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -663,7 +663,27 @@ export default function ChatView({ threadId }: ChatViewProps) { [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const selectedTextGenerationModel = settings.textGenerationModelSelection.model; - const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); + const providerOptionsForDispatch = useMemo(() => { + const providerOptions = { + ...(settings.providers.codex.binaryPath || settings.providers.codex.homePath + ? { + codex: { + binaryPath: settings.providers.codex.binaryPath, + homePath: settings.providers.codex.homePath, + }, + } + : {}), + ...(settings.providers.claudeAgent.binaryPath + ? { + claudeAgent: { + binaryPath: settings.providers.claudeAgent.binaryPath, + }, + } + : {}), + }; + + return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; + }, [settings]); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -3077,6 +3097,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, model: selectedModel, + models: selectedProviderModels, effort: selectedPromptEffort, text: implementationPrompt, }); @@ -3170,6 +3191,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedPromptEffort, selectedModelSelection, selectedProvider, + selectedProviderModels, settings.enableAssistantStreaming, providerOptionsForDispatch, selectedTextGenerationModel, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index fb2a60dacb..666abf94c5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderItemId, ThreadId, + TrimmedString, TrimmedNonEmptyString, TurnId, } from "./baseSchemas"; @@ -42,6 +43,21 @@ export const ProviderSandboxMode = Schema.Literals([ "danger-full-access", ]); export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; + +export const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional( + Schema.Struct({ + binaryPath: TrimmedString, + homePath: TrimmedString, + }), + ), + claudeAgent: Schema.optional( + Schema.Struct({ + binaryPath: TrimmedString, + }), + ), +}); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ From 618bccb6acb589c65521c03c9dd869bf20b83486 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 20:00:04 -0300 Subject: [PATCH 11/27] fix(server): catch title generation settings failures --- .../Layers/ProviderCommandReactor.ts | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 58cc8bc124..a3e3702958 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -459,40 +459,32 @@ const make = Effect.gen(function* () { readonly textGenerationModel?: string; }) { const attachments = input.attachments ?? []; - const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; - yield* textGeneration - .generateThreadTitle({ + yield* Effect.gen(function* () { + const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; + + const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), model: input.textGenerationModel ?? textGenerationModelSelection.model, - }) - .pipe( - Effect.catch((error) => - Effect.logWarning("provider command reactor failed to generate thread title", { - threadId: input.threadId, - cwd: input.cwd, - reason: error.message, - }), - ), - Effect.flatMap((generated) => { - if (!generated) return Effect.void; - - return orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("thread-title-rename"), - threadId: input.threadId, - title: generated.title, - }); + }); + if (!generated) return; + + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("thread-title-rename"), + threadId: input.threadId, + title: generated.title, + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("provider command reactor failed to generate or rename thread title", { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), }), - Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to rename thread title", { - threadId: input.threadId, - cwd: input.cwd, - cause: Cause.pretty(cause), - }), - ), - ); + ), + ); }); const processTurnStartRequested = Effect.fnUntraced(function* ( From 1fdb9836b01c0bedc042206fd3977349551d1418 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 20:00:09 -0300 Subject: [PATCH 12/27] fix(web): only send non-default provider options --- apps/web/src/components/ChatView.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d38d6dd8a0..447a981542 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,6 +21,7 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -665,7 +666,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedTextGenerationModel = settings.textGenerationModelSelection.model; const providerOptionsForDispatch = useMemo(() => { const providerOptions = { - ...(settings.providers.codex.binaryPath || settings.providers.codex.homePath + ...(settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ? { codex: { binaryPath: settings.providers.codex.binaryPath, @@ -673,7 +675,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }, } : {}), - ...(settings.providers.claudeAgent.binaryPath + ...(settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath ? { claudeAgent: { binaryPath: settings.providers.claudeAgent.binaryPath, From 3494af00c757bc0a0017f5e9d2671fb11b4c9bde Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 26 Mar 2026 20:01:45 -0300 Subject: [PATCH 13/27] style(web): format provider options condition --- apps/web/src/components/ChatView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 447a981542..fc1b573879 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -666,7 +666,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const selectedTextGenerationModel = settings.textGenerationModelSelection.model; const providerOptionsForDispatch = useMemo(() => { const providerOptions = { - ...(settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + ...(settings.providers.codex.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath ? { codex: { From 4ac61144763f3627d42515ced18fe414025a4b21 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 17:51:40 -0300 Subject: [PATCH 14/27] refactor(server): route thread titles through prompt backends --- .../git/Layers/ClaudeTextGeneration.test.ts | 59 +++++++++++ .../src/git/Layers/ClaudeTextGeneration.ts | 33 ++++++- .../git/Layers/CodexTextGeneration.test.ts | 3 + .../src/git/Layers/CodexTextGeneration.ts | 98 ++++++------------- apps/server/src/git/Layers/GitManager.test.ts | 6 +- .../src/git/Layers/RoutingTextGeneration.ts | 2 +- apps/server/src/git/Prompts.test.ts | 45 ++++++++- apps/server/src/git/Prompts.ts | 65 ++++++++++-- .../server/src/git/Services/TextGeneration.ts | 4 +- apps/server/src/git/Utils.ts | 21 ++++ .../Layers/ProviderCommandReactor.test.ts | 14 ++- .../Layers/ProviderCommandReactor.ts | 5 +- 12 files changed, 265 insertions(+), 90 deletions(-) diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts index 29ae4796fe..0847134698 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts @@ -5,6 +5,7 @@ import { expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { sanitizeThreadTitle } from "../Utils.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -247,4 +248,62 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { }), ), ); + + it.effect("generates thread titles through the Claude provider", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: + ' "Reconnect failures after restart because the session state does not recover" ', + }, + }), + stdinMustContain: "You write concise thread titles for coding conversations.", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate reconnect failures after restarting the session.", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe( + sanitizeThreadTitle( + '"Reconnect failures after restart because the session state does not recover"', + ), + ); + }), + ), + ); + + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: ' """ """ ', + }, + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); }); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 53452275c9..f4d3833627 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -20,11 +20,13 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "../Prompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, + sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; @@ -70,7 +72,11 @@ const makeClaudeTextGeneration = Effect.gen(function* () { outputSchemaJson, modelSelection, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -301,11 +307,30 @@ const makeClaudeTextGeneration = Effect.gen(function* () { const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( "ClaudeTextGeneration.generateThreadTitle", - )(function* () { - return yield* new TextGenerationError({ + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "claudeAgent") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runClaudeJson({ operation: "generateThreadTitle", - detail: "Thread title generation is only supported through Codex.", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, }); + + return { + title: sanitizeThreadTitle(generated.title), + }; }); return { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 8ed41a96eb..21a97eec9c 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -372,6 +372,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const generated = yield* textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "Please investigate websocket reconnect regressions after a worktree restore.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); @@ -392,6 +393,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const generated = yield* textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.title).toBe("New thread"); @@ -412,6 +414,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { const generated = yield* textGeneration.generateThreadTitle({ cwd: process.cwd(), message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, }); expect(generated.title).toBe("hello world"); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 6326232ff8..c82923f93e 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,10 +3,7 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { - CodexModelSelection, - DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, -} from "@t3tools/contracts"; +import { CodexModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -22,12 +19,13 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "../Prompts.ts"; import { - limitSection, normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, + sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts"; @@ -35,23 +33,6 @@ import { ServerSettingsService } from "../../serverSettings.ts"; const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; - -function sanitizeThreadTitle(raw: string): string { - const normalized = raw - .trim() - .split(/\r?\n/g)[0] - ?.trim() - .replace(/^['"`]+|['"`]+$/g, "") - .trim() - .replace(/\s+/g, " "); - if (!normalized || normalized.trim().length === 0) { - return "New thread"; - } - if (normalized.length <= 50) { - return normalized; - } - return `${normalized.slice(0, 47).trimEnd()}...`; -} const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -392,57 +373,38 @@ const makeCodexTextGeneration = Effect.gen(function* () { }; }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = (input) => { - return Effect.gen(function* () { - const { imagePaths } = yield* materializeImageAttachments( - "generateThreadTitle", - input.attachments, - ); - const attachmentLines = (input.attachments ?? []).map( - (attachment) => - `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, - ); - - const promptSections = [ - "You write concise thread titles for coding conversations.", - "Return a JSON object with key: title.", - "Rules:", - "- Title should summarize the user's request, not restate it verbatim.", - "- Keep it short and specific (3-8 words).", - "- Avoid quotes, filler, prefixes, and trailing punctuation.", - "- If images are attached, use them as primary context for visual/UI issues.", - "", - "User message:", - limitSection(input.message, 8_000), - ]; - if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); - } - const prompt = promptSections.join("\n"); + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CodexTextGeneration.generateThreadTitle", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); - const generated = yield* runCodexJson({ + if (input.modelSelection.provider !== "codex") { + return yield* new TextGenerationError({ operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: Schema.Struct({ - title: Schema.String, - }), - imagePaths, - modelSelection: { - provider: "codex", - model: input.model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex, - }, + detail: "Invalid model selection.", }); + } - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, }); - }; + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); return { generateCommitMessage, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 3a22dae673..d13c389fc8 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -6,7 +6,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { expect } from "vitest"; -import type { GitActionProgressEvent } from "@t3tools/contracts"; +import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts"; import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts"; import { type GitManagerShape } from "../Services/GitManager.ts"; @@ -49,6 +49,7 @@ interface FakeGitTextGeneration { stagedSummary: string; stagedPatch: string; includeBranch?: boolean; + modelSelection: ModelSelection; }) => Effect.Effect< { subject: string; body: string; branch?: string | undefined }, TextGenerationError @@ -60,14 +61,17 @@ interface FakeGitTextGeneration { commitSummary: string; diffSummary: string; diffPatch: string; + modelSelection: ModelSelection; }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; generateBranchName: (input: { cwd: string; message: string; + modelSelection: ModelSelection; }) => Effect.Effect<{ branch: string }, TextGenerationError>; generateThreadTitle: (input: { cwd: string; message: string; + modelSelection: ModelSelection; }) => Effect.Effect<{ title: string }, TextGenerationError>; } diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index c3602458a0..dee12a3e0e 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -47,7 +47,7 @@ const makeRoutingTextGeneration = Effect.gen(function* () { route(input.modelSelection.provider).generateCommitMessage(input), generatePrContent: (input) => route(input.modelSelection.provider).generatePrContent(input), generateBranchName: (input) => route(input.modelSelection.provider).generateBranchName(input), - generateThreadTitle: (input) => codex.generateThreadTitle(input), + generateThreadTitle: (input) => route(input.modelSelection.provider).generateThreadTitle(input), } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts index 23c3eca557..7951e78b39 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/git/Prompts.test.ts @@ -4,8 +4,9 @@ import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, + buildThreadTitlePrompt, } from "./Prompts.ts"; -import { normalizeCliError } from "./Utils.ts"; +import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; import { TextGenerationError } from "./Errors.ts"; describe("buildCommitMessagePrompt", () => { @@ -103,6 +104,48 @@ describe("buildBranchNamePrompt", () => { }); }); +describe("buildThreadTitlePrompt", () => { + it("includes the user message in the prompt", () => { + const result = buildThreadTitlePrompt({ + message: "Investigate reconnect regressions after session restore", + }); + + expect(result.prompt).toContain("User message:"); + expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); + expect(result.prompt).not.toContain("Attachment metadata:"); + }); + + it("includes attachment metadata when attachments are provided", () => { + const result = buildThreadTitlePrompt({ + message: "Name this thread from the screenshot", + attachments: [ + { + type: "image" as const, + id: "att-456", + name: "thread.png", + mimeType: "image/png", + sizeBytes: 67890, + }, + ], + }); + + expect(result.prompt).toContain("Attachment metadata:"); + expect(result.prompt).toContain("thread.png"); + expect(result.prompt).toContain("image/png"); + expect(result.prompt).toContain("67890 bytes"); + }); +}); + +describe("sanitizeThreadTitle", () => { + it("truncates long titles with the shared sidebar-safe limit", () => { + expect( + sanitizeThreadTitle( + ' "Reconnect failures after restart because the session state does not recover" ', + ), + ).toBe("Reconnect failures after restart because the se..."); + }); +}); + describe("normalizeCliError", () => { it("detects 'Command not found' and includes CLI name in the message", () => { const error = normalizeCliError( diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts index 2eacf370eb..4092358825 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/git/Prompts.ts @@ -119,19 +119,24 @@ export interface BranchNamePromptInput { attachments?: ReadonlyArray | undefined; } -export function buildBranchNamePrompt(input: BranchNamePromptInput) { +interface PromptFromMessageInput { + instruction: string; + responseShape: string; + rules: ReadonlyArray; + message: string; + attachments?: ReadonlyArray | undefined; +} + +function buildPromptFromMessage(input: PromptFromMessageInput): string { const attachmentLines = (input.attachments ?? []).map( (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, ); const promptSections = [ - "You generate concise git branch names.", - "Return a JSON object with key: branch.", + input.instruction, + input.responseShape, "Rules:", - "- Branch should describe the requested work from the user message.", - "- Keep it short and specific (2-6 words).", - "- Use plain words only, no issue prefixes and no punctuation-heavy text.", - "- If images are attached, use them as primary context for visual/UI issues.", + ...input.rules.map((rule) => `- ${rule}`), "", "User message:", limitSection(input.message, 8_000), @@ -144,10 +149,54 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { ); } - const prompt = promptSections.join("\n"); + return promptSections.join("\n"); +} + +export function buildBranchNamePrompt(input: BranchNamePromptInput) { + const prompt = buildPromptFromMessage({ + instruction: "You generate concise git branch names.", + responseShape: "Return a JSON object with key: branch.", + rules: [ + "Branch should describe the requested work from the user message.", + "Keep it short and specific (2-6 words).", + "Use plain words only, no issue prefixes and no punctuation-heavy text.", + "If images are attached, use them as primary context for visual/UI issues.", + ], + message: input.message, + attachments: input.attachments, + }); const outputSchema = Schema.Struct({ branch: Schema.String, }); return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Thread title +// --------------------------------------------------------------------------- + +export interface ThreadTitlePromptInput { + message: string; + attachments?: ReadonlyArray | undefined; +} + +export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { + const prompt = buildPromptFromMessage({ + instruction: "You write concise thread titles for coding conversations.", + responseShape: "Return a JSON object with key: title.", + rules: [ + "Title should summarize the user's request, not restate it verbatim.", + "Keep it short and specific (3-8 words).", + "Avoid quotes, filler, prefixes, and trailing punctuation.", + "If images are attached, use them as primary context for visual/UI issues.", + ], + message: input.message, + attachments: input.attachments, + }); + const outputSchema = Schema.Struct({ + title: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index e8e2955aca..0df2fff62c 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -65,8 +65,8 @@ export interface ThreadTitleGenerationInput { cwd: string; message: string; attachments?: ReadonlyArray | undefined; - /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ - model?: string; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; } export interface ThreadTitleGenerationResult { diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index eb208deccb..8f0321fd52 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -53,6 +53,27 @@ export function sanitizePrTitle(raw: string): string { return "Update project changes"; } +/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ +export function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + + if (normalized.length <= 50) { + return normalized; + } + + return `${normalized.slice(0, 47).trimEnd()}...`; +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 58aa0c39ea..c96a9ab0e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -328,9 +328,12 @@ describe("ProviderCommandReactor", () => { title: typeof input === "object" && input !== null && - "model" in input && - typeof input.model === "string" - ? `Title via ${input.model}` + "modelSelection" in input && + typeof input.modelSelection === "object" && + input.modelSelection !== null && + "model" in input.modelSelection && + typeof input.modelSelection.model === "string" + ? `Title via ${input.modelSelection.model}` : "Generated title", }), ); @@ -355,8 +358,11 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ - model: "gpt-5.4-mini", message: "Please investigate reconnect failures after restarting the session.", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, }); await waitFor(async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a3e3702958..f76b9f8867 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -466,7 +466,10 @@ const make = Effect.gen(function* () { cwd: input.cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - model: input.textGenerationModel ?? textGenerationModelSelection.model, + modelSelection: { + ...textGenerationModelSelection, + model: input.textGenerationModel ?? textGenerationModelSelection.model, + }, }); if (!generated) return; From 32d768d164f50e595be31d8c686dcc834df8d401 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 18:23:37 -0300 Subject: [PATCH 15/27] refactor(orchestration): drop unused provider start options --- apps/server/src/orchestration/decider.ts | 3 --- apps/web/src/components/ChatView.tsx | 30 ------------------------ packages/contracts/src/orchestration.ts | 18 -------------- 3 files changed, 51 deletions(-) diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4060bbb627..ab421a6fa4 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -380,9 +380,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.textGenerationModel !== undefined ? { textGenerationModel: command.textGenerationModel } : {}), - ...(command.providerOptions !== undefined - ? { providerOptions: command.providerOptions } - : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fc1b573879..95e038c0cf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,7 +21,6 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -664,30 +663,6 @@ export default function ChatView({ threadId }: ChatViewProps) { [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); const selectedTextGenerationModel = settings.textGenerationModelSelection.model; - const providerOptionsForDispatch = useMemo(() => { - const providerOptions = { - ...(settings.providers.codex.binaryPath !== - DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || - settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath - ? { - codex: { - binaryPath: settings.providers.codex.binaryPath, - homePath: settings.providers.codex.homePath, - }, - } - : {}), - ...(settings.providers.claudeAgent.binaryPath !== - DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath - ? { - claudeAgent: { - binaryPath: settings.providers.claudeAgent.binaryPath, - }, - } - : {}), - }; - - return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; - }, [settings]); const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -2737,7 +2712,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, modelSelection: selectedModelSelection, textGenerationModel: selectedTextGenerationModel, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, @@ -3021,7 +2995,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, modelSelection: selectedModelSelection, textGenerationModel: selectedTextGenerationModel, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, @@ -3070,7 +3043,6 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, - providerOptionsForDispatch, setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, @@ -3142,7 +3114,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, modelSelection: selectedModelSelection, textGenerationModel: selectedTextGenerationModel, - ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", @@ -3197,7 +3168,6 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, selectedProviderModels, settings.enableAssistantStreaming, - providerOptionsForDispatch, selectedTextGenerationModel, syncServerReadModel, selectedModel, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 666abf94c5..f7e09767e5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -11,7 +11,6 @@ import { ProjectId, ProviderItemId, ThreadId, - TrimmedString, TrimmedNonEmptyString, TurnId, } from "./baseSchemas"; @@ -44,20 +43,6 @@ export const ProviderSandboxMode = Schema.Literals([ ]); export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; -export const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional( - Schema.Struct({ - binaryPath: TrimmedString, - homePath: TrimmedString, - }), - ), - claudeAgent: Schema.optional( - Schema.Struct({ - binaryPath: TrimmedString, - }), - ), -}); -export type ProviderStartOptions = typeof ProviderStartOptions.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const CodexModelSelection = Schema.Struct({ @@ -415,7 +400,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ }), modelSelection: Schema.optional(ModelSelection), textGenerationModel: Schema.optional(TrimmedNonEmptyString), - providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -437,7 +421,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ }), modelSelection: Schema.optional(ModelSelection), textGenerationModel: Schema.optional(TrimmedNonEmptyString), - providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -734,7 +717,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ messageId: MessageId, modelSelection: Schema.optional(ModelSelection), textGenerationModel: Schema.optional(TrimmedNonEmptyString), - providerOptions: Schema.optional(ProviderStartOptions), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( From 451f55bb0a95cecf47476399de6d77b09a196381 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 18:25:05 -0300 Subject: [PATCH 16/27] fix(orchestration): preserve custom first-turn thread titles --- .../Layers/ProviderCommandReactor.test.ts | 48 +++++++++++++++ .../Layers/ProviderCommandReactor.ts | 58 +++++++++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c96a9ab0e2..a5f6e5cb93 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -338,6 +338,15 @@ describe("ProviderCommandReactor", () => { }), ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-title-seed"), + threadId: ThreadId.makeUnsafe("thread-1"), + title: "Please investigate reconnect failures after restar...", + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -377,6 +386,45 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Title via gpt-5.4-mini"); }); + it("does not overwrite an existing custom thread title on the first turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-title-custom"), + threadId: ThreadId.makeUnsafe("thread-1"), + title: "Keep this custom title", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title-preserve"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title-preserve"), + role: "user", + text: "Please investigate reconnect failures after restarting the session.", + attachments: [], + }, + textGenerationModel: "gpt-5.4-mini", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.generateThreadTitle).not.toHaveBeenCalled(); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Keep this custom title"); + }); + it("reuses the text generation model for automatic worktree branch naming", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f76b9f8867..3d78ee4742 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -73,6 +73,46 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); +const DEFAULT_THREAD_TITLE = "New thread"; + +function truncateAutoThreadTitle(text: string, maxLength = 50): string { + const trimmed = text.trim(); + if (trimmed.length <= maxLength) { + return trimmed; + } + + return `${trimmed.slice(0, maxLength)}...`; +} + +function buildReplaceableThreadTitles(input: { + readonly messageText: string; + readonly attachments?: ReadonlyArray; +}): ReadonlySet { + const titles = new Set([DEFAULT_THREAD_TITLE]); + const trimmedMessage = input.messageText.trim(); + + if (trimmedMessage.length > 0) { + titles.add(truncateAutoThreadTitle(trimmedMessage)); + return titles; + } + + const firstImageAttachment = input.attachments?.find((attachment) => attachment.type === "image"); + if (firstImageAttachment) { + titles.add(truncateAutoThreadTitle(`Image: ${firstImageAttachment.name}`)); + } + + return titles; +} + +function isReplaceableThreadTitle( + currentTitle: string, + input: { + readonly messageText: string; + readonly attachments?: ReadonlyArray; + }, +): boolean { + return buildReplaceableThreadTitles(input).has(currentTitle.trim()); +} function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = Cause.squash(cause); @@ -473,6 +513,12 @@ const make = Effect.gen(function* () { }); if (!generated) return; + const thread = yield* resolveThread(input.threadId); + if (!thread) return; + if (!isReplaceableThreadTitle(thread.title, input)) { + return; + } + yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: serverCommandId("thread-title-rename"), @@ -539,11 +585,13 @@ const make = Effect.gen(function* () { ...generationInput, }).pipe(Effect.forkScoped); - yield* maybeGenerateThreadTitleForFirstTurn({ - threadId: event.payload.threadId, - cwd: generationCwd, - ...generationInput, - }).pipe(Effect.forkScoped); + if (isReplaceableThreadTitle(thread.title, generationInput)) { + yield* maybeGenerateThreadTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: generationCwd, + ...generationInput, + }).pipe(Effect.forkScoped); + } } yield* sendTurnForThread({ From 359ce3ee10c2d3a289f814cba0fd8ccb7d53ca7e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 18:54:20 -0300 Subject: [PATCH 17/27] refactor(shared): share truncate title utility --- apps/marketing/src/layouts/Layout.astro | 2 +- apps/marketing/src/pages/index.astro | 2 +- apps/server/src/bootstrap.test.ts | 10 +------ apps/server/src/bootstrap.ts | 28 +++++++++++++++---- .../Layers/ProviderCommandReactor.ts | 14 ++-------- apps/web/src/components/ChatView.tsx | 2 +- packages/shared/package.json | 4 +++ .../shared}/src/truncateTitle.test.ts | 4 +-- .../shared}/src/truncateTitle.ts | 1 + 9 files changed, 36 insertions(+), 31 deletions(-) rename {apps/web => packages/shared}/src/truncateTitle.test.ts (75%) rename {apps/web => packages/shared}/src/truncateTitle.ts (99%) diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index b4fa945e25..cc6f86042b 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -6,7 +6,7 @@ interface Props { const { title = "T3 Code", - description = "T3 Code — The best way to code with AI.", + description = "T3 Code — A great way to code with agents.", } = Astro.props; --- diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 3a2111f4f8..e7203c21dd 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -3,7 +3,7 @@ import Layout from "../layouts/Layout.astro"; --- -

T3 Code is the best way to code with AI.

+

T3 Code is a great way to code with agents.

({ failPath: null as string | null })); @@ -38,14 +38,6 @@ vi.mock("node:fs", async (importOriginal) => { const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { - it.effect("uses platform-specific fd paths", () => - Effect.sync(() => { - assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); - assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); - assert.equal(resolveFdPath(3, "win32"), undefined); - }), - ); - it.effect("reads a bootstrap envelope from a provided fd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 0fb1352268..53f37000ed 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -87,6 +87,11 @@ const isUnavailableBootstrapFdError = Predicate.compose( (_) => _.code === "EBADF" || _.code === "ENOENT", ); +const isUnavailableBootstrapFdPathError = Predicate.compose( + Predicate.hasProperty("code"), + (_) => _.code === "EBADF" || _.code === "ENOENT" || _.code === "ENXIO", +); + const isFdReady = (fd: number) => Effect.try({ try: () => NFS.fstatSync(fd), @@ -106,6 +111,16 @@ const isFdReady = (fd: number) => const makeBootstrapInputStream = (fd: number) => Effect.try({ try: () => { + if (process.platform === "win32") { + const stream = new Net.Socket({ + fd, + readable: true, + writable: false, + }); + stream.setEncoding("utf8"); + return stream; + } + const fdPath = resolveFdPath(fd); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); @@ -126,12 +141,16 @@ const makeBootstrapInputStream = (fd: number) => } return makeDirectBootstrapStream(fd); } - throw error; + if (!isUnavailableBootstrapFdPathError(error)) { + throw error; + } + + return makeDirectBootstrapStream(fd); } }, catch: (error) => new BootstrapError({ - message: "Failed to duplicate bootstrap fd.", + message: "Failed to open bootstrap fd.", cause: error, }), }); @@ -163,11 +182,8 @@ export function resolveFdPath( fd: number, platform: NodeJS.Platform = process.platform, ): string | undefined { - if (platform === "linux") { - return `/proc/self/fd/${fd}`; - } if (platform === "win32") { return undefined; } - return `/dev/fd/${fd}`; + return platform === "linux" ? `/proc/self/fd/${fd}` : `/dev/fd/${fd}`; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3d78ee4742..0063e1e893 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { truncateTitle } from "@t3tools/shared/truncateTitle"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; @@ -75,15 +76,6 @@ const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); const DEFAULT_THREAD_TITLE = "New thread"; -function truncateAutoThreadTitle(text: string, maxLength = 50): string { - const trimmed = text.trim(); - if (trimmed.length <= maxLength) { - return trimmed; - } - - return `${trimmed.slice(0, maxLength)}...`; -} - function buildReplaceableThreadTitles(input: { readonly messageText: string; readonly attachments?: ReadonlyArray; @@ -92,13 +84,13 @@ function buildReplaceableThreadTitles(input: { const trimmedMessage = input.messageText.trim(); if (trimmedMessage.length > 0) { - titles.add(truncateAutoThreadTitle(trimmedMessage)); + titles.add(truncateTitle(trimmedMessage)); return titles; } const firstImageAttachment = input.attachments?.find((attachment) => attachment.type === "image"); if (firstImageAttachment) { - titles.add(truncateAutoThreadTitle(`Image: ${firstImageAttachment.name}`)); + titles.add(truncateTitle(`Image: ${firstImageAttachment.name}`)); } return titles; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 95e038c0cf..60d24fb21f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,6 +22,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { truncateTitle } from "@t3tools/shared/truncateTitle"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -69,7 +70,6 @@ import { proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; -import { truncateTitle } from "../truncateTitle"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, diff --git a/packages/shared/package.json b/packages/shared/package.json index d34d1ce453..d38ce667a2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -35,6 +35,10 @@ "./Struct": { "types": "./src/Struct.ts", "import": "./src/Struct.ts" + }, + "./truncateTitle": { + "types": "./src/truncateTitle.ts", + "import": "./src/truncateTitle.ts" } }, "scripts": { diff --git a/apps/web/src/truncateTitle.test.ts b/packages/shared/src/truncateTitle.test.ts similarity index 75% rename from apps/web/src/truncateTitle.test.ts rename to packages/shared/src/truncateTitle.test.ts index d7d61c5da1..fdf4e12182 100644 --- a/apps/web/src/truncateTitle.test.ts +++ b/packages/shared/src/truncateTitle.test.ts @@ -7,11 +7,11 @@ describe("truncateTitle", () => { expect(truncateTitle(" hello world ")).toBe("hello world"); }); - it("returns trimmed text when within max length", () => { + it("returns shorter strings unchanged", () => { expect(truncateTitle("alpha", 10)).toBe("alpha"); }); - it("appends ellipsis when text exceeds max length", () => { + it("truncates long strings and appends an ellipsis", () => { expect(truncateTitle("abcdefghij", 5)).toBe("abcde..."); }); }); diff --git a/apps/web/src/truncateTitle.ts b/packages/shared/src/truncateTitle.ts similarity index 99% rename from apps/web/src/truncateTitle.ts rename to packages/shared/src/truncateTitle.ts index bce5545283..4cc3e12e08 100644 --- a/apps/web/src/truncateTitle.ts +++ b/packages/shared/src/truncateTitle.ts @@ -3,5 +3,6 @@ export function truncateTitle(text: string, maxLength = 50): string { if (trimmed.length <= maxLength) { return trimmed; } + return `${trimmed.slice(0, maxLength)}...`; } From 81c3c98d644f4d67be9dca74315aa09e9257195d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 19:14:47 -0300 Subject: [PATCH 18/27] fix(server): close duplicated bootstrap fd fallback --- apps/server/src/bootstrap.test.ts | 71 ++++++++++++++++++++++++++++--- apps/server/src/bootstrap.ts | 3 ++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 1744f0146a..bc95c60e3f 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -10,10 +10,14 @@ import * as Fiber from "effect/Fiber"; import { TestClock } from "effect/testing"; import { vi } from "vitest"; -import { readBootstrapEnvelope } from "./bootstrap"; +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; import { assertNone, assertSome } from "@effect/vitest/utils"; -const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); +const bootstrapFsInterceptor = vi.hoisted(() => ({ + failOpenPath: null as string | null, + failCreateReadStreamForDuplicatedPath: null as string | null, + duplicatedFdForPathFailure: null as number | null, +})); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -23,14 +27,34 @@ vi.mock("node:fs", async (importOriginal) => { const [filePath, flags] = args; if ( typeof filePath === "string" && - filePath === openSyncInterceptor.failPath && + filePath === bootstrapFsInterceptor.failOpenPath && flags === "r" ) { const error = new Error("no such device or address"); Object.assign(error, { code: "ENXIO" }); throw error; } - return (actual.openSync as (...a: typeof args) => number)(...args); + const fd = (actual.openSync as (...a: typeof args) => number)(...args); + if ( + typeof filePath === "string" && + filePath === bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath && + flags === "r" + ) { + bootstrapFsInterceptor.duplicatedFdForPathFailure = fd; + } + return fd; + }, + createReadStream: (...args: Parameters) => { + const [, options] = args; + const fd = typeof options === "object" && options && "fd" in options ? options.fd : undefined; + if (typeof fd === "number" && fd === bootstrapFsInterceptor.duplicatedFdForPathFailure) { + const error = new Error("bad file descriptor"); + Object.assign(error, { code: "EBADF" }); + throw error; + } + return ( + actual.createReadStream as (...a: typeof args) => ReturnType + )(...args); }, }; }); @@ -80,14 +104,49 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { // stream's async close and produces an uncaught EBADF. const fd = NFS.openSync(filePath, "r"); - openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; + bootstrapFsInterceptor.failOpenPath = resolveFdPath(fd) ?? null; + try { + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertSome(payload, { + mode: "desktop", + }); + } finally { + bootstrapFsInterceptor.failOpenPath = null; + } + }), + ); + + it.effect("closes the duplicated fd before falling back when the duplicated stream fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + + yield* fs.writeFileString( + filePath, + `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ + mode: "desktop", + })}\n`, + ); + + const fd = NFS.openSync(filePath, "r"); + const duplicatedFdPath = resolveFdPath(fd); + assert.notStrictEqual(duplicatedFdPath, undefined); + const closeSyncSpy = vi.spyOn(NFS, "closeSync"); + bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = duplicatedFdPath; + try { const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertSome(payload, { mode: "desktop", }); + + const duplicatedFd = bootstrapFsInterceptor.duplicatedFdForPathFailure; + assert.notStrictEqual(duplicatedFd, null); + assert.ok(closeSyncSpy.mock.calls.some(([closedFd]) => closedFd === duplicatedFd)); } finally { - openSyncInterceptor.failPath = null; + bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = null; + bootstrapFsInterceptor.duplicatedFdForPathFailure = null; + closeSyncSpy.mockRestore(); } }), ); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 53f37000ed..84a7f427dd 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -145,6 +145,9 @@ const makeBootstrapInputStream = (fd: number) => throw error; } + if (streamFd !== undefined) { + NFS.closeSync(streamFd); + } return makeDirectBootstrapStream(fd); } }, From cb2929ee2c3c5919e930756ba7bfe143a2e16558 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 19:16:58 -0300 Subject: [PATCH 19/27] fix(threads): preserve auto-title matching after prompt formatting --- apps/server/src/bootstrap.test.ts | 2 +- apps/server/src/git/Layers/GitCore.test.ts | 5 +- .../Layers/ProviderCommandReactor.test.ts | 52 +++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 10 ++++ apps/server/src/orchestration/decider.ts | 1 + .../src/persistence/NodeSqliteClient.ts | 25 ++++----- .../src/provider/Layers/ClaudeAdapter.test.ts | 10 ++-- .../src/provider/Layers/ClaudeAdapter.ts | 9 ++-- apps/web/src/components/ChatView.tsx | 3 ++ packages/contracts/src/orchestration.test.ts | 31 +++++++++++ packages/contracts/src/orchestration.ts | 3 ++ packages/shared/src/DrainableWorker.ts | 17 +++--- 12 files changed, 134 insertions(+), 34 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index bc95c60e3f..086612d166 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { const duplicatedFdPath = resolveFdPath(fd); assert.notStrictEqual(duplicatedFdPath, undefined); const closeSyncSpy = vi.spyOn(NFS, "closeSync"); - bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = duplicatedFdPath; + bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = duplicatedFdPath ?? null; try { const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index dc97b93649..4204b5588b 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -415,8 +415,9 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => Effect.gen(function* () { - const services = yield* Effect.services(); - const runPromise = Effect.runPromiseWith(services); + const runPromise = yield* Effect.withFiber((fiber) => + Effect.succeed(Effect.runPromiseWith(fiber.services)), + ); const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a5f6e5cb93..554ed273f4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -425,6 +425,58 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Keep this custom title"); }); + it("matches the client-seeded title even when the outgoing prompt is reformatted", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + const seededTitle = "Fix reconnect spinner on resume"; + harness.generateThreadTitle.mockReturnValue( + Effect.succeed({ + title: "Reconnect spinner resume bug", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-title-formatted-seed"), + threadId: ThreadId.makeUnsafe("thread-1"), + title: seededTitle, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-title-formatted"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-title-formatted"), + role: "user", + text: "[effort:high]\\n\\nFix reconnect spinner on resume", + attachments: [], + }, + titleSeed: seededTitle, + textGenerationModel: "gpt-5.4-mini", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === + "Reconnect spinner resume bug" + ); + }); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.title).toBe("Reconnect spinner resume bug"); + }); + it("reuses the text generation model for automatic worktree branch naming", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0063e1e893..7166af62d4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -79,8 +79,16 @@ const DEFAULT_THREAD_TITLE = "New thread"; function buildReplaceableThreadTitles(input: { readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly titleSeed?: string; }): ReadonlySet { const titles = new Set([DEFAULT_THREAD_TITLE]); + const trimmedTitleSeed = input.titleSeed?.trim(); + + if (trimmedTitleSeed) { + titles.add(trimmedTitleSeed); + return titles; + } + const trimmedMessage = input.messageText.trim(); if (trimmedMessage.length > 0) { @@ -101,6 +109,7 @@ function isReplaceableThreadTitle( input: { readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly titleSeed?: string; }, ): boolean { return buildReplaceableThreadTitles(input).has(currentTitle.trim()); @@ -565,6 +574,7 @@ const make = Effect.gen(function* () { const generationInput = { messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), ...(event.payload.textGenerationModel !== undefined ? { textGenerationModel: event.payload.textGenerationModel } : {}), diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index ab421a6fa4..7f1bacf724 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -377,6 +377,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), + ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), ...(command.textGenerationModel !== undefined ? { textGenerationModel: command.textGenerationModel } : {}), diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 5577ac5b01..c56202fbaa 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError"; +import { SqlError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -29,8 +29,11 @@ export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; export type TypeId = "~local/sqlite-node/SqliteClient"; -const classifyError = (cause: unknown, message: string, operation: string) => - classifySqliteError(cause, { message, operation }); +const sqlError = (cause: unknown, message: string) => + new SqlError({ + cause, + message, + }); /** * SqliteClient - Effect service tag for the sqlite SQL client. @@ -112,10 +115,7 @@ const makeWithDatabase = ( lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), - catch: (cause) => - new SqlError({ - reason: classifyError(cause, "Failed to prepare statement", "prepare"), - }), + catch: (cause) => sqlError(cause, "Failed to prepare statement"), }), }); @@ -133,11 +133,7 @@ const makeWithDatabase = ( const result = statement.run(...(params as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { - return Effect.fail( - new SqlError({ - reason: classifyError(cause, "Failed to execute statement", "execute"), - }), - ); + return Effect.fail(sqlError(cause, "Failed to execute statement")); } }); @@ -160,10 +156,7 @@ const makeWithDatabase = ( statement.run(...(params as any)); return []; }, - catch: (cause) => - new SqlError({ - reason: classifyError(cause, "Failed to execute statement", "execute"), - }), + catch: (cause) => sqlError(cause, "Failed to execute statement"), }), (statement) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d064a8239f..88a272d606 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1100,8 +1100,9 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); + const runFork = yield* Effect.withFiber((fiber) => + Effect.succeed(Effect.runForkWith(fiber.services)), + ); const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; @@ -1200,8 +1201,9 @@ describe("ClaudeAdapterLive", () => { ); return Effect.gen(function* () { - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); + const runFork = yield* Effect.withFiber((fiber) => + Effect.succeed(Effect.runForkWith(fiber.services)), + ); const adapter = yield* ClaudeAdapter; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b0f080118e..5e6425593a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2380,9 +2380,12 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; const sessionId = existingResumeSessionId ?? newSessionId; - const services = yield* Effect.services(); - const runFork = Effect.runForkWith(services); - const runPromise = Effect.runPromiseWith(services); + const runFork = yield* Effect.withFiber((fiber) => + Effect.succeed(Effect.runForkWith(fiber.services)), + ); + const runPromise = yield* Effect.withFiber((fiber) => + Effect.succeed(Effect.runPromiseWith(fiber.services)), + ); const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 60d24fb21f..6f74feca96 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2711,6 +2711,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, modelSelection: selectedModelSelection, + titleSeed: title, textGenerationModel: selectedTextGenerationModel, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -2994,6 +2995,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + titleSeed: activeThread.title, textGenerationModel: selectedTextGenerationModel, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, @@ -3113,6 +3115,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: [], }, modelSelection: selectedModelSelection, + titleSeed: nextThreadTitle, textGenerationModel: selectedTextGenerationModel, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index a4fa3554bc..68211cc3cb 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -338,6 +338,25 @@ it.effect("accepts a text generation model in thread.turn.start", () => }), ); +it.effect("accepts a title seed in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-title-seed", + threadId: "thread-1", + message: { + messageId: "msg-title-seed", + role: "user", + text: "hello", + attachments: [], + }, + titleSeed: "Investigate reconnect failures", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures"); + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ @@ -409,6 +428,18 @@ it.effect("decodes thread.turn-start-requested text generation model when presen }), ); +it.effect("decodes thread.turn-start-requested title seed when present", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-2", + messageId: "msg-2", + titleSeed: "Investigate reconnect failures", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.titleSeed, "Investigate reconnect failures"); + }), +); + it.effect("decodes latest turn source proposed plan metadata when present", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationLatestTurn({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index f7e09767e5..e01c00cd2d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -399,6 +399,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(ChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), textGenerationModel: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), @@ -420,6 +421,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ attachments: Schema.Array(UploadChatAttachment), }), modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), textGenerationModel: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, @@ -716,6 +718,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), textGenerationModel: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index 7eb311ca57..f5152811d2 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -37,13 +37,14 @@ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, ): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { - const ref = yield* TxRef.make(0); + const ref = yield* Effect.transaction(TxRef.make(0)); - const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), (queue) => - TxQueue.shutdown(queue), + const queue = yield* Effect.acquireRelease( + Effect.transaction(TxQueue.unbounded()), + (queue) => Effect.transaction(TxQueue.shutdown(queue)), ); - const takeItem = Effect.tx( + const takeItem = Effect.transaction( Effect.gen(function* () { const item = yield* TxQueue.take(queue); yield* TxRef.update(ref, (n) => n + 1); @@ -53,24 +54,24 @@ export const makeDrainableWorker = ( yield* takeItem.pipe( Effect.flatMap((item) => - process(item).pipe(Effect.ensuring(TxRef.update(ref, (n) => n - 1))), + process(item).pipe(Effect.ensuring(Effect.transaction(TxRef.update(ref, (n) => n - 1)))), ), Effect.forever, Effect.forkScoped, ); - const drain: DrainableWorker["drain"] = Effect.tx( + const drain: DrainableWorker["drain"] = Effect.transaction( Effect.gen(function* () { const inFlight = yield* TxRef.get(ref); const isEmpty = yield* TxQueue.isEmpty(queue); if (inFlight > 0 || !isEmpty) { - return yield* Effect.txRetry; + return yield* Effect.retryTransaction; } }), ); return { - enqueue: (item) => TxQueue.offer(queue, item), + enqueue: (item) => Effect.transaction(TxQueue.offer(queue, item)).pipe(Effect.asVoid), drain, } satisfies DrainableWorker; }); From 5c0dd5d03a2caf324c863b779a94e53fec4e5d1d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 19:46:40 -0300 Subject: [PATCH 20/27] fix(ci): align typecheck with pinned effect betas --- apps/server/src/persistence/NodeSqliteClient.ts | 5 ++--- packages/shared/src/DrainableWorker.ts | 17 ++++++++--------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index c56202fbaa..7af089c444 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { SqlError } from "effect/unstable/sql/SqlError"; +import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -31,8 +31,7 @@ export type TypeId = "~local/sqlite-node/SqliteClient"; const sqlError = (cause: unknown, message: string) => new SqlError({ - cause, - message, + reason: classifySqliteError(cause, { message }), }); /** diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index f5152811d2..d2032b05b6 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -37,14 +37,13 @@ export const makeDrainableWorker = ( process: (item: A) => Effect.Effect, ): Effect.Effect, never, Scope.Scope | R> => Effect.gen(function* () { - const ref = yield* Effect.transaction(TxRef.make(0)); + const ref = yield* TxRef.make(0); - const queue = yield* Effect.acquireRelease( - Effect.transaction(TxQueue.unbounded()), - (queue) => Effect.transaction(TxQueue.shutdown(queue)), + const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), (queue) => + TxQueue.shutdown(queue).pipe(Effect.asVoid), ); - const takeItem = Effect.transaction( + const takeItem = Effect.tx( Effect.gen(function* () { const item = yield* TxQueue.take(queue); yield* TxRef.update(ref, (n) => n + 1); @@ -54,24 +53,24 @@ export const makeDrainableWorker = ( yield* takeItem.pipe( Effect.flatMap((item) => - process(item).pipe(Effect.ensuring(Effect.transaction(TxRef.update(ref, (n) => n - 1)))), + process(item).pipe(Effect.ensuring(Effect.tx(TxRef.update(ref, (n) => n - 1)))), ), Effect.forever, Effect.forkScoped, ); - const drain: DrainableWorker["drain"] = Effect.transaction( + const drain: DrainableWorker["drain"] = Effect.tx( Effect.gen(function* () { const inFlight = yield* TxRef.get(ref); const isEmpty = yield* TxQueue.isEmpty(queue); if (inFlight > 0 || !isEmpty) { - return yield* Effect.retryTransaction; + return yield* Effect.txRetry; } }), ); return { - enqueue: (item) => Effect.transaction(TxQueue.offer(queue, item)).pipe(Effect.asVoid), + enqueue: (item) => TxQueue.offer(queue, item).pipe(Effect.asVoid), drain, } satisfies DrainableWorker; }); From 4cfbe74ff10facbe93d214b2ea1b631a012cd06c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 19:48:47 -0300 Subject: [PATCH 21/27] fix(threads): declare title seed in title generation input --- apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7166af62d4..c47447778f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -497,6 +497,7 @@ const make = Effect.gen(function* () { readonly cwd: string; readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly titleSeed?: string; readonly textGenerationModel?: string; }) { const attachments = input.attachments ?? []; From c3f8be4625ea2243a1d3c4930ade370fe627414b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 19:52:07 -0300 Subject: [PATCH 22/27] fix(marketing): restore layout description copy --- apps/marketing/src/layouts/Layout.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index cc6f86042b..b4fa945e25 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -6,7 +6,7 @@ interface Props { const { title = "T3 Code", - description = "T3 Code — A great way to code with agents.", + description = "T3 Code — The best way to code with AI.", } = Astro.props; --- From 9af0a8e86be6ec9e3211bbbc903dd24babd05907 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 20:07:02 -0300 Subject: [PATCH 23/27] refactor(threads): remove unrelated client plumbing --- apps/marketing/src/pages/index.astro | 2 +- .../Layers/ProviderCommandReactor.test.ts | 34 +++---------------- .../Layers/ProviderCommandReactor.ts | 21 ++++-------- apps/server/src/orchestration/decider.ts | 6 ---- apps/web/src/components/ChatView.tsx | 11 ------ packages/contracts/src/orchestration.test.ts | 31 ----------------- packages/contracts/src/orchestration.ts | 6 ---- 7 files changed, 12 insertions(+), 99 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index e7203c21dd..3a2111f4f8 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -3,7 +3,7 @@ import Layout from "../layouts/Layout.astro"; --- -

T3 Code is a great way to code with agents.

+

T3 Code is the best way to code with AI.

{ expect(thread?.session?.runtimeMode).toBe("approval-required"); }); - it("generates a thread title on the first turn using the text generation model", async () => { + it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); - harness.generateThreadTitle.mockImplementation((input: unknown) => - Effect.succeed({ - title: - typeof input === "object" && - input !== null && - "modelSelection" in input && - typeof input.modelSelection === "object" && - input.modelSelection !== null && - "model" in input.modelSelection && - typeof input.modelSelection.model === "string" - ? `Title via ${input.modelSelection.model}` - : "Generated title", - }), - ); + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); await Effect.runPromise( harness.engine.dispatch({ @@ -358,7 +345,6 @@ describe("ProviderCommandReactor", () => { text: "Please investigate reconnect failures after restarting the session.", attachments: [], }, - textGenerationModel: "gpt-5.4-mini", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -368,22 +354,18 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1); expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({ message: "Please investigate reconnect failures after restarting the session.", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, }); await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); return ( readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"))?.title === - "Title via gpt-5.4-mini" + "Generated title" ); }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect(thread?.title).toBe("Title via gpt-5.4-mini"); + expect(thread?.title).toBe("Generated title"); }); it("does not overwrite an existing custom thread title on the first turn", async () => { @@ -410,7 +392,6 @@ describe("ProviderCommandReactor", () => { text: "Please investigate reconnect failures after restarting the session.", attachments: [], }, - textGenerationModel: "gpt-5.4-mini", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -456,7 +437,6 @@ describe("ProviderCommandReactor", () => { attachments: [], }, titleSeed: seededTitle, - textGenerationModel: "gpt-5.4-mini", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -477,7 +457,7 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Reconnect spinner resume bug"); }); - it("reuses the text generation model for automatic worktree branch naming", async () => { + it("generates a worktree branch name for the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -517,7 +497,6 @@ describe("ProviderCommandReactor", () => { text: "Add a safer reconnect backoff.", attachments: [], }, - textGenerationModel: "gpt-5.4-mini", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -526,9 +505,6 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.generateBranchName.mock.calls.length === 1); expect(harness.generateBranchName.mock.calls[0]?.[0]).toMatchObject({ - modelSelection: { - model: "gpt-5.4-mini", - }, message: "Add a safer reconnect backoff.", }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index c47447778f..fb1ec6a7f9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -443,7 +443,6 @@ const make = Effect.gen(function* () { readonly worktreePath: string | null; readonly messageText: string; readonly attachments?: ReadonlyArray; - readonly textGenerationModel?: string; }) { if (!input.branch || !input.worktreePath) { return; @@ -456,16 +455,14 @@ const make = Effect.gen(function* () { const cwd = input.worktreePath; const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateBranchName({ cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection: { - ...textGenerationModelSelection, - model: input.textGenerationModel ?? textGenerationModelSelection.model, - }, + modelSelection, }); if (!generated) return; @@ -498,20 +495,17 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly titleSeed?: string; - readonly textGenerationModel?: string; }) { const attachments = input.attachments ?? []; yield* Effect.gen(function* () { - const { textGenerationModelSelection } = yield* serverSettingsService.getSettings; + const { textGenerationModelSelection: modelSelection } = + yield* serverSettingsService.getSettings; const generated = yield* textGeneration.generateThreadTitle({ cwd: input.cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), - modelSelection: { - ...textGenerationModelSelection, - model: input.textGenerationModel ?? textGenerationModelSelection.model, - }, + modelSelection, }); if (!generated) return; @@ -576,9 +570,6 @@ const make = Effect.gen(function* () { messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), - ...(event.payload.textGenerationModel !== undefined - ? { textGenerationModel: event.payload.textGenerationModel } - : {}), }; yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 7f1bacf724..22f5bcb280 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -16,8 +16,6 @@ import { } from "./commandInvariants.ts"; const nowIso = () => new Date().toISOString(); -const DEFAULT_ASSISTANT_DELIVERY_MODE = "buffered" as const; - const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], aggregateKind: "thread", @@ -378,10 +376,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { modelSelection: command.modelSelection } : {}), ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), - ...(command.textGenerationModel !== undefined - ? { textGenerationModel: command.textGenerationModel } - : {}), - assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: targetThread.runtimeMode, interactionMode: targetThread.interactionMode, ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6f74feca96..275c644040 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -662,7 +662,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }), [selectedModel, selectedModelOptionsForDispatch, selectedProvider], ); - const selectedTextGenerationModel = settings.textGenerationModelSelection.model; const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -2712,8 +2711,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, modelSelection: selectedModelSelection, titleSeed: title, - textGenerationModel: selectedTextGenerationModel, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, @@ -2996,8 +2993,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, modelSelection: selectedModelSelection, titleSeed: activeThread.title, - textGenerationModel: selectedTextGenerationModel, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan @@ -3047,9 +3042,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProviderModels, setComposerDraftInteractionMode, setThreadError, - settings.enableAssistantStreaming, selectedModel, - selectedTextGenerationModel, ], ); @@ -3116,8 +3109,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, modelSelection: selectedModelSelection, titleSeed: nextThreadTitle, - textGenerationModel: selectedTextGenerationModel, - assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, @@ -3170,8 +3161,6 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedModelSelection, selectedProvider, selectedProviderModels, - settings.enableAssistantStreaming, - selectedTextGenerationModel, syncServerReadModel, selectedModel, ]); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 68211cc3cb..06bb35038d 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -319,25 +319,6 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); -it.effect("accepts a text generation model in thread.turn.start", () => - Effect.gen(function* () { - const parsed = yield* decodeThreadTurnStartCommand({ - type: "thread.turn.start", - commandId: "cmd-turn-text-model", - threadId: "thread-1", - message: { - messageId: "msg-text-model", - role: "user", - text: "hello", - attachments: [], - }, - textGenerationModel: "gpt-5.4-mini", - createdAt: "2026-01-01T00:00:00.000Z", - }); - assert.strictEqual(parsed.textGenerationModel, "gpt-5.4-mini"); - }), -); - it.effect("accepts a title seed in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ @@ -416,18 +397,6 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); -it.effect("decodes thread.turn-start-requested text generation model when present", () => - Effect.gen(function* () { - const parsed = yield* decodeThreadTurnStartRequestedPayload({ - threadId: "thread-2", - messageId: "msg-2", - textGenerationModel: "gpt-5.4-mini", - createdAt: "2026-01-01T00:00:00.000Z", - }); - assert.strictEqual(parsed.textGenerationModel, "gpt-5.4-mini"); - }), -); - it.effect("decodes thread.turn-start-requested title seed when present", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartRequestedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e01c00cd2d..a780a55c78 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -400,8 +400,6 @@ export const ThreadTurnStartCommand = Schema.Struct({ }), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), - textGenerationModel: Schema.optional(TrimmedNonEmptyString), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), @@ -422,8 +420,6 @@ const ClientThreadTurnStartCommand = Schema.Struct({ }), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), - textGenerationModel: Schema.optional(TrimmedNonEmptyString), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, sourceProposedPlan: Schema.optional(SourceProposedPlanReference), @@ -719,8 +715,6 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ messageId: MessageId, modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), - textGenerationModel: Schema.optional(TrimmedNonEmptyString), - assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), From f366028902f39d496c37beafa8d9f9771843de2a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 20:16:01 -0300 Subject: [PATCH 24/27] refactor(threads): remove unrelated changes --- apps/server/src/bootstrap.test.ts | 77 ++++--------------- apps/server/src/bootstrap.ts | 31 ++------ apps/server/src/git/Layers/GitCore.test.ts | 5 +- .../src/persistence/NodeSqliteClient.ts | 24 ++++-- .../src/provider/Layers/ClaudeAdapter.test.ts | 10 +-- .../src/provider/Layers/ClaudeAdapter.ts | 9 +-- packages/shared/src/DrainableWorker.ts | 6 +- 7 files changed, 47 insertions(+), 115 deletions(-) diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts index 086612d166..3fce6af9c4 100644 --- a/apps/server/src/bootstrap.test.ts +++ b/apps/server/src/bootstrap.test.ts @@ -13,11 +13,7 @@ import { vi } from "vitest"; import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; import { assertNone, assertSome } from "@effect/vitest/utils"; -const bootstrapFsInterceptor = vi.hoisted(() => ({ - failOpenPath: null as string | null, - failCreateReadStreamForDuplicatedPath: null as string | null, - duplicatedFdForPathFailure: null as number | null, -})); +const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -27,34 +23,14 @@ vi.mock("node:fs", async (importOriginal) => { const [filePath, flags] = args; if ( typeof filePath === "string" && - filePath === bootstrapFsInterceptor.failOpenPath && + filePath === openSyncInterceptor.failPath && flags === "r" ) { const error = new Error("no such device or address"); Object.assign(error, { code: "ENXIO" }); throw error; } - const fd = (actual.openSync as (...a: typeof args) => number)(...args); - if ( - typeof filePath === "string" && - filePath === bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath && - flags === "r" - ) { - bootstrapFsInterceptor.duplicatedFdForPathFailure = fd; - } - return fd; - }, - createReadStream: (...args: Parameters) => { - const [, options] = args; - const fd = typeof options === "object" && options && "fd" in options ? options.fd : undefined; - if (typeof fd === "number" && fd === bootstrapFsInterceptor.duplicatedFdForPathFailure) { - const error = new Error("bad file descriptor"); - Object.assign(error, { code: "EBADF" }); - throw error; - } - return ( - actual.createReadStream as (...a: typeof args) => ReturnType - )(...args); + return (actual.openSync as (...a: typeof args) => number)(...args); }, }; }); @@ -62,6 +38,14 @@ vi.mock("node:fs", async (importOriginal) => { const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { + it.effect("uses platform-specific fd paths", () => + Effect.sync(() => { + assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); + assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); + assert.equal(resolveFdPath(3, "win32"), undefined); + }), + ); + it.effect("reads a bootstrap envelope from a provided fd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -104,49 +88,14 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { // stream's async close and produces an uncaught EBADF. const fd = NFS.openSync(filePath, "r"); - bootstrapFsInterceptor.failOpenPath = resolveFdPath(fd) ?? null; + openSyncInterceptor.failPath = `/proc/self/fd/${fd}`; try { const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); assertSome(payload, { mode: "desktop", }); } finally { - bootstrapFsInterceptor.failOpenPath = null; - } - }), - ); - - it.effect("closes the duplicated fd before falling back when the duplicated stream fails", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); - - yield* fs.writeFileString( - filePath, - `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ - mode: "desktop", - })}\n`, - ); - - const fd = NFS.openSync(filePath, "r"); - const duplicatedFdPath = resolveFdPath(fd); - assert.notStrictEqual(duplicatedFdPath, undefined); - const closeSyncSpy = vi.spyOn(NFS, "closeSync"); - bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = duplicatedFdPath ?? null; - - try { - const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); - assertSome(payload, { - mode: "desktop", - }); - - const duplicatedFd = bootstrapFsInterceptor.duplicatedFdForPathFailure; - assert.notStrictEqual(duplicatedFd, null); - assert.ok(closeSyncSpy.mock.calls.some(([closedFd]) => closedFd === duplicatedFd)); - } finally { - bootstrapFsInterceptor.failCreateReadStreamForDuplicatedPath = null; - bootstrapFsInterceptor.duplicatedFdForPathFailure = null; - closeSyncSpy.mockRestore(); + openSyncInterceptor.failPath = null; } }), ); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts index 84a7f427dd..0fb1352268 100644 --- a/apps/server/src/bootstrap.ts +++ b/apps/server/src/bootstrap.ts @@ -87,11 +87,6 @@ const isUnavailableBootstrapFdError = Predicate.compose( (_) => _.code === "EBADF" || _.code === "ENOENT", ); -const isUnavailableBootstrapFdPathError = Predicate.compose( - Predicate.hasProperty("code"), - (_) => _.code === "EBADF" || _.code === "ENOENT" || _.code === "ENXIO", -); - const isFdReady = (fd: number) => Effect.try({ try: () => NFS.fstatSync(fd), @@ -111,16 +106,6 @@ const isFdReady = (fd: number) => const makeBootstrapInputStream = (fd: number) => Effect.try({ try: () => { - if (process.platform === "win32") { - const stream = new Net.Socket({ - fd, - readable: true, - writable: false, - }); - stream.setEncoding("utf8"); - return stream; - } - const fdPath = resolveFdPath(fd); if (fdPath === undefined) { return makeDirectBootstrapStream(fd); @@ -141,19 +126,12 @@ const makeBootstrapInputStream = (fd: number) => } return makeDirectBootstrapStream(fd); } - if (!isUnavailableBootstrapFdPathError(error)) { - throw error; - } - - if (streamFd !== undefined) { - NFS.closeSync(streamFd); - } - return makeDirectBootstrapStream(fd); + throw error; } }, catch: (error) => new BootstrapError({ - message: "Failed to open bootstrap fd.", + message: "Failed to duplicate bootstrap fd.", cause: error, }), }); @@ -185,8 +163,11 @@ export function resolveFdPath( fd: number, platform: NodeJS.Platform = process.platform, ): string | undefined { + if (platform === "linux") { + return `/proc/self/fd/${fd}`; + } if (platform === "win32") { return undefined; } - return platform === "linux" ? `/proc/self/fd/${fd}` : `/dev/fd/${fd}`; + return `/dev/fd/${fd}`; } diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 4204b5588b..dc97b93649 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -415,9 +415,8 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => Effect.gen(function* () { - const runPromise = yield* Effect.withFiber((fiber) => - Effect.succeed(Effect.runPromiseWith(fiber.services)), - ); + const services = yield* Effect.services(); + const runPromise = Effect.runPromiseWith(services); const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index 7af089c444..5577ac5b01 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -20,7 +20,7 @@ import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; -import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"; +import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; @@ -29,10 +29,8 @@ export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; export type TypeId = "~local/sqlite-node/SqliteClient"; -const sqlError = (cause: unknown, message: string) => - new SqlError({ - reason: classifySqliteError(cause, { message }), - }); +const classifyError = (cause: unknown, message: string, operation: string) => + classifySqliteError(cause, { message, operation }); /** * SqliteClient - Effect service tag for the sqlite SQL client. @@ -114,7 +112,10 @@ const makeWithDatabase = ( lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), - catch: (cause) => sqlError(cause, "Failed to prepare statement"), + catch: (cause) => + new SqlError({ + reason: classifyError(cause, "Failed to prepare statement", "prepare"), + }), }), }); @@ -132,7 +133,11 @@ const makeWithDatabase = ( const result = statement.run(...(params as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { - return Effect.fail(sqlError(cause, "Failed to execute statement")); + return Effect.fail( + new SqlError({ + reason: classifyError(cause, "Failed to execute statement", "execute"), + }), + ); } }); @@ -155,7 +160,10 @@ const makeWithDatabase = ( statement.run(...(params as any)); return []; }, - catch: (cause) => sqlError(cause, "Failed to execute statement"), + catch: (cause) => + new SqlError({ + reason: classifyError(cause, "Failed to execute statement", "execute"), + }), }), (statement) => Effect.sync(() => { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 88a272d606..d064a8239f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1100,9 +1100,8 @@ describe("ClaudeAdapterLive", () => { it.effect("closes the session when the Claude stream aborts after a turn starts", () => { const harness = makeHarness(); return Effect.gen(function* () { - const runFork = yield* Effect.withFiber((fiber) => - Effect.succeed(Effect.runForkWith(fiber.services)), - ); + const services = yield* Effect.services(); + const runFork = Effect.runForkWith(services); const adapter = yield* ClaudeAdapter; const runtimeEvents: Array = []; @@ -1201,9 +1200,8 @@ describe("ClaudeAdapterLive", () => { ); return Effect.gen(function* () { - const runFork = yield* Effect.withFiber((fiber) => - Effect.succeed(Effect.runForkWith(fiber.services)), - ); + const services = yield* Effect.services(); + const runFork = Effect.runForkWith(services); const adapter = yield* ClaudeAdapter; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5e6425593a..b0f080118e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2380,12 +2380,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( existingResumeSessionId === undefined ? yield* Random.nextUUIDv4 : undefined; const sessionId = existingResumeSessionId ?? newSessionId; - const runFork = yield* Effect.withFiber((fiber) => - Effect.succeed(Effect.runForkWith(fiber.services)), - ); - const runPromise = yield* Effect.withFiber((fiber) => - Effect.succeed(Effect.runPromiseWith(fiber.services)), - ); + const services = yield* Effect.services(); + const runFork = Effect.runForkWith(services); + const runPromise = Effect.runPromiseWith(services); const promptQueue = yield* Queue.unbounded(); const prompt = Stream.fromQueue(promptQueue).pipe( diff --git a/packages/shared/src/DrainableWorker.ts b/packages/shared/src/DrainableWorker.ts index d2032b05b6..7eb311ca57 100644 --- a/packages/shared/src/DrainableWorker.ts +++ b/packages/shared/src/DrainableWorker.ts @@ -40,7 +40,7 @@ export const makeDrainableWorker = ( const ref = yield* TxRef.make(0); const queue = yield* Effect.acquireRelease(TxQueue.unbounded(), (queue) => - TxQueue.shutdown(queue).pipe(Effect.asVoid), + TxQueue.shutdown(queue), ); const takeItem = Effect.tx( @@ -53,7 +53,7 @@ export const makeDrainableWorker = ( yield* takeItem.pipe( Effect.flatMap((item) => - process(item).pipe(Effect.ensuring(Effect.tx(TxRef.update(ref, (n) => n - 1)))), + process(item).pipe(Effect.ensuring(TxRef.update(ref, (n) => n - 1))), ), Effect.forever, Effect.forkScoped, @@ -70,7 +70,7 @@ export const makeDrainableWorker = ( ); return { - enqueue: (item) => TxQueue.offer(queue, item).pipe(Effect.asVoid), + enqueue: (item) => TxQueue.offer(queue, item), drain, } satisfies DrainableWorker; }); From f8fd4626db72ec639b742a769fc8786b8670d6c4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 20:30:38 -0300 Subject: [PATCH 25/27] refactor(shared): rename truncate helper --- .../Layers/ProviderCommandReactor.ts | 6 +++--- apps/web/src/components/ChatView.tsx | 6 +++--- packages/shared/package.json | 6 +++--- packages/shared/src/String.test.ts | 17 +++++++++++++++++ .../shared/src/{truncateTitle.ts => String.ts} | 2 +- packages/shared/src/truncateTitle.test.ts | 17 ----------------- 6 files changed, 27 insertions(+), 27 deletions(-) create mode 100644 packages/shared/src/String.test.ts rename packages/shared/src/{truncateTitle.ts => String.ts} (66%) delete mode 100644 packages/shared/src/truncateTitle.test.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fb1ec6a7f9..78ef04a85e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,7 +13,7 @@ import { } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import { truncateTitle } from "@t3tools/shared/truncateTitle"; +import { truncate } from "@t3tools/shared/String"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; @@ -92,13 +92,13 @@ function buildReplaceableThreadTitles(input: { const trimmedMessage = input.messageText.trim(); if (trimmedMessage.length > 0) { - titles.add(truncateTitle(trimmedMessage)); + titles.add(truncate(trimmedMessage)); return titles; } const firstImageAttachment = input.attachments?.find((attachment) => attachment.type === "image"); if (firstImageAttachment) { - titles.add(truncateTitle(`Image: ${firstImageAttachment.name}`)); + titles.add(truncate(`Image: ${firstImageAttachment.name}`)); } return titles; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 275c644040..e572f3c470 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,7 +22,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; -import { truncateTitle } from "@t3tools/shared/truncateTitle"; +import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -2625,7 +2625,7 @@ export default function ChatView({ threadId }: ChatViewProps) { titleSeed = "New thread"; } } - const title = truncateTitle(titleSeed); + const title = truncate(titleSeed); const threadCreateModelSelection: ModelSelection = { provider: selectedProvider, model: @@ -3072,7 +3072,7 @@ export default function ChatView({ threadId }: ChatViewProps) { effort: selectedPromptEffort, text: implementationPrompt, }); - const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); + const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = selectedModelSelection; sendInFlightRef.current = true; diff --git a/packages/shared/package.json b/packages/shared/package.json index d38ce667a2..40ffbf35c2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -36,9 +36,9 @@ "types": "./src/Struct.ts", "import": "./src/Struct.ts" }, - "./truncateTitle": { - "types": "./src/truncateTitle.ts", - "import": "./src/truncateTitle.ts" + "./String": { + "types": "./src/String.ts", + "import": "./src/String.ts" } }, "scripts": { diff --git a/packages/shared/src/String.test.ts b/packages/shared/src/String.test.ts new file mode 100644 index 0000000000..d70bfe840f --- /dev/null +++ b/packages/shared/src/String.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { truncate } from "./String"; + +describe("truncate", () => { + it("trims surrounding whitespace", () => { + expect(truncate(" hello world ")).toBe("hello world"); + }); + + it("returns shorter strings unchanged", () => { + expect(truncate("alpha", 10)).toBe("alpha"); + }); + + it("truncates long strings and appends an ellipsis", () => { + expect(truncate("abcdefghij", 5)).toBe("abcde..."); + }); +}); diff --git a/packages/shared/src/truncateTitle.ts b/packages/shared/src/String.ts similarity index 66% rename from packages/shared/src/truncateTitle.ts rename to packages/shared/src/String.ts index 4cc3e12e08..c93d0c90cb 100644 --- a/packages/shared/src/truncateTitle.ts +++ b/packages/shared/src/String.ts @@ -1,4 +1,4 @@ -export function truncateTitle(text: string, maxLength = 50): string { +export function truncate(text: string, maxLength = 50): string { const trimmed = text.trim(); if (trimmed.length <= maxLength) { return trimmed; diff --git a/packages/shared/src/truncateTitle.test.ts b/packages/shared/src/truncateTitle.test.ts deleted file mode 100644 index fdf4e12182..0000000000 --- a/packages/shared/src/truncateTitle.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { truncateTitle } from "./truncateTitle"; - -describe("truncateTitle", () => { - it("trims surrounding whitespace", () => { - expect(truncateTitle(" hello world ")).toBe("hello world"); - }); - - it("returns shorter strings unchanged", () => { - expect(truncateTitle("alpha", 10)).toBe("alpha"); - }); - - it("truncates long strings and appends an ellipsis", () => { - expect(truncateTitle("abcdefghij", 5)).toBe("abcde..."); - }); -}); From 83105bdd57490656e470032f4d001b2872cef238 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 20:31:47 -0300 Subject: [PATCH 26/27] test(server): use layer mock for text generation --- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 60e4752185..363ba4781c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -221,10 +221,10 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(TextGeneration, { + Layer.mock(TextGeneration, { generateBranchName, generateThreadTitle, - } as unknown as TextGenerationShape), + }), ), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), From 4e07295d8769a75cb1199e0426441694aef4a1a9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 28 Mar 2026 20:36:15 -0300 Subject: [PATCH 27/27] refactor(threads): simplify title seed matching --- .../Layers/ProviderCommandReactor.test.ts | 6 ++- .../Layers/ProviderCommandReactor.ts | 48 ++++--------------- 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 363ba4781c..4e87390eb1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -323,6 +323,7 @@ describe("ProviderCommandReactor", () => { it("generates a thread title on the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + const seededTitle = "Please investigate reconnect failures after restar..."; harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); await Effect.runPromise( @@ -330,7 +331,7 @@ describe("ProviderCommandReactor", () => { type: "thread.meta.update", commandId: CommandId.makeUnsafe("cmd-thread-title-seed"), threadId: ThreadId.makeUnsafe("thread-1"), - title: "Please investigate reconnect failures after restar...", + title: seededTitle, }), ); @@ -345,6 +346,7 @@ describe("ProviderCommandReactor", () => { text: "Please investigate reconnect failures after restarting the session.", attachments: [], }, + titleSeed: seededTitle, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -371,6 +373,7 @@ describe("ProviderCommandReactor", () => { it("does not overwrite an existing custom thread title on the first turn", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + const seededTitle = "Please investigate reconnect failures after restar..."; await Effect.runPromise( harness.engine.dispatch({ @@ -392,6 +395,7 @@ describe("ProviderCommandReactor", () => { text: "Please investigate reconnect failures after restarting the session.", attachments: [], }, + titleSeed: seededTitle, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 78ef04a85e..f65137f4b5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -13,7 +13,6 @@ import { } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import { truncate } from "@t3tools/shared/String"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; @@ -76,43 +75,16 @@ const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); const DEFAULT_THREAD_TITLE = "New thread"; -function buildReplaceableThreadTitles(input: { - readonly messageText: string; - readonly attachments?: ReadonlyArray; - readonly titleSeed?: string; -}): ReadonlySet { - const titles = new Set([DEFAULT_THREAD_TITLE]); - const trimmedTitleSeed = input.titleSeed?.trim(); - - if (trimmedTitleSeed) { - titles.add(trimmedTitleSeed); - return titles; +function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; } - const trimmedMessage = input.messageText.trim(); - - if (trimmedMessage.length > 0) { - titles.add(truncate(trimmedMessage)); - return titles; - } - - const firstImageAttachment = input.attachments?.find((attachment) => attachment.type === "image"); - if (firstImageAttachment) { - titles.add(truncate(`Image: ${firstImageAttachment.name}`)); - } - - return titles; -} - -function isReplaceableThreadTitle( - currentTitle: string, - input: { - readonly messageText: string; - readonly attachments?: ReadonlyArray; - readonly titleSeed?: string; - }, -): boolean { - return buildReplaceableThreadTitles(input).has(currentTitle.trim()); + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; } function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { @@ -511,7 +483,7 @@ const make = Effect.gen(function* () { const thread = yield* resolveThread(input.threadId); if (!thread) return; - if (!isReplaceableThreadTitle(thread.title, input)) { + if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { return; } @@ -579,7 +551,7 @@ const make = Effect.gen(function* () { ...generationInput, }).pipe(Effect.forkScoped); - if (isReplaceableThreadTitle(thread.title, generationInput)) { + if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, cwd: generationCwd,