feat(FN-3799): normalize empty and null assignment inputs to clear agent as
Fixes assignment clearing in the CLI extension by normalizing nullable inputs, ensuring empty strings and literal null values properly clear agent assignments rather than being passed through unchanged; adds comprehensive test coverage for the normalization behavior and a patch changeset. Fusion-Task-Id: FN-3799
This commit is contained in:
5
.changeset/fn-3799-clear-agent-assignment.md
Normal file
5
.changeset/fn-3799-clear-agent-assignment.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix `fn_task_update` (and `fn_task_create`) silently failing with "Agent not found" when callers pass an empty string or the literal string `"null"` to clear a task's agent assignment. Empty/whitespace strings and `"null"` are now normalized to a clear-assignment signal, matching the dashboard `PATCH /api/tasks/:id` contract. JSON `null` continues to work as before.
|
||||||
@@ -358,6 +358,22 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
|||||||
expect(result.details.assignedAgentId).toBeUndefined();
|
expect(result.details.assignedAgentId).toBeUndefined();
|
||||||
expect(result.content[0].text).not.toContain("Assigned to:");
|
expect(result.content[0].text).not.toContain("Assigned to:");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-3799: treats empty-string agentId as unassigned on create", async () => {
|
||||||
|
const tool = api.tools.get("fn_task_create")!;
|
||||||
|
const result = await tool.execute(
|
||||||
|
"call-1",
|
||||||
|
{ description: "Task without assignee", agentId: "" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isError).not.toBe(true);
|
||||||
|
expect(result.details.assignedAgentId).toBeUndefined();
|
||||||
|
expect(result.content[0].text).not.toContain("Agent not found");
|
||||||
|
expect(result.content[0].text).not.toContain("Assigned to:");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fn_task_update", () => {
|
describe("fn_task_update", () => {
|
||||||
@@ -458,6 +474,120 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
|||||||
expect(show.details.task.assignedAgentId).toBe(agentId);
|
expect(show.details.task.assignedAgentId).toBe(agentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("FN-3799: clears task assigned agent ID with empty string", async () => {
|
||||||
|
const agentId = await seedAgent(tmpDir);
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
await createTool.execute(
|
||||||
|
"c1",
|
||||||
|
{ description: "Original", agentId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const result = await updateTool.execute(
|
||||||
|
"u1",
|
||||||
|
{ id: "FN-001", agentId: "" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content[0].text).toContain("Updated FN-001");
|
||||||
|
expect(result.details.updatedFields).toEqual(["agentId"]);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
|
expect(show.details.task.assignedAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears task assigned agent ID with whitespace", async () => {
|
||||||
|
const agentId = await seedAgent(tmpDir);
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
await createTool.execute(
|
||||||
|
"c1",
|
||||||
|
{ description: "Original", agentId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const result = await updateTool.execute(
|
||||||
|
"u1",
|
||||||
|
{ id: "FN-001", agentId: " " },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content[0].text).toContain("Updated FN-001");
|
||||||
|
expect(result.details.updatedFields).toEqual(["agentId"]);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
|
expect(show.details.task.assignedAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears task assigned agent ID with literal null string", async () => {
|
||||||
|
const agentId = await seedAgent(tmpDir);
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
await createTool.execute(
|
||||||
|
"c1",
|
||||||
|
{ description: "Original", agentId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const result = await updateTool.execute(
|
||||||
|
"u1",
|
||||||
|
{ id: "FN-001", agentId: "null" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content[0].text).toContain("Updated FN-001");
|
||||||
|
expect(result.details.updatedFields).toEqual(["agentId"]);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
|
expect(show.details.task.assignedAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears node override with empty string", async () => {
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const setNode = await updateTool.execute(
|
||||||
|
"u1",
|
||||||
|
{ id: "FN-001", nodeId: "node-123" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
expect(setNode.isError).not.toBe(true);
|
||||||
|
|
||||||
|
const clearNode = await updateTool.execute(
|
||||||
|
"u2",
|
||||||
|
{ id: "FN-001", nodeId: "" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearNode.content[0].text).toContain("Updated FN-001");
|
||||||
|
expect(clearNode.details.updatedFields).toEqual(["nodeId"]);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute("s1", { id: "FN-001" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
|
expect(show.details.task.nodeId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unknown agent IDs on update", async () => {
|
it("rejects unknown agent IDs on update", async () => {
|
||||||
const createTool = api.tools.get("fn_task_create")!;
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
|
await createTool.execute("c1", { description: "Original" }, undefined, undefined, makeCtx(tmpDir));
|
||||||
@@ -472,7 +602,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
expect(result.content[0].text).toContain("Agent agent-does-not-exist not found");
|
expect(result.content[0].text).toBe("Agent agent-does-not-exist not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears task assigned agent ID with null", async () => {
|
it("clears task assigned agent ID with null", async () => {
|
||||||
@@ -1384,6 +1514,185 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
|||||||
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
|
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FN-3799 assignment normalization", () => {
|
||||||
|
it("FN-3799: treats empty-string agentId as unassigned on create", async () => {
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
const result = await createTool.execute(
|
||||||
|
"create-empty-agent",
|
||||||
|
{ description: "Task without assignee", agentId: "" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isError).not.toBe(true);
|
||||||
|
expect(result.details.assignedAgentId).toBeUndefined();
|
||||||
|
expect(result.content[0].text).not.toContain("Assigned to:");
|
||||||
|
expect(result.content[0].text).not.toContain("Agent not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears task assigned agent ID with empty string", async () => {
|
||||||
|
const agentId = await seedAgent(tmpDir);
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
const created = await createTool.execute(
|
||||||
|
"create-assigned",
|
||||||
|
{ description: "Original", agentId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const result = await updateTool.execute(
|
||||||
|
"update-clear-empty",
|
||||||
|
{ id: created.details.taskId, agentId: "" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content[0].text).toContain(`Updated ${created.details.taskId}`);
|
||||||
|
expect(result.details.updatedFields).toEqual(["agentId"]);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute(
|
||||||
|
"show-cleared-empty",
|
||||||
|
{ id: created.details.taskId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
expect(show.details.task.assignedAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears task assigned agent ID with whitespace", async () => {
|
||||||
|
const agentId = await seedAgent(tmpDir);
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
const created = await createTool.execute(
|
||||||
|
"create-assigned-whitespace",
|
||||||
|
{ description: "Original", agentId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
await updateTool.execute(
|
||||||
|
"update-clear-whitespace",
|
||||||
|
{ id: created.details.taskId, agentId: " " },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute(
|
||||||
|
"show-cleared-whitespace",
|
||||||
|
{ id: created.details.taskId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
expect(show.details.task.assignedAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears task assigned agent ID with literal null string", async () => {
|
||||||
|
const agentId = await seedAgent(tmpDir);
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
const created = await createTool.execute(
|
||||||
|
"create-assigned-null-string",
|
||||||
|
{ description: "Original", agentId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
await updateTool.execute(
|
||||||
|
"update-clear-null-string",
|
||||||
|
{ id: created.details.taskId, agentId: "null" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute(
|
||||||
|
"show-cleared-null-string",
|
||||||
|
{ id: created.details.taskId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
expect(show.details.task.assignedAgentId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns readable unknown-agent errors with the invalid id", async () => {
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
const created = await createTool.execute(
|
||||||
|
"create-for-error",
|
||||||
|
{ description: "Original" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const result = await updateTool.execute(
|
||||||
|
"update-unknown-agent",
|
||||||
|
{ id: created.details.taskId, agentId: "agent-does-not-exist" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.content[0].text).toBe("Agent agent-does-not-exist not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears node override with empty string", async () => {
|
||||||
|
const createTool = api.tools.get("fn_task_create")!;
|
||||||
|
const created = await createTool.execute(
|
||||||
|
"create-node-task",
|
||||||
|
{ description: "Original" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTool = api.tools.get("fn_task_update")!;
|
||||||
|
const setNode = await updateTool.execute(
|
||||||
|
"set-node",
|
||||||
|
{ id: created.details.taskId, nodeId: "node-123" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
expect(setNode.isError).not.toBe(true);
|
||||||
|
|
||||||
|
const clearNode = await updateTool.execute(
|
||||||
|
"clear-node",
|
||||||
|
{ id: created.details.taskId, nodeId: "" },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearNode.content[0].text).toContain(`Updated ${created.details.taskId}`);
|
||||||
|
expect(clearNode.details.updatedFields).toEqual(["nodeId"]);
|
||||||
|
|
||||||
|
const showTool = api.tools.get("fn_task_show")!;
|
||||||
|
const show = await showTool.execute(
|
||||||
|
"show-cleared-node",
|
||||||
|
{ id: created.details.taskId },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
makeCtx(tmpDir),
|
||||||
|
);
|
||||||
|
expect(show.details.task.nodeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("fn_list_agents", () => {
|
describe("fn_list_agents", () => {
|
||||||
it("returns agent list", async () => {
|
it("returns agent list", async () => {
|
||||||
await seedAgent(tmpDir, { name: "alpha-agent" });
|
await seedAgent(tmpDir, { name: "alpha-agent" });
|
||||||
|
|||||||
@@ -111,6 +111,22 @@ async function validateAssignableAgentId(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNullableStringInput(value: string | null | undefined): string | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.toLowerCase() === "null") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
const INSIGHT_CATEGORIES: InsightCategory[] = [
|
const INSIGHT_CATEGORIES: InsightCategory[] = [
|
||||||
"quality",
|
"quality",
|
||||||
"performance",
|
"performance",
|
||||||
@@ -394,9 +410,11 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
const store = await getStore(ctx.cwd);
|
const store = await getStore(ctx.cwd);
|
||||||
|
|
||||||
if (params.agentId !== undefined) {
|
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
|
||||||
|
|
||||||
|
if (normalizedAgentId !== undefined && normalizedAgentId !== null) {
|
||||||
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "triage" };
|
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "triage" };
|
||||||
const error = await validateAssignableAgentId(ctx.cwd, params.agentId, candidateTask);
|
const error = await validateAssignableAgentId(ctx.cwd, normalizedAgentId, candidateTask);
|
||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: error }],
|
content: [{ type: "text", text: error }],
|
||||||
@@ -409,7 +427,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
const task = await store.createTask({
|
const task = await store.createTask({
|
||||||
description: params.description.trim(),
|
description: params.description.trim(),
|
||||||
dependencies: params.depends,
|
dependencies: params.depends,
|
||||||
assignedAgentId: params.agentId,
|
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
|
||||||
source: { sourceType: "api" },
|
source: { sourceType: "api" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -513,8 +531,9 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
updatedFields.push("dependencies");
|
updatedFields.push("dependencies");
|
||||||
}
|
}
|
||||||
if (params.agentId !== undefined) {
|
if (params.agentId !== undefined) {
|
||||||
if (params.agentId !== null) {
|
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
|
||||||
const error = await validateAssignableAgentId(ctx.cwd, params.agentId, task);
|
if (normalizedAgentId !== null) {
|
||||||
|
const error = await validateAssignableAgentId(ctx.cwd, normalizedAgentId, task);
|
||||||
if (error) {
|
if (error) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: error }],
|
content: [{ type: "text", text: error }],
|
||||||
@@ -523,11 +542,12 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updates.assignedAgentId = params.agentId;
|
updates.assignedAgentId = normalizedAgentId;
|
||||||
updatedFields.push("agentId");
|
updatedFields.push("agentId");
|
||||||
}
|
}
|
||||||
if (params.nodeId !== undefined) {
|
if (params.nodeId !== undefined) {
|
||||||
const validation = validateNodeOverrideChange(task, params.nodeId ?? null);
|
const normalizedNodeId = normalizeNullableStringInput(params.nodeId);
|
||||||
|
const validation = validateNodeOverrideChange(task, normalizedNodeId ?? null);
|
||||||
if (!validation.allowed) {
|
if (!validation.allowed) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
|
content: [{ type: "text", text: validation.message ?? "Node override change blocked" }],
|
||||||
@@ -535,7 +555,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
details: { error: validation.reason },
|
details: { error: validation.reason },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
updates.nodeId = params.nodeId;
|
updates.nodeId = normalizedNodeId;
|
||||||
updatedFields.push("nodeId");
|
updatedFields.push("nodeId");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user