diff --git a/packages/core/src/__tests__/store-create.test.ts b/packages/core/src/__tests__/store-create.test.ts index 0f2cc3697e..f89cf5848c 100644 --- a/packages/core/src/__tests__/store-create.test.ts +++ b/packages/core/src/__tests__/store-create.test.ts @@ -7,6 +7,7 @@ import * as projectMemory from "../project-memory.js"; import { AgentStore } from "../agent-store.js"; import { CentralDatabase } from "../central-db.js"; import { DependencyCycleError, TaskStore, TaskHasDependentsError } from "../store.js"; +import { setCreateFnAgent } from "../ai-engine-loader.js"; import { setTaskCreatedHook } from "../task-creation-hooks.js"; import { buildResearchDocumentKey, type Task } from "../types.js"; import { createTaskStoreTestHarness, makeTmpDir } from "./store-test-helpers.js"; @@ -26,6 +27,7 @@ describe("TaskStore", () => { afterEach(async () => { setTaskCreatedHook(undefined); + setCreateFnAgent(undefined); await harness.afterEach(); }); @@ -463,6 +465,87 @@ describe("TaskStore", () => { expect(updatedTask.title).toBe("AI Title"); }); + it("resolves and calls a store-managed summarizer when summarize input flag is true", async () => { + const longDescription = "a".repeat(201); + const promptSpy = vi.fn(async () => {}); + const createFnAgent = vi.fn(async () => ({ + session: { + prompt: promptSpy, + state: { messages: [{ role: "assistant", content: "Generated Store Title" }] }, + }, + })); + setCreateFnAgent(createFnAgent); + + const task = await store.createTask( + { description: longDescription, summarize: true }, + { + settings: { + autoSummarizeTitles: false, + titleSummarizerProvider: "mock", + titleSummarizerModelId: "title-model", + }, + }, + ); + + expect(task.title).toBeUndefined(); + + await vi.waitFor(async () => { + const updatedTask = await store.getTask(task.id); + expect(updatedTask.title).toBe("Generated Store Title"); + }); + expect(createFnAgent).toHaveBeenCalledWith(expect.objectContaining({ + cwd: rootDir, + defaultProvider: "mock", + defaultModelId: "title-model", + })); + expect(promptSpy).toHaveBeenCalledWith(expect.stringContaining(longDescription)); + }); + + it("defers the task-created hook until store-managed summarize completes", async () => { + const longDescription = "a".repeat(201); + let releasePrompt!: () => void; + const promptStarted = vi.fn(); + const promptDone = new Promise((resolve) => { + releasePrompt = resolve; + }); + setCreateFnAgent(vi.fn(async () => ({ + session: { + prompt: vi.fn(async () => { + promptStarted(); + await promptDone; + }), + state: { messages: [{ role: "assistant", content: "Deferred Hook Title" }] }, + }, + }))); + const hookSpy = vi.fn(); + setTaskCreatedHook(hookSpy); + + const task = await store.createTask( + { description: longDescription, summarize: true }, + { + settings: { + autoSummarizeTitles: false, + titleSummarizerProvider: "mock", + titleSummarizerModelId: "title-model", + }, + }, + ); + + await vi.waitFor(() => expect(promptStarted).toHaveBeenCalled()); + expect(hookSpy).not.toHaveBeenCalled(); + + releasePrompt(); + await vi.waitFor(() => { + expect(hookSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: task.id, + title: "Deferred Hook Title", + }), + store, + ); + }); + }); + it("should ignore malformed confirmation-prose generated titles", async () => { const mockOnSummarize = vi .fn() diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d9bf454cdf..9ddbe9cd29 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -4026,8 +4026,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } let onSummarize = options?.onSummarize; - if (!onSummarize && resolvedSettings?.autoSummarizeTitles === true) { - // The title-summarizer model lanes MOVED to workflow settings (U4/KTD-7). + if (!onSummarize && (resolvedSettings?.autoSummarizeTitles === true || input.summarize === true)) { + // Resolve a store-managed summarizer whenever title summarization is explicitly + // requested on this create call (agent tools set `summarize: true`) or globally + // enabled via autoSummarizeTitles. The title-summarizer model lanes MOVED to + // workflow settings (U4/KTD-7). // At task-creation time there is no task/workflow yet, so resolve the // project DEFAULT workflow's effective settings (unset default normalizes to // builtin:coding) and overlay them so the moved lane reads from its new home; diff --git a/packages/engine/src/__tests__/agent-tools.test.ts b/packages/engine/src/__tests__/agent-tools.test.ts index 8e52400f89..688ff45c4e 100644 --- a/packages/engine/src/__tests__/agent-tools.test.ts +++ b/packages/engine/src/__tests__/agent-tools.test.ts @@ -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 }; + const createOptions = vi.mocked(store.createTask).mock.calls[0]?.[1] as { onSummarize?: (description: string) => Promise; 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 }; - 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; settings?: unknown }; + expect(options).toEqual(expect.objectContaining({ settings })); + expect(options.onSummarize).toBeUndefined(); }); }); diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 0e9cda5049..8e94476efd 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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"]); }); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 5acc0da0be..fe564debbf 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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, {