Skip to content
Merged
29 changes: 29 additions & 0 deletions src/lib/session-title.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,32 @@ describe("mayReplaceTitle", () => {
expect(mayReplaceTitle("Matty comp", "Fix the table layout")).toBe(false);
});
});

describe("sanitizeGeneratedTitle — auth-expiry guard", () => {
it("rejects the exact string that branded a session in v0.6.0", () => {
// Passes every other filter: 1 line, 72 chars, 11 words - which is how it
// became the permanent title "Failed to authenticate: OAuth session ...".
expect(
sanitizeGeneratedTitle(
"Failed to authenticate: OAuth session expired and could not be refreshed",
),
).toBeNull();
});

it("rejects other vendor auth-failure phrasings", () => {
expect(sanitizeGeneratedTitle("Session expired")).toBeNull();
expect(sanitizeGeneratedTitle("invalid_grant")).toBeNull();
expect(sanitizeGeneratedTitle("not_authenticated")).toBeNull();
});

it("keeps titles that merely mention auth", () => {
// The strict matcher exists for exactly these: real work about auth must
// still get a real name.
expect(sanitizeGeneratedTitle("Fixing the unauthorized API error")).toBe(
"Fixing the unauthorized API error",
);
expect(sanitizeGeneratedTitle("Add OAuth login to the desktop app")).toBe(
"Add OAuth login to the desktop app",
);
});
});
10 changes: 10 additions & 0 deletions src/lib/session-title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* generated title takes over once the first exchange is done.
*/

import { isAuthExpiryText } from "$lib/utils/auth-errors";

/** Tabs truncate around here; longer titles only add ellipsis. */
export const MAX_TITLE_LENGTH = 60;

Expand Down Expand Up @@ -51,6 +53,14 @@ export function cleanPromptTitle(prompt: string): string {
export function sanitizeGeneratedTitle(raw: string): string | null {
let text = String(raw ?? "").trim();
if (!text) return null;
// The title comes from a throwaway CLI run. When that run fails on expired
// auth it returns the failure text, which passes every check below - one
// line, 72 chars, 11 words - so the session gets permanently named
// "Failed to authenticate: OAuth session expired and could not". A transient
// auth blip must not brand a session forever; drop it and keep the
// prompt-derived title. Strict matcher: a title that merely mentions auth
// (say "Fixing the unauthorized API error") is a legitimate name.
if (isAuthExpiryText(text)) return null;
// Unwrap a fenced block before counting lines — the fence is the model's formatting, not
// part of the answer, and its newlines would otherwise look like an essay.
const fenced = text.match(/^```[a-z]*\s*\n?([\s\S]*?)\n?\s*```$/i);
Expand Down
28 changes: 28 additions & 0 deletions src/lib/utils/auth-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,31 @@ export function isAuthExpiryError(text: string | null | undefined): boolean {
const t = String(text).toLowerCase();
return AUTH_EXPIRY_MARKERS.some((m) => t.includes(m));
}

/** Phrasings the vendor CLIs actually emit when their own OAuth failed. Unlike
* AUTH_EXPIRY_MARKERS this must not match text that merely *discusses* auth:
* bare "authenticate" / "unauthorized" are deliberately absent, because a
* legitimate chat about a 401 would otherwise be mistaken for a failure.
*
* Still not airtight, and it does not need to be: "session expired" would also
* reject a title like "Session expired handling in Redis". The cost of a false
* positive here is bounded - the session keeps its prompt-derived name - so the
* list is tuned to never MISS a real failure, and to miss as few good titles as
* it reasonably can. */
const AUTH_EXPIRY_STRICT_MARKERS = [
"oauth session expired",
"could not be refreshed",
"failed to authenticate",
"session expired",
"not_authenticated",
"invalid_grant",
];

/** Strict variant of {@link isAuthExpiryError}, for places where the text may be
* ordinary content rather than a raw error — e.g. a model-generated session
* title, where a false positive silently discards a good title. */
export function isAuthExpiryText(text: string | null | undefined): boolean {
if (!text) return false;
const t = String(text).toLowerCase();
return AUTH_EXPIRY_STRICT_MARKERS.some((m) => t.includes(m));
}
Loading
Loading