From 33252b61377ae7451ce053ae2720ef541c74026d Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 2 May 2026 16:11:11 -0700 Subject: [PATCH 01/11] Enrich attachment-only task descriptions for title generation --- .../sessions/hooks/useChatTitleGenerator.ts | 10 +- .../renderer/sagas/task/task-creation.test.ts | 6 ++ .../src/renderer/sagas/task/task-creation.ts | 13 ++- apps/code/src/renderer/utils/generateTitle.ts | 97 +++++++++++++++++++ apps/code/vite.shared.mts | 7 ++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index ee3749426..a35c65536 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -5,7 +5,10 @@ import { useSessionStore, } from "@features/sessions/stores/sessionStore"; import type { Task } from "@shared/types"; -import { generateTitleAndSummary } from "@utils/generateTitle"; +import { + enrichDescriptionWithFileContent, + generateTitleAndSummary, +} from "@utils/generateTitle"; import { logger } from "@utils/logger"; import { getCachedTask, queryClient } from "@utils/queryClient"; import { extractUserPromptsFromEvents } from "@utils/session"; @@ -61,10 +64,13 @@ export function useChatTitleGenerator(taskId: string): void { const promptsForTitle = promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); - const content = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n"); + const rawContent = promptsForTitle + .map((p, i) => `${i + 1}. ${p}`) + .join("\n"); const run = async () => { try { + const content = await enrichDescriptionWithFileContent(rawContent); const result = await generateTitleAndSummary(content); if (result) { const { title, summary } = result; 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 ae214465e..620290250 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -54,8 +54,14 @@ vi.mock("@features/sessions/service/service", () => ({ })); const mockGenerateTitleAndSummary = vi.hoisted(() => vi.fn()); +const mockEnrichDescriptionWithFileContent = vi.hoisted(() => + vi + .fn() + .mockImplementation((description: string) => Promise.resolve(description)), +); vi.mock("@renderer/utils/generateTitle", () => ({ generateTitleAndSummary: mockGenerateTitleAndSummary, + enrichDescriptionWithFileContent: mockEnrichDescriptionWithFileContent, })); vi.mock("@utils/queryClient", () => ({ diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 523066bf0..d8ae23f4e 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -18,7 +18,10 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; -import { generateTitleAndSummary } from "@renderer/utils/generateTitle"; +import { + enrichDescriptionWithFileContent, + generateTitleAndSummary, +} from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import { type ExecutionMode, @@ -34,11 +37,16 @@ const log = logger.scope("task-creation-saga"); async function generateTaskTitle( taskId: string, description: string, + filePaths: string[], posthogClient: PostHogAPIClient, ): Promise { if (!description.trim()) return; - const result = await generateTitleAndSummary(description); + const enriched = await enrichDescriptionWithFileContent( + description, + filePaths, + ); + const result = await generateTitleAndSummary(enriched); if (!result?.title) return; const { title } = result; @@ -137,6 +145,7 @@ export class TaskCreationSaga extends Saga< generateTaskTitle( task.id, input.taskDescription ?? input.content ?? "", + input.filePaths ?? [], this.deps.posthogClient, ); } diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index cca4df5d0..666a4d527 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -4,6 +4,103 @@ import { logger } from "@utils/logger"; const log = logger.scope("title-generator"); +const FILE_TAG_REGEX = //g; +const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; +const PASTED_TEXT_SNIPPET_LIMIT = 500; + +const BINARY_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + "svg", + "mp3", + "mp4", + "wav", + "avi", + "mov", + "mkv", + "pdf", + "zip", + "tar", + "gz", + "rar", + "7z", + "exe", + "dll", + "so", + "dylib", + "wasm", + "ttf", + "otf", + "woff", + "woff2", + "eot", +]); + +function getExtension(filePath: string): string { + const dot = filePath.lastIndexOf("."); + return dot >= 0 ? filePath.slice(dot + 1).toLowerCase() : ""; +} + +function getFileName(filePath: string): string { + const slash = filePath.lastIndexOf("/"); + return slash >= 0 ? filePath.slice(slash + 1) : filePath; +} + +/** + * When the description only contains file references (e.g. pasted text or + * image attachments saved to temp files), read text file contents so the LLM + * has something meaningful to derive a title from. For binary files (images, + * PDFs, etc.) the filename is used as a hint instead. + */ +export async function enrichDescriptionWithFileContent( + description: string, + filePaths: string[] = [], +): Promise { + const stripped = description + .replace(FILE_TAG_REGEX, "") + .replace(ATTACHED_FILES_REGEX, "") + .replace(/^\d+\.\s*$/gm, "") + .trim(); + + if (stripped.length > 0) return description; + + const paths = + filePaths.length > 0 + ? filePaths + : [...description.matchAll(FILE_TAG_REGEX)].map((m) => m[1]); + + if (paths.length === 0) return description; + + const parts: string[] = []; + for (const filePath of paths) { + if (BINARY_EXTENSIONS.has(getExtension(filePath))) { + parts.push(`[Attached: ${getFileName(filePath)}]`); + continue; + } + try { + const content = await trpcClient.fs.readAbsoluteFile.query({ filePath }); + if (content) { + parts.push( + content.length > PASTED_TEXT_SNIPPET_LIMIT + ? content.slice(0, PASTED_TEXT_SNIPPET_LIMIT) + : content, + ); + } else { + parts.push(`[Attached: ${getFileName(filePath)}]`); + } + } catch { + parts.push(`[Attached: ${getFileName(filePath)}]`); + } + } + + return parts.length > 0 ? parts.join("\n\n") : description; +} + const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format: TITLE: diff --git a/apps/code/vite.shared.mts b/apps/code/vite.shared.mts index c44497ae6..a0eda0f5c 100644 --- a/apps/code/vite.shared.mts +++ b/apps/code/vite.shared.mts @@ -53,6 +53,13 @@ const workspaceAliases: Alias[] = [ find: "@posthog/shared", replacement: path.resolve(__dirname, "../../packages/shared/src/index.ts"), }, + { + find: "@posthog/enricher", + replacement: path.resolve( + __dirname, + "../../packages/enricher/src/index.ts", + ), + }, ]; export const mainAliases: Alias[] = [ From b9ed5230c2324122b39516036bcdfeb9ee06ed98 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 00:28:13 -0700 Subject: [PATCH 02/11] Add tests for enrichDescriptionWithFileContent and clean up --- .../src/renderer/utils/generateTitle.test.ts | 125 ++++++++++++++++++ apps/code/src/renderer/utils/generateTitle.ts | 6 - 2 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 apps/code/src/renderer/utils/generateTitle.test.ts diff --git a/apps/code/src/renderer/utils/generateTitle.test.ts b/apps/code/src/renderer/utils/generateTitle.test.ts new file mode 100644 index 000000000..ebb74c4a9 --- /dev/null +++ b/apps/code/src/renderer/utils/generateTitle.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + fs: { + readAbsoluteFile: { query: mockReadAbsoluteFile }, + }, + }, +})); + +vi.mock("@features/auth/hooks/authQueries", () => ({ + fetchAuthState: vi.fn(), +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +import { enrichDescriptionWithFileContent } from "./generateTitle"; + +describe("enrichDescriptionWithFileContent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns description unchanged when it contains real text", async () => { + const description = "Fix the login bug"; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + }); + + it("reads text file content when description only has file tags", async () => { + mockReadAbsoluteFile.mockResolvedValue("const x = 1;\nexport default x;"); + const description = '1. <file path="/tmp/code.ts" />'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("const x = 1;\nexport default x;"); + expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ + filePath: "/tmp/code.ts", + }); + }); + + it("handles multiple file tags", async () => { + mockReadAbsoluteFile + .mockResolvedValueOnce("file one") + .mockResolvedValueOnce("file two"); + + const description = + '1. <file path="/tmp/a.ts" />\n2. <file path="/tmp/b.ts" />'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("file one\n\nfile two"); + }); + + it("uses filePaths argument over parsed tags", async () => { + mockReadAbsoluteFile.mockResolvedValue("from explicit path"); + const description = '1. <file path="/tmp/ignored.ts" />'; + const result = await enrichDescriptionWithFileContent(description, [ + "/tmp/explicit.ts", + ]); + expect(result).toBe("from explicit path"); + expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ + filePath: "/tmp/explicit.ts", + }); + }); + + it("returns filename hint for binary files", async () => { + const description = '1. <file path="/tmp/screenshot.png" />'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("[Attached: screenshot.png]"); + expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + }); + + it("falls back to filename hint when read fails", async () => { + mockReadAbsoluteFile.mockRejectedValue(new Error("ENOENT")); + const description = '1. <file path="/tmp/missing.ts" />'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("[Attached: missing.ts]"); + }); + + it("falls back to filename hint when read returns null", async () => { + mockReadAbsoluteFile.mockResolvedValue(null); + const description = '1. <file path="/tmp/empty.ts" />'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("[Attached: empty.ts]"); + }); + + it("truncates content longer than 500 chars", async () => { + const longContent = "x".repeat(600); + mockReadAbsoluteFile.mockResolvedValue(longContent); + const description = '1. <file path="/tmp/big.ts" />'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("x".repeat(500)); + }); + + it("strips 'Attached files:' lines when checking for real text", async () => { + mockReadAbsoluteFile.mockResolvedValue("content"); + const description = '1. <file path="/tmp/a.ts" />\nAttached files: a.ts'; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe("content"); + }); + + it("returns original description when no file paths found", async () => { + const description = "1. \n2. "; + const result = await enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + }); + + it("mixes binary and text files", async () => { + mockReadAbsoluteFile.mockResolvedValue("text content"); + const result = await enrichDescriptionWithFileContent("", [ + "/tmp/image.jpg", + "/tmp/code.ts", + ]); + expect(result).toBe("[Attached: image.jpg]\n\ntext content"); + }); +}); diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index 666a4d527..82cd836c6 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -51,12 +51,6 @@ function getFileName(filePath: string): string { return slash >= 0 ? filePath.slice(slash + 1) : filePath; } -/** - * When the description only contains file references (e.g. pasted text or - * image attachments saved to temp files), read text file contents so the LLM - * has something meaningful to derive a title from. For binary files (images, - * PDFs, etc.) the filename is used as a hint instead. - */ export async function enrichDescriptionWithFileContent( description: string, filePaths: string[] = [], From 2ad8e826e65b44fa154431e2d9de621a663802e9 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 15:56:53 -0700 Subject: [PATCH 03/11] Collapse fallback tests into parameterized it.each --- .../src/renderer/utils/generateTitle.test.ts | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/apps/code/src/renderer/utils/generateTitle.test.ts b/apps/code/src/renderer/utils/generateTitle.test.ts index ebb74c4a9..042ecdc7f 100644 --- a/apps/code/src/renderer/utils/generateTitle.test.ts +++ b/apps/code/src/renderer/utils/generateTitle.test.ts @@ -72,26 +72,31 @@ describe("enrichDescriptionWithFileContent", () => { }); }); - it("returns filename hint for binary files", async () => { - const description = '1. <file path="/tmp/screenshot.png" />'; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("[Attached: screenshot.png]"); - expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); - }); - - it("falls back to filename hint when read fails", async () => { - mockReadAbsoluteFile.mockRejectedValue(new Error("ENOENT")); - const description = '1. <file path="/tmp/missing.ts" />'; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("[Attached: missing.ts]"); - }); - - it("falls back to filename hint when read returns null", async () => { - mockReadAbsoluteFile.mockResolvedValue(null); - const description = '1. <file path="/tmp/empty.ts" />'; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("[Attached: empty.ts]"); - }); + it.each([ + { + label: "binary file", + description: '1. <file path="/tmp/screenshot.png" />', + setup: () => {}, + }, + { + label: "read throws", + description: '1. <file path="/tmp/missing.ts" />', + setup: () => mockReadAbsoluteFile.mockRejectedValue(new Error("ENOENT")), + }, + { + label: "read returns null", + description: '1. <file path="/tmp/empty.ts" />', + setup: () => mockReadAbsoluteFile.mockResolvedValue(null), + }, + ])( + "falls back to filename hint -- $label", + async ({ description, setup }) => { + setup(); + const result = await enrichDescriptionWithFileContent(description); + const filename = description.match(/path="[^"]*\/([^"]+)"/)?.[1]; + expect(result).toBe(`[Attached: ${filename}]`); + }, + ); it("truncates content longer than 500 chars", async () => { const longContent = "x".repeat(600); From da60c06197f1776923a58241086522028b3cb8c9 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 17:15:48 -0700 Subject: [PATCH 04/11] Fire onEmptyChange when attachments change --- .../features/message-editor/tiptap/useTiptapEditor.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 731e79233..47f8aeb3a 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -545,6 +545,15 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } }, [attachments, editor]); + // Notify parent when emptiness changes due to attachment add/remove + useEffect(() => { + const newIsEmpty = !editor || (isEmptyState && attachments.length === 0); + if (newIsEmpty !== prevIsEmptyRef.current) { + prevIsEmptyRef.current = newIsEmpty; + callbackRefs.current.onEmptyChange?.(newIsEmpty); + } + }, [attachments, editor, isEmptyState]); + // Restore attachments from draft on mount useEffect(() => { setAttachments(draft.restoredAttachments); From 136db9ed601121853b8477575f7a7eeca0f4b297 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 17:20:17 -0700 Subject: [PATCH 05/11] Check editor text directly in attachment empty effect --- .../features/message-editor/tiptap/useTiptapEditor.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 47f8aeb3a..f25613416 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -545,14 +545,19 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { } }, [attachments, editor]); - // Notify parent when emptiness changes due to attachment add/remove + // Notify parent when emptiness changes due to attachment add/remove. + // Only reacts to attachment changes; text changes are handled by onUpdate. + // We read editor text directly because isEmptyState may include stale + // attachment info (isContentEmpty counts attachments in its input). useEffect(() => { - const newIsEmpty = !editor || (isEmptyState && attachments.length === 0); + if (!editor) return; + const hasText = !!editor.getText().trim(); + const newIsEmpty = !hasText && attachments.length === 0; if (newIsEmpty !== prevIsEmptyRef.current) { prevIsEmptyRef.current = newIsEmpty; callbackRefs.current.onEmptyChange?.(newIsEmpty); } - }, [attachments, editor, isEmptyState]); + }, [attachments, editor]); // Restore attachments from draft on mount useEffect(() => { From 143aefa7c16a1383394600f1c6140166f1fdb276 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 17:20:56 -0700 Subject: [PATCH 06/11] Pass description as initial title on task creation --- apps/code/src/renderer/sagas/task/task-creation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index d8ae23f4e..ecfe7a539 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -454,8 +454,10 @@ export class TaskCreationSaga extends Saga< return this.step({ name: "task_creation", execute: async () => { + const description = input.taskDescription ?? input.content ?? ""; const result = await this.deps.posthogClient.createTask({ - description: input.taskDescription ?? input.content ?? "", + title: description, + description, repository: repository ?? undefined, github_integration: input.workspaceMode === "cloud" && From 62c2509e3a761d6495877f101577432a17ea6d83 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 17:29:48 -0700 Subject: [PATCH 07/11] Generate title on first prompt via chat title hook --- .../sessions/hooks/useChatTitleGenerator.ts | 3 +- .../src/renderer/sagas/task/task-creation.ts | 56 ++----------------- 2 files changed, 5 insertions(+), 54 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index a35c65536..e1502674c 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -35,8 +35,7 @@ export function useChatTitleGenerator(taskId: string): void { if (isGenerating.current) return; if (lastGeneratedAtCount.current === null) { - lastGeneratedAtCount.current = promptCount; - return; + lastGeneratedAtCount.current = 0; } const shouldGenerate = diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index ecfe7a539..795107273 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -18,10 +18,6 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; -import { - enrichDescriptionWithFileContent, - generateTitleAndSummary, -} from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import { type ExecutionMode, @@ -30,46 +26,9 @@ import { } from "@shared/types"; import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; import { logger } from "@utils/logger"; -import { getCachedTask, queryClient } from "@utils/queryClient"; const log = logger.scope("task-creation-saga"); -async function generateTaskTitle( - taskId: string, - description: string, - filePaths: string[], - posthogClient: PostHogAPIClient, -): Promise<void> { - if (!description.trim()) return; - - const enriched = await enrichDescriptionWithFileContent( - description, - filePaths, - ); - const result = await generateTitleAndSummary(enriched); - if (!result?.title) return; - const { title } = result; - - if (getCachedTask(taskId)?.title_manually_set) { - log.debug("Skipping auto-title, user renamed task", { taskId }); - return; - } - - try { - await posthogClient.updateTask(taskId, { title }); - - // Update all cached task lists so the sidebar reflects the new title instantly - queryClient.setQueriesData<Task[]>({ queryKey: ["tasks", "list"] }, (old) => - old?.map((task) => (task.id === taskId ? { ...task, title } : task)), - ); - - // Sync to session store so notifications use the updated title - getSessionService().updateSessionTaskTitle(taskId, title); - } catch (error) { - log.error("Failed to save task title", { taskId, error }); - } -} - // Adapt our logger to SagaLogger interface const sagaLogger: SagaLogger = { info: (message, data) => log.info(message, data), @@ -140,16 +99,6 @@ export class TaskCreationSaga extends Saga< ) : await this.createTask(input); - // Fire-and-forget: generate a proper LLM title for new tasks - if (!taskId) { - generateTaskTitle( - task.id, - input.taskDescription ?? input.content ?? "", - input.filePaths ?? [], - this.deps.posthogClient, - ); - } - const repoKey = getTaskRepository(task); const repoPath = input.repoPath ?? @@ -455,8 +404,11 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; + const plainText = description + .replace(/<file\s+path="[^"]*"\s*\/>/g, "") + .trim(); const result = await this.deps.posthogClient.createTask({ - title: description, + title: plainText || "Reading attachment\u2026", description, repository: repository ?? undefined, github_integration: From b2f33d2d19933ef314184fb2f588dcb8a7d6acd4 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 17:41:29 -0700 Subject: [PATCH 08/11] Cap title to 255 chars and skip manual-set check on first generation --- .../features/sessions/hooks/useChatTitleGenerator.ts | 6 +++++- apps/code/src/renderer/sagas/task/task-creation.ts | 2 +- apps/code/src/renderer/utils/generateTitle.ts | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts index e1502674c..3cab24164 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts @@ -74,7 +74,11 @@ export function useChatTitleGenerator(taskId: string): void { if (result) { const { title, summary } = result; if (title) { - if (getCachedTask(taskId)?.title_manually_set) { + const isFirstGeneration = lastGeneratedAtCount.current === 0; + if ( + !isFirstGeneration && + getCachedTask(taskId)?.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 795107273..7866308fd 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -408,7 +408,7 @@ export class TaskCreationSaga extends Saga< .replace(/<file\s+path="[^"]*"\s*\/>/g, "") .trim(); const result = await this.deps.posthogClient.createTask({ - title: plainText || "Reading attachment\u2026", + title: (plainText || "Reading attachment\u2026").slice(0, 255), description, repository: repository ?? undefined, github_integration: diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index 82cd836c6..cf2056c5c 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -169,7 +169,11 @@ export async function generateTitleAndSummary( const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m); const summaryMatch = text.match(/SUMMARY:\s*([\s\S]+)$/m); - const title = titleMatch?.[1]?.trim().replace(/^["']|["']$/g, "") ?? ""; + const title = + titleMatch?.[1] + ?.trim() + .replace(/^["']|["']$/g, "") + .slice(0, 255) ?? ""; const summary = summaryMatch?.[1]?.trim() ?? ""; if (!title && !summary) return null; From b02624b8210b7fddda429810e66873a273e3f39a Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 17:49:12 -0700 Subject: [PATCH 09/11] Dedupe regex, parallelize reads and add missing tests --- .../renderer/sagas/task/task-creation.test.ts | 195 +++++++++--------- .../src/renderer/sagas/task/task-creation.ts | 5 +- .../src/renderer/utils/generateTitle.test.ts | 52 ++++- apps/code/src/renderer/utils/generateTitle.ts | 39 ++-- 4 files changed, 172 insertions(+), 119 deletions(-) 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 620290250..7236d2c0c 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -6,8 +6,6 @@ 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: { workspace: { @@ -53,22 +51,8 @@ vi.mock("@features/sessions/service/service", () => ({ }), })); -const mockGenerateTitleAndSummary = vi.hoisted(() => vi.fn()); -const mockEnrichDescriptionWithFileContent = vi.hoisted(() => - vi - .fn() - .mockImplementation((description: string) => Promise.resolve(description)), -); vi.mock("@renderer/utils/generateTitle", () => ({ - generateTitleAndSummary: mockGenerateTitleAndSummary, - enrichDescriptionWithFileContent: mockEnrichDescriptionWithFileContent, -})); - -vi.mock("@utils/queryClient", () => ({ - queryClient: { - setQueriesData: vi.fn(), - }, - getCachedTask: mockGetCachedTask, + FILE_TAG_REGEX: /<file\s+path="([^"]+)"\s*\/>/g, })); vi.mock("@utils/logger", () => ({ @@ -191,83 +175,6 @@ 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 createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = 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(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - 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 createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = 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(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - 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("uploads initial cloud attachments before starting the run", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); @@ -420,6 +327,106 @@ describe("TaskCreationSaga", () => { ); }); + it("sets title from plain text when description has text", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + }); + + await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Ship the fix", + description: "Ship the fix", + }), + ); + }); + + it("sets fallback title when description is attachment-only", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + }); + + await saga.run({ + taskDescription: '<file path="/tmp/code.ts" />', + content: '<file path="/tmp/code.ts" />', + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Reading attachment\u2026", + description: '<file path="/tmp/code.ts" />', + }), + ); + }); + + it("truncates title to 255 chars", async () => { + const longText = "x".repeat(300); + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + }); + + await saga.run({ + content: longText, + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + const calledTitle = createTaskMock.mock.calls[0][0].title; + expect(calledTitle).toHaveLength(255); + }); + it("uses user authorship for repo-less cloud tasks with a selected user GitHub integration", async () => { const createdTask = createTask({ repository: null, diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 7866308fd..fc59024a7 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -18,6 +18,7 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; +import { FILE_TAG_REGEX } from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import { type ExecutionMode, @@ -404,9 +405,7 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; - const plainText = description - .replace(/<file\s+path="[^"]*"\s*\/>/g, "") - .trim(); + const plainText = description.replace(FILE_TAG_REGEX, "").trim(); const result = await this.deps.posthogClient.createTask({ title: (plainText || "Reading attachment\u2026").slice(0, 255), description, diff --git a/apps/code/src/renderer/utils/generateTitle.test.ts b/apps/code/src/renderer/utils/generateTitle.test.ts index 042ecdc7f..6ecd85605 100644 --- a/apps/code/src/renderer/utils/generateTitle.test.ts +++ b/apps/code/src/renderer/utils/generateTitle.test.ts @@ -1,17 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); +const mockLlmPrompt = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc", () => ({ trpcClient: { fs: { readAbsoluteFile: { query: mockReadAbsoluteFile }, }, + llmGateway: { + prompt: { mutate: mockLlmPrompt }, + }, }, })); +const mockFetchAuthState = vi.hoisted(() => vi.fn()); vi.mock("@features/auth/hooks/authQueries", () => ({ - fetchAuthState: vi.fn(), + fetchAuthState: mockFetchAuthState, })); vi.mock("@utils/logger", () => ({ @@ -25,7 +30,10 @@ vi.mock("@utils/logger", () => ({ }, })); -import { enrichDescriptionWithFileContent } from "./generateTitle"; +import { + enrichDescriptionWithFileContent, + generateTitleAndSummary, +} from "./generateTitle"; describe("enrichDescriptionWithFileContent", () => { beforeEach(() => { @@ -128,3 +136,43 @@ describe("enrichDescriptionWithFileContent", () => { expect(result).toBe("[Attached: image.jpg]\n\ntext content"); }); }); + +describe("generateTitleAndSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchAuthState.mockResolvedValue({ status: "authenticated" }); + }); + + it("truncates title to 255 chars", async () => { + const longTitle = "A".repeat(300); + mockLlmPrompt.mockResolvedValue({ + content: `TITLE: ${longTitle}\nSUMMARY: A summary`, + }); + + const result = await generateTitleAndSummary("some content"); + expect(result?.title).toHaveLength(255); + expect(result?.summary).toBe("A summary"); + }); + + it("returns null when not authenticated", async () => { + mockFetchAuthState.mockResolvedValue({ status: "unauthenticated" }); + const result = await generateTitleAndSummary("some content"); + expect(result).toBeNull(); + expect(mockLlmPrompt).not.toHaveBeenCalled(); + }); + + it("strips surrounding quotes from title", async () => { + mockLlmPrompt.mockResolvedValue({ + content: 'TITLE: "Fix login bug"\nSUMMARY: Fixing auth', + }); + + const result = await generateTitleAndSummary("fix the login bug"); + expect(result?.title).toBe("Fix login bug"); + }); + + it("returns null on error", async () => { + mockLlmPrompt.mockRejectedValue(new Error("network error")); + const result = await generateTitleAndSummary("some content"); + expect(result).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index cf2056c5c..9c0db0108 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -4,7 +4,7 @@ import { logger } from "@utils/logger"; const log = logger.scope("title-generator"); -const FILE_TAG_REGEX = /<file\s+path="([^"]+)"\s*\/>/g; +export const FILE_TAG_REGEX = /<file\s+path="([^"]+)"\s*\/>/g; const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; const PASTED_TEXT_SNIPPET_LIMIT = 500; @@ -70,27 +70,26 @@ export async function enrichDescriptionWithFileContent( if (paths.length === 0) return description; - const parts: string[] = []; - for (const filePath of paths) { - if (BINARY_EXTENSIONS.has(getExtension(filePath))) { - parts.push(`[Attached: ${getFileName(filePath)}]`); - continue; - } - try { - const content = await trpcClient.fs.readAbsoluteFile.query({ filePath }); - if (content) { - parts.push( - content.length > PASTED_TEXT_SNIPPET_LIMIT + const parts = await Promise.all( + paths.map(async (filePath) => { + if (BINARY_EXTENSIONS.has(getExtension(filePath))) { + return `[Attached: ${getFileName(filePath)}]`; + } + try { + const content = await trpcClient.fs.readAbsoluteFile.query({ + filePath, + }); + if (content) { + return content.length > PASTED_TEXT_SNIPPET_LIMIT ? content.slice(0, PASTED_TEXT_SNIPPET_LIMIT) - : content, - ); - } else { - parts.push(`[Attached: ${getFileName(filePath)}]`); + : content; + } + return `[Attached: ${getFileName(filePath)}]`; + } catch { + return `[Attached: ${getFileName(filePath)}]`; } - } catch { - parts.push(`[Attached: ${getFileName(filePath)}]`); - } - } + }), + ); return parts.length > 0 ? parts.join("\n\n") : description; } From 2fa9a243d9e2e3ba7991b886f08d2b3bd6dd3b6c Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 18:01:51 -0700 Subject: [PATCH 10/11] Fix shared regex lastIndex bug and add title generator tests --- .../hooks/useChatTitleGenerator.test.ts | 173 ++++++++++++++++++ .../renderer/sagas/task/task-creation.test.ts | 6 +- .../src/renderer/sagas/task/task-creation.ts | 4 +- apps/code/src/renderer/utils/generateTitle.ts | 6 +- 4 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts new file mode 100644 index 000000000..365911f3c --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -0,0 +1,173 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockEnrichDescription = vi.hoisted(() => + vi.fn().mockImplementation((desc: string) => Promise.resolve(desc)), +); +const mockGenerateTitle = vi.hoisted(() => vi.fn()); +const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); +const mockGetCachedTask = vi.hoisted(() => vi.fn()); +const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockSetQueriesData = vi.hoisted(() => vi.fn()); +const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); +const mockPrompts = vi.hoisted(() => ({ value: [] as string[] })); +const mockSessionStoreSetters = vi.hoisted(() => ({ + updateSession: vi.fn(), +})); + +vi.mock("@utils/generateTitle", () => ({ + enrichDescriptionWithFileContent: mockEnrichDescription, + generateTitleAndSummary: mockGenerateTitle, +})); + +vi.mock("@features/auth/hooks/authClient", () => ({ + getAuthenticatedClient: mockGetAuthenticatedClient, +})); + +vi.mock("@utils/queryClient", () => ({ + getCachedTask: mockGetCachedTask, + queryClient: { setQueriesData: mockSetQueriesData }, +})); + +vi.mock("@utils/session", () => ({ + extractUserPromptsFromEvents: () => mockPrompts.value, +})); + +vi.mock("@features/sessions/service/service", () => ({ + getSessionService: () => ({ + updateSessionTaskTitle: mockUpdateSessionTaskTitle, + }), +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, +})); + +vi.mock("@features/sessions/stores/sessionStore", () => { + const fn: any = (selector: any) => + selector({ + taskIdIndex: { "task-1": "run-1" }, + sessions: { "run-1": { events: mockPrompts.value } }, + }); + fn.getState = () => ({ + taskIdIndex: { "task-1": "run-1" }, + sessions: { "run-1": { events: mockPrompts.value } }, + }); + return { + useSessionStore: fn, + sessionStoreSetters: mockSessionStoreSetters, + }; +}); + +import { useChatTitleGenerator } from "./useChatTitleGenerator"; + +const TASK_ID = "task-1"; + +describe("useChatTitleGenerator", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPrompts.value = []; + mockEnrichDescription.mockImplementation((desc: string) => + Promise.resolve(desc), + ); + mockGetAuthenticatedClient.mockResolvedValue({ + updateTask: mockUpdateTask, + }); + mockGetCachedTask.mockReturnValue(undefined); + }); + + it("does not generate when promptCount is 0", () => { + renderHook(() => useChatTitleGenerator(TASK_ID)); + expect(mockGenerateTitle).not.toHaveBeenCalled(); + }); + + it("generates title on first prompt", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Fix login bug", + summary: "User is fixing a login issue", + }); + mockPrompts.value = ["Fix the login bug"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Fix login bug", + }); + }); + }); + + it("allows first generation even when title_manually_set", async () => { + mockGetCachedTask.mockReturnValue({ + id: TASK_ID, + title_manually_set: true, + }); + mockGenerateTitle.mockResolvedValue({ + title: "Auto title", + summary: "", + }); + mockPrompts.value = ["some prompt"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Auto title", + }); + }); + }); + + it("calls enrichDescriptionWithFileContent before generating", async () => { + mockEnrichDescription.mockResolvedValue("enriched content"); + mockGenerateTitle.mockResolvedValue({ + title: "Enriched title", + summary: "", + }); + mockPrompts.value = ['<file path="/tmp/code.ts" />']; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockEnrichDescription).toHaveBeenCalledWith( + '1. <file path="/tmp/code.ts" />', + ); + expect(mockGenerateTitle).toHaveBeenCalledWith("enriched content"); + }); + }); + + it("updates conversation summary when returned", async () => { + mockGenerateTitle.mockResolvedValue({ + title: "Some title", + summary: "User wants to fix auth", + }); + mockPrompts.value = ["fix auth"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "run-1", + { conversationSummary: "User wants to fix auth" }, + ); + }); + }); + + it("does not update when generateTitleAndSummary returns null", async () => { + mockGenerateTitle.mockResolvedValue(null); + mockPrompts.value = ["some prompt"]; + + renderHook(() => useChatTitleGenerator(TASK_ID)); + + await waitFor(() => { + expect(mockGenerateTitle).toHaveBeenCalled(); + }); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); +}); 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 7236d2c0c..7dceb4883 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockWorkspaceCreate = vi.hoisted(() => vi.fn()); const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); -const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); vi.mock("@renderer/trpc", () => ({ trpcClient: { @@ -18,7 +17,7 @@ vi.mock("@renderer/trpc", () => ({ vi.mock("@renderer/trpc/client", () => ({ trpcClient: { fs: { - readAbsoluteFile: { query: mockReadAbsoluteFile }, + readAbsoluteFile: { query: vi.fn() }, readFileAsBase64: { query: mockReadFileAsBase64 }, }, }, @@ -52,7 +51,7 @@ vi.mock("@features/sessions/service/service", () => ({ })); vi.mock("@renderer/utils/generateTitle", () => ({ - FILE_TAG_REGEX: /<file\s+path="([^"]+)"\s*\/>/g, + createFileTagRegex: () => /<file\s+path="([^"]+)"\s*\/>/g, })); vi.mock("@utils/logger", () => ({ @@ -104,7 +103,6 @@ describe("TaskCreationSaga", () => { mockWorkspaceCreate.mockResolvedValue(undefined); mockWorkspaceDelete.mockResolvedValue(undefined); mockGetTaskDirectory.mockResolvedValue(null); - mockReadAbsoluteFile.mockResolvedValue(null); mockReadFileAsBase64.mockResolvedValue(null); }); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index fc59024a7..1e242a515 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -18,7 +18,7 @@ import type { import { Saga, type SagaLogger } from "@posthog/shared"; import type { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc"; -import { FILE_TAG_REGEX } from "@renderer/utils/generateTitle"; +import { createFileTagRegex } from "@renderer/utils/generateTitle"; import { getTaskRepository } from "@renderer/utils/repository"; import { type ExecutionMode, @@ -405,7 +405,7 @@ export class TaskCreationSaga extends Saga< name: "task_creation", execute: async () => { const description = input.taskDescription ?? input.content ?? ""; - const plainText = description.replace(FILE_TAG_REGEX, "").trim(); + const plainText = description.replace(createFileTagRegex(), "").trim(); const result = await this.deps.posthogClient.createTask({ title: (plainText || "Reading attachment\u2026").slice(0, 255), description, diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts index 9c0db0108..3a3b3911a 100644 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ b/apps/code/src/renderer/utils/generateTitle.ts @@ -4,7 +4,7 @@ import { logger } from "@utils/logger"; const log = logger.scope("title-generator"); -export const FILE_TAG_REGEX = /<file\s+path="([^"]+)"\s*\/>/g; +export const createFileTagRegex = () => /<file\s+path="([^"]+)"\s*\/>/g; const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; const PASTED_TEXT_SNIPPET_LIMIT = 500; @@ -56,7 +56,7 @@ export async function enrichDescriptionWithFileContent( filePaths: string[] = [], ): Promise<string> { const stripped = description - .replace(FILE_TAG_REGEX, "") + .replace(createFileTagRegex(), "") .replace(ATTACHED_FILES_REGEX, "") .replace(/^\d+\.\s*$/gm, "") .trim(); @@ -66,7 +66,7 @@ export async function enrichDescriptionWithFileContent( const paths = filePaths.length > 0 ? filePaths - : [...description.matchAll(FILE_TAG_REGEX)].map((m) => m[1]); + : [...description.matchAll(createFileTagRegex())].map((m) => m[1]); if (paths.length === 0) return description; From c6ab5bd88a818559547d7eb3129c2d46ef6cb074 Mon Sep 17 00:00:00 2001 From: Charles Vien <charles.v@posthog.com> Date: Sun, 3 May 2026 18:04:36 -0700 Subject: [PATCH 11/11] Fix stale getSessionService mock in task creation tests --- apps/code/src/renderer/sagas/task/task-creation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 7dceb4883..a024e0616 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -46,7 +46,8 @@ vi.mock("@features/panels/store/panelLayoutStore", () => ({ vi.mock("@features/sessions/service/service", () => ({ getSessionService: () => ({ - updateSessionTaskTitle: vi.fn(), + connectToTask: vi.fn(), + disconnectFromTask: vi.fn(), }), }));