refactor: align package folder names with npm package names
CI / check (pull_request) Failing after 8m30s

Rename packages/ subdirectories to match their @united-workforce/* scope:
  cli-workflow → cli
  workflow-agent-builtin → agent-builtin
  workflow-agent-claude-code → agent-claude-code
  workflow-agent-hermes → agent-hermes
  workflow-dashboard → dashboard
  workflow-protocol → protocol
  workflow-util-agent → util-agent
  workflow-util → util

Updated all tsconfig references, scripts, and active docs.
Historical docs (docs/plans/, docs/superpowers/) left as-is.

Closes #21
This commit is contained in:
2026-06-02 23:45:45 +08:00
parent e4e4288d00
commit 5970456a54
266 changed files with 207 additions and 207 deletions
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { HermesAcpClient } from "../src/acp-client.js";
describe("handleSessionUpdate — text extraction", () => {
let client: HermesAcpClient;
beforeEach(() => {
client = new HermesAcpClient();
});
afterEach(async () => {
await client.close();
});
it("agent_message_chunk accumulates text in messageChunks", () => {
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello" },
});
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: " world" },
});
expect((client as unknown as { messageChunks: string[] }).messageChunks).toEqual([
"hello",
" world",
]);
});
it("non-text chunks and other update types are ignored", () => {
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "agent_message_chunk",
content: { type: "image", text: "ignored" },
});
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({
sessionUpdate: "tool_call",
title: "Bash",
toolCallId: "tc-1",
});
(
client as unknown as { handleSessionUpdate: (u: Record<string, unknown>) => void }
).handleSessionUpdate({ sessionUpdate: "unknown_type", data: {} });
expect((client as unknown as { messageChunks: string[] }).messageChunks).toHaveLength(0);
});
});
@@ -0,0 +1,187 @@
import { describe, expect, test } from "bun:test";
import type { ThreadId } from "@united-workforce/protocol";
import type { AgentContext } from "@united-workforce/util-agent";
import { buildHermesPrompt } from "../src/hermes.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
return {
threadId: "01JTEST0000000000000000000" as ThreadId,
edgePrompt: "Proceed with the assigned role.",
isFirstVisit: true,
workflow: {
roles: {
developer: {
description: "TDD implementation per test spec",
goal: "Write code",
capabilities: ["coding"],
procedure: "1. Read spec\n2. Write code",
output: "List files changed",
frontmatter: "",
},
},
conditions: {},
graph: {},
},
role: "developer",
start: { prompt: "Fix the bug", workflow: "abc123" },
steps: [],
store: {} as AgentContext["store"],
outputFormatInstruction: "Use YAML frontmatter",
...overrides,
};
}
describe("buildHermesPrompt", () => {
test("first visit uses full role prompt and includes moderator instruction", () => {
const result = buildHermesPrompt(
makeCtx({ edgePrompt: "Focus on the failing test.", isFirstVisit: true }),
);
expect(result).toMatch(/^Use YAML frontmatter/);
expect(result).toContain("Write code");
expect(result).toContain("## Task\nFix the bug");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("Focus on the failing test.");
});
test("re-entry uses continuation prompt with edge instruction", () => {
const ctx = makeCtx({
isFirstVisit: false,
edgePrompt: "The reviewer rejected your work. Fix the issues.",
steps: [
{
role: "developer",
output: { summary: "Initial fix" },
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "Implement the fix.",
content: null,
},
{
role: "reviewer",
output: { approved: false },
agent: "uwf-hermes",
detail: "detail-2",
edgePrompt: "Review the code.",
content: null,
},
],
});
const result = buildHermesPrompt(ctx);
expect(result).not.toContain("## Task");
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("The reviewer rejected your work.");
});
test("forced first visit via isFirstVisit uses initial prompt even when role appears in history", () => {
const result = buildHermesPrompt(
makeCtx({
isFirstVisit: true,
steps: [
{
role: "developer",
output: { done: true },
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "First attempt.",
content: null,
},
],
edgePrompt: "Retry with a fresh approach.",
}),
);
expect(result).toContain("## Task");
expect(result).toContain("Retry with a fresh approach.");
expect(result).not.toContain("## What Happened Since Your Last Turn");
});
test("first visit includes content from previous steps", () => {
const ctx = makeCtx({
isFirstVisit: true,
steps: [
{
role: "planner",
output: { plan: "hash1" },
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "Create the plan.",
content: "# Plan\nDetailed plan markdown...",
},
{
role: "developer",
output: { files: ["app.ts"] },
agent: "uwf-hermes",
detail: "detail-2",
edgePrompt: "Implement the code.",
content: "# Implementation\nCode changes...",
},
{
role: "reviewer",
output: { approved: true },
agent: "uwf-hermes",
detail: "detail-3",
edgePrompt: "Review the work.",
content: "# Review\nApproved!",
},
],
role: "committer",
edgePrompt: "Commit the reviewed code.",
});
const result = buildHermesPrompt(ctx);
expect(result).toContain("Use YAML frontmatter");
expect(result).toContain("## Task");
expect(result).toContain("Fix the bug");
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("### Step 1: planner");
expect(result).toContain("#### Step Content");
expect(result).toContain("# Plan");
expect(result).toContain("Detailed plan markdown");
expect(result).toContain("### Step 2: developer");
expect(result).toContain("# Implementation");
expect(result).toContain("### Step 3: reviewer");
expect(result).toContain("# Review");
expect(result).toContain("## Moderator Instruction");
expect(result).toContain("Commit the reviewed code.");
});
test("re-entry omits content from previous steps", () => {
const ctx = makeCtx({
isFirstVisit: false,
steps: [
{
role: "developer",
output: { files: ["app.ts"] },
agent: "uwf-hermes",
detail: "detail-1",
edgePrompt: "Implement the code.",
content: "# Implementation\nCode changes...",
},
{
role: "reviewer",
output: { approved: false },
agent: "uwf-hermes",
detail: "detail-2",
edgePrompt: "Review the work.",
content: "# Review\nNot approved!",
},
],
role: "developer",
edgePrompt: "Fix the issues.",
});
const result = buildHermesPrompt(ctx);
expect(result).toContain("## What Happened Since Your Last Turn");
expect(result).toContain("### Step 2: reviewer");
expect(result).toContain(JSON.stringify({ approved: false }));
expect(result).not.toContain("#### Step Content");
expect(result).not.toContain("# Review");
expect(result).not.toContain("Not approved!");
});
});
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
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("HermesAcpClient", () => {
let client: HermesAcpClient;
beforeEach(() => {
client = new HermesAcpClient();
});
afterEach(async () => {
await client.close();
});
it(
"connect() returns a UUID sessionId",
async () => {
const sessionId = await client.connect(process.cwd());
expect(typeof sessionId).toBe("string");
expect(sessionId).toMatch(UUID_RE);
},
{ timeout: 2 * 60 * 1000 },
);
it(
"prompt() returns a non-empty text response",
async () => {
await client.connect(process.cwd());
const result = await client.prompt("Reply with exactly the word: PONG");
expect(typeof result.text).toBe("string");
expect(result.text.length).toBeGreaterThan(0);
expect(typeof result.sessionId).toBe("string");
expect(result.sessionId).toMatch(UUID_RE);
},
{ timeout: 2 * 60 * 1000 },
);
it(
"prompt() can be called twice on the same session (resume)",
async () => {
await client.connect(process.cwd());
const first = await client.prompt("Say the word ALPHA and nothing else.");
expect(first.text.length).toBeGreaterThan(0);
const second = await client.prompt("Now say the word BETA and nothing else.");
expect(second.text.length).toBeGreaterThan(0);
expect(first.sessionId).toBe(second.sessionId);
},
{ timeout: 2 * 60 * 1000 },
);
});
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "bun:test";
import { HermesAcpClient } from "../../src/acp-client.js";
/**
* E2E test for cross-process session resume.
*
* Simulates the workflow re-entry scenario:
* 1. Client A: connect → prompt → close (developer first run)
* 2. Client B: resume(sessionId) → prompt (developer re-entry after reviewer reject)
*
* This is what happens when uwf thread step spawns uwf-hermes twice for the same role.
*/
describe("HermesAcpClient cross-process resume", () => {
const clients: HermesAcpClient[] = [];
afterEach(async () => {
for (const c of clients) {
await c.close();
}
clients.length = 0;
});
// TODO(#435): flaky — depends on live LLM; mock or move to integration suite
it.skip(
"resume() after close — second prompt returns non-empty text",
async () => {
// --- Client A: first run ---
const clientA = new HermesAcpClient();
clients.push(clientA);
await clientA.connect(process.cwd());
const first = await clientA.prompt(
"Remember the secret code: WATERMELON. Reply with exactly: ACKNOWLEDGED",
);
expect(first.text.length).toBeGreaterThan(0);
const sessionId = first.sessionId;
// Close client A (simulates uwf-hermes process exit)
await clientA.close();
// --- Client B: resume (simulates re-entry) ---
const clientB = new HermesAcpClient();
clients.push(clientB);
await clientB.resume(sessionId, process.cwd());
const second = await clientB.prompt(
"What was the secret code I told you earlier? Reply with just the code word.",
);
// The critical assertion: resumed session produces non-empty output
expect(second.text.length).toBeGreaterThan(0);
expect(second.sessionId).toBe(sessionId);
},
{ timeout: 3 * 60 * 1000 },
);
});
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const PKG_ROOT = join(import.meta.dir, "..");
describe("Issue #551 — bin entry & engines", () => {
test("package.json declares bun in engines", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
expect(pkg.engines).toBeDefined();
expect(pkg.engines.bun).toBeDefined();
expect(pkg.engines.bun).toMatch(/^>=?\s*[\d.]+/);
});
test("bin entry file has bun shebang", () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf-8"));
const binPath = pkg.bin["uwf-hermes"];
const content = readFileSync(join(PKG_ROOT, binPath), "utf-8");
expect(content.startsWith("#!/usr/bin/env bun")).toBe(true);
});
test("README.md explains uwf-hermes is an adapter", () => {
const readme = readFileSync(join(PKG_ROOT, "README.md"), "utf-8");
expect(readme.toLowerCase()).toContain("adapter");
expect(readme).toMatch(/uwf-hermes/);
expect(readme).toMatch(/hermes/);
});
});
@@ -0,0 +1,365 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createMemoryStore, refs, validate, walk } from "@ocas/core";
import {
computeDurationMs,
extractLastAssistantContent,
getHermesDbPath,
loadHermesSessionFromDb,
messageToTurnPayload,
parseSessionIdFromStdout,
storeHermesSessionDetail,
} from "../src/session-detail.js";
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
describe("parseSessionIdFromStdout", () => {
test("reads session_id from the last non-empty line", () => {
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
});
test("reads session_id from the first line (quiet mode)", () => {
const stdout = "session_id: 20260518_165315_3467a1\nHello world\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_165315_3467a1");
});
test("returns null when no session_id line present", () => {
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
});
});
describe("messageToTurnPayload", () => {
test("maps assistant tool_calls to toolCalls", () => {
const msg: HermesSessionMessage = {
role: "assistant",
content: "",
reasoning: null,
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
};
const turn = messageToTurnPayload(msg, 0);
expect(turn).toEqual({
index: 0,
role: "assistant",
content: "",
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
reasoning: null,
});
});
test("skips user messages", () => {
const msg: HermesSessionMessage = {
role: "user",
content: "hi",
reasoning: null,
tool_calls: null,
};
expect(messageToTurnPayload(msg, 0)).toBeNull();
});
});
describe("extractLastAssistantContent", () => {
test("returns the last non-empty assistant content", () => {
const messages: HermesSessionMessage[] = [
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
];
expect(extractLastAssistantContent(messages)).toBe("final answer");
});
});
describe("computeDurationMs", () => {
test("computes elapsed time from session_start", () => {
const now = Date.parse("2026-05-18T13:32:59.028640Z");
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
expect(duration).toBe(60_000);
});
});
describe("storeHermesSessionDetail", () => {
test("stores hermes-detail root with ocas_ref turns walkable", async () => {
const session: HermesSessionJson = {
session_id: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
session_start: "2026-05-18T13:31:59.028640",
messages: [
{ role: "user", content: "task", reasoning: null, tool_calls: null },
{
role: "assistant",
content: "",
reasoning: "thinking",
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
},
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
],
};
const store = createMemoryStore();
const now = Date.parse("2026-05-18T13:32:59.028640");
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
expect(output).toBe("done");
const detailNode = store.get(detailHash);
expect(detailNode).not.toBeNull();
if (detailNode === null) {
return;
}
expect(validate(store, detailNode)).toBe(true);
expect(detailNode.payload).toMatchObject({
sessionId: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
duration: 60_000,
turnCount: 3,
});
const turnRefs = refs(store, detailNode);
expect(turnRefs).toHaveLength(3);
const visited: string[] = [];
walk(store, detailHash, (hash) => visited.push(hash));
expect(visited).toContain(detailHash);
for (const turnHash of turnRefs) {
expect(visited).toContain(turnHash);
}
});
});
// ── SQLite fallback tests ──────────────────────────────────────────
function createTestDb(dbPath: string): Database {
const db = new Database(dbPath);
db.run(`CREATE TABLE sessions (
id TEXT PRIMARY KEY,
model TEXT NOT NULL,
started_at INTEGER NOT NULL
)`);
db.run(`CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
reasoning TEXT,
tool_calls TEXT,
FOREIGN KEY (session_id) REFERENCES sessions(id)
)`);
return db;
}
describe("getHermesDbPath", () => {
test("returns correct path", () => {
const { homedir } = require("node:os");
const { join } = require("node:path");
expect(getHermesDbPath()).toBe(join(homedir(), ".hermes", "state.db"));
});
});
describe("loadHermesSessionFromDb", () => {
test("returns session data from SQLite", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-session-001";
const startedAt = 1748099519;
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"claude-opus-4.6",
startedAt,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "hello", null, null],
);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", "hi there", "thinking...", null],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.session_id).toBe(sessionId);
expect(result!.model).toBe("claude-opus-4.6");
expect(result!.messages).toHaveLength(2);
expect(result!.messages[0]!.role).toBe("user");
expect(result!.messages[0]!.content).toBe("hello");
expect(result!.messages[1]!.role).toBe("assistant");
expect(result!.messages[1]!.content).toBe("hi there");
expect(result!.messages[1]!.reasoning).toBe("thinking...");
await rm(tmpDir, { recursive: true });
});
test("returns null when no session exists in DB", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
db.close();
const result = await loadHermesSessionFromDb("nonexistent", dbPath);
expect(result).toBeNull();
await rm(tmpDir, { recursive: true });
});
test("returns null when DB file does not exist", async () => {
const result = await loadHermesSessionFromDb("any-id", "/tmp/nonexistent-hermes-db.db");
expect(result).toBeNull();
});
test("correctly parses tool_calls from DB JSON string", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-tool-calls";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"gpt-4",
1748099519,
]);
const toolCallsJson = JSON.stringify([
{ function: { name: "read_file", arguments: '{"path":"x"}' } },
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", "", null, toolCallsJson],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.messages[0]!.tool_calls).toEqual([
{ function: { name: "read_file", arguments: '{"path":"x"}' } },
]);
await rm(tmpDir, { recursive: true });
});
test("handles null fields in DB messages gracefully", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-nulls";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"model",
1748099519,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", null, null, null],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
const msg = result!.messages[0]!;
expect(msg.content).toBeNull();
expect(msg.reasoning).toBeNull();
expect(msg.tool_calls).toBeNull();
await rm(tmpDir, { recursive: true });
});
test("messages ordered by insertion order", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-order";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"model",
1748099519,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "first", null, null],
);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "assistant", "second", null, null],
);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "third", null, null],
);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.messages.map((m) => m.content)).toEqual(["first", "second", "third"]);
await rm(tmpDir, { recursive: true });
});
test("converts unix timestamp to ISO string for session_start", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const db = createTestDb(dbPath);
const sessionId = "test-timestamp";
const startedAt = 1748099519;
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"model",
startedAt,
]);
db.close();
const result = await loadHermesSessionFromDb(sessionId, dbPath);
expect(result).not.toBeNull();
expect(result!.session_start).toBe(new Date(startedAt * 1000).toISOString());
await rm(tmpDir, { recursive: true });
});
});
describe("loadHermesSession with SQLite fallback", () => {
test("JSON file takes priority over DB", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "hermes-test-"));
const dbPath = join(tmpDir, "state.db");
const jsonPath = join(tmpDir, "session.json");
// Create DB with one model value
const db = createTestDb(dbPath);
const sessionId = "test-priority";
db.run("INSERT INTO sessions (id, model, started_at) VALUES (?, ?, ?)", [
sessionId,
"db-model",
1748099519,
]);
db.run(
"INSERT INTO messages (session_id, role, content, reasoning, tool_calls) VALUES (?, ?, ?, ?, ?)",
[sessionId, "user", "from db", null, null],
);
db.close();
// Create JSON file with a different model value
const jsonData: HermesSessionJson = {
session_id: sessionId,
model: "json-model",
session_start: "2026-05-24T12:00:00.000Z",
messages: [{ role: "user", content: "from json", reasoning: null, tool_calls: null }],
};
await writeFile(jsonPath, JSON.stringify(jsonData));
// loadHermesSession reads from JSON path, so we test the existing function directly
// The JSON-first priority is inherent in the implementation
const { readFile } = await import("node:fs/promises");
const text = await readFile(jsonPath, "utf8");
const parsed = JSON.parse(text);
expect(parsed.model).toBe("json-model");
await rm(tmpDir, { recursive: true });
});
});