Skip to content
Merged
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
46 changes: 46 additions & 0 deletions apps/server/src/textGeneration/ClaudeTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) {
function withFakeClaudeEnv<A, E, R>(
input: {
output: string;
rejectsNoSessionPersistence?: boolean;
lifecyclePort?: number;
exitCode?: number;
stderr?: string;
Expand All @@ -75,6 +76,8 @@ function withFakeClaudeEnv<A, E, R>(
const binDir = yield* makeFakeClaudeBinary(tempDir, input.lifecyclePort);
const pathDelimiter = (yield* HostProcess.isWindows) ? ";" : ":";
const previousPath = process.env.PATH;
const previousRejectsNoSessionPersistence =
process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE;
const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
Expand All @@ -89,6 +92,12 @@ function withFakeClaudeEnv<A, E, R>(
process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`;
process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;

if (input.rejectsNoSessionPersistence) {
process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE = "1";
} else {
delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE;
}

if (input.exitCode !== undefined) {
process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
} else {
Expand Down Expand Up @@ -135,6 +144,13 @@ function withFakeClaudeEnv<A, E, R>(
Effect.sync(() => {
process.env.PATH = previousPath;

if (previousRejectsNoSessionPersistence === undefined) {
delete process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE;
} else {
process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE =
previousRejectsNoSessionPersistence;
}

if (previousOutput === undefined) {
delete process.env.T3_FAKE_CLAUDE_OUTPUT;
} else {
Expand Down Expand Up @@ -320,6 +336,28 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => {
}),
);

it.effect("retries without --no-session-persistence when the Claude CLI rejects it", () =>
withFakeClaudeEnv(
{
output: JSON.stringify({ structured_output: { title: "Reconnect failures" } }),
rejectsNoSessionPersistence: true,
},
(textGeneration) =>
Effect.gen(function* () {
const generated = yield* textGeneration.generateThreadTitle({
cwd: process.cwd(),
message: "Investigate reconnect failures",
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: SYNTHETIC_CLAUDE_STANDARD_MODEL,
},
});

expect(generated.title).toBe("Reconnect failures");
}),
),
);

it.effect.each([
{ verbose: false, label: "normal" },
{ verbose: true, label: "verbose" },
Expand Down Expand Up @@ -430,6 +468,14 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => {
apiError,
],
["stderr when stdout has no error", "not json", wrapperNotice],
[
"a stdout error naming --no-session-persistence without retrying",
JSON.stringify({
is_error: true,
result: "error: unknown option '--no-session-persistence'",
}),
"error: unknown option '--no-session-persistence'",
],
] as const)("reports %s when the Claude CLI fails", ([, output, expected]) =>
withFakeClaudeEnv({ output, stderr: wrapperNotice, exitCode: 1 }, (textGeneration) =>
Effect.gen(function* () {
Expand Down
44 changes: 29 additions & 15 deletions apps/server/src/textGeneration/ClaudeTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ const CLAUDE_INVESTIGATION_ALLOWED = [
// Denies override allows; block git --output/-o because allowed trailing args can otherwise write anywhere.
const CLAUDE_INVESTIGATION_DENIED = ["Bash(git * --output*)", "Bash(git * -o *)"].join(",");

const NO_SESSION_PERSISTENCE = "--no-session-persistence";
// Claude Code before 2.0.63 exits with this on stderr; those runs retry without the flag.
const NO_SESSION_PERSISTENCE_REJECTED = `error: unknown option '${NO_SESSION_PERSISTENCE}'`;

const ClaudeOutputEnvelope = Schema.Struct({
structured_output: Schema.Unknown,
});
Expand Down Expand Up @@ -181,7 +185,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
"Failed to encode Claude CLI settings.",
);

const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* (
noSessionPersistence: boolean,
) {
const workingDirectory =
operation === "generateThreadTitle"
? yield* fileSystem
Expand Down Expand Up @@ -217,6 +223,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
]
: ["--tools", ""]),
"--disable-slash-commands",
...(noSessionPersistence ? [NO_SESSION_PERSISTENCE] : []),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// managed-mcp.json already isolates servers and makes Claude reject this flag.
...(managedMcpConfig ? [] : ["--strict-mcp-config"]),
"--permission-mode",
Expand Down Expand Up @@ -255,25 +262,32 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
{ concurrency: "unbounded" },
);

if (exitCode !== 0) {
return { stdout, stderr, exitCode };
});

const rawStdout = yield* runClaudeCommand(true).pipe(
Effect.filterOrElse(
(result) =>
result.exitCode === 0 || !result.stderr.includes(NO_SESSION_PERSISTENCE_REJECTED),
() => runClaudeCommand(false),
),
Effect.flatMap(({ stdout, stderr, exitCode }) => {
if (exitCode === 0) return Effect.succeed(stdout);
const stderrDetail = stderr.trim();
const stdoutDetail = stdout.trim();
const detail =
claudeErrorResult(stdoutDetail) ??
(stderrDetail.length > 0 ? stderrDetail : stdoutDetail);
return yield* new TextGenerationError({
operation,
detail:
detail.length > 0
? `Claude CLI command failed: ${detail}`
: `Claude CLI command failed with code ${exitCode}.`,
});
}

return stdout;
});

const rawStdout = yield* runClaudeCommand().pipe(
return Effect.fail(
new TextGenerationError({
operation,
detail:
detail.length > 0
? `Claude CLI command failed: ${detail}`
: `Claude CLI command failed with code ${exitCode}.`,
}),
);
}),
Effect.scoped,
Effect.timeoutOption(investigate?.timeoutMs ?? CLAUDE_TIMEOUT_MS),
Effect.flatMap(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ if (!argv.includes("--disable-slash-commands")) {
if (!argv.includes("--strict-mcp-config")) {
fail("text generation must not load configured MCP servers", 9);
}
if (process.env.T3_FAKE_CLAUDE_REJECTS_NO_SESSION_PERSISTENCE === "1") {
if (argv.includes("--no-session-persistence")) {
fail("error: unknown option '--no-session-persistence'", 1);
}
} else if (!argv.includes("--no-session-persistence")) {
fail("text generation must not save a resumable session", 16);
}
const settingsIndex = argv.indexOf("--settings");
if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) {
fail("text generation must disable hooks", 10);
Expand Down
2 changes: 2 additions & 0 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Select a model under **Breakdown** to see its trend, cache hit rate, and cost pe

Totals depend on the history available on each server. Grok turns without a saved completed-turn
record are missing from the totals.
Text generation (thread titles, commit messages, PR text, branch names and review guides) is not in
the totals for Codex or Claude Code 2.0.63 and newer, because those runs save no session history.

OpenCode reads its SQLite database and older JSON history. Antigravity reads local conversation
databases, including T3-managed profiles. Set `OPENCODE_DATA_DIR` or `ANTIGRAVITY_DATA_DIR` on the
Expand Down
Loading