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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 03:23:25 -07:00
parent 566158232b
commit 90ce57f127
4 changed files with 98 additions and 16 deletions

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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("<description>");
});
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("<description>");
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();
});

View File

@@ -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<Awaited<ReturnType<typeof getFnAgent>>>,
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" +
"<description>\n" +
truncatedDescription +