Skip to content
59 changes: 27 additions & 32 deletions web/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ import { recreateWorktreeIfMissing } from "./migration.js";
import { access } from "node:fs/promises";
import { RelaunchQueue } from "./relaunch-queue.js";
import {
NAMER_TRIGGER_SOURCES,
shouldAllowUserMessageOverrideOnNameMismatch,
type NamerMutationRecord,
type NamerTriggerSource,
} from "./session-namer-arbitration.js";
import { formatAutoNamerSkipReason, getAutoNamerSkipReason } from "./session-namer-guard.js";
import type { SocketData } from "./ws-bridge.js";
import type { ServerWebSocket } from "bun";

Expand Down Expand Up @@ -401,7 +403,7 @@ function endNamerCall(sessionId: string, source: NamerTriggerSource, controller:

/** Cancel ALL in-flight namer calls for a session (all trigger sources). */
function cancelAllNamersForSession(sessionId: string): void {
for (const source of ["user_message", "turn_completed", "agent_paused"] as const) {
for (const source of NAMER_TRIGGER_SOURCES) {
const key = getNamerKey(sessionId, source);
const ctrl = inFlightNamer.get(key);
if (ctrl) {
Expand Down Expand Up @@ -471,6 +473,25 @@ async function isQuestOwningSessionName(sessionId: string): Promise<boolean> {
);
}

async function shouldSkipAutoNamer(
sessionId: string,
source: NamerTriggerSource,
stage: "start" | "apply",
): Promise<boolean> {
const reason = await getAutoNamerSkipReason({
isAutoNamerEnabled: () => getSettings().autoNamerEnabled,
isNoAutoNameSession: () => !!launcher.getSession(sessionId)?.noAutoName,
isUserNamed: () => sessionNames.isUserNamed(sessionId),
isQuestOwningName: () => isQuestOwningSessionName(sessionId),
});
if (!reason) return false;

const verb = stage === "apply" ? "Discarding" : "Skipping";
const noun = stage === "apply" ? `${source} namer result` : `${source} namer`;
console.log(`[session-namer] ${verb} ${noun} for ${sessionId} (${formatAutoNamerSkipReason(reason)})`);
return true;
}

/** Apply a naming result: set name, broadcast, add task entry. Shared by all triggers. */
async function applyNamingResult(
sessionId: string,
Expand All @@ -479,11 +500,7 @@ async function applyNamingResult(
history: import("./session-types.js").BrowserIncomingMessage[],
source: NamerTriggerSource,
): Promise<void> {
// Re-check: quest may have been claimed while the namer was in-flight
if (await isQuestOwningSessionName(sessionId)) {
console.log(`[session-namer] Discarding namer result for ${sessionId} (quest owns session name)`);
return;
}
if (await shouldSkipAutoNamer(sessionId, source, "apply")) return;
// Merge keywords regardless of naming action
if (result.keywords?.length) {
mergeSessionKeywords(sessionId, result.keywords);
Expand Down Expand Up @@ -593,15 +610,7 @@ wsBridge.onSessionNamedByQuest = (sessionId, title) => {

// Continuous session auto-naming via Claude Haiku (triggered on each user message)
wsBridge.onUserMessage = async (sessionId, history, cwd, wasGenerating) => {
// Suppress auto-namer when disabled in settings
if (!getSettings().autoNamerEnabled) return;
// Suppress auto-namer for sessions with noAutoName flag (e.g. temporary reviewer sessions)
if (launcher.getSession(sessionId)?.noAutoName) return;
// Suppress auto-namer while a quest owns the session name
if (await isQuestOwningSessionName(sessionId)) {
console.log(`[session-namer] Skipping user-message namer for ${sessionId} (quest owns session name)`);
return;
}
if (await shouldSkipAutoNamer(sessionId, "user_message", "start")) return;
const currentName = sessionNames.getName(sessionId);
const isRandomName = isRandomSessionName(currentName);
const isFirstEvaluation = !autoNamingEvaluated.has(sessionId);
Expand Down Expand Up @@ -629,11 +638,7 @@ wsBridge.onUserMessage = async (sessionId, history, cwd, wasGenerating) => {
const result = await generateFirstName(sessionId, history, cwd, { signal, isGenerating, claimedQuest });
if (signal.aborted) return;
if (!result || result.action !== "name") return;
// Re-check: quest may have been claimed while we were generating
if (await isQuestOwningSessionName(sessionId)) {
console.log(`[session-namer] Discarding first-name result for ${sessionId} (quest owns session name)`);
return;
}
if (await shouldSkipAutoNamer(sessionId, "user_message", "apply")) return;
// Don't overwrite if user renamed while we were generating
const freshName = sessionNames.getName(sessionId);
if (freshName && !isRandomSessionName(freshName)) return;
Expand Down Expand Up @@ -667,12 +672,7 @@ wsBridge.onUserMessage = async (sessionId, history, cwd, wasGenerating) => {
// The agent has done meaningful research/work to produce the plan, providing
// rich context for naming — and it's a natural breakpoint before execution.
wsBridge.onAgentPaused = async (sessionId, history, cwd) => {
if (!getSettings().autoNamerEnabled) return;
if (launcher.getSession(sessionId)?.noAutoName) return;
if (await isQuestOwningSessionName(sessionId)) {
console.log(`[session-namer] Skipping agent-paused namer for ${sessionId} (quest owns session name)`);
return;
}
if (await shouldSkipAutoNamer(sessionId, "agent_paused", "start")) return;
const currentName = sessionNames.getName(sessionId);
if (!currentName) return;

Expand All @@ -693,12 +693,7 @@ wsBridge.onAgentPaused = async (sessionId, history, cwd) => {
// The turn-completed namer runs independently from user-message naming.
// User-message outcomes are preferred when both produce competing revisions.
wsBridge.onTurnCompleted = async (sessionId, history, cwd) => {
if (!getSettings().autoNamerEnabled) return;
if (launcher.getSession(sessionId)?.noAutoName) return;
if (await isQuestOwningSessionName(sessionId)) {
console.log(`[session-namer] Skipping turn-completed namer for ${sessionId} (quest owns session name)`);
return;
}
if (await shouldSkipAutoNamer(sessionId, "turn_completed", "start")) return;
const currentName = sessionNames.getName(sessionId);
if (!currentName) return;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,10 @@ async function parseSSE(res: Response): Promise<{ event: string; data: string }[
return events;
}

describe("GET /api/images/:sessionId/:imageId/thumb", () => {
// The image thumb route uses Bun.file() internally, so these tests only work on Bun.
const describeBun = typeof globalThis.Bun !== "undefined" ? describe : describe.skip;

describeBun("GET /api/images/:sessionId/:imageId/thumb", () => {
it("serves the real thumbnail with immutable caching when it exists", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "routes-thumb-"));
const thumbPath = join(tempRoot, "thumb.jpeg");
Expand Down
5 changes: 5 additions & 0 deletions web/server/routes.patch-api-sessions-id-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ vi.mock("./git-utils.js", () => ({
vi.mock("./session-names.js", () => ({
getName: vi.fn(() => undefined),
setName: vi.fn(),
setUserNamed: vi.fn(),
isUserNamed: vi.fn(() => false),
clearUserNamed: vi.fn(),
getAllNames: vi.fn(() => ({})),
removeName: vi.fn(),
getNextLeaderNumber: vi.fn(() => 1),
Expand Down Expand Up @@ -548,6 +551,8 @@ describe("PATCH /api/sessions/:id/name", () => {
const json = await res.json();
expect(json).toEqual({ ok: true, name: "Fix auth bug" });
expect(sessionNames.setName).toHaveBeenCalledWith("s1", "Fix auth bug");
// Verify the session is marked as user-named to prevent auto-namer overwriting
expect(sessionNames.setUserNamed).toHaveBeenCalledWith("s1");
});

it("trims whitespace from name", async () => {
Expand Down
1 change: 1 addition & 0 deletions web/server/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,7 @@ export function createSessionsRoutes(ctx: RouteContext) {
const session = launcher.getSession(id);
if (!session) return c.json({ error: "Session not found" }, 404);
sessionNames.setName(id, body.name.trim());
sessionNames.setUserNamed(id);
wsBridge.broadcastToSession(id, { type: "session_update", session: { name: body.name.trim() } } as any);
return c.json({ ok: true, name: body.name.trim() });
});
Expand Down
4 changes: 3 additions & 1 deletion web/server/session-namer-arbitration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type NamerTriggerSource = "user_message" | "turn_completed" | "agent_paused";
export const NAMER_TRIGGER_SOURCES = ["user_message", "turn_completed", "agent_paused"] as const;

export type NamerTriggerSource = (typeof NAMER_TRIGGER_SOURCES)[number];

export interface NamerMutationRecord {
source: NamerTriggerSource;
Expand Down
58 changes: 58 additions & 0 deletions web/server/session-namer-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { NAMER_TRIGGER_SOURCES } from "./session-namer-arbitration.js";
import { getAutoNamerSkipReason, type AutoNamerGuardChecks } from "./session-namer-guard.js";

function checks(overrides: Partial<AutoNamerGuardChecks> = {}): AutoNamerGuardChecks {
return {
isAutoNamerEnabled: vi.fn(() => true),
isNoAutoNameSession: vi.fn(() => false),
isUserNamed: vi.fn(() => false),
isQuestOwningName: vi.fn(async () => false),
...overrides,
} satisfies AutoNamerGuardChecks;
}

describe("getAutoNamerSkipReason", () => {
it("uses user-named protection for every automatic namer trigger", async () => {
for (const _source of NAMER_TRIGGER_SOURCES) {
const guardChecks = checks({ isUserNamed: vi.fn(() => true) });

await expect(getAutoNamerSkipReason(guardChecks)).resolves.toBe("user_named");
expect(guardChecks.isQuestOwningName).not.toHaveBeenCalled();
}
});

it("re-checks current manual-name state when called again for result application", async () => {
let userNamed = false;
const guardChecks = checks({ isUserNamed: vi.fn(() => userNamed) });

await expect(getAutoNamerSkipReason(guardChecks)).resolves.toBeNull();

userNamed = true;

await expect(getAutoNamerSkipReason(guardChecks)).resolves.toBe("user_named");
});

it("skips before manual-name checks when auto-namer is disabled", async () => {
const guardChecks = checks({ isAutoNamerEnabled: vi.fn(() => false) });

await expect(getAutoNamerSkipReason(guardChecks)).resolves.toBe("disabled");
expect(guardChecks.isNoAutoNameSession).not.toHaveBeenCalled();
expect(guardChecks.isUserNamed).not.toHaveBeenCalled();
expect(guardChecks.isQuestOwningName).not.toHaveBeenCalled();
});

it("skips before manual-name checks for noAutoName sessions", async () => {
const guardChecks = checks({ isNoAutoNameSession: vi.fn(() => true) });

await expect(getAutoNamerSkipReason(guardChecks)).resolves.toBe("no_auto_name");
expect(guardChecks.isUserNamed).not.toHaveBeenCalled();
expect(guardChecks.isQuestOwningName).not.toHaveBeenCalled();
});

it("falls back to quest-owned protection when no earlier guard applies", async () => {
const guardChecks = checks({ isQuestOwningName: vi.fn(async () => true) });

await expect(getAutoNamerSkipReason(guardChecks)).resolves.toBe("quest_owned");
});
});
29 changes: 29 additions & 0 deletions web/server/session-namer-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export type AutoNamerSkipReason = "disabled" | "no_auto_name" | "user_named" | "quest_owned";

export interface AutoNamerGuardChecks {
isAutoNamerEnabled: () => boolean;
isNoAutoNameSession: () => boolean;
isUserNamed: () => boolean;
isQuestOwningName: () => Promise<boolean>;
}

export async function getAutoNamerSkipReason(checks: AutoNamerGuardChecks): Promise<AutoNamerSkipReason | null> {
if (!checks.isAutoNamerEnabled()) return "disabled";
if (checks.isNoAutoNameSession()) return "no_auto_name";
if (checks.isUserNamed()) return "user_named";
if (await checks.isQuestOwningName()) return "quest_owned";
return null;
}

export function formatAutoNamerSkipReason(reason: AutoNamerSkipReason): string {
switch (reason) {
case "disabled":
return "auto-namer disabled";
case "no_auto_name":
return "session is marked noAutoName";
case "user_named":
return "manually renamed by user";
case "quest_owned":
return "quest owns session name";
}
}
55 changes: 51 additions & 4 deletions web/server/session-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
getAllNames,
removeName,
getNextLeaderNumber,
setUserNamed,
isUserNamed,
clearUserNamed,
_resetForTest,
_flushForTest,
} from "./session-names.js";
Expand Down Expand Up @@ -39,7 +42,7 @@ describe("session-names", () => {
await _flushForTest();
const raw = readFileSync(join(tempDir, "session-names.json"), "utf-8");
const data = JSON.parse(raw);
expect(data).toEqual({ names: { s1: "My Session" }, leaderCounter: 0 });
expect(data).toEqual({ names: { s1: "My Session" }, leaderCounter: 0, userNamed: [] });
});

it("getAllNames returns a copy of all names", () => {
Expand All @@ -52,13 +55,16 @@ describe("session-names", () => {
expect(getName("s3")).toBeUndefined();
});

it("removeName deletes a name", async () => {
it("removeName deletes a name and clears userNamed flag", async () => {
setName("s1", "Session One");
setUserNamed("s1");
expect(isUserNamed("s1")).toBe(true);
removeName("s1");
expect(getName("s1")).toBeUndefined();
expect(isUserNamed("s1")).toBe(false);
await _flushForTest();
const raw = readFileSync(join(tempDir, "session-names.json"), "utf-8");
expect(JSON.parse(raw)).toEqual({ names: {}, leaderCounter: 0 });
expect(JSON.parse(raw)).toEqual({ names: {}, leaderCounter: 0, userNamed: [] });
});

it("overwrites existing name", () => {
Expand Down Expand Up @@ -113,7 +119,7 @@ describe("leader counter", () => {

const raw = readFileSync(join(tempDir, "session-names.json"), "utf-8");
const data = JSON.parse(raw);
expect(data).toEqual({ names: { s1: "Worker 1" }, leaderCounter: 1 });
expect(data).toEqual({ names: { s1: "Worker 1" }, leaderCounter: 1, userNamed: [] });
});

it("loads counter from old-format file as 0 (backwards compat)", () => {
Expand All @@ -125,3 +131,44 @@ describe("leader counter", () => {
expect(getNextLeaderNumber()).toBe(1);
});
});

describe("userNamed flag", () => {
it("isUserNamed returns false by default", () => {
expect(isUserNamed("s1")).toBe(false);
});

it("setUserNamed + isUserNamed round-trip", () => {
setUserNamed("s1");
expect(isUserNamed("s1")).toBe(true);
expect(isUserNamed("s2")).toBe(false);
});

it("clearUserNamed removes the flag", () => {
setUserNamed("s1");
clearUserNamed("s1");
expect(isUserNamed("s1")).toBe(false);
});

it("persists userNamed to disk and survives restart", async () => {
setUserNamed("s1");
setUserNamed("s2");
await _flushForTest();

const raw = readFileSync(join(tempDir, "session-names.json"), "utf-8");
const data = JSON.parse(raw);
expect(data.userNamed).toEqual(expect.arrayContaining(["s1", "s2"]));

// Simulate restart
_resetForTest(join(tempDir, "session-names.json"));
expect(isUserNamed("s1")).toBe(true);
expect(isUserNamed("s2")).toBe(true);
});

it("backwards-compatible: loads file without userNamed field", () => {
// Old format without userNamed
writeFileSync(join(tempDir, "session-names.json"), JSON.stringify({ names: { s1: "Test" }, leaderCounter: 1 }));
_resetForTest(join(tempDir, "session-names.json"));
expect(isUserNamed("s1")).toBe(false);
expect(getName("s1")).toBe("Test");
});
});
Loading