| author | |
| committer | |
| log | 52b2bf77a941740b4d979f3f4cae1c2851c14a69 |
| tree | cd0d09fed1035c4c973382a1fdd4da0352ce15e9 |
| parent | a07715c09e2f2b82555ca4221904fd806c770f8e |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 149 insertions(+), 5 deletions(-)
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts+94| ... | @@ -394,6 +394,100 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { | ... | @@ -394,6 +394,100 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { |
| 394 | }), | 394 | }), |
| 395 | ); | 395 | ); |
| 396 | 396 | ||
| 397 | for (const verbose of [false, true]) { | ||
| 398 | it.effect(`unwraps a JSON title in ${verbose ? "verbose" : "normal"} Claude output`, () => { | ||
| 399 | const result = { | ||
| 400 | type: "result", | ||
| 401 | structured_output: { title: '{"title": "Refresh ev-stg APP ASG instances"}' }, | ||
| 402 | }; | ||
| 403 | return withFakeClaudeEnv( | ||
| 404 | { output: JSON.stringify(verbose ? [result] : result) }, | ||
| 405 | (textGeneration) => | ||
| 406 | Effect.gen(function* () { | ||
| 407 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 408 | cwd: process.cwd(), | ||
| 409 | message: "Refresh ev-stg APP ASG instances", | ||
| 410 | modelSelection: { | ||
| 411 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 412 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | ||
| 413 | }, | ||
| 414 | }); | ||
| 415 | |||
| 416 | expect(generated.title).toBe("Refresh ev-stg APP ASG instances"); | ||
| 417 | }), | ||
| 418 | ); | ||
| 419 | }); | ||
| 420 | } | ||
| 421 | |||
| 422 | for (const previousTitle of [undefined, "Old thread title"]) { | ||
| 423 | it.effect( | ||
| 424 | `reads the result from verbose Claude output when ${previousTitle ? "regenerating" : "generating"} a title`, | ||
| 425 | () => | ||
| 426 | withFakeClaudeEnv( | ||
| 427 | { | ||
| 428 | output: JSON.stringify([ | ||
| 429 | { type: "system", subtype: "init" }, | ||
| 430 | { type: "assistant", message: { content: [] } }, | ||
| 431 | { type: "user", message: { content: [] } }, | ||
| 432 | { type: "rate_limit_event" }, | ||
| 433 | { | ||
| 434 | type: "result", | ||
| 435 | subtype: "success", | ||
| 436 | result: '{"title":"Refresh ev-stg APP ASG Instances"}', | ||
| 437 | structured_output: { title: "Refresh ev-stg APP ASG Instances" }, | ||
| 438 | }, | ||
| 439 | ]), | ||
| 440 | }, | ||
| 441 | (textGeneration) => | ||
| 442 | Effect.gen(function* () { | ||
| 443 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 444 | cwd: process.cwd(), | ||
| 445 | message: "Refresh ev-stg APP ASG instances", | ||
| 446 | previousTitle, | ||
| 447 | modelSelection: { | ||
| 448 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 449 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | ||
| 450 | }, | ||
| 451 | }); | ||
| 452 | |||
| 453 | expect(generated.title).toBe("Refresh ev-stg APP ASG Instances"); | ||
| 454 | }), | ||
| 455 | ), | ||
| 456 | ); | ||
| 457 | } | ||
| 458 | |||
| 459 | for (const [name, output] of [ | ||
| 460 | ["empty message array", []], | ||
| 461 | ["missing result", [{ type: "assistant", structured_output: { title: "Not a result" } }]], | ||
| 462 | ["invalid title", [{ type: "result", structured_output: { title: 42 } }]], | ||
| 463 | [ | ||
| 464 | "final result without structured output", | ||
| 465 | [ | ||
| 466 | { type: "result", structured_output: { title: "Earlier result" } }, | ||
| 467 | { type: "result", subtype: "error_max_structured_output_retries" }, | ||
| 468 | ], | ||
| 469 | ], | ||
| 470 | ] as const) { | ||
| 471 | it.effect(`rejects verbose Claude output with ${name}`, () => | ||
| 472 | withFakeClaudeEnv({ output: JSON.stringify(output) }, (textGeneration) => | ||
| 473 | Effect.gen(function* () { | ||
| 474 | const error = yield* Effect.flip( | ||
| 475 | textGeneration.generateThreadTitle({ | ||
| 476 | cwd: process.cwd(), | ||
| 477 | message: "Name this thread", | ||
| 478 | modelSelection: { | ||
| 479 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 480 | model: SYNTHETIC_CLAUDE_STANDARD_MODEL, | ||
| 481 | }, | ||
| 482 | }), | ||
| 483 | ); | ||
| 484 | |||
| 485 | expect(error._tag).toBe("TextGenerationError"); | ||
| 486 | }), | ||
| 487 | ), | ||
| 488 | ); | ||
| 489 | } | ||
| 490 | |||
| 397 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => | 491 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => |
| 398 | withFakeClaudeEnv( | 492 | withFakeClaudeEnv( |
| 399 | { | 493 | { |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+14-4| ... | @@ -53,14 +53,21 @@ const CLAUDE_TIMEOUT_MS = 180_000; | ... | @@ -53,14 +53,21 @@ const CLAUDE_TIMEOUT_MS = 180_000; |
| 53 | 53 | ||
| 54 | /** | 54 | /** |
| 55 | * Schema for the wrapper JSON returned by `claude -p --output-format json`. | 55 | * Schema for the wrapper JSON returned by `claude -p --output-format json`. |
| 56 | * We only care about `structured_output`. | 56 | * Verbose mode wraps the result in an array of conversation messages. |
| 57 | */ | 57 | */ |
| 58 | const ClaudeOutputEnvelope = Schema.Struct({ | 58 | const ClaudeOutputEnvelope = Schema.Struct({ |
| 59 | structured_output: Schema.Unknown, | 59 | structured_output: Schema.Unknown, |
| 60 | }); | 60 | }); |
| 61 | const ClaudeOutputMessage = Schema.Struct({ | ||
| 62 | type: Schema.String, | ||
| 63 | structured_output: Schema.optionalKey(Schema.Unknown), | ||
| 64 | }); | ||
| 65 | const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope); | ||
| 61 | 66 | ||
| 62 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 67 | const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| 63 | const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope)); | 68 | const decodeClaudeOutput = Schema.decodeEffect( |
| 69 | Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])), | ||
| 70 | ); | ||
| 64 | 71 | ||
| 65 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( | 72 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( |
| 66 | claudeSettings: ClaudeSettings, | 73 | claudeSettings: ClaudeSettings, |
| ... | @@ -254,7 +261,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -254,7 +261,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 254 | ), | 261 | ), |
| 255 | ); | 262 | ); |
| 256 | 263 | ||
| 257 | const envelope = yield* decodeClaudeOutputEnvelope(rawStdout).pipe( | 264 | const output = yield* decodeClaudeOutput(rawStdout).pipe( |
| 258 | Effect.catchTags({ | 265 | Effect.catchTags({ |
| 259 | SchemaError: (cause) => | 266 | SchemaError: (cause) => |
| 260 | Effect.fail( | 267 | Effect.fail( |
| ... | @@ -266,9 +273,12 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | ... | @@ -266,9 +273,12 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu |
| 266 | ), | 273 | ), |
| 267 | }), | 274 | }), |
| 268 | ); | 275 | ); |
| 276 | const envelope = isClaudeOutputEnvelope(output) | ||
| 277 | ? output | ||
| 278 | : output.findLast((message) => message.type === "result"); | ||
| 269 | 279 | ||
| 270 | const decodeOutput = Schema.decodeEffect(outputSchemaJson); | 280 | const decodeOutput = Schema.decodeEffect(outputSchemaJson); |
| 271 | return yield* decodeOutput(envelope.structured_output).pipe( | 281 | return yield* decodeOutput(envelope?.structured_output).pipe( |
| 272 | Effect.catchTags({ | 282 | Effect.catchTags({ |
| 273 | SchemaError: (cause) => | 283 | SchemaError: (cause) => |
| 274 | Effect.fail( | 284 | Effect.fail( |
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+29| ... | @@ -216,6 +216,35 @@ describe("buildThreadTitlePrompt", () => { | ... | @@ -216,6 +216,35 @@ describe("buildThreadTitlePrompt", () => { |
| 216 | }); | 216 | }); |
| 217 | 217 | ||
| 218 | describe("sanitizeThreadTitle", () => { | 218 | describe("sanitizeThreadTitle", () => { |
| 219 | it.each([ | ||
| 220 | '{"title": "Refresh ev-stg APP ASG instances"}', | ||
| 221 | '{\n "title": "Refresh ev-stg APP ASG instances"\n}', | ||
| 222 | ])("unwraps a JSON title before normalizing: %s", (raw) => { | ||
| 223 | expect(sanitizeThreadTitle(raw)).toBe("Refresh ev-stg APP ASG instances"); | ||
| 224 | }); | ||
| 225 | |||
| 226 | it.each([ | ||
| 227 | "Rolling ES Refresh ev-stg", | ||
| 228 | "Fix {title} interpolation", | ||
| 229 | '{"title": 42}', | ||
| 230 | '{"subject": "Fix parsing"}', | ||
| 231 | '{"title": "unfinished}', | ||
| 232 | ])("preserves text that is not a JSON title: %s", (raw) => { | ||
| 233 | expect(sanitizeThreadTitle(raw)).toBe(raw); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("normalizes the extracted title", () => { | ||
| 237 | expect(sanitizeThreadTitle('{"title": " Fix reconnect failures "}')).toBe( | ||
| 238 | "Fix reconnect failures", | ||
| 239 | ); | ||
| 240 | expect(sanitizeThreadTitle('{"title": " "}')).toBe("New thread"); | ||
| 241 | expect( | ||
| 242 | sanitizeThreadTitle( | ||
| 243 | '{"title": "Reconnect failures after restart because the session state does not recover"}', | ||
| 244 | ), | ||
| 245 | ).toBe("Reconnect failures after restart because the se..."); | ||
| 246 | }); | ||
| 247 | |||
| 219 | it("truncates long titles with the shared sidebar-safe limit", () => { | 248 | it("truncates long titles with the shared sidebar-safe limit", () => { |
| 220 | expect( | 249 | expect( |
| 221 | sanitizeThreadTitle( | 250 | sanitizeThreadTitle( |
apps/server/src/textGeneration/TextGenerationUtils.ts+8-1| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import { TextGenerationError } from "@t3tools/contracts"; | 1 | import { TextGenerationError } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; | ||
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | 4 | ||
| 4 | const isTextGenerationError = Schema.is(TextGenerationError); | 5 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 6 | const decodeJsonThreadTitle = Schema.decodeOption( | ||
| 7 | Schema.fromJsonString(Schema.Struct({ title: Schema.String })), | ||
| 8 | ); | ||
| 5 | 9 | ||
| 6 | /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ | 10 | /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ |
| 7 | export function toJsonSchemaObject(schema: Schema.Top): unknown { | 11 | export function toJsonSchemaObject(schema: Schema.Top): unknown { |
| ... | @@ -44,7 +48,10 @@ export function sanitizePrTitle(raw: string): string { | ... | @@ -44,7 +48,10 @@ export function sanitizePrTitle(raw: string): string { |
| 44 | 48 | ||
| 45 | /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ | 49 | /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ |
| 46 | export function sanitizeThreadTitle(raw: string): string { | 50 | export function sanitizeThreadTitle(raw: string): string { |
| 47 | const normalized = raw | 51 | // Unwrap a JSON-formatted title before truncation can cut off the closing brace. |
| 52 | const decoded = decodeJsonThreadTitle(raw); | ||
| 53 | const title = Option.isSome(decoded) ? decoded.value.title : raw; | ||
| 54 | const normalized = title | ||
| 48 | .trim() | 55 | .trim() |
| 49 | .split(/\r?\n/g)[0] | 56 | .split(/\r?\n/g)[0] |
| 50 | ?.trim() | 57 | ?.trim() |
docs/user/providers-claude.md+4| ... | @@ -37,6 +37,10 @@ state. Claude does not have Codex's shared-home and shadow-home arrangement. | ... | @@ -37,6 +37,10 @@ state. Claude does not have Codex's shared-home and shadow-home arrangement. |
| 37 | For presets that differ only in API keys or endpoints, use the instance's | 37 | For presets that differ only in API keys or endpoints, use the instance's |
| 38 | **Environment variables**. Variable assignments do not belong in **Launch arguments**. | 38 | **Environment variables**. Variable assignments do not belong in **Launch arguments**. |
| 39 | 39 | ||
| 40 | Claude Code's verbose mode can stay enabled when you use Claude for text generation, including | ||
| 41 | thread titles, branch names, commit messages, and pull request descriptions. On a remote connection, | ||
| 42 | T3 Code uses the Claude configuration on the connected server. | ||
| 43 | |||
| 40 | ## Compact long conversations | 44 | ## Compact long conversations |
| 41 | 45 | ||
| 42 | Set **Auto-compact after** in the Claude provider settings to an integer between | 46 | Set **Auto-compact after** in the Claude provider settings to an integer between |