feat(FN-2052): merge fusion/fn-2052

This commit is contained in:
gsxdsm
2026-04-18 10:43:23 -07:00
parent fad2c68ba2
commit 89b5a722dc
6 changed files with 441 additions and 4 deletions

View File

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

View File

@@ -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() ?? []),

View File

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

View File

@@ -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);

View File

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

View File

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