diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 3ea0502f83b33..197d412d93405 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -57,6 +57,7 @@ function makeFakeClaudeBinary(dir: string, lifecyclePort?: number) { function withFakeClaudeEnv( input: { output: string; + rejectsNoSessionPersistence?: boolean; lifecyclePort?: number; exitCode?: number; stderr?: string; @@ -75,6 +76,8 @@ function withFakeClaudeEnv( 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; @@ -89,6 +92,12 @@ function withFakeClaudeEnv( 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 { @@ -135,6 +144,13 @@ function withFakeClaudeEnv( 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 { @@ -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" }, @@ -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* () { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8fa9ccb1c8094..51acced2745e5 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -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, }); @@ -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 @@ -217,6 +223,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ] : ["--tools", ""]), "--disable-slash-commands", + ...(noSessionPersistence ? [NO_SESSION_PERSISTENCE] : []), // managed-mcp.json already isolates servers and makes Claude reject this flag. ...(managedMcpConfig ? [] : ["--strict-mcp-config"]), "--permission-mode", @@ -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( diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs index d5ca2f185eb8d..22d406a4dcba0 100644 --- a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs +++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs @@ -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); diff --git a/docs/user/usage.md b/docs/user/usage.md index 447924a072eff..0289defe8bc71 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -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