From 90ce57f12712572b7308dfa37eba1b4f329a0ecf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 03:23:25 -0700 Subject: [PATCH] FN-8070: preserve task title input language Ensure auto-generated task titles use the task description's language. - Detect description language and add a confident language hint to title prompts - Cover multilingual title generation and stale-model fallback behavior - Document the language-preserving title-summary setting and add a patch changeset Files changed: .changeset/fn-8070-title-summary-input-language.md | 7 ++ docs/settings-reference.md | 2 +- packages/core/src/__tests__/ai-summarize.test.ts | 87 ++++++++++++++++++---- packages/core/src/ai-summarize.ts | 18 +++++ 4 files changed, 98 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-8070 Fusion-Task-Lineage: e003e2e0-eb0c-487e-83de-8a9b37ee1c02 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8070-title-summary-input-language.md | 7 ++ docs/settings-reference.md | 2 +- .../core/src/__tests__/ai-summarize.test.ts | 87 +++++++++++++++---- packages/core/src/ai-summarize.ts | 18 ++++ 4 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 .changeset/fn-8070-title-summary-input-language.md diff --git a/.changeset/fn-8070-title-summary-input-language.md b/.changeset/fn-8070-title-summary-input-language.md new file mode 100644 index 0000000000..a5dc332038 --- /dev/null +++ b/.changeset/fn-8070-title-summary-input-language.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Auto-summarized task titles now match the language of the task description. +category: fix +dev: The existing ai-summarize prompt uses a synchronous input-language hint with no extra model calls. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 33c9ce9f46..6e7eb26241 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -674,7 +674,7 @@ GitLab configuration examples: leave both URL fields blank for GitLab.com (`http | `memoryBackupRetention` | `number` | `14` | Number of memory backups to retain. | | `memoryBackupDir` | `string` | `".fusion/backups/memory"` | Relative memory backup directory path. | | `memoryBackupScope` | `"project" \| "agents" \| "all"` | `"all"` | Backup scope: project memory, agent memory, or both. | -| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions across dashboard/API task creation. Agent-created tasks from `fn_task_create` and `fn_delegate_task` always request summarization for untitled tasks, regardless of this setting. | +| `autoSummarizeTitles` | `boolean` | `false` | Auto-generate titles for long untitled descriptions across dashboard/API task creation. Generated titles match the operator's input language from the task description. Agent-created tasks from `fn_task_create` and `fn_delegate_task` always request summarization for untitled tasks, regardless of this setting. | | `useAiMergeCommitSummary` | `boolean` | `true` | Use AI-generated merge commit summaries (subject + bullet body + diff-stat) instead of raw step-commit subject lists. | | `titleSummarizerProvider` | `string` | `undefined` | Provider for title summarization. | | `titleSummarizerModelId` | `string` | `undefined` | Model ID for title summarization. | diff --git a/packages/core/src/__tests__/ai-summarize.test.ts b/packages/core/src/__tests__/ai-summarize.test.ts index 0b4e65051b..caf973f37a 100644 --- a/packages/core/src/__tests__/ai-summarize.test.ts +++ b/packages/core/src/__tests__/ai-summarize.test.ts @@ -48,7 +48,8 @@ describe("ai-summarize", () => { // ── Constants ────────────────────────────────────────────────────────────── describe("constants", () => { - it("should have correct system prompt", () => { + it("should require titles in the description language", () => { + expect(SUMMARIZE_SYSTEM_PROMPT).toContain("SAME language as the task description"); expect(SUMMARIZE_SYSTEM_PROMPT).toContain("max 60 characters"); expect(SUMMARIZE_SYSTEM_PROMPT).toContain("title summarization"); }); @@ -168,6 +169,7 @@ describe("ai-summarize", () => { it("should return null for descriptions <= 200 characters", async () => { const result = await summarizeTitle("Short description", "/tmp"); expect(result).toBeNull(); + expect(getFnAgentMock).not.toHaveBeenCalled(); }); it("should throw AiServiceError when AI service cannot process request", async () => { @@ -187,6 +189,50 @@ describe("ai-summarize", () => { ).rejects.toThrow(AiServiceError); }); + it.each([ + ["French", "Nous devons améliorer la création des tâches afin que les opérateurs puissent suivre leurs demandes plus facilement. ".repeat(3), "Français"], + ["Spanish", "Necesitamos mejorar la creación de tareas para que los operadores puedan seguir sus solicitudes con mayor facilidad. ".repeat(3), "Español"], + ["Chinese", "我们需要改进任务创建流程,让操作员能够更轻松地跟踪他们的请求并完成日常工作。".repeat(8), "简体中文"], + ["Korean", "운영자가 요청을 더 쉽게 추적하고 일상 업무를 완료할 수 있도록 작업 생성 과정을 개선해야 합니다. ".repeat(4), "한국어"], + ])("instructs %s descriptions to retain their language", async (_language, description, languageHint) => { + const prompt = vi.fn().mockResolvedValue(undefined); + const createFnAgent = vi.fn().mockResolvedValue({ + session: { + prompt, + dispose: vi.fn(), + state: { + messages: [{ role: "assistant", content: "Generated task title" }], + }, + }, + }); + getFnAgentMock.mockResolvedValue(createFnAgent); + + await expect(summarizeTitle(description, "/tmp")).resolves.toBe("Generated task title"); + + const agentOptions = createFnAgent.mock.calls[0][0] as { systemPrompt: string }; + const wrappedPrompt = prompt.mock.calls[0][0] as string; + expect(agentOptions.systemPrompt).toContain("SAME language as the task description"); + expect(wrappedPrompt).toContain("SAME language as the task description"); + expect(wrappedPrompt).toContain(`Likely language: ${languageHint}`); + expect(wrappedPrompt).toContain(""); + }); + + it("keeps English descriptions in English without forcing another language", async () => { + const prompt = vi.fn().mockResolvedValue(undefined); + getFnAgentMock.mockResolvedValue(() => Promise.resolve({ + session: { + prompt, + dispose: vi.fn(), + state: { messages: [{ role: "assistant", content: "Improve task creation" }] }, + }, + })); + const description = "We need to improve task creation so operators can follow requests more easily during their daily work. ".repeat(3); + + await expect(summarizeTitle(description, "/tmp")).resolves.toBe("Improve task creation"); + expect(SUMMARIZE_SYSTEM_PROMPT).toContain("SAME language as the task description"); + expect(prompt.mock.calls[0][0]).toContain("Likely language: English"); + }); + it("returns sanitized title when AI responds cleanly", async () => { const prompt = vi.fn().mockResolvedValue(undefined); getFnAgentMock.mockResolvedValue(() => @@ -207,24 +253,24 @@ describe("ai-summarize", () => { // Verify wrapped prompt was sent (prompt-injection mitigation) expect(prompt).toHaveBeenCalledTimes(1); expect(prompt.mock.calls[0][0]).toContain(""); + expect(prompt.mock.calls[0][0]).toContain("SAME language as the task description"); expect(prompt.mock.calls[0][0]).toContain("Do not call any tools"); }); it("summarizes long descriptions with bounded prompt input", async () => { const prompt = vi.fn().mockResolvedValue(undefined); - getFnAgentMock.mockResolvedValue(() => - Promise.resolve({ - session: { - prompt, - dispose: vi.fn(), - state: { - messages: [ - { role: "assistant", content: "Summarize long description" }, - ], - }, + const createFnAgent = vi.fn().mockResolvedValue({ + session: { + prompt, + dispose: vi.fn(), + state: { + messages: [ + { role: "assistant", content: "Summarize long description" }, + ], }, - }) - ); + }, + }); + getFnAgentMock.mockResolvedValue(createFnAgent); const description = "a".repeat(MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "tail".repeat(250); const title = await summarizeTitle(description, "/tmp"); @@ -234,6 +280,9 @@ describe("ai-summarize", () => { const promptText = prompt.mock.calls[0][0] as string; expect(promptText).toContain("…(truncated)"); expect(promptText).not.toContain("tail"); + expect(promptText).toContain("SAME language as the task description"); + expect((createFnAgent.mock.calls[0][0] as { systemPrompt: string }).systemPrompt) + .toContain("SAME language as the task description"); expect(promptText.length).toBeLessThanOrEqual(MAX_TITLE_SUMMARIZE_INPUT_LENGTH + 250); }); @@ -289,6 +338,7 @@ describe("ai-summarize", () => { it("retries stale configured model ids with automatic resolution and logs the stale id", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const fallbackPrompt = vi.fn().mockResolvedValue(undefined); const createFnAgent = vi .fn() .mockRejectedValueOnce(new Error( @@ -297,7 +347,7 @@ describe("ai-summarize", () => { )) .mockResolvedValueOnce({ session: { - prompt: vi.fn().mockResolvedValue(undefined), + prompt: fallbackPrompt, dispose: vi.fn(), state: { messages: [{ role: "assistant", content: "Fix pi upgrade regressions" }], @@ -305,9 +355,10 @@ describe("ai-summarize", () => { }, }); getFnAgentMock.mockResolvedValue(createFnAgent); + const description = "Nous devons corriger les régressions de mise à niveau afin que les opérateurs puissent poursuivre leur travail sans interruption. ".repeat(3); const title = await summarizeTitle( - "a".repeat(201), + description, "/tmp", "fireworksai", "accounts/fireworks/routers/kimi-k2p5-turbo", @@ -322,6 +373,12 @@ describe("ai-summarize", () => { defaultProvider: expect.any(String), defaultModelId: expect.any(String), })); + const primaryOptions = createFnAgent.mock.calls[0][0] as { systemPrompt: string }; + const fallbackOptions = createFnAgent.mock.calls[1][0] as { systemPrompt: string }; + expect(primaryOptions.systemPrompt).toContain("SAME language as the task description"); + expect(fallbackOptions.systemPrompt).toContain("SAME language as the task description"); + expect(fallbackPrompt.mock.calls[0][0]).toContain("SAME language as the task description"); + expect(fallbackPrompt.mock.calls[0][0]).toContain("Likely language: Français"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("fireworksai/accounts/fireworks/routers/kimi-k2p5-turbo")); warnSpy.mockRestore(); }); diff --git a/packages/core/src/ai-summarize.ts b/packages/core/src/ai-summarize.ts index 6e9c0adc7c..8a06d7339f 100644 --- a/packages/core/src/ai-summarize.ts +++ b/packages/core/src/ai-summarize.ts @@ -12,6 +12,7 @@ */ import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js"; +import { detectContentLanguage, localeDisplayName } from "./detect-content-language.js"; import { DANGLING_TAIL_STOPWORDS, stripDanglingTail, stripEmptyPlaceholders } from "./task-title-id-drift.js"; // ── Constants ─────────────────────────────────────────────────────────────── @@ -29,6 +30,7 @@ Your ONLY job is to create a concise title (max 60 characters) that summarizes t ## Style - Clear, descriptive, actionable, professional +- Write the title in the SAME language as the task description content. For example, a French description requires a French title. - Maximum 60 characters - Focus on the main goal or deliverable of the task`; @@ -218,6 +220,21 @@ function formatConfiguredModel(provider?: string, modelId?: string): string { return provider && modelId ? `${provider}/${modelId}` : "unknown configured model"; } +/* + * FNXC:TitleSummaryInputLanguage 2026-07-16-00:00: + * When existing autoSummarizeTitles or per-request summarize behavior invokes this shared summarizer, + * the generated title must match the operator's description language without another model call. + * Detection only adds a medium-or-higher confidence hint; matching the description remains the rule. + */ +function buildTitleLanguageInstruction(description: string): string { + const detected = detectContentLanguage(description); + if (detected.locale !== "unknown" && detected.confidence !== "low") { + return `Write the title in the SAME language as the task description. Likely language: ${localeDisplayName(detected.locale)}.`; + } + + return "Write the title in the SAME language as the task description."; +} + async function runTitleSummarizer( createFnAgent: NonNullable>>, description: string, @@ -264,6 +281,7 @@ async function runTitleSummarizer( : description; const wrappedPrompt = "Summarize the following task description into a title (≤60 chars). " + + buildTitleLanguageInstruction(description) + " " + "Output ONLY the title text on a single line. Do not call any tools.\n\n" + "\n" + truncatedDescription +