Compare commits

...

7 Commits

Author SHA1 Message Date
xiaoju af2a25bf87 feat(setup): auto-discover and configure agents during uwf setup
CI / test (pull_request) Failing after 14m13s
- Add agent discovery step to cmdSetupInteractive flow
- _promptAgentSelection: discover uwf-* binaries, auto-select if only one,
  prompt user to choose if multiple, show install hints if none found
- mergeConfig: always write selected agent entry, update defaultAgent
- Known agent labels for hermes, claude-code, cursor, builtin
- 10 new tests for _agentNameFromBinary, _printAgentMenu, cmdSetup agent config

Fixes #424
2026-05-25 12:38:40 +00:00
xiaomo 0abc8bcb3e Merge pull request 'fix(test): correct import path in resume-e2e integration test' (#514) from fix/hermes-integration-test-import into main
CI / test (push) Failing after 1m44s
2026-05-25 12:31:27 +00:00
xiaoju 524e00a0a6 fix(test): correct import path in resume-e2e integration test
CI / check (pull_request) Failing after 3m7s
The test file moved to __tests__/integration/ but the import path
was not updated from ../src/ to ../../src/.

小橘 🍊
2026-05-25 12:29:18 +00:00
xingyue eba3c70e76 ci: add gitea actions workflow
CI / test (push) Failing after 6m22s
2026-05-25 19:43:57 +08:00
xiaoju e2d60fa72e fix(test): use valid JSON Schema in workflow-resolution test fixture
CI / check (push) Failing after 32s
The test used a fake CasRef string as frontmatter, which fails
putSchema validation when loading from YAML files. Replace with
a proper JSON Schema object.

Fixes pre-existing failures in workflow-resolution, cas-exit-code,
and thread-step-count tests.
2026-05-25 11:29:05 +00:00
xiaoju dfae96ad45 style: fix biome import ordering after package rename
CI / check (push) Has been cancelled
2026-05-25 11:26:01 +00:00
xiaomo 2f4473f22c Merge pull request 'refactor: rename workflow-agent-kit → workflow-util-agent, merge moderator' (#513) from refactor/512-rename-packages into main
CI / check (push) Has been cancelled
2026-05-25 11:20:39 +00:00
17 changed files with 269 additions and 22 deletions
+28
View File
@@ -0,0 +1,28 @@
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
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import type { Target, WorkflowPayload } from "@uncaged/workflow-protocol";
import { describe, expect, test } from "vitest";
import { evaluate } from "../moderator/evaluate.js";
@@ -0,0 +1,137 @@
import { readFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { parse } from "yaml";
import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js";
// ─── _agentNameFromBinary ────────────────────────────────────────────────────
describe("_agentNameFromBinary", () => {
test("strips uwf- prefix", () => {
expect(_agentNameFromBinary("uwf-hermes")).toBe("hermes");
});
test("strips uwf- prefix for compound names", () => {
expect(_agentNameFromBinary("uwf-claude-code")).toBe("claude-code");
});
test("returns as-is when no uwf- prefix", () => {
expect(_agentNameFromBinary("hermes")).toBe("hermes");
});
test("handles uwf-builtin", () => {
expect(_agentNameFromBinary("uwf-builtin")).toBe("builtin");
});
});
// ─── _printAgentMenu ─────────────────────────────────────────────────────────
describe("_printAgentMenu", () => {
test("prints known agents with labels", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
_printAgentMenu(["uwf-hermes", "uwf-claude-code"]);
expect(logs.some((l) => l.includes("Hermes"))).toBe(true);
expect(logs.some((l) => l.includes("Claude Code"))).toBe(true);
vi.restoreAllMocks();
});
test("prints unknown agents with binary name as label", () => {
const logs: string[] = [];
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
logs.push(args.join(" "));
});
_printAgentMenu(["uwf-custom-agent"]);
expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true);
vi.restoreAllMocks();
});
});
// ─── cmdSetup agent config ───────────────────────────────────────────────────
describe("cmdSetup agent configuration", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-agent-"));
});
afterEach(async () => {
vi.restoreAllMocks();
await rm(storageRoot, { recursive: true, force: true });
});
const baseArgs = () => ({
provider: "testprovider",
baseUrl: "https://api.test.com/v1",
apiKey: "sk-test",
model: "test-model",
storageRoot,
});
test("defaults to hermes agent when no agent specified", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup(baseArgs());
expect(result.defaultAgent).toBe("hermes");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] });
expect(config.defaultAgent).toBe("hermes");
});
test("writes specified agent as default", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" });
expect(result.defaultAgent).toBe("claude-code");
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] });
expect(config.defaultAgent).toBe("claude-code");
});
test("preserves existing agents when adding new one", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
// First setup with hermes
await cmdSetup(baseArgs());
// Second setup with claude-code
await cmdSetup({ ...baseArgs(), agent: "claude-code" });
const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config.agents.hermes).toBeDefined();
expect(config.agents["claude-code"]).toBeDefined();
expect(config.defaultAgent).toBe("claude-code");
});
test("updates defaultAgent on re-run with different agent", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({}), { status: 200 }),
);
await cmdSetup(baseArgs());
const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config1.defaultAgent).toBe("hermes");
await cmdSetup({ ...baseArgs(), agent: "builtin" });
const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8"));
expect(config2.defaultAgent).toBe("builtin");
});
});
@@ -31,7 +31,13 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
capabilities: [],
procedure: "",
output: "",
frontmatter: { type: "0000000000000" } as unknown as CasRef,
frontmatter: {
type: "object",
properties: {
$status: { type: "string" },
},
required: ["$status"],
} as unknown as CasRef,
},
},
graph: {
+84 -3
View File
@@ -297,6 +297,80 @@ export function _printModelMenu(models: string[], termCols: number): void {
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Agent selection prompt
// ──────────────────────────────────────────────────────────────────────────────
/** Known agent binary → display label mapping. */
const KNOWN_AGENTS: Record<string, string> = {
"uwf-hermes": "Hermes (hermes-agent)",
"uwf-claude-code": "Claude Code",
"uwf-cursor": "Cursor",
"uwf-builtin": "Built-in (lightweight, no external agent)",
};
/** Extract short agent name from binary name: uwf-claude-code → claude-code */
export function _agentNameFromBinary(binary: string): string {
return binary.replace(/^uwf-/, "");
}
/** Prints numbered agent list to stdout. */
export function _printAgentMenu(agents: string[]): void {
const numWidth = String(agents.length).length;
for (let i = 0; i < agents.length; i++) {
const bin = agents[i] ?? "";
const label = KNOWN_AGENTS[bin] ?? bin;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${label} (${bin})`);
}
console.log("");
}
/**
* Interactive agent selection. Discovers uwf-* binaries, lets user pick default.
* Returns short agent name (e.g. "hermes", "claude-code").
*/
export async function _promptAgentSelection(
rl: ReturnType<typeof createInterface>,
): Promise<string> {
console.log("Discovering installed agents...\n");
const agents = await _discoverAgents();
if (agents.length === 0) {
console.log(" No uwf-* agent binaries found in PATH.\n");
console.log(" Install one first, for example:");
console.log(" npm i -g @uncaged/workflow-agent-hermes");
console.log(" npm i -g @uncaged/workflow-agent-claude-code\n");
const manual = (
await rl.question("Agent binary name (e.g. uwf-hermes), or press Enter to skip: ")
).trim();
if (!manual) return "hermes";
return _agentNameFromBinary(manual.startsWith("uwf-") ? manual : `uwf-${manual}`);
}
if (agents.length === 1) {
const name = _agentNameFromBinary(agents[0] ?? "uwf-hermes");
const label = KNOWN_AGENTS[agents[0] ?? ""] ?? agents[0];
console.log(` Found 1 agent: ${label} — auto-selected.\n`);
return name;
}
console.log(` Found ${agents.length} agents:\n`);
_printAgentMenu(agents);
const choice = (await rl.question(`Choose default agent [1-${agents.length}]: `)).trim();
const n = Number.parseInt(choice, 10);
if (!Number.isNaN(n) && n >= 1 && n <= agents.length) {
const selected = agents[n - 1] ?? "uwf-hermes";
const name = _agentNameFromBinary(selected);
console.log(`${name}\n`);
return name;
}
// Treat as literal name
const name = _agentNameFromBinary(choice.startsWith("uwf-") ? choice : `uwf-${choice}`);
console.log(`${name}\n`);
return name;
}
type ValidationResult = { ok: boolean; error: string | null };
/** Prints the model validation result to stdout. */
@@ -340,8 +414,9 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
// Ensure the selected agent has an entry
if (!agents[agentName]) {
agents[agentName] = { command: `uwf-${agentName}`, args: [] };
}
return {
@@ -349,7 +424,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultAgent: agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
@@ -543,11 +618,17 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
rl2.close();
console.log(`${providerName}/${model}\n`);
// 4. Agent discovery & selection
const rl3 = createInterface({ input, output });
const agentName = await _promptAgentSelection(rl3);
rl3.close();
const setupResult = await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
agent: agentName,
storageRoot,
});
+2 -2
View File
@@ -2,8 +2,6 @@ import { execFileSync, spawn } from "node:child_process";
import { access, readFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import { validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { evaluate } from "../moderator/index.js";
import type {
AgentAlias,
AgentConfig,
@@ -24,9 +22,11 @@ import {
generateUlid,
type ProcessLogger,
} from "@uncaged/workflow-util";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-util-agent";
import { config as loadDotenv } from "dotenv";
import { parse } from "yaml";
import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
import { evaluate } from "../moderator/index.js";
import {
appendThreadHistory,
createUwfStore,
+1 -4
View File
@@ -5,8 +5,5 @@
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-util-agent" }
]
"references": [{ "path": "../workflow-protocol" }, { "path": "../workflow-util-agent" }]
}
+1 -1
View File
@@ -1,4 +1,5 @@
import type { Store } from "@uncaged/json-cas";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -7,7 +8,6 @@ import {
resolveModel,
resolveStorageRoot,
} from "@uncaged/workflow-util-agent";
import { createLogger, generateUlid } from "@uncaged/workflow-util";
import { storeBuiltinDetail } from "./detail.js";
import type { ChatMessage } from "./llm/index.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
import { createLogger } from "@uncaged/workflow-util";
import type { ResolvedLlmProvider } from "@uncaged/workflow-util-agent";
import {
type ChatMessage,
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import type { ThreadId } from "@uncaged/workflow-protocol";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import { buildClaudeCodePrompt } from "../src/claude-code.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -9,7 +10,6 @@ import {
getCachedSessionId,
setCachedSessionId,
} from "@uncaged/workflow-util-agent";
import { createLogger } from "@uncaged/workflow-util";
import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js";
@@ -91,5 +91,3 @@ describe("handleSessionUpdate — helper extraction", () => {
expect((client as any).messageChunks).toHaveLength(0);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import type { ThreadId } from "@uncaged/workflow-protocol";
import type { AgentContext } from "@uncaged/workflow-util-agent";
import { buildHermesPrompt } from "../src/hermes.js";
function makeCtx(overrides: Partial<AgentContext> = {}): AgentContext {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "bun:test";
import { HermesAcpClient } from "../src/acp-client.js";
import { HermesAcpClient } from "../../src/acp-client.js";
/**
* E2E test for cross-process session resume.
+1 -1
View File
@@ -19,7 +19,7 @@
},
"scripts": {
"test": "bun test",
"test:ci": "bun test __tests__/*.test.ts"
"test:ci": "bun test __tests__/*.test.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.5.2",
+1 -1
View File
@@ -1,4 +1,5 @@
import type { Store } from "@uncaged/json-cas";
import { createLogger } from "@uncaged/workflow-util";
import {
type AgentContext,
type AgentRunResult,
@@ -6,7 +7,6 @@ import {
buildRolePrompt,
createAgent,
} from "@uncaged/workflow-util-agent";
import { createLogger } from "@uncaged/workflow-util";
import { HermesAcpClient } from "./acp-client.js";
import { getCachedSessionId, isResumeDisabled, setCachedSessionId } from "./session-cache.js";
@@ -1,10 +1,10 @@
// Re-export session cache from the shared agent-kit package with agent name injected.
import type { ThreadId } from "@uncaged/workflow-protocol";
import {
getCachedSessionId as getCachedSessionIdBase,
setCachedSessionId as setCachedSessionIdBase,
} from "@uncaged/workflow-util-agent";
import type { ThreadId } from "@uncaged/workflow-protocol";
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
return getCachedSessionIdBase("hermes", threadId, role);