Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8416ecd
Add AI-generated worktree branch naming and safe branch rename
juliusmarminge Mar 1, 2026
06912c3
Move worktree branch auto-rename into provider turn reactor
juliusmarminge Mar 1, 2026
61e1bab
Scope upstream refresh in checkout effect lifecycle
juliusmarminge Mar 1, 2026
0df68cc
Remove git.renameBranch RPC and harden branch rename args
juliusmarminge Mar 1, 2026
9b0fe5e
Generate temporary worktree branch names from UUID tokens
juliusmarminge Mar 1, 2026
d3d0a08
Resolve persisted attachment paths across server pipelines
juliusmarminge Mar 1, 2026
44e409f
nit
juliusmarminge Mar 1, 2026
4bc43a0
data uri for app server
juliusmarminge Mar 1, 2026
ad79b5a
nit
juliusmarminge Mar 1, 2026
700579b
fine there
juliusmarminge Mar 1, 2026
0e146e2
Handle branch-name generation failures as typed errors
juliusmarminge Mar 1, 2026
e64ad4b
Use Effect Schema to decode Codex structured outputs
juliusmarminge Mar 1, 2026
e1a8ee1
Handle parameterized base64 image data URLs
juliusmarminge Mar 1, 2026
875e6ed
Inject test ServerConfig across orchestration and git layers
juliusmarminge Mar 1, 2026
cd732a2
fix: address PR review feedback
juliusmarminge Mar 1, 2026
5850315
Switch image attachments to persisted ID-based paths
juliusmarminge Mar 1, 2026
88dc6c9
Harden attachment ID resolution and missing-file handling
juliusmarminge Mar 1, 2026
fb92741
Read stdin before validating required image flag in codex test stub
juliusmarminge Mar 2, 2026
dadda0d
Reuse attachment thread segment sanitizer in projection pipeline
juliusmarminge Mar 2, 2026
afc0c78
Fix attachment cleanup to match exact thread segments
juliusmarminge Mar 2, 2026
39f0786
Normalize attachment thread segments to lowercase
juliusmarminge Mar 2, 2026
150e171
Remove unused attachment route path helpers
juliusmarminge Mar 2, 2026
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
96 changes: 96 additions & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) {
"#!/bin/sh",
'output_path=""',
"while [ $# -gt 0 ]; do",
' if [ "$1" = "--image" ]; then',
" shift",
' if [ -n "$1" ]; then',
' seen_image="1"',
" fi",
" continue",
" fi",
' if [ "$1" = "--output-last-message" ]; then',
" shift",
' output_path="$1"',
" fi",
" shift",
"done",
'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then',
' printf "%s\\n" "missing --image input" >&2',
" exit 2",
"fi",
"cat >/dev/null",
'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
Expand All @@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>(
output: string;
exitCode?: number;
stderr?: string;
requireImage?: boolean;
},
effect: Effect.Effect<A, E, R>,
) {
Expand All @@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>(
const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64;
const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;

yield* Effect.sync(() => {
process.env.PATH = `${binDir}:${previousPath ?? ""}`;
Expand All @@ -76,13 +89,20 @@ function withFakeCodexEnv<A, E, R>(
} else {
delete process.env.T3_FAKE_CODEX_STDERR;
}

if (input.requireImage) {
process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1";
} else {
delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
}
});

return {
previousPath,
previousOutput,
previousExitCode,
previousStderr,
previousRequireImage,
};
}),
() => effect,
Expand All @@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>(
} else {
process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr;
}

if (previous.previousRequireImage === undefined) {
delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
} else {
process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
}
}),
);
}
Expand Down Expand Up @@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);

it.effect("generates branch names and normalizes branch fragments", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
branch: " Feat/Session ",
}),
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateBranchName({
cwd: process.cwd(),
message: "Please update session handling.",
});

expect(generated.branch).toBe("feat/session");
}),
),
);

it.effect("passes image attachments through as codex image inputs", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
branch: "fix/ui-regression",
}),
requireImage: true,
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateBranchName({
cwd: process.cwd(),
message: "Fix layout bug from screenshot.",
attachments: [
{
type: "image",
name: "bug.png",
mimeType: "image/png",
sizeBytes: 5,
dataUrl: "data:image/png;base64,SGVsbG8=",
},
],
});

expect(generated.branch).toBe("fix/ui-regression");
}),
),
);

it.effect("returns null branch when codex returns the wrong object shape", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
title: "This is not a branch payload",
}),
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateBranchName({
cwd: process.cwd(),
message: "Fix websocket reconnect flake",
});

expect(generated.branch).toBeNull();
}),
),
);

it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
withFakeCodexEnv(
{
Expand Down
171 changes: 167 additions & 4 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { TextGenerationError } from "../Errors.ts";
import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
import {
type BranchNameGenerationInput,
type BranchNameGenerationResult,
type CommitMessageGenerationResult,
type PrContentGenerationResult,
type TextGenerationShape,
Expand Down Expand Up @@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = {
additionalProperties: false,
} as const;

const BRANCH_NAME_OUTPUT_SCHEMA_JSON = {
type: "object",
properties: {
branch: { type: "string" },
},
required: ["branch"],
additionalProperties: false,
} as const;

function normalizeCodexError(
operation: string,
error: unknown,
Expand Down Expand Up @@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } {
return { title, body };
}

function parseBranchNameOutput(raw: unknown): { branch: string } {
if (!raw || typeof raw !== "object") {
throw new Error("Codex returned a non-object branch payload.");
}
const record = raw as Record<string, unknown>;
const branch = typeof record.branch === "string" ? record.branch.trim() : "";
if (branch.length === 0) {
throw new Error("Codex returned an empty branch name payload.");
}
return { branch };
}

function limitSection(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
const truncated = value.slice(0, maxChars);
Expand Down Expand Up @@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string {
return "Update project changes";
}

function sanitizeBranchName(raw: string): string {
const normalized = raw
.trim()
.toLowerCase()
.replace(/['"`]/g, "")
.replace(/^[./\s_-]+|[./\s_-]+$/g, "");

const branchFragment = normalized
.replace(/[^a-z0-9/_-]+/g, "-")
.replace(/\/+/g, "/")
.replace(/-+/g, "-")
.replace(/^[./_-]+|[./_-]+$/g, "")
.slice(0, 64)
.replace(/[./_-]+$/g, "");

return branchFragment.length > 0 ? branchFragment : "update";
}

const makeCodexTextGeneration = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand Down Expand Up @@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () {
);
};

const writeTempBinaryFile = (
operation: string,
prefix: string,
bytes: Uint8Array,
extension: string,
): Effect.Effect<string, TextGenerationError> => {
const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`;
const filePath = path.join(
tempDir,
`t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`,
);
return fileSystem.writeFile(filePath, bytes).pipe(
Effect.mapError(
(cause) =>
new TextGenerationError({
operation,
detail: `Failed to write temp file at ${filePath}.`,
cause,
}),
),
Effect.as(filePath),
);
};

const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));

const materializeImageAttachments = (
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
attachments: BranchNameGenerationInput["attachments"],
): Effect.Effect<ReadonlyArray<string>, TextGenerationError> =>
Effect.gen(function* () {
if (!attachments || attachments.length === 0) {
return [];
}

const imagePaths: string[] = [];
for (const [index, attachment] of attachments.entries()) {
if (attachment.type !== "image") {
continue;
}

const parsed = parseBase64DataUrl(attachment.dataUrl);
if (!parsed || !parsed.mimeType.startsWith("image/")) {
continue;
}

const bytes = Buffer.from(parsed.base64, "base64");
if (bytes.byteLength === 0) {
continue;
}
Comment thread
cursor[bot] marked this conversation as resolved.

const extension = inferImageExtension({
mimeType: parsed.mimeType,
fileName: attachment.name,
});
const imagePath = yield* writeTempBinaryFile(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
operation,
`codex-image-${index}`,
bytes,
extension,
);
imagePaths.push(imagePath);
}
return imagePaths;
});

const runCodexJson = <T>({
operation,
cwd,
prompt,
outputSchemaJson,
imagePaths = [],
parse,
}: {
operation: "generateCommitMessage" | "generatePrContent";
operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
cwd: string;
prompt: string;
outputSchemaJson: object;
imagePaths?: ReadonlyArray<string>;
parse: (raw: unknown) => T;
}): Effect.Effect<T, TextGenerationError> =>
Effect.gen(function* () {
Expand All @@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
schemaPath,
"--output-last-message",
outputPath,
...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
"-",
],
{
Expand Down Expand Up @@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () {
}
});

const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], {
concurrency: "unbounded",
}).pipe(Effect.asVoid);
const cleanup = Effect.all(
[schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
{
concurrency: "unbounded",
},
).pipe(Effect.asVoid);
Comment thread
juliusmarminge marked this conversation as resolved.

return yield* Effect.gen(function* () {
yield* runCodexCommand.pipe(
Expand Down Expand Up @@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () {
);
};

const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
return Effect.gen(function* () {
const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments);
const attachmentLines = (input.attachments ?? []).map(
(attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
);

const prompt = [
"You generate concise git branch names.",
"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.",
"",
`Image attachments supplied to the model: ${imagePaths.length}`,
"",
"User message:",
limitSection(input.message, 8_000),
"",
"Attachment metadata:",
attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none",
].join("\n");

const generated = yield* runCodexJson({
operation: "generateBranchName",
cwd: input.cwd,
prompt,
outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,
imagePaths,
parse: (raw) => parseBranchNameOutput(raw),
}).pipe(
Effect.catch((error) =>
Effect.gen(function* () {
yield* Effect.logWarning("branch-name generation failed, skipping rename", {
operation: "generateBranchName",
reason: error instanceof Error ? error.message : String(error),
});
return { branch: null };
}),
),
);

return {
branch: generated.branch ? sanitizeBranchName(generated.branch) : null,
} satisfies BranchNameGenerationResult;
});
};

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

Expand Down
Loading