FN-6164: route agent task title summarization through the store

Ensure agent-created tasks use store-managed title summarization before creation hooks fire.

- resolve a store-managed summarizer when task creation explicitly requests summarization
- defer the task-created hook until store summarization finishes and persists the generated title
- stop agent tools from wiring ad hoc summarizer callbacks and pass resolved settings through to createTask
- update core and engine tests to cover store-managed summarization, hook deferral, and inherited settings expectations

Files changed:
 packages/core/src/__tests__/store-create.test.ts  | 83 +++++++++++++++++++++++
 packages/core/src/store.ts                        |  7 +-
 packages/engine/src/__tests__/agent-tools.test.ts | 70 +++++++++----------
 packages/engine/src/__tests__/triage.test.ts      | 12 +++-
 packages/engine/src/agent-tools.ts                | 10 +--
 5 files changed, 136 insertions(+), 46 deletions(-)

Fusion-Task-Id: FN-6164

Fusion-Task-Lineage: f8da7bd7-b5d4-4505-98ed-60c19d82904c
This commit is contained in:
gsxdsm
2026-06-09 22:18:48 -07:00
parent c27c321a8b
commit 4d7c51d526
5 changed files with 136 additions and 46 deletions

View File

@@ -148,7 +148,6 @@ describe("createTaskCreateTool", () => {
source: undefined,
}, {
settings: { autoSummarizeTitles: false },
onSummarize: undefined,
});
expect(result.details).toEqual({ taskId: "PROJ-042" });
const responseText = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -302,10 +301,15 @@ describe("createTaskCreateTool", () => {
expect(store.createTask).toHaveBeenCalledTimes(1);
});
it("passes summarize: true when no title provided", async () => {
it("passes summarize: true and full settings when no title provided", async () => {
const created = { id: "FN-201", description: "untitled follow-up", dependencies: [], column: "triage" };
const settings = {
autoSummarizeTitles: false,
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-4o-mini",
};
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
getSettings: vi.fn().mockResolvedValue(settings),
createTask: vi.fn().mockResolvedValue(created),
};
@@ -314,13 +318,18 @@ describe("createTaskCreateTool", () => {
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
description: "untitled follow-up",
summarize: true,
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
}), expect.objectContaining({ settings }));
});
it("does not set summarize when title is provided", async () => {
it("passes full settings and does not set summarize when title is provided", async () => {
const created = { id: "FN-202", title: "Explicit title", description: "titled follow-up", dependencies: [], column: "triage" };
const settings = {
autoSummarizeTitles: false,
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-4o-mini",
};
const store = {
getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }),
getSettings: vi.fn().mockResolvedValue(settings),
createTask: vi.fn().mockResolvedValue(created),
};
@@ -329,36 +338,31 @@ describe("createTaskCreateTool", () => {
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Explicit title",
summarize: undefined,
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
}), expect.objectContaining({ settings }));
});
it("summarize triggers title generation even when autoSummarizeTitles is false", async () => {
const summarizeSpy = vi.spyOn(core, "summarizeTitle").mockResolvedValue("Generated title");
it("passes summarize: true without onSummarize so the store resolves title generation", async () => {
const created = { id: "FN-203", description: "untitled follow-up", dependencies: [], column: "triage" };
const settings = {
autoSummarizeTitles: false,
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-4o-mini",
};
const store = {
getSettings: vi.fn().mockResolvedValue({
autoSummarizeTitles: false,
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-4o-mini",
}),
getSettings: vi.fn().mockResolvedValue(settings),
createTask: vi.fn().mockResolvedValue(created),
};
await createAgentTask(store as any, { description: "untitled follow-up" } as any, { rootDir: "/repo" });
const createInput = vi.mocked(store.createTask).mock.calls[0]?.[0];
const createOptions = vi.mocked(store.createTask).mock.calls[0]?.[1] as { onSummarize?: (description: string) => Promise<string | null> };
const createOptions = vi.mocked(store.createTask).mock.calls[0]?.[1] as { onSummarize?: (description: string) => Promise<string | null>; settings?: unknown };
expect(createInput).toEqual(expect.objectContaining({
description: "untitled follow-up",
summarize: true,
}));
expect(createOptions).toEqual(expect.objectContaining({
settings: { autoSummarizeTitles: false },
}));
expect(createOptions.onSummarize).toBeTypeOf("function");
await expect(createOptions.onSummarize?.("Long agent-created task description")).resolves.toBe("Generated title");
expect(summarizeSpy).toHaveBeenCalledWith("Long agent-created task description", "/repo", "openai", "gpt-4o-mini");
summarizeSpy.mockRestore();
expect(createOptions).toEqual(expect.objectContaining({ settings }));
expect(createOptions.onSummarize).toBeUndefined();
});
});
@@ -465,28 +469,26 @@ describe("createDelegateTaskTool", () => {
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("wires title summarization callback when rootDir is provided", async () => {
const summarizeSpy = vi.spyOn(core, "summarizeTitle").mockResolvedValue("Short title");
it("does not wire a title summarization callback when rootDir is provided", async () => {
const agentStore = {
getAgent: vi.fn().mockResolvedValue({ id: "agent-1", name: "Worker", role: "executor", state: "idle" }),
};
const settings = {
autoSummarizeTitles: true,
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-4o-mini",
};
const taskStore = {
getSettings: vi.fn().mockResolvedValue({
autoSummarizeTitles: true,
titleSummarizerProvider: "openai",
titleSummarizerModelId: "gpt-4o-mini",
}),
getSettings: vi.fn().mockResolvedValue(settings),
createTask: vi.fn().mockResolvedValue({ id: "FN-101", dependencies: [], description: "Delegated" }),
};
const tool = createDelegateTaskTool(agentStore as any, taskStore as any, { rootDir: "/repo" });
await tool.execute("call-1", { agent_id: "agent-1", description: "Delegated" } as any, undefined, undefined, {} as any);
const options = vi.mocked(taskStore.createTask).mock.calls[0]?.[1] as { onSummarize?: (description: string) => Promise<string | null> };
expect(options.onSummarize).toBeTypeOf("function");
await options.onSummarize?.("Long description");
expect(summarizeSpy).toHaveBeenCalledWith("Long description", "/repo", "openai", "gpt-4o-mini");
summarizeSpy.mockRestore();
const options = vi.mocked(taskStore.createTask).mock.calls[0]?.[1] as { onSummarize?: (description: string) => Promise<string | null>; settings?: unknown };
expect(options).toEqual(expect.objectContaining({ settings }));
expect(options.onSummarize).toBeUndefined();
});
});

View File

@@ -2044,7 +2044,10 @@ describe("taskCreate tool model inheritance", () => {
title: "Child Task",
description: "Child task description",
}), expect.objectContaining({
settings: { autoSummarizeTitles: false },
settings: expect.objectContaining({
maxConcurrent: 2,
maxWorktrees: 4,
}),
}));
});
@@ -2143,7 +2146,12 @@ describe("taskCreate tool model inheritance", () => {
// The second createTask call should have the resolved sibling id preserved.
expect(createTaskMock).toHaveBeenLastCalledWith(
expect.objectContaining({ dependencies: ["FN-701"] }),
expect.objectContaining({ settings: { autoSummarizeTitles: false } }),
expect.objectContaining({
settings: expect.objectContaining({
maxConcurrent: 2,
maxWorktrees: 4,
}),
}),
);
expect(createdSubtasksRef.current).toEqual(["FN-701", "FN-702"]);
});

View File

@@ -14,7 +14,7 @@ import { join, relative, resolve } from "node:path";
import type { AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js";
@@ -793,13 +793,7 @@ export async function createAgentTask(
};
const createdTask = await store.createTask(createInput, {
settings: { autoSummarizeTitles: settings.autoSummarizeTitles === true },
onSummarize: rootDir
? async (description: string) => {
const resolved = resolveTitleSummarizerSettingsModel(settings);
return summarizeTitle(description, rootDir, resolved.provider, resolved.modelId);
}
: undefined,
settings,
});
const reconcile = await reconcileDeterministicDuplicate(store, {