feat(FN-2052): merge fusion/fn-2052
This commit is contained in:
@@ -10786,3 +10786,169 @@ describe("TaskExecutor skillSelection regression (FN-1511)", () => {
|
||||
it.skip("step-session skill selection covered in step-session-executor.test.ts", () => {});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Messaging Tool Tests ────────────────────────────────────────
|
||||
|
||||
describe("TaskExecutor messaging tools", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedCreateHaiAgent.mockResolvedValue({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
sessionManager: {
|
||||
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
||||
branchWithSummary: vi.fn(),
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: execute a task and capture the customTools array passed to createKbAgent.
|
||||
*/
|
||||
async function captureCustomTools(options?: {
|
||||
messageStore?: unknown;
|
||||
agentStore?: unknown;
|
||||
assignedAgentId?: string;
|
||||
}): Promise<any[]> {
|
||||
const { messageStore, agentStore, assignedAgentId } = options || {};
|
||||
let captured: any[] = [];
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
|
||||
captured = opts.customTools || [];
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
sessionManager: {
|
||||
getLeafId: vi.fn().mockReturnValue("leaf-id"),
|
||||
branchWithSummary: vi.fn(),
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
navigateTree: vi.fn().mockResolvedValue({ cancelled: false }),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
// Override getTask to return the correct assignedAgentId
|
||||
store.getTask.mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
assignedAgentId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const taskExecutor = new TaskExecutor(store, "/tmp/test", {
|
||||
messageStore: messageStore as any,
|
||||
agentStore: agentStore as any,
|
||||
});
|
||||
|
||||
await taskExecutor.execute({
|
||||
id: "FN-MSG",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
assignedAgentId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
it("includes send_message when messageStore and assignedAgentId are available", async () => {
|
||||
const mockMessageStore = {
|
||||
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
||||
};
|
||||
const tools = await captureCustomTools({
|
||||
messageStore: mockMessageStore,
|
||||
assignedAgentId: "agent-001",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("send_message");
|
||||
});
|
||||
|
||||
it("includes read_messages when messageStore and assignedAgentId are available", async () => {
|
||||
const mockMessageStore = {
|
||||
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
||||
getInbox: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
const tools = await captureCustomTools({
|
||||
messageStore: mockMessageStore,
|
||||
assignedAgentId: "agent-001",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("read_messages");
|
||||
});
|
||||
|
||||
it("excludes read_messages when messageStore is not provided", async () => {
|
||||
const tools = await captureCustomTools({
|
||||
assignedAgentId: "agent-001",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("read_messages");
|
||||
});
|
||||
|
||||
it("excludes read_messages when assignedAgentId is not provided", async () => {
|
||||
const mockMessageStore = {
|
||||
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
||||
getInbox: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
const tools = await captureCustomTools({
|
||||
messageStore: mockMessageStore,
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("read_messages");
|
||||
});
|
||||
|
||||
it("excludes messaging tools when messageStore is not provided", async () => {
|
||||
const tools = await captureCustomTools({
|
||||
assignedAgentId: "agent-001",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("send_message");
|
||||
expect(toolNames).not.toContain("read_messages");
|
||||
});
|
||||
|
||||
it("includes list_agents and delegate_task when agentStore is available", async () => {
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
const tools = await captureCustomTools({
|
||||
agentStore: mockAgentStore,
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("list_agents");
|
||||
expect(toolNames).toContain("delegate_task");
|
||||
});
|
||||
|
||||
it("excludes delegation tools when agentStore is not provided", async () => {
|
||||
const tools = await captureCustomTools({});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("list_agents");
|
||||
expect(toolNames).not.toContain("delegate_task");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
createDelegateTaskTool,
|
||||
createListAgentsTool,
|
||||
createMemoryTools,
|
||||
createReadMessagesTool,
|
||||
createReflectOnPerformanceTool,
|
||||
createSendMessageTool,
|
||||
createTaskCreateTool as sharedCreateTaskCreateTool,
|
||||
@@ -50,6 +51,7 @@ export { summarizeToolArgs } from "./agent-logger.js";
|
||||
export {
|
||||
createDelegateTaskTool,
|
||||
createListAgentsTool,
|
||||
createReadMessagesTool,
|
||||
createSendMessageTool,
|
||||
createTaskCreateTool,
|
||||
createTaskDocumentReadTool,
|
||||
@@ -60,6 +62,7 @@ export {
|
||||
memoryAppendParams,
|
||||
memoryGetParams,
|
||||
memorySearchParams,
|
||||
readMessagesParams,
|
||||
sendMessageParams,
|
||||
taskCreateParams,
|
||||
taskLogParams,
|
||||
@@ -1292,6 +1295,9 @@ export class TaskExecutor {
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
// Pass skill selection context from the main executor session
|
||||
skillSelection: skillContext.skillSelectionContext,
|
||||
// Pass agentStore and messageStore for delegation and messaging tools
|
||||
agentStore: this.options.agentStore,
|
||||
messageStore: this.options.messageStore,
|
||||
onStepStart: (stepIndex) => {
|
||||
this.options.stuckTaskDetector?.recordProgress(task.id);
|
||||
try {
|
||||
@@ -1543,9 +1549,10 @@ export class TaskExecutor {
|
||||
createListAgentsTool(this.options.agentStore),
|
||||
createDelegateTaskTool(this.options.agentStore, this.store),
|
||||
] : []),
|
||||
// Messaging tool — allows executor agents to send messages to other agents.
|
||||
// Messaging tools — allows executor agents to send and receive messages.
|
||||
...(this.options.messageStore && assignedAgentId ? [
|
||||
createSendMessageTool(this.options.messageStore, assignedAgentId),
|
||||
createReadMessagesTool(this.options.messageStore, assignedAgentId),
|
||||
] : []),
|
||||
// Add plugin tools from PluginRunner
|
||||
...(this.options.pluginRunner?.getPluginTools() ?? []),
|
||||
|
||||
@@ -1933,3 +1933,141 @@ describe("StepSessionExecutor skillSelection regression (FN-1511)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Tool Availability Tests ──────────────────────────────────────
|
||||
|
||||
describe("StepSessionExecutor tool availability", () => {
|
||||
/**
|
||||
* These tests verify tool configuration by capturing the customTools
|
||||
* passed to createKbAgent during executeStep execution. Each test
|
||||
* uses fake timers and advances time to resolve any pending sleep()s.
|
||||
*/
|
||||
async function captureCustomTools(options?: {
|
||||
agentStore?: unknown;
|
||||
messageStore?: unknown;
|
||||
assignedAgentId?: string;
|
||||
}): Promise<any[]> {
|
||||
let captured: any[] = [];
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockedCreateKbAgent.mockImplementation(async (opts: any) => {
|
||||
captured = opts.customTools || [];
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
const taskDetail = makeTaskDetail({
|
||||
prompt: makeStepPrompt("FN-TOOLS", 0),
|
||||
steps: [{ name: "Step 0", status: "pending" }],
|
||||
assignedAgentId: options?.assignedAgentId,
|
||||
});
|
||||
|
||||
const mockStore = {
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
getTaskDocument: vi.fn().mockResolvedValue(null),
|
||||
upsertTaskDocument: vi.fn().mockResolvedValue({ id: "doc-001", key: "test", content: "test", revision: 1, updatedAt: new Date().toISOString(), createdAt: new Date().toISOString(), author: "test" }),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
const mockSettings = makeSettings({ maxParallelSteps: 1 });
|
||||
|
||||
const executor = new StepSessionExecutor({
|
||||
store: mockStore,
|
||||
taskDetail,
|
||||
worktreePath: "/project/.worktrees/main",
|
||||
rootDir: "/project",
|
||||
settings: mockSettings,
|
||||
agentStore: options?.agentStore as any,
|
||||
messageStore: options?.messageStore as any,
|
||||
});
|
||||
|
||||
try {
|
||||
const executePromise = executor.executeAll();
|
||||
// Advance fake timers to allow sleep() calls in retry loop to complete
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
await executePromise;
|
||||
} catch {
|
||||
// Ignore execution errors — we're only capturing the tools
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
it("includes list_agents and delegate_task when agentStore is available", async () => {
|
||||
const mockAgentStore = {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const tools = await captureCustomTools({
|
||||
agentStore: mockAgentStore,
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("list_agents");
|
||||
expect(toolNames).toContain("delegate_task");
|
||||
});
|
||||
|
||||
it("excludes delegation tools when agentStore is not provided", async () => {
|
||||
const tools = await captureCustomTools({});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("list_agents");
|
||||
expect(toolNames).not.toContain("delegate_task");
|
||||
});
|
||||
|
||||
it("includes send_message and read_messages when messageStore and assignedAgentId are available", async () => {
|
||||
const mockMessageStore = {
|
||||
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
||||
getInbox: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
const tools = await captureCustomTools({
|
||||
messageStore: mockMessageStore,
|
||||
assignedAgentId: "agent-001",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("send_message");
|
||||
expect(toolNames).toContain("read_messages");
|
||||
});
|
||||
|
||||
it("excludes messaging tools when messageStore is not provided", async () => {
|
||||
const tools = await captureCustomTools({
|
||||
assignedAgentId: "agent-001",
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("send_message");
|
||||
expect(toolNames).not.toContain("read_messages");
|
||||
});
|
||||
|
||||
it("excludes messaging tools when assignedAgentId is not provided", async () => {
|
||||
const mockMessageStore = {
|
||||
sendMessage: vi.fn().mockReturnValue({ id: "msg-001" }),
|
||||
getInbox: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
const tools = await captureCustomTools({
|
||||
messageStore: mockMessageStore,
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).not.toContain("send_message");
|
||||
expect(toolNames).not.toContain("read_messages");
|
||||
});
|
||||
|
||||
it("includes task_log and task_create when store is available", async () => {
|
||||
const tools = await captureCustomTools({});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("task_log");
|
||||
expect(toolNames).toContain("task_create");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ const execAsync = promisify(exec);
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
||||
import type { TaskDetail, Settings, TaskStore } from "@fusion/core";
|
||||
import type { AgentStore, MessageStore, TaskDetail, Settings, TaskStore } from "@fusion/core";
|
||||
|
||||
import { createKbAgent, promptWithFallback, describeModel } from "./pi.js";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
@@ -28,7 +28,17 @@ import { AgentLogger } from "./agent-logger.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
import { createMemoryTools, createTaskDocumentWriteTool, createTaskDocumentReadTool } from "./agent-tools.js";
|
||||
import {
|
||||
createDelegateTaskTool,
|
||||
createListAgentsTool,
|
||||
createMemoryTools,
|
||||
createReadMessagesTool,
|
||||
createSendMessageTool,
|
||||
createTaskCreateTool,
|
||||
createTaskDocumentReadTool,
|
||||
createTaskDocumentWriteTool,
|
||||
createTaskLogTool,
|
||||
} from "./agent-tools.js";
|
||||
|
||||
const stepExecLog = createLogger("step-session-executor");
|
||||
|
||||
@@ -78,6 +88,10 @@ export interface StepSessionExecutorOptions {
|
||||
onStepComplete?: (stepIndex: number, result: StepResult) => void;
|
||||
/** Optional skill selection context for session creation. */
|
||||
skillSelection?: SkillSelectionContext;
|
||||
/** Optional agent store for delegation tools. */
|
||||
agentStore?: AgentStore;
|
||||
/** Optional message store for messaging tools. */
|
||||
messageStore?: MessageStore;
|
||||
}
|
||||
|
||||
// ── File Scope Extraction ─────────────────────────────────────────────
|
||||
@@ -788,6 +802,31 @@ export class StepSessionExecutor {
|
||||
: [];
|
||||
const memoryTools = createMemoryTools(this.options.rootDir, settings);
|
||||
|
||||
// Task log and create tools — task context for step sessions.
|
||||
const taskLogTool = this.options.store
|
||||
? [createTaskLogTool(this.options.store, taskDetail.id)]
|
||||
: [];
|
||||
const taskCreateTool = this.options.store
|
||||
? [createTaskCreateTool(this.options.store)]
|
||||
: [];
|
||||
|
||||
// Agent delegation tools — discover and delegate work to other agents.
|
||||
const delegationTools = this.options.agentStore
|
||||
? [
|
||||
createListAgentsTool(this.options.agentStore),
|
||||
createDelegateTaskTool(this.options.agentStore, this.options.store!),
|
||||
]
|
||||
: [];
|
||||
|
||||
// Messaging tools — allows step sessions to send and receive messages.
|
||||
const messagingTools =
|
||||
this.options.messageStore && taskDetail.assignedAgentId
|
||||
? [
|
||||
createSendMessageTool(this.options.messageStore, taskDetail.assignedAgentId),
|
||||
createReadMessagesTool(this.options.messageStore, taskDetail.assignedAgentId),
|
||||
]
|
||||
: [];
|
||||
|
||||
// Create fresh agent session for this attempt
|
||||
// Resolve executor model using canonical lane hierarchy:
|
||||
// 1. Task override pair (taskDetail.modelProvider + taskDetail.modelId)
|
||||
@@ -817,7 +856,15 @@ export class StepSessionExecutor {
|
||||
fallbackProvider: settings.fallbackProvider,
|
||||
fallbackModelId: settings.fallbackModelId,
|
||||
defaultThinkingLevel: taskDetail.thinkingLevel ?? settings.defaultThinkingLevel,
|
||||
customTools: [...pluginTools, ...documentTools, ...memoryTools],
|
||||
customTools: [
|
||||
...pluginTools,
|
||||
...documentTools,
|
||||
...memoryTools,
|
||||
...taskLogTool,
|
||||
...taskCreateTool,
|
||||
...delegationTools,
|
||||
...messagingTools,
|
||||
],
|
||||
onText: (delta) => {
|
||||
agentLogger.onText(delta);
|
||||
stuckTaskDetector?.recordActivity(trackingKey);
|
||||
|
||||
@@ -2649,3 +2649,75 @@ describe("evictStaleProcessing", () => {
|
||||
expect(processor.getProcessingTaskIds().has("FN-002")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Delegation Tool Tests ──────────────────────────────────────
|
||||
|
||||
describe("TriageProcessor delegation tools", () => {
|
||||
function createMockAgentStore() {
|
||||
return {
|
||||
listAgents: vi.fn().mockResolvedValue([]),
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore() {
|
||||
return {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "FN-TRIAGE",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
status: "specifying",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 10000,
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
}),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
on: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it("createTriageTools returns task_list, task_get, task_create (no delegation tools — those are in customTools)", () => {
|
||||
const store = createMockStore();
|
||||
const processor = new TriageProcessor(store as any, "/tmp/root");
|
||||
|
||||
const tools = (processor as any).createTriageTools({
|
||||
parentTaskId: "FN-TRIAGE",
|
||||
allowTaskCreate: true,
|
||||
createdSubtasksRef: { current: [] },
|
||||
});
|
||||
|
||||
const toolNames = tools.map((t: any) => t.name);
|
||||
expect(toolNames).toContain("task_list");
|
||||
expect(toolNames).toContain("task_get");
|
||||
expect(toolNames).toContain("task_create");
|
||||
// list_agents and delegate_task are added in customTools, not createTriageTools
|
||||
expect(toolNames).not.toContain("list_agents");
|
||||
expect(toolNames).not.toContain("delegate_task");
|
||||
});
|
||||
|
||||
it("delegation tools are accessible when agentStore is available", () => {
|
||||
const mockAgentStore = createMockAgentStore();
|
||||
const store = createMockStore();
|
||||
const processor = new TriageProcessor(store as any, "/tmp/root", {
|
||||
agentStore: mockAgentStore as any,
|
||||
});
|
||||
|
||||
// Verify agentStore is injected into processor options
|
||||
expect((processor as any).options.agentStore).toBe(mockAgentStore);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ import type { StuckTaskDetector } from "./stuck-task-detector.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createDelegateTaskTool,
|
||||
createListAgentsTool,
|
||||
createMemoryTools,
|
||||
createTaskDocumentReadTool,
|
||||
createTaskDocumentWriteTool,
|
||||
@@ -653,6 +655,11 @@ export class TriageProcessor {
|
||||
createTaskDocumentWriteTool(this.store, task.id),
|
||||
createTaskDocumentReadTool(this.store, task.id),
|
||||
...createMemoryTools(this.rootDir, settings),
|
||||
// Agent delegation tools — discover and delegate work to other agents.
|
||||
...(this.options.agentStore ? [
|
||||
createListAgentsTool(this.options.agentStore),
|
||||
createDelegateTaskTool(this.options.agentStore, this.store),
|
||||
] : []),
|
||||
this.createReviewSpecTool(
|
||||
task.id,
|
||||
promptPath,
|
||||
|
||||
Reference in New Issue
Block a user