Skip to content

Commit 52b2bf7

Browse files
authored
fix(server): handle JSON-wrapped titles and verbose Claude output (#10446)
1 parent a07715c commit 52b2bf7

5 files changed

Lines changed: 149 additions & 5 deletions

File tree

apps/server/src/textGeneration/ClaudeTextGeneration.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,100 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
394394
}),
395395
);
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+
397491
it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
398492
withFakeClaudeEnv(
399493
{

apps/server/src/textGeneration/ClaudeTextGeneration.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,21 @@ const CLAUDE_TIMEOUT_MS = 180_000;
5353

5454
/**
5555
* 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.
5757
*/
5858
const ClaudeOutputEnvelope = Schema.Struct({
5959
structured_output: Schema.Unknown,
6060
});
61+
const ClaudeOutputMessage = Schema.Struct({
62+
type: Schema.String,
63+
structured_output: Schema.optionalKey(Schema.Unknown),
64+
});
65+
const isClaudeOutputEnvelope = Schema.is(ClaudeOutputEnvelope);
6166

6267
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+
);
6471

6572
export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
6673
claudeSettings: ClaudeSettings,
@@ -254,7 +261,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
254261
),
255262
);
256263

257-
const envelope = yield* decodeClaudeOutputEnvelope(rawStdout).pipe(
264+
const output = yield* decodeClaudeOutput(rawStdout).pipe(
258265
Effect.catchTags({
259266
SchemaError: (cause) =>
260267
Effect.fail(
@@ -266,9 +273,12 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
266273
),
267274
}),
268275
);
276+
const envelope = isClaudeOutputEnvelope(output)
277+
? output
278+
: output.findLast((message) => message.type === "result");
269279

270280
const decodeOutput = Schema.decodeEffect(outputSchemaJson);
271-
return yield* decodeOutput(envelope.structured_output).pipe(
281+
return yield* decodeOutput(envelope?.structured_output).pipe(
272282
Effect.catchTags({
273283
SchemaError: (cause) =>
274284
Effect.fail(

apps/server/src/textGeneration/TextGenerationPrompts.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,35 @@ describe("buildThreadTitlePrompt", () => {
216216
});
217217

218218
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+
219248
it("truncates long titles with the shared sidebar-safe limit", () => {
220249
expect(
221250
sanitizeThreadTitle(

apps/server/src/textGeneration/TextGenerationUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { TextGenerationError } from "@t3tools/contracts";
2+
import * as Option from "effect/Option";
23
import * as Schema from "effect/Schema";
34

45
const isTextGenerationError = Schema.is(TextGenerationError);
6+
const decodeJsonThreadTitle = Schema.decodeOption(
7+
Schema.fromJsonString(Schema.Struct({ title: Schema.String })),
8+
);
59

610
/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
711
export function toJsonSchemaObject(schema: Schema.Top): unknown {
@@ -44,7 +48,10 @@ export function sanitizePrTitle(raw: string): string {
4448

4549
/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
4650
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
4855
.trim()
4956
.split(/\r?\n/g)[0]
5057
?.trim()

docs/user/providers-claude.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ state. Claude does not have Codex's shared-home and shadow-home arrangement.
3737
For presets that differ only in API keys or endpoints, use the instance's
3838
**Environment variables**. Variable assignments do not belong in **Launch arguments**.
3939

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+
4044
## Compact long conversations
4145

4246
Set **Auto-compact after** in the Claude provider settings to an integer between

0 commit comments

Comments
 (0)