523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
|
|
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
|
import type { ThreadFilterClause, ThreadView } from "@/lib/state/slices/ui/thread-view-types";
|
|
import { queryThreadView, selectThreadCandidates } from "./thread-view-query";
|
|
|
|
type TaskOverrides = Partial<KanbanState["tasks"][number]> & { id: string };
|
|
const REVIEW_STEP_ID = "step-review";
|
|
const SECOND_WORKFLOW_ID = "workflow-2";
|
|
const SAME_ACTIVITY = "2026-08-31T12:00:00Z";
|
|
|
|
function task(overrides: TaskOverrides): KanbanState["tasks"][number] {
|
|
const { id, ...rest } = overrides;
|
|
return {
|
|
id,
|
|
workspaceId: "workspace-1",
|
|
workflowId: "workflow-1",
|
|
workflowStepId: "step-build",
|
|
title: `Task ${id}`,
|
|
position: 0,
|
|
state: "IN_PROGRESS",
|
|
primarySessionId: `session-${id}`,
|
|
primarySessionState: "RUNNING",
|
|
updatedAt: "2026-08-31T10:00:00Z",
|
|
createdAt: "2026-08-30T10:00:00Z",
|
|
...rest,
|
|
};
|
|
}
|
|
|
|
function summary(overrides: Partial<TaskStatusSummary> = {}): TaskStatusSummary {
|
|
return {
|
|
revision: 1,
|
|
updated_at: "2026-08-31T10:00:00Z",
|
|
primary_session: { id: "session-default", state: "RUNNING" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function snapshot(
|
|
tasks: KanbanState["tasks"],
|
|
workflowId = "workflow-1",
|
|
workflowName = "Delivery",
|
|
): WorkflowSnapshotData {
|
|
return {
|
|
workflowId,
|
|
workflowName,
|
|
steps: [
|
|
{ id: REVIEW_STEP_ID, title: "Review", color: "green", position: 1 },
|
|
{ id: "step-build", title: "Build", color: "blue", position: 0 },
|
|
],
|
|
tasks,
|
|
};
|
|
}
|
|
|
|
function view(overrides: Partial<ThreadView> = {}): ThreadView {
|
|
return {
|
|
id: "view-all-threads",
|
|
name: "All threads",
|
|
taskScope: { mode: "all", taskIds: [] },
|
|
filters: [],
|
|
sort: { key: "attention", direction: "asc" },
|
|
maxColumns: null,
|
|
layout: "columns",
|
|
autoHideComposer: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function clause(
|
|
dimension: ThreadFilterClause["dimension"],
|
|
value: ThreadFilterClause["value"],
|
|
op: ThreadFilterClause["op"] = "is",
|
|
): ThreadFilterClause {
|
|
return { id: `${dimension}-${op}`, dimension, op, value };
|
|
}
|
|
|
|
function richTask(): KanbanState["tasks"][number] {
|
|
return task({
|
|
id: "rich",
|
|
workflowStepId: REVIEW_STEP_ID,
|
|
title: "Fix checkout regression",
|
|
priority: "critical",
|
|
state: "REVIEW",
|
|
blocked: true,
|
|
labels: ["urgent", "frontend"],
|
|
primaryAgentProfileId: "agent-profile-1",
|
|
primaryAgentName: "Reviewer",
|
|
primaryExecutorType: "ssh",
|
|
foregroundActivity: "background",
|
|
interrupted: true,
|
|
sessionCount: 2,
|
|
isPRReview: true,
|
|
statusSummary: summary({
|
|
primary_session: { id: "session-rich", state: "WAITING_FOR_INPUT" },
|
|
pending_action: "permission",
|
|
queued_prompt_count: 2,
|
|
active_subagent_count: 1,
|
|
active_error: {
|
|
stamp: "error-1",
|
|
occurred_at: "2026-08-31T09:00:00Z",
|
|
preview: "The check failed",
|
|
},
|
|
git: { changed_files: 2 },
|
|
foreground_activity: "background",
|
|
pull_request: {
|
|
count: 1,
|
|
open_count: 1,
|
|
attention: true,
|
|
number: 42,
|
|
state: "open",
|
|
aggregate_state: "checks_failed",
|
|
},
|
|
last_activity_at: "2026-08-31T11:00:00Z",
|
|
}),
|
|
});
|
|
}
|
|
|
|
describe("selectThreadCandidates", () => {
|
|
it("projects bounded task data without applying the active workflow filter", () => {
|
|
const candidates = selectThreadCandidates({
|
|
"workflow-1": snapshot([richTask()]),
|
|
[SECOND_WORKFLOW_ID]: snapshot(
|
|
[
|
|
task({
|
|
id: "other-workflow",
|
|
workflowId: SECOND_WORKFLOW_ID,
|
|
workspaceId: "workspace-1",
|
|
}),
|
|
],
|
|
SECOND_WORKFLOW_ID,
|
|
"Operations",
|
|
),
|
|
});
|
|
|
|
expect(candidates.map((candidate) => candidate.taskId)).toEqual(
|
|
expect.arrayContaining(["rich", "other-workflow"]),
|
|
);
|
|
expect(candidates).toHaveLength(2);
|
|
expect(candidates.find((candidate) => candidate.taskId === "rich")).toMatchObject({
|
|
workflowStepId: REVIEW_STEP_ID,
|
|
taskState: "REVIEW",
|
|
priority: "critical",
|
|
blocked: true,
|
|
labels: ["urgent", "frontend"],
|
|
taskType: "pull_request_review",
|
|
primaryAgentProfileId: "agent-profile-1",
|
|
executorType: "ssh",
|
|
repositoryIds: [],
|
|
threadStatus: "needs_action",
|
|
hasDiff: true,
|
|
hasPR: true,
|
|
prNeedsAttention: true,
|
|
hasActiveError: true,
|
|
hasMultipleSessions: true,
|
|
foregroundActivity: "background",
|
|
interrupted: true,
|
|
isOnLastWorkflowStep: true,
|
|
prInfo: {
|
|
number: 42,
|
|
state: "Open",
|
|
aggregateState: "checks_failed",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps selected IDs out of the result while they are ineligible", () => {
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([
|
|
task({ id: "selected", primarySessionState: "COMPLETED" }),
|
|
task({ id: "running" }),
|
|
]),
|
|
},
|
|
view({ taskScope: { mode: "selected", taskIds: ["selected", "running"] } }),
|
|
);
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual(["running"]);
|
|
expect(result.candidates.map((candidate) => candidate.taskId)).toEqual(["running"]);
|
|
});
|
|
});
|
|
|
|
describe("queryThreadView filters", () => {
|
|
const filterCases: Array<{
|
|
dimension: ThreadFilterClause["dimension"];
|
|
value: ThreadFilterClause["value"];
|
|
op?: ThreadFilterClause["op"];
|
|
}> = [
|
|
{ dimension: "threadStatus", value: "needs_action" },
|
|
{ dimension: "pendingAction", value: "permission" },
|
|
{ dimension: "taskState", value: "REVIEW" },
|
|
{ dimension: "workflow", value: "workflow-1" },
|
|
{ dimension: "workflowStep", value: REVIEW_STEP_ID },
|
|
{ dimension: "repository", value: "repo-1" },
|
|
{ dimension: "primaryAgent", value: "agent-profile-1" },
|
|
{ dimension: "executorType", value: "ssh" },
|
|
{ dimension: "priority", value: "critical" },
|
|
{ dimension: "blocked", value: true },
|
|
{ dimension: "hasQueuedPrompts", value: true },
|
|
{ dimension: "hasActiveSubagents", value: true },
|
|
{ dimension: "hasDiff", value: true },
|
|
{ dimension: "hasPR", value: true },
|
|
{ dimension: "prNeedsAttention", value: true },
|
|
{ dimension: "taskType", value: "pull_request_review" },
|
|
{ dimension: "titleMatch", value: "CHECKOUT", op: "matches" },
|
|
{ dimension: "hasActiveError", value: true },
|
|
{ dimension: "taskLabel", value: "urgent" },
|
|
{ dimension: "taskOrigin", value: "automation_run" },
|
|
{ dimension: "hasMultipleSessions", value: true },
|
|
];
|
|
|
|
it.each(filterCases)("supports the $dimension dimension", ({ dimension, value, op }) => {
|
|
const candidateTask = richTask();
|
|
candidateTask.repositories = [
|
|
{
|
|
id: "task-repo-1",
|
|
repository_id: "repo-1",
|
|
base_branch: "main",
|
|
position: 0,
|
|
},
|
|
];
|
|
candidateTask.metadata = { origin: "automation_run" };
|
|
const snapshots: Record<string, WorkflowSnapshotData> =
|
|
dimension === "workflow"
|
|
? {
|
|
"workflow-1": snapshot([candidateTask]),
|
|
[SECOND_WORKFLOW_ID]: snapshot(
|
|
[task({ id: "plain", workflowId: SECOND_WORKFLOW_ID })],
|
|
SECOND_WORKFLOW_ID,
|
|
"Operations",
|
|
),
|
|
}
|
|
: { "workflow-1": snapshot([candidateTask, task({ id: "plain" })]) };
|
|
const result = queryThreadView(
|
|
snapshots,
|
|
view({ filters: [clause(dimension, value, op ?? "is")] }),
|
|
);
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual(["rich"]);
|
|
});
|
|
|
|
it("uses AND between clauses and OR inside an in clause", () => {
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([
|
|
richTask(),
|
|
task({ id: "plain", priority: "critical" }),
|
|
task({ id: "other", priority: "low" }),
|
|
]),
|
|
},
|
|
view({
|
|
filters: [
|
|
clause("priority", ["critical", "high"], "in"),
|
|
clause("taskLabel", ["urgent", "missing"], "in"),
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual(["rich"]);
|
|
});
|
|
|
|
it("supports negative and substring operators", () => {
|
|
const result = queryThreadView(
|
|
{ "workflow-1": snapshot([richTask(), task({ id: "plain", title: "Small fix" })]) },
|
|
view({
|
|
filters: [
|
|
clause("titleMatch", "checkout", "matches"),
|
|
clause("taskType", "standard", "not_matches"),
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(result.matchingCandidates).toHaveLength(1);
|
|
expect(result.matchingCandidates[0].taskId).toBe("rich");
|
|
});
|
|
});
|
|
|
|
describe("queryThreadView attention sort", () => {
|
|
it("keeps attention buckets and recency order for All threads", () => {
|
|
const candidates = [
|
|
task({
|
|
id: "waiting-new",
|
|
primarySessionState: "WAITING_FOR_INPUT",
|
|
statusSummary: summary({
|
|
primary_session: { id: "session-waiting-new", state: "WAITING_FOR_INPUT" },
|
|
last_activity_at: "2026-08-31T13:00:00Z",
|
|
}),
|
|
}),
|
|
task({
|
|
id: "review-old",
|
|
state: "REVIEW",
|
|
primarySessionState: "COMPLETED",
|
|
statusSummary: summary({
|
|
primary_session: { id: "session-review-old", state: "COMPLETED" },
|
|
last_activity_at: "2026-08-31T10:00:00Z",
|
|
}),
|
|
}),
|
|
task({
|
|
id: "running-new",
|
|
primarySessionState: "RUNNING",
|
|
statusSummary: summary({
|
|
primary_session: { id: "session-running-new", state: "RUNNING" },
|
|
last_activity_at: SAME_ACTIVITY,
|
|
}),
|
|
}),
|
|
task({
|
|
id: "action-old",
|
|
primarySessionState: "WAITING_FOR_INPUT",
|
|
statusSummary: summary({
|
|
primary_session: { id: "session-action-old", state: "WAITING_FOR_INPUT" },
|
|
pending_action: "clarification",
|
|
last_activity_at: "2026-08-31T09:00:00Z",
|
|
}),
|
|
}),
|
|
task({
|
|
id: "action-new",
|
|
primarySessionState: "WAITING_FOR_INPUT",
|
|
statusSummary: summary({
|
|
primary_session: { id: "session-action-new", state: "WAITING_FOR_INPUT" },
|
|
pending_action: "permission",
|
|
last_activity_at: "2026-08-31T11:00:00Z",
|
|
}),
|
|
}),
|
|
];
|
|
|
|
const result = queryThreadView({ "workflow-1": snapshot(candidates) }, view());
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual([
|
|
"action-new",
|
|
"action-old",
|
|
"running-new",
|
|
"review-old",
|
|
"waiting-new",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("queryThreadView sort and admission", () => {
|
|
it("sorts deterministically and limits the admitted candidates", () => {
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([
|
|
task({ id: "c", updatedAt: SAME_ACTIVITY }),
|
|
task({ id: "a", updatedAt: SAME_ACTIVITY }),
|
|
task({ id: "b", updatedAt: SAME_ACTIVITY }),
|
|
]),
|
|
},
|
|
view({ sort: { key: "updatedAt", direction: "desc" }, maxColumns: 2 }),
|
|
);
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual(["a", "b", "c"]);
|
|
expect(result.admittedCandidates.map((candidate) => candidate.taskId)).toEqual(["a", "b"]);
|
|
expect(result.matchingCount).toBe(3);
|
|
expect(result.hiddenCount).toBe(1);
|
|
});
|
|
|
|
it("temporarily admits a valid hidden deep-link target without changing the view", () => {
|
|
const activeView = view({
|
|
taskScope: { mode: "selected", taskIds: ["first", "second", "hidden"] },
|
|
maxColumns: 2,
|
|
});
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([
|
|
task({ id: "first" }),
|
|
task({ id: "second" }),
|
|
task({ id: "hidden" }),
|
|
]),
|
|
},
|
|
activeView,
|
|
{ requestedTaskId: "hidden" },
|
|
);
|
|
|
|
expect(result.admittedCandidates.map((candidate) => candidate.taskId)).toEqual([
|
|
"first",
|
|
"hidden",
|
|
]);
|
|
expect(result.effectiveView).toEqual(activeView);
|
|
});
|
|
|
|
it("admits an eligible deep-link target outside a selected scope", () => {
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([task({ id: "selected" }), task({ id: "linked" })]),
|
|
},
|
|
view({ taskScope: { mode: "selected", taskIds: ["selected"] }, maxColumns: 1 }),
|
|
{ requestedTaskId: "linked" },
|
|
);
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual(["selected"]);
|
|
expect(result.admittedCandidates.map((candidate) => candidate.taskId)).toEqual(["linked"]);
|
|
});
|
|
|
|
it("temporarily admits an eligible deep-link target hidden by a saved filter", () => {
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([
|
|
task({ id: "matching", title: "Matching task" }),
|
|
task({ id: "filtered", title: "Filtered task" }),
|
|
]),
|
|
},
|
|
view({
|
|
filters: [clause("titleMatch", "matching", "matches")],
|
|
maxColumns: 1,
|
|
}),
|
|
{ requestedTaskId: "filtered" },
|
|
);
|
|
|
|
expect(result.matchingCandidates.map((candidate) => candidate.taskId)).toEqual(["matching"]);
|
|
expect(result.admittedCandidates.map((candidate) => candidate.taskId)).toEqual(["filtered"]);
|
|
expect(result.matchingCount).toBe(1);
|
|
expect(result.temporaryAdmissionCount).toBe(1);
|
|
expect(result.hiddenCount).toBe(1);
|
|
});
|
|
|
|
it("merges a matching draft without changing the saved view identity", () => {
|
|
const activeView = view({ maxColumns: null });
|
|
const result = queryThreadView(
|
|
{ "workflow-1": snapshot([task({ id: "one" }), task({ id: "two" })]) },
|
|
activeView,
|
|
{
|
|
draft: {
|
|
baseViewId: activeView.id,
|
|
taskScope: { mode: "selected", taskIds: ["two"] },
|
|
filters: [],
|
|
sort: { key: "title", direction: "asc" },
|
|
maxColumns: 1,
|
|
layout: "columns",
|
|
autoHideComposer: false,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.admittedCandidates.map((candidate) => candidate.taskId)).toEqual(["two"]);
|
|
expect(result.effectiveView).toMatchObject({ id: activeView.id, name: activeView.name });
|
|
expect(result.effectiveView.maxColumns).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("thread view fingerprint", () => {
|
|
// @covers AC-UI-THREADS-SAVED-VIEWS-005.7
|
|
it("applies draft presentation without changing admission or the query fingerprint", () => {
|
|
const snapshots = { "workflow-1": snapshot([task({ id: "one" }), task({ id: "two" })]) };
|
|
const saved = view();
|
|
const before = queryThreadView(snapshots, saved);
|
|
const after = queryThreadView(snapshots, saved, {
|
|
draft: { ...saved, baseViewId: saved.id, layout: "grid", autoHideComposer: true },
|
|
});
|
|
expect(after.effectiveView).toMatchObject({ layout: "grid", autoHideComposer: true });
|
|
expect(after.fingerprint).toBe(before.fingerprint);
|
|
expect(after.admittedCandidates).toEqual(before.admittedCandidates);
|
|
expect(after.stableCandidates).toEqual(before.stableCandidates);
|
|
});
|
|
it("changes when query inputs change and remains stable for equivalent views", () => {
|
|
const snapshots = { "workflow-1": snapshot([task({ id: "one" })]) };
|
|
const first = queryThreadView(snapshots, view({ maxColumns: 2 }));
|
|
const equivalent = queryThreadView(snapshots, view({ maxColumns: 2 }));
|
|
const changed = queryThreadView(snapshots, view({ maxColumns: 3 }));
|
|
|
|
expect(first.fingerprint).toBe(equivalent.fingerprint);
|
|
expect(first.fingerprint).not.toBe(changed.fingerprint);
|
|
});
|
|
});
|
|
|
|
// @covers AC-TASKS-THREADS-ACTIONS-003.7, AC-TASKS-THREADS-ACTIONS-003.8
|
|
describe("pending archive exclusion", () => {
|
|
it("excludes pending tasks before scopes, counts and column limits", () => {
|
|
const snapshots = {
|
|
"workflow-1": snapshot([task({ id: "a" }), task({ id: "b" }), task({ id: "c" })]),
|
|
};
|
|
const activeView = view({
|
|
taskScope: { mode: "selected", taskIds: ["a", "b", "c"] },
|
|
maxColumns: 1,
|
|
});
|
|
const baseline = queryThreadView(snapshots, activeView);
|
|
const result = queryThreadView(snapshots, activeView, {
|
|
requestedTaskId: "a",
|
|
excludedTaskIds: new Set(["a"]),
|
|
});
|
|
|
|
for (const candidates of [
|
|
result.candidates,
|
|
result.matchingCandidates,
|
|
result.stableCandidates,
|
|
]) {
|
|
expect(candidates.map(({ taskId }) => taskId)).toEqual(["b", "c"]);
|
|
}
|
|
expect(result.admittedCandidates.map(({ taskId }) => taskId)).toEqual(["b"]);
|
|
expect(result).toMatchObject({ matchingCount: 2, temporaryAdmissionCount: 0, hiddenCount: 1 });
|
|
expect(result.fingerprint).toBe(baseline.fingerprint);
|
|
expect(result.effectiveView).toEqual(activeView);
|
|
expect(snapshots["workflow-1"].tasks.map(({ id }) => id)).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("does not temporarily admit an excluded deep link outside the saved filter", () => {
|
|
const result = queryThreadView(
|
|
{ "workflow-1": snapshot([task({ id: "a" }), task({ id: "b" })]) },
|
|
view({ filters: [clause("titleMatch", "Task b", "matches")], maxColumns: 1 }),
|
|
{ requestedTaskId: "a", excludedTaskIds: new Set(["a"]) },
|
|
);
|
|
|
|
expect(result.admittedCandidates.map(({ taskId }) => taskId)).toEqual(["b"]);
|
|
expect(result).toMatchObject({ matchingCount: 1, temporaryAdmissionCount: 0, hiddenCount: 0 });
|
|
});
|
|
|
|
it("excludes every covered descendant and returns an empty query when none survive", () => {
|
|
const result = queryThreadView(
|
|
{
|
|
"workflow-1": snapshot([
|
|
task({ id: "parent" }),
|
|
task({ id: "child", parentTaskId: "parent" }),
|
|
]),
|
|
},
|
|
view(),
|
|
{ excludedTaskIds: new Set(["parent", "child"]) },
|
|
);
|
|
|
|
expect(result.candidates).toEqual([]);
|
|
expect(result.stableCandidates).toEqual([]);
|
|
expect(result.admittedCandidates).toEqual([]);
|
|
expect(result).toMatchObject({ matchingCount: 0, temporaryAdmissionCount: 0, hiddenCount: 0 });
|
|
});
|
|
});
|