feat(FN-4076): tighten mobile agents header and popup, add compact planning

Merges FN-4076: tightens the mobile Agents header and aligns the controls popup on smaller screens, while also introducing compact planning breakdown task creation with preserved payload coverage — tests for scoped and empty-generated planning payloads were added alongside fixes to the legacy API an

Fusion-Task-Id: FN-4076
This commit is contained in:
Fusion
2026-05-12 03:54:10 -07:00
committed by gsxdsm
parent 0eae54d9b2
commit ca0cb2b52e
14 changed files with 959 additions and 202 deletions

View File

@@ -35,6 +35,7 @@ import {
parseAgentResponse,
buildDepthPromptSuffix,
generateSubtasksFromPlanning,
mergePlanningSubtaskDrafts,
formatInterviewQA,
SESSION_TTL_MS,
GENERATION_TIMEOUT_MS,
@@ -2242,6 +2243,76 @@ describe("planning module", () => {
expect(result[i]?.dependsOn).toEqual([`subtask-${i}`]);
}
});
it("merges compact subtask drafts onto generated planning subtasks", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Compact draft merge test");
const generated = generateSubtasksFromPlanning(sessionId);
const merged = mergePlanningSubtaskDrafts(sessionId, [
{ id: generated[0]!.id },
{
id: generated[1]!.id,
title: "Edited tests deliverable",
description: "Edited description",
suggestedSize: "L",
priority: "urgent",
dependsOn: [generated[0]!.id],
},
{
id: generated[2]!.id,
dependsOn: [generated[0]!.id, generated[1]!.id],
},
]);
expect(merged[0]).toEqual(generated[0]);
expect(merged[1]).toEqual({
...generated[1],
title: "Edited tests deliverable",
description: "Edited description",
suggestedSize: "L",
priority: "urgent",
});
expect(merged[2]).toEqual({
...generated[2],
dependsOn: [generated[0]!.id, generated[1]!.id],
});
});
it("preserves client-added subtasks when merging compact drafts", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Client-added compact draft test");
const merged = mergePlanningSubtaskDrafts(sessionId, [
{ id: "subtask-1" },
{
id: "subtask-99",
title: "New client-added subtask",
description: "Create docs and rollout notes",
suggestedSize: "S",
priority: "high",
dependsOn: ["subtask-1"],
},
]);
expect(merged[1]).toEqual({
id: "subtask-99",
title: "New client-added subtask",
description: "Create docs and rollout notes",
suggestedSize: "S",
priority: "high",
dependsOn: ["subtask-1"],
});
});
it("throws when a client-added compact subtask draft omits its title", async () => {
const mockIp = getUniqueIp();
const sessionId = await createCompletedSession(mockIp, "Unknown compact draft test");
expect(() => mergePlanningSubtaskDrafts(sessionId, [{ id: "subtask-999" }])).toThrow(
"Client-added subtask must have a title: subtask-999",
);
});
});
});

View File

@@ -1255,8 +1255,8 @@ describe("projectId store scoping regressions", () => {
planningSessionId: "plan-session-2",
projectId,
subtasks: [
{ id: "sub-1", title: "First scoped task", description: "First", suggestedSize: "S", dependsOn: [] },
{ id: "sub-2", title: "Second scoped task", description: "Second", suggestedSize: "M", dependsOn: ["sub-1"] },
{ id: "subtask-1", title: "First scoped task", description: "First", suggestedSize: "S", dependsOn: [] },
{ id: "subtask-2", title: "Second scoped task", description: "Second", suggestedSize: "M", dependsOn: ["subtask-1"] },
],
}),
{ "Content-Type": "application/json" },

View File

@@ -1526,7 +1526,7 @@ describe("Planning Mode Routes", () => {
);
});
it("creates multiple planning tasks with per-subtask priorities and defaults", async () => {
it("creates multiple planning tasks from compact subtask drafts while preserving edited fields", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
id: "FN-201",
@@ -1543,6 +1543,14 @@ describe("Planning Mode Routes", () => {
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
.mockResolvedValueOnce({
id: "FN-203",
description: "Third",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
@@ -1560,6 +1568,24 @@ describe("Planning Mode Routes", () => {
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
const breakdownRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-breakdown",
JSON.stringify({ sessionId: planningSessionId }),
{ "Content-Type": "application/json" }
);
expect(breakdownRes.status).toBe(200);
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
priority?: string;
dependsOn: string[];
}>;
const res = await REQUEST(
buildApp(),
"POST",
@@ -1568,19 +1594,19 @@ describe("Planning Mode Routes", () => {
planningSessionId,
subtasks: [
{
id: "subtask-1",
id: generatedSubtasks[0]!.id,
title: "Auth backend",
description: "Implement backend",
suggestedSize: "M",
suggestedSize: "L",
priority: "urgent",
dependsOn: [],
},
{
id: "subtask-2",
title: "Auth UI",
description: "Implement UI",
suggestedSize: "S",
dependsOn: ["subtask-1"],
id: generatedSubtasks[1]!.id,
},
{
id: generatedSubtasks[2]!.id,
dependsOn: [generatedSubtasks[0]!.id, generatedSubtasks[1]!.id],
},
],
}),
@@ -1590,12 +1616,211 @@ describe("Planning Mode Routes", () => {
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ title: "Auth backend", priority: "urgent" }),
expect.objectContaining({ title: "Auth backend", description: "Implement backend", priority: "urgent" }),
);
expect(store.createTask).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ title: "Auth UI", priority: "normal" }),
expect.objectContaining({
title: generatedSubtasks[1]!.title,
description: generatedSubtasks[1]!.description,
priority: "normal",
}),
);
expect(store.createTask).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
title: generatedSubtasks[2]!.title,
description: generatedSubtasks[2]!.description,
priority: "normal",
}),
);
expect(store.updateTask).toHaveBeenCalledWith("FN-201", { size: "L" });
expect(store.updateTask).toHaveBeenCalledWith("FN-203", { dependencies: ["FN-201", "FN-202"] });
});
it("supports client-added subtasks and omitted generated subtasks in compact breakdown payloads", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
id: "FN-210",
description: "Generated task",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
.mockResolvedValueOnce({
id: "FN-211",
description: "Client-added task",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Build a user auth system" }),
{ "Content-Type": "application/json" }
);
const planningSessionId = startRes.body.sessionId;
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
const breakdownRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-breakdown",
JSON.stringify({ sessionId: planningSessionId }),
{ "Content-Type": "application/json" }
);
expect(breakdownRes.status).toBe(200);
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
priority?: string;
dependsOn: string[];
}>;
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-tasks",
JSON.stringify({
planningSessionId,
subtasks: [
{ id: generatedSubtasks[0]!.id },
{
id: "subtask-99",
title: "Rollout follow-up",
description: "Prepare rollout notes",
suggestedSize: "S",
priority: "high",
dependsOn: [generatedSubtasks[0]!.id],
},
],
}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledTimes(2);
expect(store.createTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
title: generatedSubtasks[0]!.title,
description: generatedSubtasks[0]!.description,
}),
);
expect(store.createTask).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
title: "Rollout follow-up",
description: "Prepare rollout notes",
priority: "high",
}),
);
expect(store.updateTask).toHaveBeenCalledWith("FN-211", { size: "S" });
expect(store.updateTask).toHaveBeenCalledWith("FN-211", { dependencies: ["FN-210"] });
});
it("accepts compact breakdown payloads that avoid oversized planning create-tasks requests", async () => {
const createdTaskBase = {
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
for (let index = 0; index < 15; index += 1) {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...createdTaskBase,
id: `FN-${300 + index}`,
description: `Task ${index + 1}`,
});
}
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan: "Break a large platform plan into many tasks" }),
{ "Content-Type": "application/json" }
);
const planningSessionId = startRes.body.sessionId;
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { scope: "large" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { requirements: "Must support auth, settings, dashboards, workflows, imports, sync, audits, search, mobile, docs, QA, releases, telemetry, reliability, and security." } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId: planningSessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
const summaryOverride = {
title: "Large planning summary",
description: `${"Large planning context. ".repeat(400)}${"Detailed implementation note. ".repeat(400)}`,
suggestedSize: "L",
suggestedDependencies: [],
keyDeliverables: Array.from({ length: 15 }, (_, index) => `Deliverable ${index + 1}`),
};
const breakdownRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-breakdown",
JSON.stringify({ sessionId: planningSessionId, summary: summaryOverride }),
{ "Content-Type": "application/json" }
);
expect(breakdownRes.status).toBe(200);
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
priority?: string;
dependsOn: string[];
}>;
expect(generatedSubtasks).toHaveLength(15);
const oversizedLegacyPayload = JSON.stringify({ planningSessionId, subtasks: generatedSubtasks });
const compactPayload = JSON.stringify({
planningSessionId,
subtasks: generatedSubtasks.map((subtask) => ({ id: subtask.id })),
});
expect(Buffer.byteLength(oversizedLegacyPayload)).toBeGreaterThan(100 * 1024);
expect(Buffer.byteLength(compactPayload)).toBeLessThan(8 * 1024);
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-tasks",
compactPayload,
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledTimes(15);
expect(store.createTask).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
title: generatedSubtasks[0]!.title,
description: generatedSubtasks[0]!.description,
}),
);
expect(store.createTask).toHaveBeenNthCalledWith(
15,
expect.objectContaining({
title: generatedSubtasks[14]!.title,
description: generatedSubtasks[14]!.description,
}),
);
expect(store.logEntry).toHaveBeenCalledTimes(15);
});
it("applies branchSelection when creating a planning task", async () => {