feat(FN-1017): enable proactive subtask splitting during triage and review

- Update triage agent to proactively suggest splitting large tasks into subtasks during specification
- Tighten reviewer guidance to flag undersplit tasks that should be broken down further
- Add comprehensive tests for proactive subtask creation in triage (192 lines)
- Add reviewer tests for undersplit task detection (12 lines)
- Update README with documentation on proactive subtask splitting behavior
This commit is contained in:
gsxdsm
2026-04-05 23:50:36 -07:00
parent cd719b45ef
commit facd5c520b
5 changed files with 221 additions and 22 deletions

View File

@@ -904,6 +904,198 @@ describe("taskCreate tool model inheritance", () => {
}));
});
describe("proactive subtask creation (task_create always available)", () => {
it("task_create tool is included in triage tools regardless of breakIntoSubtasks", () => {
const store = createMockStore();
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-400",
allowTaskCreate: true,
createdSubtasksRef,
});
const toolNames = tools.map((t: any) => t.name);
expect(toolNames).toContain("task_create");
expect(toolNames).toContain("task_list");
expect(toolNames).toContain("task_get");
expect(tools).toHaveLength(3);
});
it("task_create tool succeeds and tracks created subtask", async () => {
const parentTask: Task = {
id: "FN-400",
description: "Large task without breakIntoSubtasks",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const createdSubtask: Task = {
id: "FN-401",
description: "Child task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(parentTask),
createTask: vi.fn().mockResolvedValue(createdSubtask),
});
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-400",
allowTaskCreate: true,
createdSubtasksRef,
});
const taskCreateTool = tools.find((t: any) => t.name === "task_create");
const result = await taskCreateTool.execute("call-1", {
description: "Child task description",
title: "Child Task",
dependencies: [],
});
// Should NOT return an error about task creation being disabled
const text = result.content[0].text;
expect(text).not.toContain("ERROR");
expect(text).not.toContain("not enabled");
expect(text).toContain("Created child task FN-401");
// Subtask should be tracked in the ref
expect(createdSubtasksRef.current).toContain("FN-401");
// Should inherit parent model settings
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
title: "Child Task",
description: "Child task description",
}));
});
it("closes parent after proactive split even when breakIntoSubtasks is undefined", async () => {
// Test that the post-session closure path doesn't gate on breakIntoSubtasks.
// Strategy: capture the customTools from createKbAgent, then have
// promptWithFallback invoke the task_create tool to simulate the agent
// proactively splitting an oversized task.
const task: Task = {
id: "FN-500",
description: "Oversized task without breakIntoSubtasks flag",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const childTask1: Task = {
id: "FN-501",
description: "Child part 1",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const childTask2: Task = {
id: "FN-502",
description: "Child part 2",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const taskDetail: TaskDetail = {
...task,
prompt: "",
attachments: [],
// breakIntoSubtasks is explicitly undefined
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(taskDetail),
createTask: vi.fn()
.mockResolvedValueOnce(childTask1)
.mockResolvedValueOnce(childTask2),
});
// Capture customTools from createKbAgent call
let capturedCustomTools: any[] = [];
const mockDispose = vi.fn();
mockCreateKbAgent.mockImplementation(async (opts: any) => {
capturedCustomTools = opts.customTools || [];
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: mockDispose,
subscribe: vi.fn(),
sessionManager: {
getLeafId: vi.fn().mockReturnValue(null),
navigateTree: vi.fn(),
},
},
};
});
// Make promptWithFallback invoke the task_create tool twice to simulate
// the agent proactively splitting the oversized task
const { promptWithFallback } = await import("./pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(
async () => {
const taskCreateTool = capturedCustomTools.find(
(t: any) => t.name === "task_create",
);
expect(taskCreateTool).toBeDefined();
// Simulate agent creating two child tasks
await taskCreateTool.execute("call-1", {
description: "Child part 1",
title: "Part 1",
dependencies: [],
});
await taskCreateTool.execute("call-2", {
description: "Child part 2",
title: "Part 2",
dependencies: [],
});
},
);
const processor = new TriageProcessor(store, "/test/root", {
pollIntervalMs: 100_000,
});
await processor.specifyTask(task);
// The parent task should be deleted because subtasks were created,
// even though breakIntoSubtasks was NOT set
expect(store.logEntry).toHaveBeenCalledWith(
"FN-500",
expect.stringContaining("Converted into subtasks: FN-501, FN-502"),
);
expect(store.deleteTask).toHaveBeenCalledWith("FN-500");
});
});
describe("bounded recovery retries for triage", () => {
it("sets recoveryRetryCount and nextRecoveryAt on first transient error via specifyTask", async () => {
const task = {