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 @@ -303,7 +303,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
21 changes: 21 additions & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,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(
{
Expand Down
79 changes: 77 additions & 2 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type BranchNameGenerationResult,
type CommitMessageGenerationResult,
type PrContentGenerationResult,
type ThreadTitleGenerationResult,
type TextGenerationShape,
TextGeneration,
} from "../Services/TextGeneration.ts";
Expand Down Expand Up @@ -95,6 +96,22 @@ function sanitizePrTitle(raw: string): string {
return "Update project changes";
}

function sanitizeThreadTitle(raw: string): string {
const normalized = raw
.trim()
.split(/\r?\n/g)[0]
?.trim()
.replace(/^['"`]+|['"`]+$/g, "")
.replace(/\s+/g, " ");
if (!normalized) {
return "New thread";
}
if (normalized.length <= 50) {
return normalized;
}
return `${normalized.slice(0, 47).trimEnd()}...`;
}
Comment on lines +99 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/CodexTextGeneration.ts:99

After stripping quotes and collapsing whitespace, a string like " " or ''' becomes " " — a non-empty whitespace-only string. The !normalized check passes, so the function returns whitespace instead of the expected "New thread" fallback. Consider checking !normalized.trim() instead of !normalized to ensure whitespace-only results fall back to the default.

  function sanitizeThreadTitle(raw: string): string {
    const normalized = raw
      .trim()
      .split(/\r?\n/g)[0]
      ?.trim()
      .replace(/^['"`]+|['"`]+$/g, "")
      .replace(/\s+/g, " ");
-  if (!normalized) {
+  if (!normalized || !normalized.trim()) {
      return "New thread";
    }
    if (normalized.length <= 50) {
      return normalized;
    }
    return `${normalized.slice(0, 47).trimEnd()}...`;
  }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/git/Layers/CodexTextGeneration.ts around lines 99-113:

After stripping quotes and collapsing whitespace, a string like `"   "` or `'''` becomes `" "` — a non-empty whitespace-only string. The `!normalized` check passes, so the function returns whitespace instead of the expected `"New thread"` fallback. Consider checking `!normalized.trim()` instead of `!normalized` to ensure whitespace-only results fall back to the default.

Evidence trail:
apps/server/src/git/Layers/CodexTextGeneration.ts lines 99-111 at REVIEWED_COMMIT - The sanitizeThreadTitle function's normalization pipeline strips quotes and collapses whitespace, but the `!normalized` check doesn't catch whitespace-only results because `" "` is truthy in JavaScript.


const makeCodexTextGeneration = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -148,7 +165,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 @@ -189,7 +210,11 @@ const makeCodexTextGeneration = Effect.gen(function* () {
cleanupPaths = [],
model,
}: {
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
operation:
| "generateCommitMessage"
| "generatePrContent"
| "generateBranchName"
| "generateThreadTitle";
cwd: string;
prompt: string;
outputSchemaJson: S;
Expand Down Expand Up @@ -462,10 +487,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;
});

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 @@ -64,6 +64,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 @@ -167,6 +171,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
Effect.succeed({
branch: "update-workflow",
}),
generateThreadTitle: () =>
Effect.succeed({
title: "Update workflow",
}),
...overrides,
};

Expand Down Expand Up @@ -204,6 +212,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
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 @@ -58,12 +58,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 @@ -90,6 +103,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
118 changes: 116 additions & 2 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,22 @@ describe("ProviderCommandReactor", () => {
: "renamed-branch",
}),
);
const generateBranchName = vi.fn(() =>
const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>((_) =>
Effect.fail(
new TextGenerationError({
operation: "generateBranchName",
detail: "disabled in test harness",
}),
),
);
const generateThreadTitle = vi.fn<TextGenerationShape["generateThreadTitle"]>((_) =>
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 = {
Expand Down Expand Up @@ -222,7 +230,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(ServerConfig.layerTest(process.cwd(), baseDir)),
Layer.provideMerge(NodeServices.layer),
Expand Down Expand Up @@ -272,6 +283,7 @@ describe("ProviderCommandReactor", () => {
stopSession,
renameBranch,
generateBranchName,
generateThreadTitle,
stateDir,
drain,
};
Expand Down Expand Up @@ -313,6 +325,108 @@ 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.",
});

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");
});

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();
Expand Down
Loading