| 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 | 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 | 491 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => |
| 398 | 492 | withFakeClaudeEnv( |
| 399 | 493 | { |
apps/server/src/textGeneration/ClaudeTextGeneration.ts+14-4| ... | ... | @@ -53,14 +53,21 @@ const CLAUDE_TIMEOUT_MS = 180_000; |
| 53 | 53 | |
| 54 | 54 | /** |
| 55 | 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 | 58 | const ClaudeOutputEnvelope = Schema.Struct({ |
| 59 | 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 | 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 | 72 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( |
| 66 | 73 | claudeSettings: ClaudeSettings, |
| ... | ... | @@ -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 | 265 | Effect.catchTags({ |
| 259 | 266 | SchemaError: (cause) => |
| 260 | 267 | Effect.fail( |
| ... | ... | @@ -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 | 280 | const decodeOutput = Schema.decodeEffect(outputSchemaJson); |
| 271 | return yield* decodeOutput(envelope.structured_output).pipe( | |
| 281 | return yield* decodeOutput(envelope?.structured_output).pipe( | |
| 272 | 282 | Effect.catchTags({ |
| 273 | 283 | SchemaError: (cause) => |
| 274 | 284 | Effect.fail( |
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+29| ... | ... | @@ -216,6 +216,35 @@ describe("buildThreadTitlePrompt", () => { |
| 216 | 216 | }); |
| 217 | 217 | |
| 218 | 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 | 248 | it("truncates long titles with the shared sidebar-safe limit", () => { |
| 220 | 249 | expect( |
| 221 | 250 | sanitizeThreadTitle( |
apps/server/src/textGeneration/TextGenerationUtils.ts+8-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import { TextGenerationError } from "@t3tools/contracts"; |
| 2 | import * as Option from "effect/Option"; | |
| 2 | 3 | import * as Schema from "effect/Schema"; |
| 3 | 4 | |
| 4 | 5 | const isTextGenerationError = Schema.is(TextGenerationError); |
| 6 | const decodeJsonThreadTitle = Schema.decodeOption( | |
| 7 | Schema.fromJsonString(Schema.Struct({ title: Schema.String })), | |
| 8 | ); | |
| 5 | 9 | |
| 6 | 10 | /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ |
| 7 | 11 | export function toJsonSchemaObject(schema: Schema.Top): unknown { |
| ... | ... | @@ -44,7 +48,10 @@ export function sanitizePrTitle(raw: string): string { |
| 44 | 48 | |
| 45 | 49 | /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ |
| 46 | 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 | 55 | .trim() |
| 49 | 56 | .split(/\r?\n/g)[0] |
| 50 | 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 | 37 | For presets that differ only in API keys or endpoints, use the instance's |
| 38 | 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 | 44 | ## Compact long conversations |
| 41 | 45 | |
| 42 | 46 | Set **Auto-compact after** in the Claude provider settings to an integer between |