From 29cf41a74bf79e31cdb3f473e0569eb954cfdd21 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 15 Apr 2026 13:34:46 -0700 Subject: [PATCH 1/3] Prevent auto-generated title from overwriting manual rename --- .../features/sessions/hooks/useChatTitleGenerator.ts | 8 ++++++-- apps/code/src/renderer/sagas/task/task-creation.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index d66695126..f83e60de6 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -65,8 +65,12 @@ export function useChatTitleGenerator(taskId: string): void { const run = async () => { try { - const cachedTasks = queryClient.getQueryData(["tasks", "list"]); - const cachedTask = cachedTasks?.find((t) => t.id === taskId); + const allTaskQueries = queryClient.getQueriesData({ + queryKey: ["tasks", "list"], + }); + const cachedTask = allTaskQueries + .flatMap(([, tasks]) => tasks ?? []) + .find((t) => t.id === taskId); if (cachedTask?.title_manually_set) { log.debug("Skipping auto-title, user renamed task", { taskId }); return; diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 68d351271..bfd1fa509 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -39,6 +39,17 @@ async function generateTaskTitle( if (!result?.title) return; const { title } = result; + const allTaskQueries = queryClient.getQueriesData({ + queryKey: ["tasks", "list"], + }); + const cachedTask = allTaskQueries + .flatMap(([, tasks]) => tasks ?? []) + .find((t) => t.id === taskId); + if (cachedTask?.title_manually_set) { + log.debug("Skipping auto-title, user renamed task", { taskId }); + return; + } + try { await posthogClient.updateTask(taskId, { title }); From a8904c2362edb9ec3fe734f69d4eb24e4eff1136 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 15 Apr 2026 14:39:14 -0700 Subject: [PATCH 2/3] Extract getCachedTask helper and add post-generation race guard --- .../sessions/hooks/useChatTitleGenerator.ts | 17 ++-- .../renderer/sagas/task/task-creation.test.ts | 78 ++++++++++++++++++- .../src/renderer/sagas/task/task-creation.ts | 10 +-- .../src/renderer/utils/queryClient.test.ts | 63 +++++++++++++++ apps/code/src/renderer/utils/queryClient.ts | 8 ++ 5 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 apps/code/src/renderer/utils/queryClient.test.ts diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index f83e60de6..4ff501495 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -7,7 +7,7 @@ import { import type { Task } from "@shared/types"; import { generateTitleAndSummary } from "@utils/generateTitle"; import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; +import { getCachedTask, queryClient } from "@utils/queryClient"; import { extractUserPromptsFromEvents } from "@utils/session"; import { useEffect, useRef } from "react"; @@ -65,13 +65,7 @@ export function useChatTitleGenerator(taskId: string): void { const run = async () => { try { - const allTaskQueries = queryClient.getQueriesData({ - queryKey: ["tasks", "list"], - }); - const cachedTask = allTaskQueries - .flatMap(([, tasks]) => tasks ?? []) - .find((t) => t.id === taskId); - if (cachedTask?.title_manually_set) { + if (getCachedTask(taskId)?.title_manually_set) { log.debug("Skipping auto-title, user renamed task", { taskId }); return; } @@ -80,6 +74,13 @@ export function useChatTitleGenerator(taskId: string): void { if (result) { const { title, summary } = result; if (title) { + if (getCachedTask(taskId)?.title_manually_set) { + log.debug( + "Skipping auto-title, user renamed task during generation", + { taskId }, + ); + return; + } const client = await getAuthenticatedClient(); if (client) { await client.updateTask(taskId, { title }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index ee2e36aff..9b808de7d 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -6,6 +6,7 @@ const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); +const mockGetCachedTask = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc", () => ({ trpcClient: { @@ -52,14 +53,16 @@ vi.mock("@features/sessions/service/service", () => ({ }), })); +const mockGenerateTitleAndSummary = vi.hoisted(() => vi.fn()); vi.mock("@renderer/utils/generateTitle", () => ({ - generateTitleAndSummary: vi.fn(async () => null), + generateTitleAndSummary: mockGenerateTitleAndSummary, })); vi.mock("@utils/queryClient", () => ({ queryClient: { setQueriesData: vi.fn(), }, + getCachedTask: mockGetCachedTask, })); vi.mock("@utils/logger", () => ({ @@ -179,6 +182,79 @@ describe("TaskCreationSaga", () => { ); }); + it("skips auto-title when task has been manually renamed", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const updateTaskMock = vi.fn(); + + mockGenerateTitleAndSummary.mockResolvedValue({ title: "Auto title" }); + mockGetCachedTask.mockReturnValue({ + id: "task-123", + title_manually_set: true, + }); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + runTaskInCloud: runTaskInCloudMock, + sendRunCommand: vi.fn(), + updateTask: updateTaskMock, + } as never, + }); + + await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + await vi.waitFor(() => { + expect(mockGenerateTitleAndSummary).toHaveBeenCalled(); + }); + + expect(updateTaskMock).not.toHaveBeenCalled(); + }); + + it("applies auto-title when task has not been manually renamed", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const runTaskInCloudMock = vi.fn().mockResolvedValue(startedTask); + const updateTaskMock = vi.fn().mockResolvedValue(undefined); + + mockGenerateTitleAndSummary.mockResolvedValue({ title: "Auto title" }); + mockGetCachedTask.mockReturnValue(undefined); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + runTaskInCloud: runTaskInCloudMock, + sendRunCommand: vi.fn(), + updateTask: updateTaskMock, + } as never, + }); + + await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + await vi.waitFor(() => { + expect(updateTaskMock).toHaveBeenCalledWith("task-123", { + title: "Auto title", + }); + }); + }); + it("sends initial cloud prompts with attachments as pending user messages", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index bfd1fa509..27d179467 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -24,7 +24,7 @@ import type { ExecutionMode, Task } from "@shared/types"; import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; import { getGhUserTokenOrThrow } from "@utils/github"; import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; +import { getCachedTask, queryClient } from "@utils/queryClient"; const log = logger.scope("task-creation-saga"); @@ -39,13 +39,7 @@ async function generateTaskTitle( if (!result?.title) return; const { title } = result; - const allTaskQueries = queryClient.getQueriesData({ - queryKey: ["tasks", "list"], - }); - const cachedTask = allTaskQueries - .flatMap(([, tasks]) => tasks ?? []) - .find((t) => t.id === taskId); - if (cachedTask?.title_manually_set) { + if (getCachedTask(taskId)?.title_manually_set) { log.debug("Skipping auto-title, user renamed task", { taskId }); return; } diff --git a/apps/code/src/renderer/utils/queryClient.test.ts b/apps/code/src/renderer/utils/queryClient.test.ts new file mode 100644 index 000000000..2551570dd --- /dev/null +++ b/apps/code/src/renderer/utils/queryClient.test.ts @@ -0,0 +1,63 @@ +import type { Task } from "@shared/types"; +import { beforeEach, describe, expect, it } from "vitest"; +import { getCachedTask, queryClient } from "./queryClient"; + +const createTask = (overrides: Partial = {}): Task => ({ + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Test task", + description: "", + origin_product: "user_created", + repository: null, + created_at: "2026-04-15T00:00:00Z", + updated_at: "2026-04-15T00:00:00Z", + ...overrides, +}); + +describe("getCachedTask", () => { + beforeEach(() => { + queryClient.clear(); + }); + + it("returns matching task from cache", () => { + const tasks = [createTask({ id: "task-1" }), createTask({ id: "task-2" })]; + queryClient.setQueryData(["tasks", "list"], tasks); + + expect(getCachedTask("task-1")?.id).toBe("task-1"); + expect(getCachedTask("task-2")?.id).toBe("task-2"); + }); + + it("returns undefined when task is not in cache", () => { + queryClient.setQueryData(["tasks", "list"], [createTask({ id: "task-1" })]); + + expect(getCachedTask("nonexistent")).toBeUndefined(); + }); + + it("returns undefined when no task queries exist", () => { + expect(getCachedTask("task-1")).toBeUndefined(); + }); + + it("searches across multiple task list queries", () => { + queryClient.setQueryData( + ["tasks", "list", { folder: "a" }], + [createTask({ id: "task-a" })], + ); + queryClient.setQueryData( + ["tasks", "list", { folder: "b" }], + [createTask({ id: "task-b" })], + ); + + expect(getCachedTask("task-a")?.id).toBe("task-a"); + expect(getCachedTask("task-b")?.id).toBe("task-b"); + }); + + it("preserves title_manually_set flag", () => { + queryClient.setQueryData( + ["tasks", "list"], + [createTask({ id: "task-1", title_manually_set: true })], + ); + + expect(getCachedTask("task-1")?.title_manually_set).toBe(true); + }); +}); diff --git a/apps/code/src/renderer/utils/queryClient.ts b/apps/code/src/renderer/utils/queryClient.ts index 96916e9f8..743432015 100644 --- a/apps/code/src/renderer/utils/queryClient.ts +++ b/apps/code/src/renderer/utils/queryClient.ts @@ -1,3 +1,4 @@ +import type { Task } from "@shared/types"; import { QueryClient } from "@tanstack/react-query"; export const queryClient = new QueryClient({ @@ -8,3 +9,10 @@ export const queryClient = new QueryClient({ }, }, }); + +export function getCachedTask(taskId: string): Task | undefined { + return queryClient + .getQueriesData({ queryKey: ["tasks", "list"] }) + .flatMap(([, tasks]) => tasks ?? []) + .find((t) => t.id === taskId); +} From 2b53d3c98e290234557e84fe8ddea29195086c1e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Wed, 15 Apr 2026 14:41:44 -0700 Subject: [PATCH 3/3] Remove redundant pre-LLM title_manually_set check --- .../features/sessions/hooks/useChatTitleGenerator.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index 4ff501495..ee3749426 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -65,20 +65,12 @@ export function useChatTitleGenerator(taskId: string): void { const run = async () => { try { - if (getCachedTask(taskId)?.title_manually_set) { - log.debug("Skipping auto-title, user renamed task", { taskId }); - return; - } - const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; if (title) { if (getCachedTask(taskId)?.title_manually_set) { - log.debug( - "Skipping auto-title, user renamed task during generation", - { taskId }, - ); + log.debug("Skipping auto-title, user renamed task", { taskId }); return; } const client = await getAuthenticatedClient();