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 @@ -126,6 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
threadId: THREAD_ID,
projectId: PROJECT_ID,
title: "Integration Thread",
titleSummaryState: "missing",
model: defaultModel,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -265,6 +266,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
threadId: THREAD_ID,
projectId: PROJECT_ID,
title: "Integration Thread",
titleSummaryState: "missing",
model: "gpt-5.3-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function makeSnapshot(input: {
id: input.threadId,
projectId: input.projectId,
title: "Thread",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "full-access",
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);

it.effect("generates and sanitizes thread titles", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
title: ' "Investigate sidebar thread naming regression"\nextra',
}),
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateThreadTitle({
cwd: process.cwd(),
message: "Please investigate why thread names are hard to scan in the sidebar.",
});

expect(generated.title).toBe("Investigate sidebar thread naming regression");
}),
),
);

it.effect("omits attachment metadata section when no attachments are provided", () =>
withFakeCodexEnv(
{
Expand Down
75 changes: 73 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,18 @@ function sanitizePrTitle(raw: string): string {
return "Update project changes";
}

function sanitizeThreadTitle(raw: string): string {
const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
const normalized = singleLine.replace(/^["'`]+|["'`]+$/g, "").trim();
if (normalized.length === 0) {
return "New thread";
}
if (normalized.length <= 60) {
return normalized;
}
return normalized.slice(0, 60).trimEnd();
}

const makeCodexTextGeneration = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -148,7 +161,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 +206,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 +483,60 @@ const makeCodexTextGeneration = Effect.gen(function* () {
});
};

const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = (input) =>
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 chat thread titles.",
"Return a JSON object with key: title.",
"Rules:",
"- Title must describe the user's request clearly.",
"- Keep it short and specific.",
"- Use sentence case or title case.",
"- No surrounding quotes.",
"- Max 60 characters.",
"- If images are attached, use them as primary context for visual/UI issues.",
"",
"Thread context:",
limitSection(input.message, 12_000),
];
if (attachmentLines.length > 0) {
promptSections.push(
"",
"Attachment metadata:",
limitSection(attachmentLines.join("\n"), 4_000),
);
}

const generated = yield* runCodexJson({
operation: "generateThreadTitle",
cwd: input.cwd,
prompt: promptSections.join("\n"),
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 @@ -63,6 +63,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 @@ -166,6 +170,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
Effect.succeed({
branch: "update-workflow",
}),
generateThreadTitle: () =>
Effect.succeed({
title: "Update workflow",
}),
...overrides,
};

Expand Down Expand Up @@ -203,6 +211,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 thread message context.
*/
readonly generateThreadTitle: (
input: ThreadTitleGenerationInput,
) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ describe("CheckpointReactor", () => {
threadId: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -134,6 +135,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-replay"),
projectId: asProjectId("project-replay"),
title: "replay",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -196,6 +198,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-stream"),
projectId: asProjectId("project-stream"),
title: "domain-stream",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -241,6 +244,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-turn-diff"),
projectId: asProjectId("project-turn-diff"),
title: "Turn diff thread",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -350,6 +354,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-flaky-fail"),
projectId: asProjectId("project-flaky"),
title: "flaky-fail",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand All @@ -367,6 +372,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-flaky-ok"),
projectId: asProjectId("project-flaky"),
title: "flaky-ok",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -432,6 +438,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-atomic"),
projectId: asProjectId("project-atomic"),
title: "atomic",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -567,6 +574,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-sync"),
projectId: asProjectId("project-sync"),
title: "sync-before",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down Expand Up @@ -647,6 +655,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-duplicate"),
projectId: asProjectId("project-duplicate"),
title: "duplicate",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand All @@ -664,6 +673,7 @@ describe("OrchestrationEngine", () => {
threadId: ThreadId.makeUnsafe("thread-duplicate"),
projectId: asProjectId("project-duplicate"),
title: "duplicate",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
threadId: event.payload.threadId,
projectId: event.payload.projectId,
title: event.payload.title,
titleSummaryState: event.payload.titleSummaryState,
model: event.payload.model,
runtimeMode: event.payload.runtimeMode,
interactionMode: event.payload.interactionMode,
Expand All @@ -442,6 +443,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
yield* projectionThreadRepository.upsert({
...existingRow.value,
...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
...(event.payload.titleSummaryState !== undefined
? { titleSummaryState: event.payload.titleSummaryState }
: {}),
...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
...(event.payload.worktreePath !== undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
id: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread 1",
titleSummaryState: "missing",
model: "gpt-5-codex",
interactionMode: "default",
runtimeMode: "full-access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
thread_id AS "threadId",
project_id AS "projectId",
title,
title_summary_state AS "titleSummaryState",
model,
runtime_mode AS "runtimeMode",
interaction_mode AS "interactionMode",
Expand Down Expand Up @@ -546,6 +547,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
id: row.threadId,
projectId: row.projectId,
title: row.title,
titleSummaryState: row.titleSummaryState,
model: row.model,
runtimeMode: row.runtimeMode,
interactionMode: row.interactionMode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ describe("ProviderCommandReactor", () => {
threadId: ThreadId.makeUnsafe("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread",
titleSummaryState: "missing",
model: threadModel,
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
Expand Down
Loading