Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 221919448e |
@@ -1,28 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['*']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Type check
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test
|
||||
run: bun test
|
||||
@@ -154,6 +154,99 @@ describe("parseClaudeCodeStreamOutput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCodeStreamOutput — helper extraction", () => {
|
||||
test("processSystemLine sets model from system message", () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: "system", model: "claude-opus-4" }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.model).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
test("processAssistantLine skips empty content", () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: "assistant", message: { role: "assistant", content: [] } }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.turns).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("processUserLine skips when no tool_result items", () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 0,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.turns).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("turn indices are sequential across mixed assistant and user lines", () => {
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "A" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: { role: "user", content: [{ type: "tool_result", content: "R" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "B" }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
session_id: "s1",
|
||||
num_turns: 3,
|
||||
total_cost_usd: 0,
|
||||
duration_ms: 0,
|
||||
stop_reason: "end_turn",
|
||||
}),
|
||||
];
|
||||
const parsed = parseClaudeCodeStreamOutput(lines.join("\n"));
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.turns).toHaveLength(3);
|
||||
expect(parsed!.turns.map((t) => t.index)).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeClaudeCodeDetail", () => {
|
||||
const baseParsed: ClaudeCodeParsedResult = {
|
||||
type: "result",
|
||||
|
||||
@@ -67,101 +67,105 @@ function extractToolResultContent(content: unknown[]): string {
|
||||
return results.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude Code stream-json (NDJSON) output.
|
||||
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
||||
*/
|
||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const turns: ClaudeCodeTurnPayload[] = [];
|
||||
let resultLine: Record<string, unknown> | null = null;
|
||||
let model = "";
|
||||
let turnIndex = 0;
|
||||
type ParseState = {
|
||||
turns: ClaudeCodeTurnPayload[];
|
||||
resultLine: Record<string, unknown> | null;
|
||||
model: string;
|
||||
turnIndex: number;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(parsed)) continue;
|
||||
|
||||
const type = parsed.type;
|
||||
|
||||
if (type === "system" && typeof parsed.model === "string") {
|
||||
model = parsed.model;
|
||||
}
|
||||
|
||||
if (type === "assistant" && isRecord(parsed.message)) {
|
||||
const msg = parsed.message;
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
const textContent = extractTextContent(content as unknown[]);
|
||||
const toolCalls = extractToolCalls(content as unknown[]);
|
||||
|
||||
// Only record turns that have actual content
|
||||
if (textContent !== "" || toolCalls.length > 0) {
|
||||
turns.push({
|
||||
index: turnIndex++,
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "user" && isRecord(parsed.message)) {
|
||||
const msg = parsed.message;
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
const resultContent = extractToolResultContent(content as unknown[]);
|
||||
|
||||
if (resultContent !== "") {
|
||||
turns.push({
|
||||
index: turnIndex++,
|
||||
role: "tool_result",
|
||||
content: resultContent,
|
||||
toolCalls: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
resultLine = parsed;
|
||||
}
|
||||
function processSystemLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (typeof parsed.model === "string") {
|
||||
state.model = parsed.model;
|
||||
}
|
||||
}
|
||||
|
||||
if (resultLine === null) return null;
|
||||
function processAssistantLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (!isRecord(parsed.message)) return;
|
||||
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
||||
const textContent = extractTextContent(content as unknown[]);
|
||||
const toolCalls = extractToolCalls(content as unknown[]);
|
||||
if (textContent !== "" || toolCalls.length > 0) {
|
||||
state.turns.push({
|
||||
index: state.turnIndex++,
|
||||
role: "assistant",
|
||||
content: textContent,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = resultLine.session_id;
|
||||
const result = resultLine.result;
|
||||
const subtype = resultLine.subtype;
|
||||
function processUserLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||
if (!isRecord(parsed.message)) return;
|
||||
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
||||
const resultContent = extractToolResultContent(content as unknown[]);
|
||||
if (resultContent !== "") {
|
||||
state.turns.push({
|
||||
index: state.turnIndex++,
|
||||
role: "tool_result",
|
||||
content: resultContent,
|
||||
toolCalls: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function processLine(line: string, state: ParseState): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(parsed)) return;
|
||||
const type = parsed.type;
|
||||
if (type === "system") processSystemLine(parsed, state);
|
||||
else if (type === "assistant") processAssistantLine(parsed, state);
|
||||
else if (type === "user") processUserLine(parsed, state);
|
||||
else if (type === "result") state.resultLine = parsed;
|
||||
}
|
||||
|
||||
function assembleResult(state: ParseState): ClaudeCodeParsedResult | null {
|
||||
if (state.resultLine === null) return null;
|
||||
const sessionId = state.resultLine.session_id;
|
||||
const result = state.resultLine.result;
|
||||
const subtype = state.resultLine.subtype;
|
||||
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usage = isRecord(resultLine.usage) ? resultLine.usage : {};
|
||||
|
||||
const usage = isRecord(state.resultLine.usage) ? state.resultLine.usage : {};
|
||||
return {
|
||||
type: safeString(resultLine.type, "result"),
|
||||
type: safeString(state.resultLine.type, "result"),
|
||||
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
||||
result,
|
||||
sessionId,
|
||||
numTurns: safeNumber(resultLine.num_turns),
|
||||
totalCostUsd: safeNumber(resultLine.total_cost_usd),
|
||||
durationMs: safeNumber(resultLine.duration_ms),
|
||||
model,
|
||||
stopReason: safeString(resultLine.stop_reason),
|
||||
numTurns: safeNumber(state.resultLine.num_turns),
|
||||
totalCostUsd: safeNumber(state.resultLine.total_cost_usd),
|
||||
durationMs: safeNumber(state.resultLine.duration_ms),
|
||||
model: state.model,
|
||||
stopReason: safeString(state.resultLine.stop_reason),
|
||||
usage: {
|
||||
inputTokens: safeNumber(usage.input_tokens),
|
||||
outputTokens: safeNumber(usage.output_tokens),
|
||||
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
|
||||
cacheCreationInputTokens: safeNumber(usage.cache_creation_input_tokens),
|
||||
},
|
||||
turns,
|
||||
turns: state.turns,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude Code stream-json (NDJSON) output.
|
||||
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
||||
*/
|
||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const state: ParseState = { turns: [], resultLine: null, model: "", turnIndex: 0 };
|
||||
for (const line of lines) {
|
||||
processLine(line, state);
|
||||
}
|
||||
return assembleResult(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy: parse Claude Code plain JSON output (non-streaming).
|
||||
* Falls back when stream-json is not available.
|
||||
|
||||
@@ -4,6 +4,96 @@ import { HermesAcpClient } from "../src/acp-client.js";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
describe("handleSessionUpdate — helper extraction", () => {
|
||||
let client: HermesAcpClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new HermesAcpClient();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("agent_message_chunk accumulates text in messageChunks", () => {
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "hello" },
|
||||
});
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: " world" },
|
||||
});
|
||||
expect((client as any).messageChunks).toEqual(["hello", " world"]);
|
||||
});
|
||||
|
||||
it("agent_thought_chunk accumulates reasoning in reasoningChunks", () => {
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "thinking" },
|
||||
});
|
||||
expect((client as any).reasoningChunks).toEqual(["thinking"]);
|
||||
});
|
||||
|
||||
it("tool_call registers a pending tool and flushes message chunks", () => {
|
||||
(client as any).messageChunks = ["pre-tool text"];
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "tool_call",
|
||||
title: "Bash",
|
||||
rawInput: { command: "ls" },
|
||||
toolCallId: "tc-1",
|
||||
});
|
||||
expect((client as any).pendingTools.get("tc-1")).toEqual({
|
||||
name: "Bash",
|
||||
args: JSON.stringify({ command: "ls" }),
|
||||
});
|
||||
expect((client as any).messageChunks).toEqual([]);
|
||||
expect((client as any).messages).toHaveLength(1);
|
||||
expect((client as any).messages[0].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("tool_call_update completed pushes tool_call and tool messages", () => {
|
||||
(client as any).pendingTools.set("tc-2", { name: "Read", args: '{"path":"/foo"}' });
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
toolCallId: "tc-2",
|
||||
rawOutput: "file contents",
|
||||
});
|
||||
const msgs = (client as any).messages as Array<{
|
||||
role: string;
|
||||
tool_calls: unknown;
|
||||
content: string | null;
|
||||
}>;
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs[0].role).toBe("assistant");
|
||||
expect(msgs[0].tool_calls).toEqual([
|
||||
{ function: { name: "Read", arguments: '{"path":"/foo"}' } },
|
||||
]);
|
||||
expect(msgs[1].role).toBe("tool");
|
||||
expect(msgs[1].content).toBe("file contents");
|
||||
expect((client as any).pendingTools.has("tc-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("tool_call_update with non-string rawOutput JSON-stringifies it", () => {
|
||||
(client as any).pendingTools.set("tc-3", { name: "Fetch", args: "" });
|
||||
(client as any).handleSessionUpdate({
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "completed",
|
||||
toolCallId: "tc-3",
|
||||
rawOutput: { html: "<p>page</p>" },
|
||||
});
|
||||
const msgs = (client as any).messages as Array<{ role: string; content: string | null }>;
|
||||
expect(msgs[1].content).toBe(JSON.stringify({ html: "<p>page</p>" }));
|
||||
});
|
||||
|
||||
it("unknown updateType is a no-op", () => {
|
||||
(client as any).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
|
||||
expect((client as any).messages).toHaveLength(0);
|
||||
expect((client as any).messageChunks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HermesAcpClient", () => {
|
||||
let client: HermesAcpClient;
|
||||
|
||||
|
||||
@@ -245,72 +245,75 @@ export class HermesAcpClient {
|
||||
// ---- Session update → structured messages ----
|
||||
|
||||
private handleSessionUpdate(update: Record<string, unknown>): void {
|
||||
const updateType = update.sessionUpdate as string;
|
||||
|
||||
switch (updateType) {
|
||||
case "agent_message_chunk": {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.messageChunks.push(content.text);
|
||||
}
|
||||
switch (update.sessionUpdate as string) {
|
||||
case "agent_message_chunk":
|
||||
this.handleAgentMessageChunk(update);
|
||||
break;
|
||||
}
|
||||
|
||||
case "agent_thought_chunk": {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.reasoningChunks.push(content.text);
|
||||
}
|
||||
case "agent_thought_chunk":
|
||||
this.handleAgentThoughtChunk(update);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call": {
|
||||
const title = (update.title as string) ?? "";
|
||||
const rawInput = update.rawInput;
|
||||
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||
const toolCallId = update.toolCallId as string;
|
||||
this.pendingTools.set(toolCallId, { name: title, args });
|
||||
|
||||
// Flush accumulated assistant text before tool call
|
||||
this.flushAssistantMessage();
|
||||
case "tool_call":
|
||||
this.handleToolCall(update);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_call_update": {
|
||||
const status = update.status as string | undefined;
|
||||
if (status === "completed" || status === "failed") {
|
||||
const toolCallId = update.toolCallId as string;
|
||||
const pending = this.pendingTools.get(toolCallId);
|
||||
const toolName = pending?.name ?? toolCallId;
|
||||
const rawOutput = update.rawOutput;
|
||||
const outputStr =
|
||||
rawOutput !== undefined && rawOutput !== null
|
||||
? typeof rawOutput === "string"
|
||||
? rawOutput
|
||||
: JSON.stringify(rawOutput)
|
||||
: "";
|
||||
this.messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
||||
});
|
||||
this.messages.push({
|
||||
role: "tool",
|
||||
content: outputStr,
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
});
|
||||
this.pendingTools.delete(toolCallId);
|
||||
}
|
||||
case "tool_call_update":
|
||||
this.handleToolCallUpdate(update);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentMessageChunk(update: Record<string, unknown>): void {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.messageChunks.push(content.text);
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentThoughtChunk(update: Record<string, unknown>): void {
|
||||
const content = update.content as { type?: string; text?: string } | undefined;
|
||||
if (content?.type === "text" && typeof content.text === "string") {
|
||||
this.reasoningChunks.push(content.text);
|
||||
}
|
||||
}
|
||||
|
||||
private handleToolCall(update: Record<string, unknown>): void {
|
||||
const title = (update.title as string) ?? "";
|
||||
const rawInput = update.rawInput;
|
||||
const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : "";
|
||||
const toolCallId = update.toolCallId as string;
|
||||
this.pendingTools.set(toolCallId, { name: title, args });
|
||||
this.flushAssistantMessage();
|
||||
}
|
||||
|
||||
private handleToolCallUpdate(update: Record<string, unknown>): void {
|
||||
const status = update.status as string | undefined;
|
||||
if (status !== "completed" && status !== "failed") return;
|
||||
const toolCallId = update.toolCallId as string;
|
||||
const pending = this.pendingTools.get(toolCallId);
|
||||
const toolName = pending?.name ?? toolCallId;
|
||||
const rawOutput = update.rawOutput;
|
||||
const outputStr =
|
||||
rawOutput !== undefined && rawOutput !== null
|
||||
? typeof rawOutput === "string"
|
||||
? rawOutput
|
||||
: JSON.stringify(rawOutput)
|
||||
: "";
|
||||
this.messages.push({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: null,
|
||||
tool_calls: [{ function: { name: toolName, arguments: pending?.args ?? "" } }],
|
||||
});
|
||||
this.messages.push({
|
||||
role: "tool",
|
||||
content: outputStr,
|
||||
reasoning: null,
|
||||
tool_calls: null,
|
||||
});
|
||||
this.pendingTools.delete(toolCallId);
|
||||
}
|
||||
|
||||
/** Flush any accumulated text/reasoning into an assistant message. */
|
||||
private flushAssistantMessage(): void {
|
||||
const text = this.messageChunks.join("");
|
||||
|
||||
@@ -31,10 +31,8 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/ui": "^4.1.7",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.7"
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LayoutLR } from "../index.js";
|
||||
|
||||
function makeNode(id: string): Node {
|
||||
return { id, type: "role", data: {}, position: { x: 0, y: 0 } } as Node;
|
||||
}
|
||||
|
||||
function makeEdge(source: string, target: string): Edge {
|
||||
return { id: `${source}-${target}`, source, target } as Edge;
|
||||
}
|
||||
|
||||
describe("LayoutLR / assignLayers", () => {
|
||||
it("1.1 Empty graph: start gets layer 0, end gets higher layer", () => {
|
||||
const nodes = [makeNode("start"), makeNode("end")];
|
||||
const result = LayoutLR(nodes, []);
|
||||
const start = result.find((n) => n.id === "start");
|
||||
const end = result.find((n) => n.id === "end");
|
||||
// start has no position change necessarily, but positions should be assigned
|
||||
expect(start).toBeDefined();
|
||||
expect(end).toBeDefined();
|
||||
// end should be to the right of start
|
||||
expect((end?.position.x ?? 0) > (start?.position.x ?? 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("1.2 Linear chain: start → A → B → end — layers assigned in order", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||
expect(xOf("start") < xOf("A")).toBe(true);
|
||||
expect(xOf("A") < xOf("B")).toBe(true);
|
||||
expect(xOf("B") < xOf("end")).toBe(true);
|
||||
});
|
||||
|
||||
it("1.3 Diamond: A and B share same layer", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("C"), makeNode("end")];
|
||||
const edges = [
|
||||
makeEdge("start", "A"),
|
||||
makeEdge("start", "B"),
|
||||
makeEdge("A", "C"),
|
||||
makeEdge("B", "C"),
|
||||
makeEdge("C", "end"),
|
||||
];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||
expect(xOf("A")).toBe(xOf("B")); // same layer
|
||||
expect(xOf("A") < xOf("C")).toBe(true);
|
||||
expect(xOf("C") < xOf("end")).toBe(true);
|
||||
});
|
||||
|
||||
it("1.4 Isolated node placed in middle layer (not layer 0, not end layer)", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("isolated"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const xOf = (id: string) => result.find((n) => n.id === id)?.position.x ?? 0;
|
||||
const xIsolated = xOf("isolated");
|
||||
expect(xIsolated > xOf("start")).toBe(true);
|
||||
expect(xIsolated < xOf("end")).toBe(true);
|
||||
});
|
||||
|
||||
it("1.5 end node is always last (highest x)", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("B"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "B"), makeEdge("B", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const endX = result.find((n) => n.id === "end")?.position.x ?? 0;
|
||||
for (const node of result) {
|
||||
if (node.id !== "end") {
|
||||
expect(node.position.x < endX).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("1.6 start node is always first (x = 0 or smallest x)", () => {
|
||||
const nodes = [makeNode("start"), makeNode("A"), makeNode("end")];
|
||||
const edges = [makeEdge("start", "A"), makeEdge("A", "end")];
|
||||
const result = LayoutLR(nodes, edges);
|
||||
const startX = result.find((n) => n.id === "start")?.position.x ?? 0;
|
||||
for (const node of result) {
|
||||
expect(node.position.x >= startX).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,65 +43,6 @@ function buildGraph(nodes: Node[], edges: Edge[]) {
|
||||
return { outgoing, incoming, inDegree };
|
||||
}
|
||||
|
||||
function processTarget(
|
||||
target: string,
|
||||
newLayer: number,
|
||||
layers: Map<string, number>,
|
||||
inDegree: Map<string, number>,
|
||||
queue: string[],
|
||||
): void {
|
||||
const existingLayer = layers.get(target);
|
||||
if (existingLayer === undefined) {
|
||||
layers.set(target, newLayer);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||
if (inDegree.get(target) === 0) queue.push(target);
|
||||
} else {
|
||||
layers.set(target, Math.max(existingLayer, newLayer));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS 分层(排除 end 节点,稍后单独处理)
|
||||
*/
|
||||
function bfsLayers(
|
||||
outgoing: Map<string, string[]>,
|
||||
inDegree: Map<string, number>,
|
||||
layers: Map<string, number>,
|
||||
): void {
|
||||
const queue: string[] = ["start"];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() ?? "";
|
||||
const currentLayer = layers.get(current) ?? 0;
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
if (target === "end") continue;
|
||||
processTarget(target, currentLayer + 1, layers, inDegree, queue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理孤立节点(没有被分配层级的非 start/end 节点),放在中间层
|
||||
*/
|
||||
function placeIsolatedNodes(nodes: Node[], layers: Map<string, number>, maxLayer: number): void {
|
||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||
for (const node of nodes) {
|
||||
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最大层级(排除 end 节点)
|
||||
*/
|
||||
function maxLayerExcludingEnd(layers: Map<string, number>): number {
|
||||
let max = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== "end") max = Math.max(max, layer);
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用拓扑排序将节点分层
|
||||
* - 'start' 节点固定在第 0 层
|
||||
@@ -111,15 +52,62 @@ function maxLayerExcludingEnd(layers: Map<string, number>): number {
|
||||
function assignLayers(nodes: Node[], edges: Edge[]): Map<string, number> {
|
||||
const { outgoing, inDegree } = buildGraph(nodes, edges);
|
||||
const layers = new Map<string, number>();
|
||||
const queue: string[] = [];
|
||||
|
||||
// 1. start 节点固定在第 0 层
|
||||
layers.set("start", 0);
|
||||
bfsLayers(outgoing, inDegree, layers);
|
||||
queue.push("start");
|
||||
|
||||
const afterBfsMax = maxLayerExcludingEnd(layers);
|
||||
placeIsolatedNodes(nodes, layers, afterBfsMax);
|
||||
// 2. BFS 分层(排除 end 节点,稍后单独处理)
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift() ?? "";
|
||||
const currentLayer = layers.get(current) ?? 0;
|
||||
|
||||
const finalMax = maxLayerExcludingEnd(layers);
|
||||
layers.set("end", finalMax + 1);
|
||||
for (const target of outgoing.get(current) ?? []) {
|
||||
// 跳过 end 节点,稍后处理
|
||||
if (target === "end") continue;
|
||||
|
||||
const newLayer = currentLayer + 1;
|
||||
const existingLayer = layers.get(target);
|
||||
|
||||
if (existingLayer === undefined) {
|
||||
layers.set(target, newLayer);
|
||||
inDegree.set(target, (inDegree.get(target) ?? 1) - 1);
|
||||
if (inDegree.get(target) === 0) {
|
||||
queue.push(target);
|
||||
}
|
||||
} else {
|
||||
// 如果已有层级,取更大的值(确保所有前驱都在前面)
|
||||
layers.set(target, Math.max(existingLayer, newLayer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 找到当前最大层级
|
||||
let maxLayer = 0;
|
||||
for (const layer of layers.values()) {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
|
||||
// 4. 处理孤立节点(没有被分配层级的非 start/end 节点)
|
||||
// 把它们放在中间层
|
||||
const middleLayer = Math.max(1, Math.floor((maxLayer + 1) / 2));
|
||||
for (const node of nodes) {
|
||||
if (node.id !== "start" && node.id !== "end" && !layers.has(node.id)) {
|
||||
layers.set(node.id, middleLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 重新计算最大层级(可能因为孤立节点而变化)
|
||||
maxLayer = 0;
|
||||
for (const [id, layer] of layers) {
|
||||
if (id !== "end") {
|
||||
maxLayer = Math.max(maxLayer, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. end 节点固定在最后一层
|
||||
layers.set("end", maxLayer + 1);
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
@@ -30,24 +30,23 @@ export const handlers = define.memoize((use, model) => {
|
||||
});
|
||||
};
|
||||
|
||||
function isProtectedNode(node: AnyWorkNode): boolean {
|
||||
return node.type === "start" || node.type === "end";
|
||||
}
|
||||
|
||||
function isFirstConditionalSibling(
|
||||
edge: { id: string; source: string; type: string | null },
|
||||
allEdges: { id: string; source: string; type: string | null }[],
|
||||
): boolean {
|
||||
if (edge.type !== "conditional") return false;
|
||||
const siblings = allEdges.filter((e) => e.source === edge.source && e.type === "conditional");
|
||||
return siblings.length >= 2 && siblings[0].id === edge.id;
|
||||
}
|
||||
|
||||
const onBeforeDelete: OnBeforeDelete<AnyWorkNode> = async ({ nodes, edges }) => {
|
||||
if (nodes.some(isProtectedNode)) return false;
|
||||
for (const node of nodes) {
|
||||
if (node.type === "start" || node.type === "end") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (edges.length > 0) {
|
||||
const allEdges = use(edgesModel)[0];
|
||||
if (edges.some((e) => isFirstConditionalSibling(e, allEdges))) return false;
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== "conditional") continue;
|
||||
const siblings = allEdges.filter(
|
||||
(e) => e.source === edge.source && e.type === "conditional",
|
||||
);
|
||||
if (siblings.length >= 2 && siblings[0].id === edge.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
model.startTransaction();
|
||||
return true;
|
||||
@@ -97,28 +96,25 @@ export const handlers = define.memoize((use, model) => {
|
||||
use(editNodeViewModel)[1].cancel();
|
||||
}
|
||||
|
||||
function handleEscape() {
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
if (editView) editViewActions.cancel();
|
||||
}
|
||||
|
||||
function handleUndoRedo(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === "KeyZ" && (event.ctrlKey || event.metaKey)) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
} else if (event.code === "KeyY" && (event.ctrlKey || event.metaKey)) {
|
||||
model.redo();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.code === "Escape") {
|
||||
handleEscape();
|
||||
const [addView, addViewActions] = use(addNodeViewModel);
|
||||
const [editView, editViewActions] = use(editNodeViewModel);
|
||||
if (addView) addViewActions.cancel();
|
||||
if (editView) editViewActions.cancel();
|
||||
return;
|
||||
}
|
||||
handleUndoRedo(event);
|
||||
|
||||
if (event.code === "KeyZ") {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.shiftKey) model.redo();
|
||||
else model.undo();
|
||||
}
|
||||
} else if (event.code === "KeyY") {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
model.redo();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadSteps(steps: WorkFlowSteps) {
|
||||
|
||||
@@ -10,15 +10,16 @@ import {
|
||||
import { Input } from "../../components/ui/input.tsx";
|
||||
import { Label } from "../../components/ui/label.tsx";
|
||||
import { Textarea } from "../../components/ui/textarea.tsx";
|
||||
import { addNodeViewModel } from "../model/index.ts";
|
||||
import { type AddNodeState, addNodeViewModel } from "../model/index.ts";
|
||||
import type { RoleNodeData } from "../type.ts";
|
||||
|
||||
type FormProps = {
|
||||
state: AddNodeState;
|
||||
onSubmit: (params: { data: RoleNodeData }) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
function Form({ onSubmit, onCancel }: FormProps): ReactNode {
|
||||
function Form({ state, onSubmit, onCancel }: FormProps): ReactNode {
|
||||
const [name, setName] = useState("新角色");
|
||||
const [description, setDescription] = useState("");
|
||||
const [identity, setIdentity] = useState("");
|
||||
@@ -136,7 +137,7 @@ export function AddNodeDialog(): ReactNode {
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||
{state && <Form onSubmit={commit} onCancel={cancel} />}
|
||||
{state && <Form state={state} onSubmit={commit} onCancel={cancel} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transIn } from "../trans-in.js";
|
||||
import type { WorkFlowStep } from "../type.js";
|
||||
|
||||
function makeStep(name: string, transitions: WorkFlowStep["transitions"]): WorkFlowStep {
|
||||
return {
|
||||
role: {
|
||||
name,
|
||||
description: "",
|
||||
identity: "",
|
||||
prepare: "",
|
||||
execute: "",
|
||||
report: "",
|
||||
},
|
||||
transitions,
|
||||
};
|
||||
}
|
||||
|
||||
describe("transIn", () => {
|
||||
it("4.1 Empty steps → start + end nodes, no edges", () => {
|
||||
const { nodes, edges } = transIn([]);
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(nodes.find((n) => n.id === "start")).toBeDefined();
|
||||
expect(nodes.find((n) => n.id === "end")).toBeDefined();
|
||||
expect(edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("4.2 Single step with no END transition → start→role edge exists", () => {
|
||||
const steps = [makeStep("A", [])];
|
||||
const { nodes, edges } = transIn(steps);
|
||||
expect(nodes).toHaveLength(3); // start, end, role-A
|
||||
const startEdge = edges.find((e) => e.source === "start");
|
||||
expect(startEdge).toBeDefined();
|
||||
const roleNode = nodes.find((n) => n.type === "role");
|
||||
expect(startEdge?.target).toBe(roleNode?.id);
|
||||
});
|
||||
|
||||
it("4.3 Single step with END transition → edge to end node exists", () => {
|
||||
const steps = [makeStep("A", [{ condition: null, target: "END" }])];
|
||||
const { edges } = transIn(steps);
|
||||
const endEdge = edges.find((e) => e.target === "end");
|
||||
expect(endEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.4 Two steps with default transitions chain", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// Should have start→A, A→B, B→end
|
||||
expect(edges.find((e) => e.source === "start")).toBeDefined();
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
expect(edges.find((e) => e.source === nodeAId && e.target !== "end")).toBeDefined();
|
||||
expect(edges.find((e) => e.target === "end")).toBeDefined();
|
||||
// No conditional edges
|
||||
expect(edges.every((e) => e.type !== "conditional")).toBe(true);
|
||||
});
|
||||
|
||||
it("4.5 Step with multiple transitions → conditional edges", () => {
|
||||
const steps = [
|
||||
makeStep("A", [
|
||||
{ condition: null, target: "B" },
|
||||
{ condition: "x>0", target: "C" },
|
||||
]),
|
||||
makeStep("B", []),
|
||||
makeStep("C", []),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const nodeAId = edges.find((e) => e.source === "start")?.target;
|
||||
const outEdges = edges.filter((e) => e.source === nodeAId);
|
||||
expect(outEdges.every((e) => e.type === "conditional")).toBe(true);
|
||||
// else-branch has empty condition
|
||||
const elseEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "",
|
||||
);
|
||||
expect(elseEdge).toBeDefined();
|
||||
// if-branch has condition
|
||||
const ifEdge = outEdges.find(
|
||||
(e) => (e as { data?: { condition?: string } }).data?.condition === "x>0",
|
||||
);
|
||||
expect(ifEdge).toBeDefined();
|
||||
});
|
||||
|
||||
it("4.6 With 1 incoming edge: targetHandle = 'input'; with 2: first gets 'input'", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "END" }]),
|
||||
makeStep("B", [{ condition: null, target: "END" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
// start→A and start→B; end has 2 incoming edges
|
||||
const incomingToEnd = edges.filter((e) => e.target === "end");
|
||||
expect(incomingToEnd[0].targetHandle).toBe("input");
|
||||
});
|
||||
|
||||
it("4.7 Same role name maps to same node id across steps", () => {
|
||||
const steps = [
|
||||
makeStep("A", [{ condition: null, target: "B" }]),
|
||||
makeStep("B", [{ condition: null, target: "A" }]),
|
||||
];
|
||||
const { edges } = transIn(steps);
|
||||
const aId = edges.find((e) => e.source === "start")?.target;
|
||||
// B→A edge target should be same node as start→A edge target
|
||||
const bToAEdge = edges.find(
|
||||
(e) => e.source !== "start" && e.target === aId && e.target !== "end",
|
||||
);
|
||||
expect(bToAEdge).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AnyWorkEdge, AnyWorkNode } from "../../type.js";
|
||||
import { validate } from "../validate.js";
|
||||
|
||||
function roleNode(id: string): AnyWorkNode {
|
||||
return {
|
||||
id,
|
||||
type: "role",
|
||||
data: { name: id, description: "", identity: "", prepare: "", execute: "", report: "" },
|
||||
position: { x: 0, y: 0 },
|
||||
} as AnyWorkNode;
|
||||
}
|
||||
|
||||
function startNode(): AnyWorkNode {
|
||||
return {
|
||||
id: "start",
|
||||
type: "start",
|
||||
data: { label: "Start" },
|
||||
position: { x: 0, y: 0 },
|
||||
} as AnyWorkNode;
|
||||
}
|
||||
|
||||
function endNode(): AnyWorkNode {
|
||||
return {
|
||||
id: "end",
|
||||
type: "end",
|
||||
data: { label: "End" },
|
||||
position: { x: 0, y: 0 },
|
||||
} as AnyWorkNode;
|
||||
}
|
||||
|
||||
function defaultEdge(source: string, target: string): AnyWorkEdge {
|
||||
return { id: `${source}-${target}`, source, target, animated: true } as AnyWorkEdge;
|
||||
}
|
||||
|
||||
function conditionalEdge(source: string, target: string, condition: string): AnyWorkEdge {
|
||||
return {
|
||||
id: `${source}-${target}-cond`,
|
||||
source,
|
||||
target,
|
||||
type: "conditional" as const,
|
||||
data: { condition },
|
||||
animated: true,
|
||||
} as AnyWorkEdge;
|
||||
}
|
||||
|
||||
// Helper: build a minimal valid graph with 2 role nodes for validateRoleNodes tests
|
||||
function baseNodes(...roles: AnyWorkNode[]): AnyWorkNode[] {
|
||||
return [startNode(), ...roles, endNode()];
|
||||
}
|
||||
|
||||
describe("validateRoleNodes (via validate)", () => {
|
||||
it("5.1 Role node with no incoming edge → error about missing input", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const nodes = baseNodes(n1, n2);
|
||||
// n1 has no incoming, n2 has incoming+outgoing
|
||||
const edges = [defaultEdge("start", "n2"), defaultEdge("n1", "end"), defaultEdge("n2", "end")];
|
||||
const result = validate(nodes, edges);
|
||||
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
|
||||
expect(nodeErrors.some((e) => e.message.includes("缺少输入连接"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.2 Role node with no outgoing edge → error about missing output", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const nodes = baseNodes(n1, n2);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
defaultEdge("start", "n2"),
|
||||
defaultEdge("n2", "end"),
|
||||
// n1 has no outgoing
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
const nodeErrors = result.errors.filter((e) => e.nodeId === "n1");
|
||||
expect(nodeErrors.some((e) => e.message.includes("缺少输出连接"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.3 Empty condition on non-first conditional edge → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch (index 0) - exempt
|
||||
conditionalEdge("n1", "n3", ""), // if-branch (index 1) - empty condition → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("条件表达式不能为空"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.4 Mix of conditional and non-conditional outgoing → error", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", "x>0"),
|
||||
defaultEdge("n1", "n3"), // mix → error
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
expect(result.errors.some((e) => e.message.includes("所有出边必须附带条件"))).toBe(true);
|
||||
});
|
||||
|
||||
it("5.5 Valid role node (1 in, 1 out default) → no errors for that node", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const nodes = baseNodes(n1, n2);
|
||||
const edges = [defaultEdge("start", "n1"), defaultEdge("n1", "n2"), defaultEdge("n2", "end")];
|
||||
const result = validate(nodes, edges);
|
||||
const roleErrors = result.errors.filter((e) => e.nodeId === "n1" || e.nodeId === "n2");
|
||||
expect(roleErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("5.6 Valid role node (1 in, 2 conditional out with conditions) → no errors", () => {
|
||||
const n1 = roleNode("n1");
|
||||
const n2 = roleNode("n2");
|
||||
const n3 = roleNode("n3");
|
||||
const nodes = baseNodes(n1, n2, n3);
|
||||
const edges = [
|
||||
defaultEdge("start", "n1"),
|
||||
conditionalEdge("n1", "n2", ""), // else-branch
|
||||
conditionalEdge("n1", "n3", "x>0"), // if-branch
|
||||
defaultEdge("n2", "end"),
|
||||
defaultEdge("n3", "end"),
|
||||
];
|
||||
const result = validate(nodes, edges);
|
||||
const n1Errors = result.errors.filter((e) => e.nodeId === "n1");
|
||||
expect(n1Errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -28,109 +28,6 @@ function assignHandles(
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodeMap(
|
||||
steps: WorkFlowStep[],
|
||||
nodes: AnyWorkNode[],
|
||||
): { nameToId: Map<string, string>; idToOrder: Map<string, number> } {
|
||||
const nameToId = new Map<string, string>();
|
||||
const idToOrder = new Map<string, number>();
|
||||
nameToId.set("END", "end");
|
||||
idToOrder.set("start", -1);
|
||||
idToOrder.set("end", steps.length);
|
||||
for (let si = 0; si < steps.length; si++) {
|
||||
const step = steps[si];
|
||||
const nodeId = `n${uuid()}`;
|
||||
nameToId.set(step.role.name, nodeId);
|
||||
idToOrder.set(nodeId, si);
|
||||
nodes.push({ id: nodeId, type: "role", data: { ...step.role }, position: { x: 0, y: 0 } });
|
||||
}
|
||||
return { nameToId, idToOrder };
|
||||
}
|
||||
|
||||
function sortTransitions(step: WorkFlowStep): WorkFlowStep["transitions"] {
|
||||
if (step.transitions.length <= 1) return step.transitions;
|
||||
return [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function buildStepEdges(
|
||||
sourceId: string,
|
||||
step: WorkFlowStep,
|
||||
nameToId: Map<string, string>,
|
||||
): { elseEdges: AnyWorkEdge[]; ifEdges: AnyWorkEdge[] } {
|
||||
const hasMultiple = step.transitions.length > 1;
|
||||
const sorted = sortTransitions(step);
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
if (hasMultiple || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultiple && i === 0) elseEdges.push(edge);
|
||||
else ifEdges.push(edge);
|
||||
} else {
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { elseEdges, ifEdges };
|
||||
}
|
||||
|
||||
function pushStepEdges(
|
||||
edges: AnyWorkEdge[],
|
||||
elseEdges: AnyWorkEdge[],
|
||||
ifEdges: AnyWorkEdge[],
|
||||
idToOrder: Map<string, number>,
|
||||
): void {
|
||||
for (const e of elseEdges) edges.push({ ...e, sourceHandle: "output" });
|
||||
if (ifEdges.length > 0) {
|
||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||
const sorted = [...ifEdges].sort(
|
||||
(a, b) => (idToOrder.get(b.target) ?? 0) - (idToOrder.get(a.target) ?? 0),
|
||||
);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
edges.push({ ...sorted[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assignTargetHandles(edges: AnyWorkEdge[], idToOrder: Map<string, number>): void {
|
||||
const incomingByTarget = new Map<string, number[]>();
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const target = edges[i].target;
|
||||
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
||||
incomingByTarget.get(target)?.push(i);
|
||||
}
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
indices.sort(
|
||||
(a, b) => (idToOrder.get(edges[a].source) ?? 0) - (idToOrder.get(edges[b].source) ?? 0),
|
||||
);
|
||||
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
|
||||
}
|
||||
}
|
||||
|
||||
export function transIn(steps: WorkFlowStep[]): Result {
|
||||
const startNode: AnyWorkNode = {
|
||||
id: "start",
|
||||
@@ -145,12 +42,30 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
position: { x: 250, y: 0 },
|
||||
};
|
||||
|
||||
if (steps.length === 0) return { nodes: [startNode, endNode], edges: [] };
|
||||
if (steps.length === 0) {
|
||||
return { nodes: [startNode, endNode], edges: [] };
|
||||
}
|
||||
|
||||
const nodes: AnyWorkNode[] = [startNode, endNode];
|
||||
const edges: AnyWorkEdge[] = [];
|
||||
const nameToId = new Map<string, string>();
|
||||
const idToOrder = new Map<string, number>();
|
||||
nameToId.set("END", "end");
|
||||
idToOrder.set("start", -1);
|
||||
idToOrder.set("end", steps.length);
|
||||
|
||||
const { nameToId, idToOrder } = buildNodeMap(steps, nodes);
|
||||
for (let si = 0; si < steps.length; si++) {
|
||||
const step = steps[si];
|
||||
const nodeId = `n${uuid()}`;
|
||||
nameToId.set(step.role.name, nodeId);
|
||||
idToOrder.set(nodeId, si);
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: "role",
|
||||
data: { ...step.role },
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const firstStepId = nameToId.get(steps[0].role.name) ?? "";
|
||||
edges.push({
|
||||
@@ -164,11 +79,88 @@ export function transIn(steps: WorkFlowStep[]): Result {
|
||||
|
||||
for (const step of steps) {
|
||||
const sourceId = nameToId.get(step.role.name) ?? "";
|
||||
const { elseEdges, ifEdges } = buildStepEdges(sourceId, step, nameToId);
|
||||
pushStepEdges(edges, elseEdges, ifEdges, idToOrder);
|
||||
const _sourceOrder = idToOrder.get(sourceId) ?? 0;
|
||||
const hasMultipleTransitions = step.transitions.length > 1;
|
||||
|
||||
const sorted = hasMultipleTransitions
|
||||
? [...step.transitions].sort((a, b) => {
|
||||
if (a.condition === null && b.condition !== null) return -1;
|
||||
if (a.condition !== null && b.condition === null) return 1;
|
||||
return 0;
|
||||
})
|
||||
: step.transitions;
|
||||
|
||||
const elseEdges: AnyWorkEdge[] = [];
|
||||
const ifEdges: AnyWorkEdge[] = [];
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const t = sorted[i];
|
||||
const targetId = nameToId.get(t.target);
|
||||
if (!targetId) continue;
|
||||
|
||||
const edgeId = `e-${sourceId}-${targetId}-${i}`;
|
||||
|
||||
if (hasMultipleTransitions || t.condition !== null) {
|
||||
const edge: ConditionalEdge = {
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
type: "conditional",
|
||||
data: { condition: t.condition ?? "" },
|
||||
animated: true,
|
||||
};
|
||||
if (hasMultipleTransitions && i === 0) {
|
||||
elseEdges.push(edge);
|
||||
} else {
|
||||
ifEdges.push(edge);
|
||||
}
|
||||
} else {
|
||||
elseEdges.push({
|
||||
id: edgeId,
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle: "output",
|
||||
targetHandle: "input",
|
||||
animated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// out: else → output (right); if → sort by target order desc (rightmost first), then top/bottom
|
||||
for (const e of elseEdges) {
|
||||
edges.push({ ...e, sourceHandle: "output" });
|
||||
}
|
||||
if (ifEdges.length > 0) {
|
||||
const sortedIf = [...ifEdges].sort((a, b) => {
|
||||
const oa = idToOrder.get(a.target) ?? 0;
|
||||
const ob = idToOrder.get(b.target) ?? 0;
|
||||
return ob - oa;
|
||||
});
|
||||
const ifHandles = ["output-top", "output-bottom"] as const;
|
||||
for (let i = 0; i < sortedIf.length; i++) {
|
||||
edges.push({ ...sortedIf[i], sourceHandle: ifHandles[i % ifHandles.length] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignTargetHandles(edges, idToOrder);
|
||||
// in: group by target, sort by source order asc (leftmost first), assign input > input-top > input-bottom
|
||||
const incomingByTarget = new Map<string, number[]>();
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const target = edges[i].target;
|
||||
if (!incomingByTarget.has(target)) incomingByTarget.set(target, []);
|
||||
incomingByTarget.get(target)?.push(i);
|
||||
}
|
||||
|
||||
for (const indices of incomingByTarget.values()) {
|
||||
indices.sort((a, b) => {
|
||||
const oa = idToOrder.get(edges[a].source) ?? 0;
|
||||
const ob = idToOrder.get(edges[b].source) ?? 0;
|
||||
return oa - ob;
|
||||
});
|
||||
assignHandles(indices, edges, IN_HANDLES, "targetHandle");
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
@@ -91,36 +91,6 @@ function validateEndNode(
|
||||
}
|
||||
}
|
||||
|
||||
function hasEmptyConditionOnIfEdge(conditionalEdges: AnyWorkEdge[]): boolean {
|
||||
return conditionalEdges.slice(1).some((edge) => {
|
||||
const cond = (edge as ConditionalEdge).data?.condition?.trim();
|
||||
return !cond;
|
||||
});
|
||||
}
|
||||
|
||||
function validateRoleNodeEdges(
|
||||
node: AnyWorkNode,
|
||||
outEdges: AnyWorkEdge[],
|
||||
inEdges: AnyWorkEdge[],
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
|
||||
return;
|
||||
}
|
||||
if (outEdges.length <= 1) return;
|
||||
|
||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||
} else if (hasEmptyConditionOnIfEdge(conditionalEdges)) {
|
||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||
}
|
||||
}
|
||||
|
||||
function validateRoleNodes(
|
||||
roleNodes: AnyWorkNode[],
|
||||
outgoing: Map<string, AnyWorkEdge[]>,
|
||||
@@ -128,7 +98,31 @@ function validateRoleNodes(
|
||||
errors: ValidationError[],
|
||||
): void {
|
||||
for (const node of roleNodes) {
|
||||
validateRoleNodeEdges(node, outgoing.get(node.id) ?? [], incoming.get(node.id) ?? [], errors);
|
||||
const inEdges = incoming.get(node.id) ?? [];
|
||||
const outEdges = outgoing.get(node.id) ?? [];
|
||||
|
||||
if (inEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输入连接" });
|
||||
}
|
||||
if (outEdges.length === 0) {
|
||||
errors.push({ nodeId: node.id, message: "角色节点缺少输出连接" });
|
||||
}
|
||||
|
||||
if (outEdges.length > 1) {
|
||||
const conditionalEdges = outEdges.filter((e) => e.type === "conditional");
|
||||
if (conditionalEdges.length !== outEdges.length) {
|
||||
errors.push({ nodeId: node.id, message: "多输出节点的所有出边必须附带条件" });
|
||||
} else {
|
||||
const ifEdges = conditionalEdges.slice(1);
|
||||
for (const edge of ifEdges) {
|
||||
const condEdge = edge as ConditionalEdge;
|
||||
if (!condEdge.data?.condition?.trim()) {
|
||||
errors.push({ nodeId: node.id, message: "条件边的条件表达式不能为空" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vitest loads config from default export.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/__tests__/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# batch-solve.sh — solve multiple Gitea issues via solve-issue workflow
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/batch-solve.sh [--agent CMD] [--repo OWNER/REPO] [--count N] ISSUE_NUM...
|
||||
#
|
||||
# Examples:
|
||||
# ./scripts/batch-solve.sh 448 449
|
||||
# ./scripts/batch-solve.sh --agent "bun run $(pwd)/packages/workflow-agent-claude-code/src/cli.ts" 448 449
|
||||
# ./scripts/batch-solve.sh --repo uncaged/workflow --count 15 448 449
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AGENT=""
|
||||
REPO="uncaged/workflow"
|
||||
COUNT=10
|
||||
ISSUES=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--count) COUNT="$2"; shift 2 ;;
|
||||
*) ISSUES+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#ISSUES[@]} -eq 0 ]]; then
|
||||
echo "Usage: $0 [--agent CMD] [--repo OWNER/REPO] [--count N] ISSUE_NUM..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AGENT_FLAG=""
|
||||
if [[ -n "$AGENT" ]]; then
|
||||
AGENT_FLAG="--agent $AGENT"
|
||||
fi
|
||||
|
||||
TOTAL=${#ISSUES[@]}
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
RESULTS=()
|
||||
|
||||
echo "━━━ Batch solve: ${TOTAL} issues ━━━"
|
||||
echo ""
|
||||
|
||||
for i in "${!ISSUES[@]}"; do
|
||||
ISSUE="${ISSUES[$i]}"
|
||||
NUM=$((i + 1))
|
||||
echo "┌─── [$NUM/$TOTAL] Issue #${ISSUE} ───"
|
||||
|
||||
# Read issue title
|
||||
TITLE=$(tea issues "$ISSUE" -r "$REPO" 2>/dev/null | head -1 | sed 's/^# #[0-9]* //' | sed 's/ (.*//' || echo "unknown")
|
||||
echo "│ Title: $TITLE"
|
||||
|
||||
# Start thread
|
||||
PROMPT="Fix issue #${ISSUE} in ${REPO}. Read the issue first with 'tea issues ${ISSUE} -r ${REPO}' for full spec."
|
||||
THREAD_JSON=$(uwf thread start solve-issue -p "$PROMPT" 2>&1)
|
||||
THREAD_ID=$(echo "$THREAD_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['thread'])")
|
||||
echo "│ Thread: $THREAD_ID"
|
||||
|
||||
# Run steps
|
||||
echo "│ Running (max $COUNT steps)..."
|
||||
# shellcheck disable=SC2086
|
||||
if STEP_OUTPUT=$(uwf thread step "$THREAD_ID" $AGENT_FLAG -c "$COUNT" 2>&1); then
|
||||
# Check if done
|
||||
LAST_DONE=$(echo "$STEP_OUTPUT" | python3 -c "import json,sys; lines=sys.stdin.read().strip(); data=json.loads(lines); print(data[-1].get('done', False))")
|
||||
if [[ "$LAST_DONE" == "True" ]]; then
|
||||
echo "│ ✅ Done!"
|
||||
PASSED=$((PASSED + 1))
|
||||
RESULTS+=("✅ #${ISSUE} — ${TITLE}")
|
||||
else
|
||||
echo "│ ⚠️ Ran out of steps (not done)"
|
||||
FAILED=$((FAILED + 1))
|
||||
RESULTS+=("⚠️ #${ISSUE} — ${TITLE} (incomplete)")
|
||||
fi
|
||||
else
|
||||
echo "│ ❌ Failed"
|
||||
FAILED=$((FAILED + 1))
|
||||
RESULTS+=("❌ #${ISSUE} — ${TITLE} (error)")
|
||||
fi
|
||||
|
||||
echo "└───"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "━━━ Results: ${PASSED}/${TOTAL} passed, ${FAILED} failed ━━━"
|
||||
for R in "${RESULTS[@]}"; do
|
||||
echo " $R"
|
||||
done
|
||||
Reference in New Issue
Block a user