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:
7
.changeset/fn-8070-title-summary-input-language.md
Normal file
7
.changeset/fn-8070-title-summary-input-language.md
Normal 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.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user