authorgravatar for Noojuno@users.noreply.github.comJono Kemball <Noojuno@users.noreply.github.com> 2026-09-07 13:50:36+12:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:50:36-05:00
log52b2bf77a941740b4d979f3f4cae1c2851c14a69
treecd0d09fed1035c4c973382a1fdd4da0352ce15e9
parenta07715c09e2f2b82555ca4221904fd806c770f8e
signature Signed by PGP key B5690EEEBB952194

fix(server): handle JSON-wrapped titles and verbose Claude output (#10446)


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 );
396396
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;
5353
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 */
58const ClaudeOutputEnvelope = Schema.Struct({58const ClaudeOutputEnvelope = Schema.Struct({
59 structured_output: Schema.Unknown,59 structured_output: Schema.Unknown,
60});60});
61const ClaudeOutputMessage = Schema.Struct({
62 type: Schema.String,
63 structured_output: Schema.optionalKey(Schema.Unknown),
64});
65const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope);
6166
62const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));67const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
63const decodeClaudeOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope));68const decodeClaudeOutput = Schema.decodeEffect(
69 Schema.fromJsonString(Schema.Union([ClaudeOutputEnvelope, Schema.Array(ClaudeOutputMessage)])),
70);
6471
65export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (72export 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 );
256263
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");
269279
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});
217217
218describe("sanitizeThreadTitle", () => {218describe("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 @@
1import { TextGenerationError } from "@t3tools/contracts";1import { TextGenerationError } from "@t3tools/contracts";
2import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
34
4const isTextGenerationError = Schema.is(TextGenerationError);5const isTextGenerationError = Schema.is(TextGenerationError);
6const decodeJsonThreadTitle = Schema.decodeOption(
7 Schema.fromJsonString(Schema.Struct({ title: Schema.String })),
8);
59
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. */
7export function toJsonSchemaObject(schema: Schema.Top): unknown {11export 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 {
4448
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. */
46export function sanitizeThreadTitle(raw: string): string {50export function sanitizeThreadTitle(raw: string): string {
47 const normalized = raw51 // 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.
37For presets that differ only in API keys or endpoints, use the instance's37For 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**.
3939
40Claude Code's verbose mode can stay enabled when you use Claude for text generation, including
41thread titles, branch names, commit messages, and pull request descriptions. On a remote connection,
42T3 Code uses the Claude configuration on the connected server.
43
40## Compact long conversations44## Compact long conversations
4145
42Set **Auto-compact after** in the Claude provider settings to an integer between46Set **Auto-compact after** in the Claude provider settings to an integer between