Browse Source

test(35-02): add failing tests for subgraph extraction

- Create comprehensive test suite for extractSubgraph function
- Test cases cover: no selection, select all, single node, connected pairs,
  subset selection, type breakdown, disconnected nodes, multi-handle scenarios
- All 8 tests failing with "Not implemented" error (RED phase)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
d102922a4a
  1. 10
      .planning/ROADMAP.md
  2. 289
      src/lib/chat/subgraphExtractor.test.ts
  3. 54
      src/lib/chat/subgraphExtractor.ts

10
.planning/ROADMAP.md

@ -501,8 +501,8 @@ Plans:
Plans:
- [x] 34-01-PLAN.md — Chat agent library: tool definitions, edit operations, context builder
- [ ] 34-02-PLAN.md — Enhanced /api/chat route with tool calling for intent routing
- [ ] 34-03-PLAN.md — Store applyEditOperations, ChatPanel tool result handling, end-to-end wiring
- [x] 34-02-PLAN.md — Enhanced /api/chat route with tool calling for intent routing
- [x] 34-03-PLAN.md — Store applyEditOperations, ChatPanel tool result handling, end-to-end wiring
#### Phase 35: Large Workflow Handling
@ -510,10 +510,12 @@ Plans:
**Depends on**: Phase 34
**Research**: Likely (chunking, context window strategies)
**Research topics**: Token optimization for large payloads, base64 extraction/reinsertion, workflow summarization techniques
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 35-01: TBD (run /gsd:plan-phase 35 to break down)
- [ ] 35-01-PLAN.md — Binary stripping utility and rich workflow context builder (TDD)
- [ ] 35-02-PLAN.md — Selection-aware subgraph extraction (TDD)
- [ ] 35-03-PLAN.md — Client-side wiring, ChatPanel selection chip, API subgraph integration
### 📋 v1.5 Store Refactoring (Planned)

289
src/lib/chat/subgraphExtractor.test.ts

@ -0,0 +1,289 @@
/**
* Tests for subgraphExtractor.ts
*
* Validates selection-aware subgraph extraction for context scoping.
*/
import { describe, it, expect } from "vitest";
import type { WorkflowNode } from "@/types/nodes";
import type { WorkflowEdge } from "@/types/workflow";
import { extractSubgraph, SubgraphResult } from "./subgraphExtractor";
// Helper to create test nodes
function createNode(id: string, type: string, selected = false): WorkflowNode {
return {
id,
type: type as any,
position: { x: 0, y: 0 },
data: {},
selected,
};
}
// Helper to create test edges
function createEdge(
source: string,
target: string,
sourceHandle = "image",
targetHandle = "image"
): WorkflowEdge {
return {
id: `${source}-${target}`,
source,
target,
sourceHandle,
targetHandle,
};
}
describe("extractSubgraph", () => {
describe("No selection (empty array)", () => {
it("returns all nodes and edges when selectedNodeIds is empty", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "nanoBanana"),
createNode("C", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("A", "B", "text", "text"),
createEdge("B", "C", "image", "image"),
];
const result = extractSubgraph(nodes, edges, []);
expect(result.isScoped).toBe(false);
expect(result.selectedNodes).toHaveLength(3);
expect(result.selectedEdges).toHaveLength(2);
expect(result.restSummary).toBeNull();
});
});
describe("Select all nodes", () => {
it("returns all nodes with isScoped=true and empty restSummary", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "nanoBanana"),
createNode("C", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("A", "B", "text", "text"),
createEdge("B", "C", "image", "image"),
];
const result = extractSubgraph(nodes, edges, ["A", "B", "C"]);
expect(result.isScoped).toBe(true);
expect(result.selectedNodes).toHaveLength(3);
expect(result.selectedEdges).toHaveLength(2);
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(0);
expect(result.restSummary!.boundaryConnections).toHaveLength(0);
});
});
describe("Select one node in chain", () => {
it("selects middle node with incoming and outgoing boundary connections", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "nanoBanana"),
createNode("C", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("A", "B", "text", "text"),
createEdge("B", "C", "image", "image"),
];
const result = extractSubgraph(nodes, edges, ["B"]);
expect(result.isScoped).toBe(true);
expect(result.selectedNodes).toHaveLength(1);
expect(result.selectedNodes[0].id).toBe("B");
expect(result.selectedEdges).toHaveLength(0); // No edges fully within selection
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(2);
expect(result.restSummary!.boundaryConnections).toHaveLength(2);
const incoming = result.restSummary!.boundaryConnections.find(
(c) => c.direction === "incoming"
);
expect(incoming).toBeDefined();
expect(incoming!.selectedNodeId).toBe("B");
expect(incoming!.otherNodeId).toBe("A");
expect(incoming!.handleType).toBe("text");
const outgoing = result.restSummary!.boundaryConnections.find(
(c) => c.direction === "outgoing"
);
expect(outgoing).toBeDefined();
expect(outgoing!.selectedNodeId).toBe("B");
expect(outgoing!.otherNodeId).toBe("C");
expect(outgoing!.handleType).toBe("image");
});
});
describe("Select two connected nodes", () => {
it("includes edge between selected nodes", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "nanoBanana"),
createNode("C", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("A", "B", "text", "text"),
createEdge("B", "C", "image", "image"),
];
const result = extractSubgraph(nodes, edges, ["A", "B"]);
expect(result.isScoped).toBe(true);
expect(result.selectedNodes).toHaveLength(2);
expect(result.selectedEdges).toHaveLength(1);
expect(result.selectedEdges[0].id).toBe("A-B");
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(1);
expect(result.restSummary!.boundaryConnections).toHaveLength(1);
const boundary = result.restSummary!.boundaryConnections[0];
expect(boundary.direction).toBe("outgoing");
expect(boundary.selectedNodeId).toBe("B");
expect(boundary.otherNodeId).toBe("C");
});
});
describe("Select subset of chain", () => {
it("correctly identifies boundary connections for middle segment", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "nanoBanana"),
createNode("C", "annotation"),
createNode("D", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("A", "B", "text", "text"),
createEdge("B", "C", "image", "image"),
createEdge("C", "D", "image", "image"),
];
const result = extractSubgraph(nodes, edges, ["B", "C"]);
expect(result.isScoped).toBe(true);
expect(result.selectedNodes).toHaveLength(2);
expect(result.selectedEdges).toHaveLength(1);
expect(result.selectedEdges[0].id).toBe("B-C");
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(2);
expect(result.restSummary!.boundaryConnections).toHaveLength(2);
const incoming = result.restSummary!.boundaryConnections.find(
(c) => c.direction === "incoming" && c.selectedNodeId === "B"
);
expect(incoming).toBeDefined();
expect(incoming!.otherNodeId).toBe("A");
const outgoing = result.restSummary!.boundaryConnections.find(
(c) => c.direction === "outgoing" && c.selectedNodeId === "C"
);
expect(outgoing).toBeDefined();
expect(outgoing!.otherNodeId).toBe("D");
});
});
describe("Type breakdown", () => {
it("correctly counts node types in unselected nodes", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "prompt"),
createNode("C", "nanoBanana"),
createNode("D", "nanoBanana"),
createNode("E", "nanoBanana"),
createNode("F", "output"),
];
const edges: WorkflowEdge[] = [];
const result = extractSubgraph(nodes, edges, ["A"]);
expect(result.isScoped).toBe(true);
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(5);
expect(result.restSummary!.typeBreakdown).toEqual({
prompt: 1,
nanoBanana: 3,
output: 1,
});
});
});
describe("Disconnected selected nodes", () => {
it("handles selection with no edges between selected nodes", () => {
const nodes: WorkflowNode[] = [
createNode("A", "prompt"),
createNode("B", "nanoBanana"),
createNode("C", "prompt"),
createNode("D", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("A", "B", "text", "text"),
createEdge("C", "D", "text", "image"),
];
const result = extractSubgraph(nodes, edges, ["A", "C"]);
expect(result.isScoped).toBe(true);
expect(result.selectedNodes).toHaveLength(2);
expect(result.selectedEdges).toHaveLength(0); // No edges between A and C
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(2);
expect(result.restSummary!.boundaryConnections).toHaveLength(2);
// Both are outgoing since A->B and C->D
const outgoingConnections = result.restSummary!.boundaryConnections.filter(
(c) => c.direction === "outgoing"
);
expect(outgoingConnections).toHaveLength(2);
});
});
describe("Complex multi-handle scenario", () => {
it("handles multiple connections to same node with different handle types", () => {
const nodes: WorkflowNode[] = [
createNode("prompt1", "prompt"),
createNode("prompt2", "prompt"),
createNode("img1", "imageInput"),
createNode("gen", "nanoBanana"),
createNode("out", "output"),
];
const edges: WorkflowEdge[] = [
createEdge("prompt1", "gen", "text", "text"),
createEdge("prompt2", "gen", "text", "text"),
createEdge("img1", "gen", "image", "image"),
createEdge("gen", "out", "image", "image"),
];
const result = extractSubgraph(nodes, edges, ["gen"]);
expect(result.isScoped).toBe(true);
expect(result.selectedNodes).toHaveLength(1);
expect(result.selectedEdges).toHaveLength(0);
expect(result.restSummary).not.toBeNull();
expect(result.restSummary!.nodeCount).toBe(4);
expect(result.restSummary!.boundaryConnections).toHaveLength(4);
// 3 incoming (2 text from prompts, 1 image from img1)
const incoming = result.restSummary!.boundaryConnections.filter(
(c) => c.direction === "incoming"
);
expect(incoming).toHaveLength(3);
// 1 outgoing (to out)
const outgoing = result.restSummary!.boundaryConnections.filter(
(c) => c.direction === "outgoing"
);
expect(outgoing).toHaveLength(1);
});
});
});

54
src/lib/chat/subgraphExtractor.ts

@ -0,0 +1,54 @@
/**
* Subgraph Extractor
*
* Splits a workflow into a detailed selected subgraph and a summary of the rest,
* based on React Flow's node.selected property. When users select nodes before
* chatting, the LLM gets focused context on the selection with a lightweight
* summary of the surrounding workflow.
*/
import type { WorkflowNode } from "@/types/nodes";
import type { WorkflowEdge } from "@/types/workflow";
/**
* Boundary connection between selected and unselected nodes
*/
export interface BoundaryConnection {
direction: "incoming" | "outgoing";
selectedNodeId: string;
otherNodeId: string;
handleType: string;
}
/**
* Result of subgraph extraction
*/
export interface SubgraphResult {
// When selectedNodeIds is empty, these are ALL nodes/edges (no scoping)
selectedNodes: WorkflowNode[];
selectedEdges: WorkflowEdge[];
// Summary of unselected nodes (null when no selection)
restSummary: {
nodeCount: number;
typeBreakdown: Record<string, number>;
boundaryConnections: BoundaryConnection[];
} | null;
isScoped: boolean; // true when selectedNodeIds is non-empty
}
/**
* Extract subgraph based on selected node IDs
*
* @param nodes - All workflow nodes
* @param edges - All workflow edges
* @param selectedNodeIds - IDs of selected nodes
* @returns Subgraph result with selected nodes/edges and rest summary
*/
export function extractSubgraph(
nodes: WorkflowNode[],
edges: WorkflowEdge[],
selectedNodeIds: string[]
): SubgraphResult {
// TODO: Implement
throw new Error("Not implemented");
}
Loading…
Cancel
Save