diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 3ea0502f83b33..d1250d51a9d0c 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -65,6 +65,7 @@ function withFakeClaudeEnv( stdinMustContain?: string; configDirMustBe?: string; cwdMustNotBe?: string; + settingsMustEqual?: unknown; claudeConfig?: Partial; }, effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, @@ -83,6 +84,7 @@ function withFakeClaudeEnv( const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; const previousConfigDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; const previousCwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; + const previousSettingsMustEqual = process.env.T3_FAKE_CLAUDE_SETTINGS_MUST_EQUAL; yield* Effect.acquireRelease( Effect.sync(() => { @@ -130,6 +132,12 @@ function withFakeClaudeEnv( } else { delete process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE; } + + if (input.settingsMustEqual !== undefined) { + process.env.T3_FAKE_CLAUDE_SETTINGS_MUST_EQUAL = JSON.stringify(input.settingsMustEqual); + } else { + delete process.env.T3_FAKE_CLAUDE_SETTINGS_MUST_EQUAL; + } }), () => Effect.sync(() => { @@ -182,6 +190,12 @@ function withFakeClaudeEnv( } else { process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE = previousConfigDirMustBe; } + + if (previousSettingsMustEqual === undefined) { + delete process.env.T3_FAKE_CLAUDE_SETTINGS_MUST_EQUAL; + } else { + process.env.T3_FAKE_CLAUDE_SETTINGS_MUST_EQUAL = previousSettingsMustEqual; + } }), ); @@ -289,6 +303,76 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { ), ); + const launchSettings = { + disableAllHooks: false, + fastMode: false, + env: { ANTHROPIC_VERTEX_PROJECT_ID: "issue-25017-marker" }, + }; + it.effect.each([ + { label: "inline JSON", inline: true }, + { label: "a request-cwd-relative file", inline: false }, + ])("merges --settings launch args from $label under T3 settings", ({ inline }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const requestCwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-settings-" }); + yield* fs.writeFileString( + path.join(requestCwd, "launch-settings.json"), + JSON.stringify(launchSettings), + ); + return yield* withFakeClaudeEnv( + { + output: JSON.stringify({ structured_output: { title: "Use launch settings" } }), + settingsMustEqual: { ...launchSettings, disableAllHooks: true, fastMode: true }, + claudeConfig: { + launchArgs: `--settings '${inline ? JSON.stringify(launchSettings) : "launch-settings.json"}'`, + }, + }, + (textGeneration) => + textGeneration.generateThreadTitle({ + cwd: requestCwd, + message: "thread title", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + SYNTHETIC_CLAUDE_CAPABLE_MODEL, + [{ id: "fastMode", value: true }], + ), + }), + ); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + [ + "malformed JSON", + `--settings '{"env":'`, + "The --settings launch argument is not a JSON object.", + ], + ["no value", "--settings", "The --settings launch argument needs a JSON object or file path."], + ] as const)("rejects a --settings launch arg with %s", ([, launchArgs, detail]) => + withFakeClaudeEnv( + { + output: JSON.stringify({ structured_output: { title: "Unused" } }), + claudeConfig: { launchArgs }, + }, + (textGeneration) => + Effect.gen(function* () { + const error = yield* Effect.flip( + textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }), + ); + + expect(error.detail).toBe(detail); + }), + ), + ); + it.effect("runs Claude text generation with the configured CLAUDE_CONFIG_DIR", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8fa9ccb1c8094..35f40e96d5e2d 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -1,11 +1,13 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { type ClaudeSettings, TextGenerationError } from "@t3tools/contracts"; +import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; @@ -65,6 +67,9 @@ const decodeClaudeOutput = Schema.decodeEffect( const ClaudeErrorResult = Schema.Struct({ is_error: Schema.Literal(true), result: Schema.String }); const isClaudeErrorResult = Schema.is(ClaudeErrorResult); +const decodeLaunchSettings = Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); const decodeJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); // Claude reports JSON errors on stdout; stderr may contain only wrapper notices. @@ -84,6 +89,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const scopedModelCatalog = modelCatalog.pipe( Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), @@ -120,6 +126,34 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ), ); + // Claude passes one --settings flag, so ours has to carry the launch-arg settings too. + // Like the CLI, a value starting with "{" is JSON and anything else is a file path. + const readLaunchSettings = Effect.fn("readLaunchSettings")(function* ( + operation: TextGenerationOperations.Operation, + cwd: string, + ) { + const raw = parseCliArgs(claudeSettings.launchArgs).flags.settings; + if (raw === undefined) return {}; + const value = raw?.trim() ?? ""; + const fail = (detail: string) => (cause: unknown) => + new TextGenerationError({ operation, detail, cause }); + if (value.length === 0) { + return yield* new TextGenerationError({ + operation, + detail: "The --settings launch argument needs a JSON object or file path.", + }); + } + const filePath = path.resolve(cwd, value); + const json = value.startsWith("{") + ? value + : yield* fileSystem + .readFileString(filePath) + .pipe(Effect.mapError(fail(`Failed to read Claude settings file ${filePath}.`))); + return yield* decodeLaunchSettings(json).pipe( + Effect.mapError(fail("The --settings launch argument is not a JSON object.")), + ); + }); + const runClaudeJson = Effect.fn("runClaudeJson")(function* ({ operation, cwd, @@ -170,6 +204,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const fastMode = fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; const settings = { + ...(yield* readLaunchSettings(operation, cwd)), disableAllHooks: true, ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), diff --git a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs index d5ca2f185eb8d..774d481425aa6 100644 --- a/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs +++ b/apps/server/src/textGeneration/testing/ClaudeTextGeneration.fixture.mjs @@ -1,4 +1,5 @@ import * as NodeFS from "node:fs"; +import * as NodeUtil from "node:util"; const argv = process.argv.slice(2); const args = argv.join(" "); @@ -29,6 +30,19 @@ const settingsIndex = argv.indexOf("--settings"); if (settingsIndex === -1 || JSON.parse(argv[settingsIndex + 1]).disableAllHooks !== true) { fail("text generation must disable hooks", 10); } +const settingsMustEqual = process.env.T3_FAKE_CLAUDE_SETTINGS_MUST_EQUAL; +if (settingsMustEqual) { + const settingsValues = argv.flatMap((arg, index) => + arg === "--settings" ? [argv[index + 1]] : [], + ); + const received = settingsValues.map((value) => JSON.parse(value)); + if ( + received.length !== 1 || + !NodeUtil.isDeepStrictEqual(received[0], JSON.parse(settingsMustEqual)) + ) { + fail("--settings was " + settingsValues.join(" --settings "), 13); + } +} const cwdMustNotBe = process.env.T3_FAKE_CLAUDE_CWD_MUST_NOT_BE; if (cwdMustNotBe && NodeFS.realpathSync(process.cwd()) === NodeFS.realpathSync(cwdMustNotBe)) { fail("text generation ran in the project directory", 11);