Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 85 additions & 1 deletion apps/server/src/textGeneration/TextGenerationPrompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
buildPrContentPrompt,
buildThreadTitlePrompt,
} from "./TextGenerationPrompts.ts";
import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts";
import { normalizeCliError, sanitizePrTitle, sanitizeThreadTitle } from "./TextGenerationUtils.ts";
import { TextGenerationError } from "@t3tools/contracts";

describe("buildCommitMessagePrompt", () => {
Expand Down Expand Up @@ -144,6 +144,90 @@ describe("sanitizeThreadTitle", () => {
),
).toBe("Reconnect failures after restart because the se...");
});

it("unwraps a self-wrapped JSON envelope emitted as the title value", () => {
expect(sanitizeThreadTitle('{"title": "Fix the flaky login test"}')).toBe(
"Fix the flaky login test",
);
});

it("unwraps a single string value regardless of the key name", () => {
expect(sanitizeThreadTitle('{"name": "Add dark mode toggle"}')).toBe("Add dark mode toggle");
expect(sanitizeThreadTitle('{"summary": "Investigate reconnect regressions"}')).toBe(
"Investigate reconnect regressions",
);
});

it("extracts the sole string field even when other non-string fields exist", () => {
expect(
sanitizeThreadTitle('{"name": "Add dark mode toggle", "priority": 2, "done": false}'),
).toBe("Add dark mode toggle");
expect(sanitizeThreadTitle('{"confidence": 0.9, "title": "Fix the flaky login test"}')).toBe(
"Fix the flaky login test",
);
});

it("prefers the title key when several string values are ambiguous", () => {
expect(
sanitizeThreadTitle('{"summary": "restate the request", "title": "Real thread name"}'),
).toBe("Real thread name");
});

it("leaves an ambiguous object without a title key untouched", () => {
const raw = '{"name": "one", "label": "two"}';
expect(sanitizeThreadTitle(raw)).toBe(raw);
});

it("unwraps a JSON-encoded string wrapping the title", () => {
// The whole title arrives JSON-string-encoded, e.g. `"Fix the flaky login test"`.
expect(sanitizeThreadTitle('"Fix the flaky login test"')).toBe("Fix the flaky login test");
});

it("unwraps a JSON-string-encoded envelope (object serialised as a JSON string)", () => {
// Decodes to the string `{"title": "Fix the flaky login test"}`, then to the title.
expect(sanitizeThreadTitle('"{\\"title\\": \\"Fix the flaky login test\\"}"')).toBe(
"Fix the flaky login test",
);
});

it("does not unwrap a JSON object embedded in surrounding prose", () => {
expect(sanitizeThreadTitle('Document {"foo":"bar"} syntax')).toBe(
'Document {"foo":"bar"} syntax',
);
expect(sanitizeThreadTitle('Explain the {"name": "widget"} config')).toBe(
'Explain the {"name": "widget"} config',
);
});

it("unwraps a doubly-wrapped JSON envelope", () => {
expect(sanitizeThreadTitle('{"title": "{\\"title\\": \\"Refactor auth flow\\"}"}')).toBe(
"Refactor auth flow",
);
});

it("leaves a plain title containing braces untouched", () => {
expect(sanitizeThreadTitle("Handle { and } in the parser")).toBe(
"Handle { and } in the parser",
);
});
});

describe("sanitizePrTitle", () => {
it("unwraps a self-wrapped JSON envelope emitted as the title value", () => {
expect(sanitizePrTitle('{"title": "fix(auth): reject expired tokens"}')).toBe(
"fix(auth): reject expired tokens",
);
});

it("unwraps a JSON-string-encoded envelope (PR titles are not quote-stripped)", () => {
expect(sanitizePrTitle('"{\\"title\\": \\"fix(auth): reject expired tokens\\"}"')).toBe(
"fix(auth): reject expired tokens",
);
});

it("keeps a normal single-line title", () => {
expect(sanitizePrTitle("feat: add retry to uploader")).toBe("feat: add retry to uploader");
});
});

describe("normalizeCliError", () => {
Expand Down
68 changes: 65 additions & 3 deletions apps/server/src/textGeneration/TextGenerationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,69 @@
import { TextGenerationError } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

/** Guard against pathological nesting when unwrapping a self-wrapped title. */
const MAX_TITLE_UNWRAP_DEPTH = 8;

/**
* Some models ignore the structured-output contract and emit the whole JSON
* envelope as the field's value, so a title comes back as the literal string
* `{"title": "Fix the flaky test"}` (or a JSON-encoded string, possibly nested)
* instead of `Fix the flaky test`. Peel that back by decoding as JSON:
*
* - decodes to a JSON string → recursively unwrap the decoded string;
* - decodes to a JSON object with exactly one string value → recursively unwrap
* that value, whatever its key (`title`, `name`, `summary`, ...) and however
* many non-string fields sit alongside it (`confidence`, `reasoning`, ...);
* - decodes to a JSON object with several string values → recursively unwrap a
* string `title` when present, otherwise give up;
* - anything else (not JSON, a number, an array, an ambiguous object) → return
* the value unchanged.
*
* Because plain prose is not valid JSON, a legitimate title that merely
* mentions an object, like `Document {"foo":"bar"} syntax`, decodes as nothing
* and is left intact.
*/
export function unwrapJsonEnvelopeTitle(raw: string): string {
return unwrapJsonValue(raw.trim(), 0);
}

function unwrapJsonValue(value: string, depth: number): string {
if (depth >= MAX_TITLE_UNWRAP_DEPTH) {
return value;
}

let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}

if (typeof parsed === "string") {
return unwrapJsonValue(parsed.trim(), depth + 1);
}

if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const stringValues = Object.values(parsed as Record<string, unknown>).filter(
(entry): entry is string => typeof entry === "string",
);
const [firstStringValue] = stringValues;
if (stringValues.length === 1 && firstStringValue !== undefined) {
// Single string value: use it whatever the key is called.
return unwrapJsonValue(firstStringValue.trim(), depth + 1);
}
if (stringValues.length > 1) {
// Ambiguous: disambiguate with a `title` key when present.
const title = (parsed as { title?: unknown }).title;
if (typeof title === "string") {
return unwrapJsonValue(title.trim(), depth + 1);
}
}
}

return value;
}

const isTextGenerationError = Schema.is(TextGenerationError);

/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
Expand Down Expand Up @@ -35,7 +98,7 @@ export function sanitizeCommitSubject(raw: string): string {

/** Normalise a raw PR title to a single line with a sensible fallback. */
export function sanitizePrTitle(raw: string): string {
const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
const singleLine = unwrapJsonEnvelopeTitle(raw).split(/\r?\n/g)[0]?.trim() ?? "";
if (singleLine.length > 0) {
return singleLine;
}
Expand All @@ -44,8 +107,7 @@ export function sanitizePrTitle(raw: string): string {

/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
export function sanitizeThreadTitle(raw: string): string {
const normalized = raw
.trim()
const normalized = unwrapJsonEnvelopeTitle(raw)
.split(/\r?\n/g)[0]
?.trim()
.replace(/^['"`]+|['"`]+$/g, "")
Expand Down
Loading