a2d086efb8
* feat(web): @-mention tasks in the chat composer Lets users type @<taskname> in the chat input to reference other tasks. The picked task is inserted as a chip and its metadata (id, workflow_id, step, state) is appended to the prompt as a <kandev-system> block so the agent can act on it via the kandev MCP tools. Extends the existing TipTap mention extension with a new "task" kind; task data comes from the kanban store (current workflow + visited kanbanMulti snapshots), so no backend changes are needed. * fix(web): harden task @-mentions from review feedback (#963) - Sanitize interpolated task strings (title, id, workflow_id, step, state) before they land in the <kandev-system> block so a hostile task title (e.g. from Jira/Linear sync) can't terminate the block early via embedded newlines or angle brackets. - Expand the task-mentions context block on the onSend code path too (used by preview-session-tabs), which was silently dropping the agent-facing task context when chips were present in the editor. - Deduplicate task mentions by taskId in getTaskMentions so mentioning the same task twice no longer produces redundant lines. - Skip stale state.kanban.tasks entries whose workflowStepId isn't in the current workflow's steps to prevent tagging them with the wrong workflow_id. * fix(web): skip task mentions missing workflowId or workflowStepId (#963) CodeRabbit flagged that getTaskMentions was filling missing workflowId / workflowStepId with empty strings, propagating malformed metadata into the prompt block. Skip such nodes during extraction. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { buildTaskMentionItems } from "./task-mention-items";
|
|
import type { AppState } from "@/lib/state/store";
|
|
|
|
function makeState(overrides: Partial<AppState> = {}): AppState {
|
|
const base = {
|
|
kanban: { workflowId: "wf-1", steps: [], tasks: [] },
|
|
kanbanMulti: { snapshots: {}, isLoading: false },
|
|
workflows: { items: [], activeId: null },
|
|
tasks: { activeTaskId: null, activeSessionId: null, pinnedSessionId: null },
|
|
} as unknown as AppState;
|
|
return { ...base, ...overrides } as AppState;
|
|
}
|
|
|
|
describe("buildTaskMentionItems / basics", () => {
|
|
it("returns tasks from the current workflow with workflow/step names resolved", () => {
|
|
const state = makeState({
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [{ id: "step-1", title: "Todo", color: "", position: 0 }],
|
|
tasks: [
|
|
{
|
|
id: "task-a",
|
|
workflowStepId: "step-1",
|
|
title: "Implement auth",
|
|
position: 0,
|
|
state: "in_progress",
|
|
},
|
|
],
|
|
},
|
|
workflows: {
|
|
items: [{ id: "wf-1", workspaceId: "ws-1", name: "Main flow" }],
|
|
activeId: "wf-1",
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
const items = buildTaskMentionItems(state, null);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0]).toMatchObject({
|
|
kind: "task",
|
|
label: "Implement auth",
|
|
description: "Main flow · Todo",
|
|
task: {
|
|
taskId: "task-a",
|
|
title: "Implement auth",
|
|
workflowId: "wf-1",
|
|
workflowStepId: "step-1",
|
|
state: "in_progress",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("excludes the current task by id", () => {
|
|
const state = makeState({
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [
|
|
{ id: "task-a", workflowStepId: "step-1", title: "A", position: 0 },
|
|
{ id: "task-b", workflowStepId: "step-1", title: "B", position: 1 },
|
|
],
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
const items = buildTaskMentionItems(state, "task-a");
|
|
expect(items.map((i) => i.task?.taskId)).toEqual(["task-b"]);
|
|
});
|
|
});
|
|
|
|
describe("buildTaskMentionItems / merging and filtering", () => {
|
|
it("merges tasks from kanbanMulti snapshots and dedupes by id", () => {
|
|
const state = makeState({
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [{ id: "task-a", workflowStepId: "step-1", title: "A", position: 0 }],
|
|
},
|
|
kanbanMulti: {
|
|
snapshots: {
|
|
"wf-1": {
|
|
workflowId: "wf-1",
|
|
workflowName: "Main",
|
|
steps: [],
|
|
tasks: [
|
|
{ id: "task-a", workflowStepId: "step-1", title: "A (dup)", position: 0 },
|
|
{ id: "task-c", workflowStepId: "step-2", title: "C", position: 0 },
|
|
],
|
|
},
|
|
"wf-2": {
|
|
workflowId: "wf-2",
|
|
workflowName: "Other",
|
|
steps: [{ id: "step-9", title: "Review", color: "", position: 0 }],
|
|
tasks: [{ id: "task-d", workflowStepId: "step-9", title: "D", position: 0 }],
|
|
},
|
|
},
|
|
isLoading: false,
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
const ids = buildTaskMentionItems(state, null).map((i) => i.task?.taskId);
|
|
expect(ids).toEqual(["task-a", "task-c", "task-d"]);
|
|
});
|
|
|
|
it("skips stale kanban tasks whose step is not in the current workflow's steps", () => {
|
|
const state = makeState({
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [{ id: "step-current", title: "Todo", color: "", position: 0 }],
|
|
tasks: [
|
|
{ id: "task-fresh", workflowStepId: "step-current", title: "Fresh", position: 0 },
|
|
// Left over from a previous workflow: its step is not in wf-1's steps,
|
|
// so tagging it with wf-1 would be wrong.
|
|
{ id: "task-stale", workflowStepId: "step-other", title: "Stale", position: 1 },
|
|
],
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
const ids = buildTaskMentionItems(state, null).map((i) => i.task?.taskId);
|
|
expect(ids).toEqual(["task-fresh"]);
|
|
});
|
|
|
|
it("falls back to placeholder names when workflow/step are missing", () => {
|
|
const state = makeState({
|
|
kanban: {
|
|
workflowId: "wf-1",
|
|
steps: [],
|
|
tasks: [{ id: "task-a", workflowStepId: "step-missing", title: "A", position: 0 }],
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
const [item] = buildTaskMentionItems(state, null);
|
|
expect(item.description).toBe("Workflow · Step");
|
|
});
|
|
});
|