Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/git/Layers/ClaudeTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,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;
});

Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,67 @@ 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("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("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(
{
Expand Down
88 changes: 85 additions & 3 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ 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";
import { ServerConfig } from "../../config.ts";
import { TextGenerationError } from "../Errors.ts";
import {
type BranchNameGenerationInput,
type ThreadTitleGenerationResult,
type TextGenerationShape,
TextGeneration,
} from "../Services/TextGeneration.ts";
Expand All @@ -20,6 +24,7 @@ import {
buildPrContentPrompt,
} from "../Prompts.ts";
import {
limitSection,
normalizeCliError,
sanitizeCommitSubject,
sanitizePrTitle,
Expand All @@ -31,6 +36,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;
Expand Down Expand Up @@ -83,7 +104,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<MaterializedImageAttachments, TextGenerationError> =>
Effect.gen(function* () {
Expand Down Expand Up @@ -124,7 +149,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
cleanupPaths = [],
modelSelection,
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
operation:
| "generateCommitMessage"
| "generatePrContent"
| "generateBranchName"
| "generateThreadTitle";
cwd: string;
prompt: string;
outputSchemaJson: S;
Expand Down Expand Up @@ -363,10 +392,63 @@ 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,
modelSelection: {
provider: "codex",
model: input.model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER.codex,
},
});

return {
title: sanitizeThreadTitle(generated.title),
} satisfies ThreadTitleGenerationResult;
});
};

return {
generateCommitMessage,
generatePrContent,
generateBranchName,
generateThreadTitle,
} satisfies TextGenerationShape;
});

Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FakeGhScenario["pullRequest"]>;
Expand Down Expand Up @@ -168,6 +172,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
Effect.succeed({
branch: "update-workflow",
}),
generateThreadTitle: () =>
Effect.succeed({
title: "Update workflow",
}),
...overrides,
};

Expand Down Expand Up @@ -205,6 +213,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
}),
),
),
generateThreadTitle: (input) =>
implementation.generateThreadTitle(input).pipe(
Effect.mapError(
(cause) =>
new TextGenerationError({
operation: "generateThreadTitle",
detail: "fake text generation failed",
...(cause !== undefined ? { cause } : {}),
}),
),
),
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/Layers/RoutingTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/git/Services/TextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,25 @@ export interface BranchNameGenerationResult {
branch: string;
}

export interface ThreadTitleGenerationInput {
cwd: string;
message: string;
attachments?: ReadonlyArray<ChatAttachment> | 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<CommitMessageGenerationResult>;
generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
}

/**
Expand All @@ -93,6 +106,13 @@ export interface TextGenerationShape {
readonly generateBranchName: (
input: BranchNameGenerationInput,
) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;

/**
* Generate a concise thread title from a user's first message.
*/
readonly generateThreadTitle: (
input: ThreadTitleGenerationInput,
) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
}

/**
Expand Down
Loading