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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@ plugins/posthog/local-skills/

# Symlinked copies of posthog, to make developing against those APIs easier
posthog-sym
.pi-lens/
Progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Task } from "@shared/types";
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock queryClient before importing the module under test
vi.mock("@utils/queryClient", () => ({
queryClient: {
getQueryData: vi.fn(),
setQueriesData: vi.fn(),
},
}));

import { queryClient } from "@utils/queryClient";
import { shouldApplyAutoTitle } from "./shouldApplyAutoTitle";

describe("shouldApplyAutoTitle", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns true when title_manually_set is false", () => {
const cachedTasks: Task[] = [
{
id: "task-1",
task_number: 1,
slug: "task-1",
title: "Auto title",
origin_product: "user_created",
description: "",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
title_manually_set: false,
},
];

vi.mocked(queryClient.getQueryData).mockReturnValue(cachedTasks);

expect(shouldApplyAutoTitle("task-1")).toBe(true);
});

it("returns false when title_manually_set is true", () => {
const cachedTasks: Task[] = [
{
id: "task-1",
task_number: 1,
slug: "task-1",
title: "My custom title",
origin_product: "user_created",
description: "",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
title_manually_set: true,
},
];

vi.mocked(queryClient.getQueryData).mockReturnValue(cachedTasks);

expect(shouldApplyAutoTitle("task-1")).toBe(false);
});

it("returns true when task is not found in cache", () => {
const cachedTasks: Task[] = [
{
id: "task-2",
task_number: 2,
slug: "task-2",
title: "Other task",
origin_product: "user_created",
description: "",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
},
];

vi.mocked(queryClient.getQueryData).mockReturnValue(cachedTasks);

expect(shouldApplyAutoTitle("task-1")).toBe(true);
});

it("returns true when cache is empty", () => {
vi.mocked(queryClient.getQueryData).mockReturnValue(undefined);

expect(shouldApplyAutoTitle("task-1")).toBe(true);
});

it("detects race condition: user renames during async title generation", async () => {
const taskId = "task-1";
const manualTitle = "My custom title";

// Simulate: at start of generation, title_manually_set is false
const initialTasks: Task[] = [
{
id: taskId,
task_number: 1,
slug: "task-1",
title: "Initial title",
origin_product: "user_created",
description: "",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
title_manually_set: false,
},
];

// Simulate: after async generation, user has renamed (title_manually_set: true)
const renamedTasks: Task[] = [
{
...initialTasks[0],
title: manualTitle,
title_manually_set: true,
},
];

// First call (before async): allows generation
vi.mocked(queryClient.getQueryData).mockReturnValueOnce(initialTasks);
expect(shouldApplyAutoTitle(taskId)).toBe(true);

// Second call (after async): should block - user renamed during generation
vi.mocked(queryClient.getQueryData).mockReturnValueOnce(renamedTasks);
expect(shouldApplyAutoTitle(taskId)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Task } from "@shared/types";
import { queryClient } from "@utils/queryClient";

/**
* Check whether the auto-generated title should be applied for a given task.
* This must be called AFTER any async work (e.g., LLM title generation)
* to avoid race conditions where the user manually renames during generation.
*/
export function shouldApplyAutoTitle(taskId: string): boolean {
const cachedTasks = queryClient.getQueryData<Task[]>(["tasks", "list"]);
const cachedTask = cachedTasks?.find((t) => t.id === taskId);
if (cachedTask?.title_manually_set) {
return false;
}
return true;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getAuthenticatedClient } from "@features/auth/hooks/authClient";
import { shouldApplyAutoTitle } from "@features/sessions/hooks/shouldApplyAutoTitle";
import { getSessionService } from "@features/sessions/service/service";
import {
sessionStoreSetters,
Expand Down Expand Up @@ -65,9 +66,8 @@ export function useChatTitleGenerator(taskId: string): void {

const run = async () => {
try {
const cachedTasks = queryClient.getQueryData<Task[]>(["tasks", "list"]);
const cachedTask = cachedTasks?.find((t) => t.id === taskId);
if (cachedTask?.title_manually_set) {
// Early exit if title was manually set before generation started
if (!shouldApplyAutoTitle(taskId)) {
log.debug("Skipping auto-title, user renamed task", { taskId });
return;
}
Expand All @@ -76,6 +76,16 @@ export function useChatTitleGenerator(taskId: string): void {
if (result) {
const { title, summary } = result;
if (title) {
// Re-check after async generation to prevent race condition:
// user may have renamed while the LLM was generating the title
if (!shouldApplyAutoTitle(taskId)) {
log.debug(
"Skipping auto-title, user renamed task during generation",
{ taskId },
);
return;
}

const client = await getAuthenticatedClient();
if (client) {
await client.updateTask(taskId, { title });
Expand Down