Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 221919448e | |||
| 68b82c9574 | |||
| 335b8a4ae6 | |||
| bf31fa0d03 | |||
| c39f2f3e63 | |||
| 6481fc0cc5 | |||
| 3190e06ebe | |||
| f8ae2fe25b | |||
| 48a274685b | |||
| 5b68359dfc | |||
| c2ddfb8558 | |||
| 603018caf2 |
@@ -0,0 +1,67 @@
|
|||||||
|
# Sync README
|
||||||
|
|
||||||
|
When updating README.md files in this monorepo, follow these conventions.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Root `README.md` — project overview and navigation hub
|
||||||
|
- Per-package `packages/*/README.md` — each package self-contained
|
||||||
|
|
||||||
|
## Root README Structure
|
||||||
|
|
||||||
|
The root README should have these sections in order:
|
||||||
|
|
||||||
|
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
|
||||||
|
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
|
||||||
|
3. **Architecture** — dependency layer diagram (text-based)
|
||||||
|
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
|
||||||
|
5. **Quick Start** — install, build, register workflow, start thread, run step
|
||||||
|
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
|
||||||
|
7. **Development** — bun install / build / check / test
|
||||||
|
|
||||||
|
## Per-Package README Structure
|
||||||
|
|
||||||
|
Each package README should have:
|
||||||
|
|
||||||
|
1. **Title** — package name
|
||||||
|
2. **One-line description** — matching package.json
|
||||||
|
3. **Overview** — what it does, where it sits in the architecture, dependencies
|
||||||
|
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
|
||||||
|
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
|
||||||
|
6. **CLI Usage** (cli/agent packages) — command reference with examples
|
||||||
|
7. **Internal Structure** — brief src/ file organization
|
||||||
|
8. **Configuration** (if applicable)
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
|
||||||
|
### Step 1: Gather current state
|
||||||
|
For each package read:
|
||||||
|
- package.json (name, version, description, dependencies, bin)
|
||||||
|
- src/index.ts (public API exports)
|
||||||
|
- Existing README.md (preserve hand-written content worth keeping)
|
||||||
|
|
||||||
|
### Step 2: Update root README
|
||||||
|
- Ensure ALL packages in packages/ directory are listed in the table
|
||||||
|
- Update CLI command reference from uwf --help output
|
||||||
|
- Keep Quick Start examples valid
|
||||||
|
|
||||||
|
### Step 3: Write/update each package README
|
||||||
|
- Follow the per-package structure
|
||||||
|
- API section MUST match actual src/index.ts exports — never invent
|
||||||
|
- For agent packages: document CLI binary name, how it is invoked
|
||||||
|
- For lib packages: document exported types and functions
|
||||||
|
- Internal structure: list actual files in src/
|
||||||
|
|
||||||
|
### Step 4: Verify
|
||||||
|
- All relative links work
|
||||||
|
- Package names match package.json
|
||||||
|
- No references to removed/renamed packages
|
||||||
|
- bun run build still passes
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Only document what src/index.ts actually exports
|
||||||
|
- Root README summarizes, package READMEs go into detail
|
||||||
|
- Verify CLI examples against actual commands
|
||||||
|
- Preserve existing good prose when updating
|
||||||
|
- English for all README content
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
_discoverAgents,
|
||||||
|
_isBackspace,
|
||||||
|
_isTerminator,
|
||||||
|
_parseWhichOutput,
|
||||||
|
_printModelMenu,
|
||||||
|
_printProviderMenu,
|
||||||
|
_printValidationResult,
|
||||||
|
_resolveModelChoice,
|
||||||
|
_resolveProviderChoice,
|
||||||
|
_searchPathDirs,
|
||||||
|
} from "../commands/setup.js";
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1a. _searchPathDirs
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_searchPathDirs", () => {
|
||||||
|
test("returns empty array for empty PATH", async () => {
|
||||||
|
const result = await _searchPathDirs("");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds uwf-hermes in a single dir", async () => {
|
||||||
|
const dir = mkdirSync(join(tmpdir(), `uwf-test-${Date.now()}`), { recursive: true }) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const actualDir = dir ?? join(tmpdir(), `uwf-test-${Date.now()}`);
|
||||||
|
mkdirSync(actualDir, { recursive: true });
|
||||||
|
const filePath = join(actualDir, "uwf-hermes");
|
||||||
|
writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(actualDir);
|
||||||
|
expect(result).toContain("uwf-hermes");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips non-uwf- prefixed binaries", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-2`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entry named exactly 'uwf'", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-3`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "uwf"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips non-executable files", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-4`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "uwf-foo"), "#!/bin/sh\n", { mode: 0o644 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates across PATH dirs", async () => {
|
||||||
|
const dir1 = join(tmpdir(), `uwf-test-${Date.now()}-5a`);
|
||||||
|
const dir2 = join(tmpdir(), `uwf-test-${Date.now()}-5b`);
|
||||||
|
mkdirSync(dir1, { recursive: true });
|
||||||
|
mkdirSync(dir2, { recursive: true });
|
||||||
|
writeFileSync(join(dir1, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir2, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(`${dir1}:${dir2}`);
|
||||||
|
expect(result).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns sorted array", async () => {
|
||||||
|
const dir = join(tmpdir(), `uwf-test-${Date.now()}-6`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "uwf-zoo"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-alpha"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
writeFileSync(join(dir, "uwf-mid"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
|
const result = await _searchPathDirs(dir);
|
||||||
|
expect(result).toEqual(["uwf-alpha", "uwf-mid", "uwf-zoo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips inaccessible/nonexistent directories silently", async () => {
|
||||||
|
const result = await _searchPathDirs("/nonexistent-dir-xyz-abc-12345");
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1b. _parseWhichOutput
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_parseWhichOutput", () => {
|
||||||
|
test("returns empty array for empty string", () => {
|
||||||
|
expect(_parseWhichOutput("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses single path", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple paths", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/local/bin/uwf-hermes\n/usr/bin/uwf-claude-code")).toEqual([
|
||||||
|
"uwf-claude-code",
|
||||||
|
"uwf-hermes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deduplicates identical basenames from different dirs", () => {
|
||||||
|
expect(_parseWhichOutput("/a/uwf-hermes\n/b/uwf-hermes")).toEqual(["uwf-hermes"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips blank lines", () => {
|
||||||
|
expect(_parseWhichOutput("/a/uwf-hermes\n\n/b/uwf-cursor")).toEqual([
|
||||||
|
"uwf-cursor",
|
||||||
|
"uwf-hermes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips entry named exactly 'uwf'", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/bin/uwf")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips basenames not starting with uwf-", () => {
|
||||||
|
expect(_parseWhichOutput("/usr/bin/node")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns sorted array", () => {
|
||||||
|
expect(_parseWhichOutput("/a/uwf-zoo\n/a/uwf-alpha")).toEqual(["uwf-alpha", "uwf-zoo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2a. _isTerminator
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_isTerminator", () => {
|
||||||
|
test("\\n is a terminator", () => {
|
||||||
|
expect(_isTerminator("\n")).toBe(true);
|
||||||
|
});
|
||||||
|
test("\\r is a terminator", () => {
|
||||||
|
expect(_isTerminator("\r")).toBe(true);
|
||||||
|
});
|
||||||
|
test("\\u0004 (EOT) is a terminator", () => {
|
||||||
|
expect(_isTerminator("")).toBe(true);
|
||||||
|
});
|
||||||
|
test("regular char is not a terminator", () => {
|
||||||
|
expect(_isTerminator("a")).toBe(false);
|
||||||
|
});
|
||||||
|
test("empty string is not a terminator", () => {
|
||||||
|
expect(_isTerminator("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2b. _isBackspace
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_isBackspace", () => {
|
||||||
|
test("\\u007F is a backspace", () => {
|
||||||
|
expect(_isBackspace("")).toBe(true);
|
||||||
|
});
|
||||||
|
test("\\b is a backspace", () => {
|
||||||
|
expect(_isBackspace("\b")).toBe(true);
|
||||||
|
});
|
||||||
|
test("regular char is not a backspace", () => {
|
||||||
|
expect(_isBackspace("x")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3a. _printProviderMenu
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printProviderMenu", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test("prints correct number of lines (one per provider + custom)", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printProviderMenu(providers);
|
||||||
|
// 2 providers + 1 custom = 3 lines
|
||||||
|
expect(lines.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom option number = providers.length + 1", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printProviderMenu(providers);
|
||||||
|
const lastLine = lines[lines.length - 1] ?? "";
|
||||||
|
expect(lastLine).toMatch(/3\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each provider line contains its label and baseUrl", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printProviderMenu(providers);
|
||||||
|
expect(lines[0]).toContain("OpenAI");
|
||||||
|
expect(lines[0]).toContain("https://api.openai.com/v1");
|
||||||
|
expect(lines[1]).toContain("xAI");
|
||||||
|
expect(lines[1]).toContain("https://api.x.ai/v1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3b. _resolveProviderChoice
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_resolveProviderChoice", () => {
|
||||||
|
const providers = [
|
||||||
|
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
|
||||||
|
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
|
||||||
|
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test("valid index 1 returns first provider", () => {
|
||||||
|
const result = _resolveProviderChoice("1", providers);
|
||||||
|
expect(result).toEqual({ providerName: "openai", baseUrl: "https://api.openai.com/v1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid index N (last preset) returns last provider", () => {
|
||||||
|
const result = _resolveProviderChoice("3", providers);
|
||||||
|
expect(result).toEqual({ providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index providers.length+1 (custom) returns null", () => {
|
||||||
|
const result = _resolveProviderChoice("4", providers);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-numeric string returns null", () => {
|
||||||
|
expect(_resolveProviderChoice("abc", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("0 returns null (out of range)", () => {
|
||||||
|
expect(_resolveProviderChoice("0", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("N+2 returns null (out of range)", () => {
|
||||||
|
expect(_resolveProviderChoice("5", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("negative number returns null", () => {
|
||||||
|
expect(_resolveProviderChoice("-1", providers)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3c. _resolveModelChoice
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_resolveModelChoice", () => {
|
||||||
|
test("numeric input within range returns model at that index", () => {
|
||||||
|
expect(_resolveModelChoice("2", ["a", "b", "c"])).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("numeric input out of range returns input as-is", () => {
|
||||||
|
expect(_resolveModelChoice("5", ["a"])).toBe("5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-numeric input returns input as-is", () => {
|
||||||
|
expect(_resolveModelChoice("gpt-4o", ["a", "b"])).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("numeric input 1 returns first model", () => {
|
||||||
|
expect(_resolveModelChoice("1", ["alpha", "beta"])).toBe("alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty models list with numeric input returns input as-is", () => {
|
||||||
|
expect(_resolveModelChoice("1", [])).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3d. _printModelMenu
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printModelMenu", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prints all models — each model name appears in output", () => {
|
||||||
|
const output: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
output.push(msg);
|
||||||
|
});
|
||||||
|
const models = ["model-a", "model-b", "model-c"];
|
||||||
|
_printModelMenu(models, 100);
|
||||||
|
const combined = output.join("\n");
|
||||||
|
for (const m of models) {
|
||||||
|
expect(combined).toContain(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single column when termCols is very small", () => {
|
||||||
|
const output: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
output.push(msg);
|
||||||
|
});
|
||||||
|
_printModelMenu(["a", "b", "c"], 1);
|
||||||
|
// Each model on its own row → 3 lines
|
||||||
|
expect(output.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wide terminal fits multiple columns", () => {
|
||||||
|
const output: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
output.push(msg);
|
||||||
|
});
|
||||||
|
const models = Array.from({ length: 6 }, (_, i) => `m${i}`);
|
||||||
|
_printModelMenu(models, 200);
|
||||||
|
// With wide terminal and short names, should fit in fewer than 6 rows
|
||||||
|
expect(output.length).toBeLessThan(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3e. _printValidationResult
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_printValidationResult", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ok=true prints success message containing '✓'", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printValidationResult({ ok: true, error: null });
|
||||||
|
expect(lines.join("\n")).toContain("✓");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ok=false prints warning message containing '⚠'", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||||
|
expect(lines.join("\n")).toContain("⚠");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ok=false includes the error string in output", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
vi.spyOn(console, "log").mockImplementation((msg: string) => {
|
||||||
|
lines.push(msg);
|
||||||
|
});
|
||||||
|
_printValidationResult({ ok: false, error: "HTTP 401" });
|
||||||
|
expect(lines.join("\n")).toContain("HTTP 401");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Regression
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("_discoverAgents regression", () => {
|
||||||
|
test("returns an array (may be empty) — never throws", async () => {
|
||||||
|
const result = await _discoverAgents();
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -382,10 +382,6 @@ describe("cmdThreadStepDetails", () => {
|
|||||||
content: "done",
|
content: "done",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws when step hash does not exist", async () => {
|
|
||||||
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
|
// ── cmdThreadRead: ### Prompt deduplication ───────────────────────────────────
|
||||||
@@ -471,3 +467,181 @@ describe("cmdThreadRead ### Prompt deduplication", () => {
|
|||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── cmdThreadRead: showStart / before / quota ─────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadRead start section / before / quota", () => {
|
||||||
|
async function makeSimpleThread(
|
||||||
|
uwf: UwfStore,
|
||||||
|
roles: string[],
|
||||||
|
): Promise<{ startHash: CasRef; stepHashes: CasRef[] }> {
|
||||||
|
const uniqueRoles = [...new Set(roles)];
|
||||||
|
const workflowHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "simple-wf",
|
||||||
|
description: "desc",
|
||||||
|
roles: Object.fromEntries(
|
||||||
|
uniqueRoles.map((r) => [
|
||||||
|
r,
|
||||||
|
{
|
||||||
|
description: r,
|
||||||
|
goal: `Goal for ${r}`,
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "Do stuff.",
|
||||||
|
output: "Output.",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = (await uwf.store.put(uwf.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "Initial prompt",
|
||||||
|
})) as CasRef;
|
||||||
|
const outputHash = await uwf.store.put(uwf.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepHashes: CasRef[] = [];
|
||||||
|
let prev: CasRef | null = null;
|
||||||
|
for (const role of roles) {
|
||||||
|
const stepHash = (await uwf.store.put(uwf.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev,
|
||||||
|
role,
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
})) as CasRef;
|
||||||
|
stepHashes.push(stepHash);
|
||||||
|
prev = stepHash;
|
||||||
|
}
|
||||||
|
return { startHash, stepHashes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("showStart=true includes # Thread header and ## Task section", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||||
|
const threadId = "01JTEST0000000000000006" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, true);
|
||||||
|
expect(markdown).toContain("# Thread");
|
||||||
|
expect(markdown).toContain("## Task");
|
||||||
|
expect(markdown).toContain("Initial prompt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("showStart=false with before=null still shows # Thread header (default behavior)", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||||
|
const threadId = "01JTEST0000000000000007" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||||
|
|
||||||
|
// When before=null, the start section is always shown regardless of showStart
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
expect(markdown).toContain("# Thread");
|
||||||
|
expect(markdown).toContain("## Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("before filter: only steps before the given hash appear", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
|
||||||
|
const [_hashA, hashB, hashC] = stepHashes as [CasRef, CasRef, CasRef];
|
||||||
|
const threadId = "01JTEST0000000000000008" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: hashC });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, hashB, false);
|
||||||
|
expect(markdown).toContain("roleA");
|
||||||
|
expect(markdown).not.toContain("roleB");
|
||||||
|
expect(markdown).not.toContain("roleC");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quota=1 limits output and includes skip hint", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const { stepHashes } = await makeSimpleThread(uwf, ["roleA", "roleB", "roleC"]);
|
||||||
|
const threadId = "01JTEST000000000000000A" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[stepHashes.length - 1]! });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, 1, null, false);
|
||||||
|
expect(markdown).toContain("earlier step");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all steps fit in quota: no skip hint", async () => {
|
||||||
|
const uwf = await makeUwfStore(tmpDir);
|
||||||
|
const { stepHashes } = await makeSimpleThread(uwf, ["roleA"]);
|
||||||
|
const threadId = "01JTEST000000000000000B" as ThreadId;
|
||||||
|
await saveThreadsIndex(tmpDir, { [threadId]: stepHashes[0]! });
|
||||||
|
|
||||||
|
const markdown = await cmdThreadRead(tmpDir, threadId, THREAD_READ_DEFAULT_QUOTA, null, false);
|
||||||
|
expect(markdown).not.toContain("earlier step");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tests that call process.exit must be last ─────────────────────────────────
|
||||||
|
|
||||||
|
describe("cmdThreadStepDetails (process.exit tests - must be last)", () => {
|
||||||
|
test("throws when step hash does not exist", async () => {
|
||||||
|
await expect(cmdThreadStepDetails(tmpDir, "nonexistenth0" as CasRef)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("before with unknown hash rejects", async () => {
|
||||||
|
const _uwf = await makeUwfStore(tmpDir);
|
||||||
|
const casDir = join(tmpDir, "cas");
|
||||||
|
await mkdir(casDir, { recursive: true });
|
||||||
|
const store = createFsStore(casDir);
|
||||||
|
const schemas = await registerUwfSchemas(store);
|
||||||
|
const uwfStore: UwfStore = { storageRoot: tmpDir, store, schemas };
|
||||||
|
|
||||||
|
const workflowHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
|
||||||
|
name: "wf2",
|
||||||
|
description: "",
|
||||||
|
roles: {
|
||||||
|
roleA: {
|
||||||
|
description: "r",
|
||||||
|
goal: "g",
|
||||||
|
capabilities: [],
|
||||||
|
procedure: "p",
|
||||||
|
output: "o",
|
||||||
|
meta: "placeholder00" as CasRef,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const startHash = await uwfStore.store.put(uwfStore.schemas.startNode, {
|
||||||
|
workflow: workflowHash,
|
||||||
|
prompt: "p",
|
||||||
|
});
|
||||||
|
const outputHash = await uwfStore.store.put(uwfStore.schemas.workflow, {
|
||||||
|
name: "out",
|
||||||
|
description: "",
|
||||||
|
roles: {},
|
||||||
|
conditions: {},
|
||||||
|
graph: {},
|
||||||
|
});
|
||||||
|
const stepHash = await uwfStore.store.put(uwfStore.schemas.stepNode, {
|
||||||
|
start: startHash,
|
||||||
|
prev: null,
|
||||||
|
role: "roleA",
|
||||||
|
output: outputHash,
|
||||||
|
detail: null,
|
||||||
|
agent: "uwf-test",
|
||||||
|
});
|
||||||
|
await saveThreadsIndex(tmpDir, { ["01JTEST000000000000000C" as ThreadId]: stepHash as CasRef });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
cmdThreadRead(
|
||||||
|
tmpDir,
|
||||||
|
"01JTEST000000000000000C" as ThreadId,
|
||||||
|
THREAD_READ_DEFAULT_QUOTA,
|
||||||
|
"unknownhash0" as CasRef,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { stdin as input, stdout as output } from "node:process";
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
import { createInterface } from "node:readline/promises";
|
import { createInterface } from "node:readline/promises";
|
||||||
@@ -137,75 +137,182 @@ function apiKeyEnvName(providerName: string): string {
|
|||||||
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Extracted helpers — _discoverAgents
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans directories from a PATH string for uwf-* executables.
|
||||||
|
*/
|
||||||
|
export async function _searchPathDirs(pathEnv: string): Promise<string[]> {
|
||||||
|
if (!pathEnv) return [];
|
||||||
|
const dirs = pathEnv.split(":").filter((d) => d.length > 0);
|
||||||
|
const agents = new Set<string>();
|
||||||
|
for (const dir of dirs) {
|
||||||
|
_scanDirForAgents(dir, agents);
|
||||||
|
}
|
||||||
|
return Array.from(agents).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _scanDirForAgents(dir: string, agents: Set<string>): void {
|
||||||
|
try {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
const entries = readdirSync(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
||||||
|
if (_isExecutableFile(join(dir, entry))) {
|
||||||
|
agents.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip inaccessible directories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isExecutableFile(fullPath: string): boolean {
|
||||||
|
try {
|
||||||
|
const s = statSync(fullPath);
|
||||||
|
return s.isFile() && (s.mode & 0o111) !== 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the stdout of `which -a` into sorted unique basenames.
|
||||||
|
*/
|
||||||
|
export function _parseWhichOutput(text: string): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
const agents = new Set<string>();
|
||||||
|
for (const line of text.trim().split("\n")) {
|
||||||
|
if (!line) continue;
|
||||||
|
const basename = line.split("/").pop() ?? "";
|
||||||
|
if (basename.startsWith("uwf-") && basename !== "uwf") {
|
||||||
|
agents.add(basename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(agents).sort();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover uwf-* agent binaries in PATH.
|
* Discover uwf-* agent binaries in PATH.
|
||||||
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
|
* Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]).
|
||||||
*/
|
*/
|
||||||
async function _discoverAgents(): Promise<string[]> {
|
export async function _discoverAgents(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const agents = await _tryWhichDiscovery();
|
||||||
|
if (agents !== null) return agents;
|
||||||
|
return await _searchPathDirs(process.env.PATH ?? "");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _tryWhichDiscovery(): Promise<string[] | null> {
|
||||||
try {
|
try {
|
||||||
// Use which -a to find all uwf-* binaries in PATH
|
|
||||||
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
|
const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], {
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await new Response(proc.stdout).text();
|
const text = await new Response(proc.stdout).text();
|
||||||
await proc.exited;
|
await proc.exited;
|
||||||
|
if (proc.exitCode !== 0) return null;
|
||||||
if (proc.exitCode !== 0) {
|
return _parseWhichOutput(text);
|
||||||
// Try alternative approach: search PATH directories manually
|
|
||||||
const pathEnv = process.env.PATH || "";
|
|
||||||
const pathDirs = pathEnv.split(":").filter((d) => d.length > 0);
|
|
||||||
const agents = new Set<string>();
|
|
||||||
|
|
||||||
for (const dir of pathDirs) {
|
|
||||||
try {
|
|
||||||
if (!existsSync(dir)) continue;
|
|
||||||
const { readdirSync, statSync } = await import("node:fs");
|
|
||||||
const entries = readdirSync(dir);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.startsWith("uwf-") || entry === "uwf") continue;
|
|
||||||
const fullPath = join(dir, entry);
|
|
||||||
try {
|
|
||||||
const stat = statSync(fullPath);
|
|
||||||
// Check if executable (owner, group, or other has execute bit)
|
|
||||||
if (stat.isFile() && (stat.mode & 0o111) !== 0) {
|
|
||||||
agents.add(entry);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip if can't stat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip inaccessible directories
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(agents).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse which output - each line is a path to a binary
|
|
||||||
const paths = text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
const agents = new Set<string>();
|
|
||||||
|
|
||||||
for (const path of paths) {
|
|
||||||
const basename = path.split("/").pop();
|
|
||||||
if (basename?.startsWith("uwf-") && basename !== "uwf") {
|
|
||||||
agents.add(basename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(agents).sort();
|
|
||||||
} catch {
|
} catch {
|
||||||
// If all fails, return empty array
|
return null;
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Extracted helpers — onData closure (promptSecret)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns true for newline, carriage return, or EOF (EOT). */
|
||||||
|
export function _isTerminator(c: string): boolean {
|
||||||
|
return c === "\n" || c === "\r" || c === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true for DEL or backspace. */
|
||||||
|
export function _isBackspace(c: string): boolean {
|
||||||
|
return c === "" || c === "\b";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Extracted helpers — cmdSetupInteractive
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ProviderEntry = { name: string; label: string; baseUrl: string };
|
||||||
|
|
||||||
|
/** Prints the numbered provider list and custom option to stdout. */
|
||||||
|
export function _printProviderMenu(providers: readonly ProviderEntry[]): void {
|
||||||
|
const numWidth = String(providers.length + 1).length;
|
||||||
|
for (let i = 0; i < providers.length; i++) {
|
||||||
|
const p = providers[i];
|
||||||
|
if (!p) continue;
|
||||||
|
const num = String(i + 1).padStart(numWidth);
|
||||||
|
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||||
|
}
|
||||||
|
const customNum = String(providers.length + 1).padStart(numWidth);
|
||||||
|
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a numeric choice string to a preset provider, or null for custom/invalid. */
|
||||||
|
export function _resolveProviderChoice(
|
||||||
|
choice: string,
|
||||||
|
providers: readonly ProviderEntry[],
|
||||||
|
): { providerName: string; baseUrl: string } | null {
|
||||||
|
const n = Number.parseInt(choice, 10);
|
||||||
|
if (Number.isNaN(n) || n < 1 || n > providers.length) return null;
|
||||||
|
const p = providers[n - 1];
|
||||||
|
if (!p) return null;
|
||||||
|
return { providerName: p.name, baseUrl: p.baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves numeric index or literal model name to a model string. */
|
||||||
|
export function _resolveModelChoice(input: string, models: string[]): string {
|
||||||
|
const n = Number.parseInt(input, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1 && n <= models.length) {
|
||||||
|
return models[n - 1] ?? input;
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the multi-column model list to stdout. */
|
||||||
|
export function _printModelMenu(models: string[], termCols: number): void {
|
||||||
|
const nw = String(models.length).length;
|
||||||
|
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
||||||
|
const colWidth = nw + 2 + maxLen + 4;
|
||||||
|
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
||||||
|
const rows = Math.ceil(models.length / cols);
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
let line = "";
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const idx = c * rows + r;
|
||||||
|
if (idx >= models.length) break;
|
||||||
|
const num = String(idx + 1).padStart(nw);
|
||||||
|
const name = (models[idx] ?? "").padEnd(maxLen);
|
||||||
|
line += ` ${num}) ${name} `;
|
||||||
|
}
|
||||||
|
console.log(line.trimEnd());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidationResult = { ok: boolean; error: string | null };
|
||||||
|
|
||||||
|
/** Prints the model validation result to stdout. */
|
||||||
|
export function _printValidationResult(validation: ValidationResult): void {
|
||||||
|
if (validation.ok) {
|
||||||
|
console.log("✓ Model verified — connection successful.\n");
|
||||||
|
} else {
|
||||||
|
console.log(`\n⚠ Warning: Could not reach model — ${validation.error}`);
|
||||||
|
console.log(
|
||||||
|
" Config saved, but you may want to try a different model or check your API key.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
|
||||||
*/
|
*/
|
||||||
@@ -281,6 +388,46 @@ export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecretState = {
|
||||||
|
buf: string;
|
||||||
|
rawWasSet: boolean;
|
||||||
|
resolve: (value: string) => void;
|
||||||
|
onData: (chunk: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function _handleSecretTerminator(state: SecretState): void {
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdin.removeListener("data", state.onData);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
state.resolve(state.buf.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleSecretBackspace(state: SecretState): void {
|
||||||
|
if (state.buf.length > 0) {
|
||||||
|
state.buf = state.buf.slice(0, -1);
|
||||||
|
process.stdout.write("\b \b");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleSecretChar(c: string, state: SecretState): boolean {
|
||||||
|
if (_isTerminator(c)) {
|
||||||
|
_handleSecretTerminator(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_isBackspace(c)) {
|
||||||
|
_handleSecretBackspace(state);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (c === "") {
|
||||||
|
if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet);
|
||||||
|
process.exit(130);
|
||||||
|
}
|
||||||
|
state.buf += c;
|
||||||
|
process.stdout.write("*");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Read a line with terminal echo disabled (for secrets). */
|
/** Read a line with terminal echo disabled (for secrets). */
|
||||||
async function promptSecret(label: string): Promise<string> {
|
async function promptSecret(label: string): Promise<string> {
|
||||||
process.stdout.write(label);
|
process.stdout.write(label);
|
||||||
@@ -292,33 +439,13 @@ async function promptSecret(label: string): Promise<string> {
|
|||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
|
|
||||||
let buf = "";
|
const state: SecretState = { buf: "", rawWasSet, resolve, onData: () => {} };
|
||||||
const onData = (chunk: string) => {
|
state.onData = (chunk: string) => {
|
||||||
for (const c of chunk.toString()) {
|
for (const c of chunk.toString()) {
|
||||||
if (c === "\n" || c === "\r" || c === "\u0004") {
|
if (_handleSecretChar(c, state)) return;
|
||||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
|
||||||
process.stdin.pause();
|
|
||||||
process.stdin.removeListener("data", onData);
|
|
||||||
process.stdout.write("\n");
|
|
||||||
resolve(buf.trim());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (c === "\u007F" || c === "\b") {
|
|
||||||
if (buf.length > 0) {
|
|
||||||
buf = buf.slice(0, -1);
|
|
||||||
process.stdout.write("\b \b");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === "\u0003") {
|
|
||||||
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
|
|
||||||
process.exit(130);
|
|
||||||
}
|
|
||||||
buf += c;
|
|
||||||
process.stdout.write("*");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
process.stdin.on("data", onData);
|
process.stdin.on("data", state.onData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +471,56 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _promptProviderSelection(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
): Promise<{ providerName: string; baseUrl: string }> {
|
||||||
|
console.log("Select a provider:\n");
|
||||||
|
_printProviderMenu(PRESET_PROVIDERS);
|
||||||
|
|
||||||
|
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
||||||
|
const choiceNum = Number.parseInt(choice, 10);
|
||||||
|
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
||||||
|
throw new Error(`Invalid choice: ${choice}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = _resolveProviderChoice(choice, PRESET_PROVIDERS);
|
||||||
|
if (preset) {
|
||||||
|
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
||||||
|
if (selected) {
|
||||||
|
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||||
|
}
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
||||||
|
if (!providerName) throw new Error("Provider name required");
|
||||||
|
const baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
||||||
|
if (!baseUrl) throw new Error("Base URL required");
|
||||||
|
return { providerName, baseUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _promptModelSelection(
|
||||||
|
rl: ReturnType<typeof createInterface>,
|
||||||
|
baseUrl: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<string> {
|
||||||
|
console.log("\nFetching available models...");
|
||||||
|
const models = await fetchModels(baseUrl, apiKey);
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
console.log("Could not fetch models. Enter model name manually.");
|
||||||
|
const model = (await rl.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
||||||
|
if (!model) throw new Error("Model required");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
console.log(`\nAvailable models (${models.length}):\n`);
|
||||||
|
_printModelMenu(models, process.stdout.columns || 100);
|
||||||
|
console.log(`\nChoose a number, or type a model name directly.`);
|
||||||
|
const modelInput = (await rl.question(`Default model [1-${models.length}]: `)).trim();
|
||||||
|
if (!modelInput) throw new Error("Model required");
|
||||||
|
return _resolveModelChoice(modelInput, models);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive setup — prompts user for provider, API key, model.
|
* Interactive setup — prompts user for provider, API key, model.
|
||||||
*/
|
*/
|
||||||
@@ -353,39 +530,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
try {
|
try {
|
||||||
console.log("Configure LLM provider for uwf workflow agents.\n");
|
console.log("Configure LLM provider for uwf workflow agents.\n");
|
||||||
|
|
||||||
// 1. Provider selection
|
const { providerName, baseUrl } = await _promptProviderSelection(rl);
|
||||||
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
|
|
||||||
console.log("Select a provider:\n");
|
|
||||||
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
|
|
||||||
const p = PRESET_PROVIDERS[i];
|
|
||||||
if (!p) continue;
|
|
||||||
const num = String(i + 1).padStart(numWidth);
|
|
||||||
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
|
||||||
}
|
|
||||||
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
|
|
||||||
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
|
|
||||||
|
|
||||||
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
|
|
||||||
const choiceNum = Number.parseInt(choice, 10);
|
|
||||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
|
|
||||||
throw new Error(`Invalid choice: ${choice}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let providerName: string;
|
|
||||||
let baseUrl: string;
|
|
||||||
|
|
||||||
if (choiceNum <= PRESET_PROVIDERS.length) {
|
|
||||||
const selected = PRESET_PROVIDERS[choiceNum - 1];
|
|
||||||
if (!selected) throw new Error("Invalid selection");
|
|
||||||
providerName = selected.name;
|
|
||||||
baseUrl = selected.baseUrl;
|
|
||||||
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
|
||||||
} else {
|
|
||||||
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
|
|
||||||
if (!providerName) throw new Error("Provider name required");
|
|
||||||
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
|
|
||||||
if (!baseUrl) throw new Error("Base URL required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. API key
|
// 2. API key
|
||||||
rl.close();
|
rl.close();
|
||||||
@@ -394,47 +539,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
|
|
||||||
// 3. Model selection
|
// 3. Model selection
|
||||||
const rl2 = createInterface({ input, output });
|
const rl2 = createInterface({ input, output });
|
||||||
console.log("\nFetching available models...");
|
const model = await _promptModelSelection(rl2, baseUrl, apiKey);
|
||||||
const models = await fetchModels(baseUrl, apiKey);
|
|
||||||
|
|
||||||
let model: string;
|
|
||||||
if (models.length > 0) {
|
|
||||||
console.log(`\nAvailable models (${models.length}):\n`);
|
|
||||||
const nw = String(models.length).length;
|
|
||||||
// Multi-column layout
|
|
||||||
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
|
|
||||||
const colWidth = nw + 2 + maxLen + 4; // " N) name "
|
|
||||||
const termCols = process.stdout.columns || 100;
|
|
||||||
const cols = Math.max(1, Math.floor(termCols / colWidth));
|
|
||||||
const rows = Math.ceil(models.length / cols);
|
|
||||||
for (let r = 0; r < rows; r++) {
|
|
||||||
let line = "";
|
|
||||||
for (let c = 0; c < cols; c++) {
|
|
||||||
const idx = c * rows + r;
|
|
||||||
if (idx >= models.length) break;
|
|
||||||
const num = String(idx + 1).padStart(nw);
|
|
||||||
const name = (models[idx] ?? "").padEnd(maxLen);
|
|
||||||
line += ` ${num}) ${name} `;
|
|
||||||
}
|
|
||||||
console.log(line.trimEnd());
|
|
||||||
}
|
|
||||||
console.log(`\nChoose a number, or type a model name directly.`);
|
|
||||||
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
|
|
||||||
if (!modelInput) throw new Error("Model required");
|
|
||||||
const modelNum = Number.parseInt(modelInput, 10);
|
|
||||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
|
||||||
model = models[modelNum - 1] ?? modelInput;
|
|
||||||
} else {
|
|
||||||
model = modelInput;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("Could not fetch models. Enter model name manually.");
|
|
||||||
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
|
|
||||||
if (!model) throw new Error("Model required");
|
|
||||||
}
|
|
||||||
|
|
||||||
rl2.close();
|
rl2.close();
|
||||||
|
|
||||||
console.log(` → ${providerName}/${model}\n`);
|
console.log(` → ${providerName}/${model}\n`);
|
||||||
|
|
||||||
const setupResult = await cmdSetup({
|
const setupResult = await cmdSetup({
|
||||||
@@ -447,17 +553,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise<Record<s
|
|||||||
|
|
||||||
// Show validation result
|
// Show validation result
|
||||||
if (setupResult.validation && typeof setupResult.validation === "object") {
|
if (setupResult.validation && typeof setupResult.validation === "object") {
|
||||||
const v = setupResult.validation as { ok: boolean; error?: string };
|
_printValidationResult(setupResult.validation as ValidationResult);
|
||||||
if (v.ok) {
|
|
||||||
console.log("✓ Model verified — connection successful.\n");
|
|
||||||
} else {
|
|
||||||
console.log(`\n⚠ Warning: Could not reach model — ${v.error}`);
|
|
||||||
console.log(
|
|
||||||
" Config saved, but you may want to try a different model or check your API key.\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Setup complete! Get started:\n");
|
console.log("Setup complete! Get started:\n");
|
||||||
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
|
||||||
console.log(' uwf thread start <name> -p "..." Start a thread');
|
console.log(' uwf thread start <name> -p "..." Start a thread');
|
||||||
|
|||||||
@@ -462,49 +462,68 @@ function expandDeep(store: CasStore, hash: CasRef, visited?: Set<string>): unkno
|
|||||||
return expandValue(store, schema, node.payload, seen);
|
return expandValue(store, schema, node.payload, seen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandCasRefField(store: CasStore, value: unknown, visited: Set<string>): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return expandDeep(store, value as CasRef, visited);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAnyOfField(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (!Array.isArray(schema.anyOf)) return value;
|
||||||
|
for (const sub of schema.anyOf as JSONSchema[]) {
|
||||||
|
if (sub.format === "cas_ref" && typeof value === "string") {
|
||||||
|
return expandDeep(store, value as CasRef, visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandArrayField(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (!schema.items || !Array.isArray(value)) return value;
|
||||||
|
const itemSchema = schema.items as JSONSchema;
|
||||||
|
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandObjectField(
|
||||||
|
store: CasStore,
|
||||||
|
schema: JSONSchema,
|
||||||
|
value: unknown,
|
||||||
|
visited: Set<string>,
|
||||||
|
): unknown {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value) || !schema.properties) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const props = schema.properties as Record<string, JSONSchema>;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
const propSchema = props[key];
|
||||||
|
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function expandValue(
|
function expandValue(
|
||||||
store: CasStore,
|
store: CasStore,
|
||||||
schema: JSONSchema,
|
schema: JSONSchema,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
visited: Set<string>,
|
visited: Set<string>,
|
||||||
): unknown {
|
): unknown {
|
||||||
// If this field is a cas_ref, expand it
|
if (schema.format === "cas_ref") return expandCasRefField(store, value, visited);
|
||||||
if (schema.format === "cas_ref") {
|
if (Array.isArray(schema.anyOf)) return expandAnyOfField(store, schema, value, visited);
|
||||||
if (typeof value === "string") {
|
if (schema.type === "array") return expandArrayField(store, schema, value, visited);
|
||||||
return expandDeep(store, value as CasRef, visited);
|
return expandObjectField(store, schema, value, visited);
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// anyOf (nullable refs)
|
|
||||||
if (Array.isArray(schema.anyOf)) {
|
|
||||||
for (const sub of schema.anyOf as JSONSchema[]) {
|
|
||||||
if (sub.format === "cas_ref" && typeof value === "string") {
|
|
||||||
return expandDeep(store, value as CasRef, visited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Array of cas_ref items
|
|
||||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
|
||||||
const itemSchema = schema.items as JSONSchema;
|
|
||||||
return (value as unknown[]).map((item) => expandValue(store, itemSchema, item, visited));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object with properties
|
|
||||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && schema.properties) {
|
|
||||||
const props = schema.properties as Record<string, JSONSchema>;
|
|
||||||
const obj = value as Record<string, unknown>;
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
for (const [key, val] of Object.entries(obj)) {
|
|
||||||
const propSchema = props[key];
|
|
||||||
result[key] = propSchema ? expandValue(store, propSchema, val, visited) : val;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectOrderedSteps(
|
function collectOrderedSteps(
|
||||||
@@ -588,6 +607,85 @@ export function extractLastAssistantContent(uwf: UwfStore, detailRef: CasRef): s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sliceBeforeHash(
|
||||||
|
candidates: OrderedStepItem[],
|
||||||
|
before: CasRef,
|
||||||
|
threadId: ThreadId,
|
||||||
|
): OrderedStepItem[] {
|
||||||
|
const idx = candidates.findIndex((s) => s.hash === before);
|
||||||
|
if (idx === -1) {
|
||||||
|
fail(`step ${before} not found in thread ${threadId}`);
|
||||||
|
}
|
||||||
|
return candidates.slice(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectByQuota(
|
||||||
|
candidates: OrderedStepItem[],
|
||||||
|
uwf: UwfStore,
|
||||||
|
quota: number,
|
||||||
|
): { selected: OrderedStepItem[]; skippedCount: number } {
|
||||||
|
const selected: OrderedStepItem[] = [];
|
||||||
|
let totalChars = 0;
|
||||||
|
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||||
|
const item = candidates[i];
|
||||||
|
if (item === undefined) continue;
|
||||||
|
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
||||||
|
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
||||||
|
selected.unshift(item);
|
||||||
|
totalChars += blockLen;
|
||||||
|
if (totalChars > quota) break;
|
||||||
|
}
|
||||||
|
return { selected, skippedCount: candidates.length - selected.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepHeader(stepNum: number, item: OrderedStepItem): string {
|
||||||
|
const ts = new Date(item.timestamp)
|
||||||
|
.toISOString()
|
||||||
|
.replace("T", " ")
|
||||||
|
.replace(/\.\d+Z$/, "");
|
||||||
|
return [
|
||||||
|
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
||||||
|
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepPrompt(
|
||||||
|
roleDef: WorkflowPayload["roles"][string] | undefined,
|
||||||
|
role: string,
|
||||||
|
shownPromptRoles: Set<string>,
|
||||||
|
): string {
|
||||||
|
if (!roleDef || shownPromptRoles.has(role)) return "";
|
||||||
|
shownPromptRoles.add(role);
|
||||||
|
return ["", "", "### Prompt", "", roleDef.goal].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStepContent(uwf: UwfStore, item: OrderedStepItem): string {
|
||||||
|
if (!item.payload.detail) return "";
|
||||||
|
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
||||||
|
if (content === null) return "";
|
||||||
|
return ["", "", "### Content", "", content].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStartSection(options: {
|
||||||
|
threadId: ThreadId;
|
||||||
|
workflowName: string;
|
||||||
|
workflowHash: CasRef;
|
||||||
|
prompt: string;
|
||||||
|
before: CasRef | null;
|
||||||
|
showStart: boolean;
|
||||||
|
}): string {
|
||||||
|
if (options.before !== null && !options.showStart) return "";
|
||||||
|
return [
|
||||||
|
`# Thread \`${options.threadId}\``,
|
||||||
|
"",
|
||||||
|
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
||||||
|
"",
|
||||||
|
"## Task",
|
||||||
|
"",
|
||||||
|
options.prompt,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function formatThreadReadMarkdown(options: {
|
function formatThreadReadMarkdown(options: {
|
||||||
threadId: ThreadId;
|
threadId: ThreadId;
|
||||||
workflowName: string;
|
workflowName: string;
|
||||||
@@ -600,50 +698,16 @@ function formatThreadReadMarkdown(options: {
|
|||||||
before: CasRef | null;
|
before: CasRef | null;
|
||||||
showStart: boolean;
|
showStart: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const { ordered, uwf, workflow, quota, before, showStart } = options;
|
const { ordered, uwf, workflow, quota, before } = options;
|
||||||
|
|
||||||
// Determine which steps to consider
|
const candidates = before !== null ? sliceBeforeHash(ordered, before, options.threadId) : ordered;
|
||||||
let candidates = ordered;
|
const { selected, skippedCount } = selectByQuota(candidates, uwf, quota);
|
||||||
if (before !== null) {
|
|
||||||
const idx = candidates.findIndex((s) => s.hash === before);
|
|
||||||
if (idx === -1) {
|
|
||||||
fail(`step ${before} not found in thread ${options.threadId}`);
|
|
||||||
}
|
|
||||||
candidates = candidates.slice(0, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk backward from newest, accumulating chars until quota exceeded
|
|
||||||
const selected: OrderedStepItem[] = [];
|
|
||||||
let totalChars = 0;
|
|
||||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
|
||||||
const item = candidates[i];
|
|
||||||
if (item === undefined) continue;
|
|
||||||
const outputYaml = formatYaml(expandOutput(uwf, item.payload.output));
|
|
||||||
const blockLen = formatCompactStep(i + 1, item, outputYaml).length;
|
|
||||||
selected.unshift(item);
|
|
||||||
totalChars += blockLen;
|
|
||||||
if (totalChars > quota) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const skippedCount = candidates.length - selected.length;
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
// Start section
|
const startSection = formatStartSection(options);
|
||||||
if (before === null || showStart) {
|
if (startSection !== "") parts.push(startSection);
|
||||||
parts.push(
|
|
||||||
[
|
|
||||||
`# Thread \`${options.threadId}\``,
|
|
||||||
"",
|
|
||||||
`**Workflow:** ${options.workflowName} (\`${options.workflowHash}\`)`,
|
|
||||||
"",
|
|
||||||
"## Task",
|
|
||||||
"",
|
|
||||||
options.prompt,
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip hint
|
|
||||||
if (skippedCount > 0 && selected.length > 0) {
|
if (skippedCount > 0 && selected.length > 0) {
|
||||||
const firstSelected = selected[0];
|
const firstSelected = selected[0];
|
||||||
if (firstSelected !== undefined) {
|
if (firstSelected !== undefined) {
|
||||||
@@ -653,34 +717,21 @@ function formatThreadReadMarkdown(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step blocks
|
|
||||||
const startIndex = candidates.length - selected.length;
|
const startIndex = candidates.length - selected.length;
|
||||||
const shownPromptRoles = new Set<string>();
|
const shownPromptRoles = new Set<string>();
|
||||||
for (let i = 0; i < selected.length; i++) {
|
for (let i = 0; i < selected.length; i++) {
|
||||||
const item = selected[i];
|
const item = selected[i];
|
||||||
if (item === undefined) continue;
|
if (item === undefined) continue;
|
||||||
const stepNum = startIndex + i + 1;
|
const stepNum = startIndex + i + 1;
|
||||||
const ts = new Date(item.timestamp)
|
|
||||||
.toISOString()
|
|
||||||
.replace("T", " ")
|
|
||||||
.replace(/\.\d+Z$/, "");
|
|
||||||
const stepLines = [
|
|
||||||
`## Step ${stepNum}: ${item.payload.role} \`${item.hash}\``,
|
|
||||||
`**Agent:** ${item.payload.agent} | **Time:** ${ts}`,
|
|
||||||
];
|
|
||||||
const roleDef = workflow.roles[item.payload.role];
|
const roleDef = workflow.roles[item.payload.role];
|
||||||
if (roleDef && !shownPromptRoles.has(item.payload.role)) {
|
const stepBlock = [
|
||||||
const prompt = roleDef.goal;
|
formatStepHeader(stepNum, item),
|
||||||
stepLines.push("", "### Prompt", "", prompt);
|
formatStepPrompt(roleDef, item.payload.role, shownPromptRoles),
|
||||||
shownPromptRoles.add(item.payload.role);
|
formatStepContent(uwf, item),
|
||||||
}
|
]
|
||||||
if (item.payload.detail) {
|
.filter((s) => s !== "")
|
||||||
const content = extractLastAssistantContent(uwf, item.payload.detail);
|
.join("");
|
||||||
if (content !== null) {
|
parts.push(stepBlock);
|
||||||
stepLines.push("", "### Content", "", content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.push(stepLines.join("\n"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts.join("\n\n---\n\n");
|
return parts.join("\n\n---\n\n");
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
|
||||||
|
const mockChatCompletionWithTools = mock(async () => ({
|
||||||
|
content: "---\nstatus: done\n---",
|
||||||
|
toolCalls: [],
|
||||||
|
}));
|
||||||
|
const mockAppendSessionTurn = mock(async () => {});
|
||||||
|
const mockExecuteBuiltinTool = mock(async () => "tool-result");
|
||||||
|
|
||||||
|
mock.module("../src/llm/index.js", () => ({
|
||||||
|
chatCompletionWithTools: mockChatCompletionWithTools,
|
||||||
|
}));
|
||||||
|
mock.module("../src/session.js", () => ({
|
||||||
|
appendSessionTurn: mockAppendSessionTurn,
|
||||||
|
}));
|
||||||
|
mock.module("../src/tools/index.js", () => ({
|
||||||
|
builtinToolsToOpenAi: () => [],
|
||||||
|
executeBuiltinTool: mockExecuteBuiltinTool,
|
||||||
|
getBuiltinTools: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { executeTurnTools, runBuiltinLoop, shouldNudge } from "../src/loop.js";
|
||||||
|
|
||||||
|
const fakeProvider = {} as any;
|
||||||
|
const fakeToolCtx = {} as any;
|
||||||
|
|
||||||
|
function makeOptions(overrides: Partial<Parameters<typeof runBuiltinLoop>[0]> = {}) {
|
||||||
|
return {
|
||||||
|
provider: fakeProvider,
|
||||||
|
messages: [{ role: "system" as const, content: "sys" }],
|
||||||
|
toolCtx: fakeToolCtx,
|
||||||
|
maxTurns: 5,
|
||||||
|
storageRoot: "/tmp",
|
||||||
|
sessionId: "sess",
|
||||||
|
noTools: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockChatCompletionWithTools.mockReset();
|
||||||
|
mockAppendSessionTurn.mockReset();
|
||||||
|
mockExecuteBuiltinTool.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldNudge", () => {
|
||||||
|
test("2.1 returns true when all conditions met", () => {
|
||||||
|
expect(shouldNudge({ noTools: false, text: "some text", turn: 0, maxTurns: 5 })).toBe(true);
|
||||||
|
});
|
||||||
|
test("2.2 returns false when noTools=true", () => {
|
||||||
|
expect(shouldNudge({ noTools: true, text: "some text", turn: 0, maxTurns: 5 })).toBe(false);
|
||||||
|
});
|
||||||
|
test("2.3 returns false when text starts with ---", () => {
|
||||||
|
expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("2.4 returns false on last turn", () => {
|
||||||
|
expect(shouldNudge({ noTools: false, text: "some text", turn: 4, maxTurns: 5 })).toBe(false);
|
||||||
|
});
|
||||||
|
test("2.5 returns true on second-to-last turn", () => {
|
||||||
|
expect(shouldNudge({ noTools: false, text: "some text", turn: 3, maxTurns: 5 })).toBe(true);
|
||||||
|
});
|
||||||
|
test("2.6 leading whitespace before --- suppresses nudge", () => {
|
||||||
|
expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("executeTurnTools", () => {
|
||||||
|
test("4.1 executes each tool call and pushes tool result messages", async () => {
|
||||||
|
mockExecuteBuiltinTool.mockResolvedValue("result");
|
||||||
|
const messages: any[] = [];
|
||||||
|
const calls = [
|
||||||
|
{ id: "c1", name: "tool_a", arguments: "{}" },
|
||||||
|
{ id: "c2", name: "tool_b", arguments: "{}" },
|
||||||
|
];
|
||||||
|
const count = await executeTurnTools(calls, fakeToolCtx, messages, "/tmp", "sess");
|
||||||
|
expect(messages.length).toBe(2);
|
||||||
|
expect(messages[0].role).toBe("tool");
|
||||||
|
expect(messages[1].role).toBe("tool");
|
||||||
|
expect(count).toBe(2);
|
||||||
|
});
|
||||||
|
test("4.2 tool result content matches executeBuiltinTool return value", async () => {
|
||||||
|
mockExecuteBuiltinTool.mockResolvedValue("result-A");
|
||||||
|
const messages: any[] = [];
|
||||||
|
await executeTurnTools(
|
||||||
|
[{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||||
|
fakeToolCtx,
|
||||||
|
messages,
|
||||||
|
"/tmp",
|
||||||
|
"sess",
|
||||||
|
);
|
||||||
|
expect(messages[0].content).toBe("result-A");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runBuiltinLoop integration", () => {
|
||||||
|
test("3.1 single text-only response returns finalText immediately", async () => {
|
||||||
|
mockChatCompletionWithTools.mockResolvedValue({
|
||||||
|
content: "---\nstatus: done\n---",
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
const result = await runBuiltinLoop(makeOptions());
|
||||||
|
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||||
|
expect(result.turnCount).toBe(1);
|
||||||
|
});
|
||||||
|
test("3.2 noTools=true suppresses tool calls", async () => {
|
||||||
|
mockChatCompletionWithTools.mockResolvedValue({
|
||||||
|
content: "ok",
|
||||||
|
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||||
|
});
|
||||||
|
const result = await runBuiltinLoop(makeOptions({ noTools: true }));
|
||||||
|
expect(result.finalText).toBe("ok");
|
||||||
|
expect(result.turnCount).toBe(1);
|
||||||
|
});
|
||||||
|
test("3.3 tool call followed by text response", async () => {
|
||||||
|
mockChatCompletionWithTools
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
content: null,
|
||||||
|
toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||||
|
mockExecuteBuiltinTool.mockResolvedValue("file contents");
|
||||||
|
const result = await runBuiltinLoop(makeOptions());
|
||||||
|
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||||
|
expect(result.turnCount).toBe(3);
|
||||||
|
});
|
||||||
|
test("3.4 nudge cycle inserts nudge message", async () => {
|
||||||
|
mockChatCompletionWithTools
|
||||||
|
.mockResolvedValueOnce({ content: "I am thinking", toolCalls: [] })
|
||||||
|
.mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] });
|
||||||
|
const result = await runBuiltinLoop(makeOptions());
|
||||||
|
expect(result.finalText).toBe("---\nstatus: done\n---");
|
||||||
|
const nudgeMsg = result.messages.find(
|
||||||
|
(m) =>
|
||||||
|
m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"),
|
||||||
|
);
|
||||||
|
expect(nudgeMsg).toBeDefined();
|
||||||
|
});
|
||||||
|
test("3.5 maxTurns exhaustion falls back to last assistant content", async () => {
|
||||||
|
mockChatCompletionWithTools.mockResolvedValue({ content: "still thinking", toolCalls: [] });
|
||||||
|
const result = await runBuiltinLoop(makeOptions({ maxTurns: 3 }));
|
||||||
|
expect(result.finalText).toBe("still thinking");
|
||||||
|
});
|
||||||
|
test("3.6 original messages array is not mutated", async () => {
|
||||||
|
mockChatCompletionWithTools.mockResolvedValue({
|
||||||
|
content: "---\nstatus: done\n---",
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
const original = [{ role: "system" as const, content: "sys" }];
|
||||||
|
await runBuiltinLoop(makeOptions({ messages: original }));
|
||||||
|
expect(original.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ async function appendTurn(
|
|||||||
await appendSessionTurn(storageRoot, sessionId, payload);
|
await appendSessionTurn(storageRoot, sessionId, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeTurnTools(
|
export async function executeTurnTools(
|
||||||
calls: Array<{ id: string; name: string; arguments: string }>,
|
calls: Array<{ id: string; name: string; arguments: string }>,
|
||||||
toolCtx: ToolContext,
|
toolCtx: ToolContext,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
@@ -70,6 +70,20 @@ async function executeTurnTools(
|
|||||||
return turnCount;
|
return turnCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ShouldNudgeOptions = {
|
||||||
|
noTools: boolean;
|
||||||
|
text: string;
|
||||||
|
turn: number;
|
||||||
|
maxTurns: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_NUDGES = 3;
|
||||||
|
const DEADLINE_WARNING_TURNS = 3;
|
||||||
|
|
||||||
|
export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOptions): boolean {
|
||||||
|
return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */
|
||||||
export async function runBuiltinLoop(
|
export async function runBuiltinLoop(
|
||||||
options: RunBuiltinLoopOptions,
|
options: RunBuiltinLoopOptions,
|
||||||
@@ -78,23 +92,43 @@ export async function runBuiltinLoop(
|
|||||||
const openAiTools = options.noTools ? [] : builtinToolsToOpenAi(getBuiltinTools());
|
const openAiTools = options.noTools ? [] : builtinToolsToOpenAi(getBuiltinTools());
|
||||||
let finalText = "";
|
let finalText = "";
|
||||||
let turnCount = 0;
|
let turnCount = 0;
|
||||||
|
let nudgeCount = 0;
|
||||||
|
let deadlineWarned = false;
|
||||||
|
|
||||||
for (let turn = 0; turn < options.maxTurns; turn++) {
|
for (let turn = 0; turn < options.maxTurns; turn++) {
|
||||||
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`);
|
||||||
|
|
||||||
|
// Warn agent when approaching turn limit
|
||||||
|
const turnsRemaining = options.maxTurns - turn;
|
||||||
|
if (!options.noTools && !deadlineWarned && turnsRemaining <= DEADLINE_WARNING_TURNS) {
|
||||||
|
deadlineWarned = true;
|
||||||
|
log("4NRXW6KT", `${turnsRemaining} turns remaining, injecting deadline warning`);
|
||||||
|
messages.push({
|
||||||
|
role: "user",
|
||||||
|
content:
|
||||||
|
`⚠️ You have ${turnsRemaining} turns remaining. ` +
|
||||||
|
"Wrap up your work and output the YAML frontmatter starting with `---`. " +
|
||||||
|
"If you cannot finish in time, output frontmatter with `status: failed` and describe what remains.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response = await chatCompletionWithTools(
|
const response = await chatCompletionWithTools(
|
||||||
options.provider,
|
options.provider,
|
||||||
messages,
|
messages,
|
||||||
openAiTools.length > 0 ? openAiTools : null,
|
openAiTools.length > 0 ? openAiTools : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When noTools is set, ignore any tool_calls the LLM might still return
|
||||||
|
const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null);
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
const assistantMessage: ChatMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: response.content,
|
content: response.content,
|
||||||
tool_calls: response.toolCalls,
|
tool_calls: effectiveToolCalls,
|
||||||
};
|
};
|
||||||
messages.push(assistantMessage);
|
messages.push(assistantMessage);
|
||||||
|
|
||||||
if (response.toolCalls === null || response.toolCalls.length === 0) {
|
if (effectiveToolCalls === null || effectiveToolCalls.length === 0) {
|
||||||
const text = response.content ?? "";
|
const text = response.content ?? "";
|
||||||
await appendTurn(options.storageRoot, options.sessionId, {
|
await appendTurn(options.storageRoot, options.sessionId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -104,14 +138,17 @@ export async function runBuiltinLoop(
|
|||||||
});
|
});
|
||||||
turnCount += 1;
|
turnCount += 1;
|
||||||
|
|
||||||
// If tools are available but LLM stopped calling them without producing
|
if (shouldNudge({ noTools: options.noTools, text, turn, maxTurns: options.maxTurns })) {
|
||||||
// frontmatter, nudge it to continue working or output frontmatter.
|
nudgeCount += 1;
|
||||||
if (!options.noTools && !text.trimStart().startsWith("---") && turn < options.maxTurns - 1) {
|
log("7FXQM2KN", `text-only turn without frontmatter, nudge ${nudgeCount}/${MAX_NUDGES}`);
|
||||||
log("7FXQM2KN", "text-only turn without frontmatter, nudging LLM to continue");
|
|
||||||
const nudge =
|
const nudge =
|
||||||
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
|
"You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " +
|
||||||
"Either continue using tools to complete your work, or output your final response starting with `---`.";
|
"Either continue using tools to complete your work, or output your final response starting with `---`.";
|
||||||
messages.push({ role: "user", content: nudge });
|
messages.push({ role: "user", content: nudge });
|
||||||
|
// Nudge doesn't consume turn budget (up to MAX_NUDGES)
|
||||||
|
if (nudgeCount <= MAX_NUDGES) {
|
||||||
|
turn -= 1;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,14 +160,14 @@ export async function runBuiltinLoop(
|
|||||||
await appendTurn(options.storageRoot, options.sessionId, {
|
await appendTurn(options.storageRoot, options.sessionId, {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: response.content ?? "",
|
content: response.content ?? "",
|
||||||
toolCalls: mapToolCallsForPayload(response.toolCalls),
|
toolCalls: mapToolCallsForPayload(effectiveToolCalls),
|
||||||
reasoning: null,
|
reasoning: null,
|
||||||
});
|
});
|
||||||
turnCount += 1;
|
turnCount += 1;
|
||||||
|
|
||||||
// Execute tools
|
// Execute tools
|
||||||
turnCount += await executeTurnTools(
|
turnCount += await executeTurnTools(
|
||||||
response.toolCalls,
|
effectiveToolCalls,
|
||||||
options.toolCtx,
|
options.toolCtx,
|
||||||
messages,
|
messages,
|
||||||
options.storageRoot,
|
options.storageRoot,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function buildBuiltinMessages(ctx: AgentContext): ChatMessage[] {
|
|||||||
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
|
"Your task is described in the user message below — do NOT use uwf or workflow CLI commands to discover your task. " +
|
||||||
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
|
"When you are done, output your final response with the YAML frontmatter block as specified above. " +
|
||||||
"Do NOT output the frontmatter until you have completed all necessary work. " +
|
"Do NOT output the frontmatter until you have completed all necessary work. " +
|
||||||
|
"If you are running low on turns and cannot finish, output the frontmatter with `status: failed` and explain what remains in the body. " +
|
||||||
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
|
"CRITICAL: Your final output MUST start with the `---` fence on the very first line — " +
|
||||||
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
|
"no preamble text, no explanation before it. The parser requires `---` at position 0.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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", () => {
|
describe("storeClaudeCodeDetail", () => {
|
||||||
const baseParsed: ClaudeCodeParsedResult = {
|
const baseParsed: ClaudeCodeParsedResult = {
|
||||||
type: "result",
|
type: "result",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const log = createLogger({ sink: { kind: "stderr" } });
|
|||||||
|
|
||||||
const CLAUDE_COMMAND = "claude";
|
const CLAUDE_COMMAND = "claude";
|
||||||
const CLAUDE_MAX_TURNS = 90;
|
const CLAUDE_MAX_TURNS = 90;
|
||||||
|
const CLAUDE_MODEL = process.env.CLAUDE_MODEL ?? null;
|
||||||
|
|
||||||
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
function buildHistorySummary(steps: AgentContext["steps"]): string {
|
||||||
if (steps.length === 0) {
|
if (steps.length === 0) {
|
||||||
@@ -87,7 +88,7 @@ function spawnClaude(args: string[]): Promise<{ stdout: string; stderr: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: string }> {
|
||||||
return spawnClaude([
|
const args = [
|
||||||
"-p",
|
"-p",
|
||||||
prompt,
|
prompt,
|
||||||
"--output-format",
|
"--output-format",
|
||||||
@@ -96,14 +97,18 @@ function spawnClaudeRun(prompt: string): Promise<{ stdout: string; stderr: strin
|
|||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
"--max-turns",
|
"--max-turns",
|
||||||
String(CLAUDE_MAX_TURNS),
|
String(CLAUDE_MAX_TURNS),
|
||||||
]);
|
];
|
||||||
|
if (CLAUDE_MODEL !== null) {
|
||||||
|
args.push("--model", CLAUDE_MODEL);
|
||||||
|
}
|
||||||
|
return spawnClaude(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnClaudeResume(
|
function spawnClaudeResume(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
message: string,
|
message: string,
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
return spawnClaude([
|
const args = [
|
||||||
"-p",
|
"-p",
|
||||||
message,
|
message,
|
||||||
"--resume",
|
"--resume",
|
||||||
@@ -114,7 +119,11 @@ function spawnClaudeResume(
|
|||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
"--max-turns",
|
"--max-turns",
|
||||||
String(CLAUDE_MAX_TURNS),
|
String(CLAUDE_MAX_TURNS),
|
||||||
]);
|
];
|
||||||
|
if (CLAUDE_MODEL !== null) {
|
||||||
|
args.push("--model", CLAUDE_MODEL);
|
||||||
|
}
|
||||||
|
return spawnClaude(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
|
async function processClaudeOutput(stdout: string, store: Store): Promise<AgentRunResult> {
|
||||||
|
|||||||
@@ -67,101 +67,105 @@ function extractToolResultContent(content: unknown[]): string {
|
|||||||
return results.join("\n");
|
return results.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type ParseState = {
|
||||||
* Parse Claude Code stream-json (NDJSON) output.
|
turns: ClaudeCodeTurnPayload[];
|
||||||
* Each line is a JSON object with type: "system" | "assistant" | "user" | "result".
|
resultLine: Record<string, unknown> | null;
|
||||||
*/
|
model: string;
|
||||||
export function parseClaudeCodeStreamOutput(stdout: string): ClaudeCodeParsedResult | null {
|
turnIndex: number;
|
||||||
const lines = stdout.trim().split("\n");
|
};
|
||||||
const turns: ClaudeCodeTurnPayload[] = [];
|
|
||||||
let resultLine: Record<string, unknown> | null = null;
|
|
||||||
let model = "";
|
|
||||||
let turnIndex = 0;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
function processSystemLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||||
let parsed: unknown;
|
if (typeof parsed.model === "string") {
|
||||||
try {
|
state.model = parsed.model;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
function processUserLine(parsed: Record<string, unknown>, state: ParseState): void {
|
||||||
const result = resultLine.result;
|
if (!isRecord(parsed.message)) return;
|
||||||
const subtype = resultLine.subtype;
|
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") {
|
if (typeof sessionId !== "string" || typeof result !== "string" || typeof subtype !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const usage = isRecord(state.resultLine.usage) ? state.resultLine.usage : {};
|
||||||
const usage = isRecord(resultLine.usage) ? resultLine.usage : {};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: safeString(resultLine.type, "result"),
|
type: safeString(state.resultLine.type, "result"),
|
||||||
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
subtype: subtype as ClaudeCodeParsedResult["subtype"],
|
||||||
result,
|
result,
|
||||||
sessionId,
|
sessionId,
|
||||||
numTurns: safeNumber(resultLine.num_turns),
|
numTurns: safeNumber(state.resultLine.num_turns),
|
||||||
totalCostUsd: safeNumber(resultLine.total_cost_usd),
|
totalCostUsd: safeNumber(state.resultLine.total_cost_usd),
|
||||||
durationMs: safeNumber(resultLine.duration_ms),
|
durationMs: safeNumber(state.resultLine.duration_ms),
|
||||||
model,
|
model: state.model,
|
||||||
stopReason: safeString(resultLine.stop_reason),
|
stopReason: safeString(state.resultLine.stop_reason),
|
||||||
usage: {
|
usage: {
|
||||||
inputTokens: safeNumber(usage.input_tokens),
|
inputTokens: safeNumber(usage.input_tokens),
|
||||||
outputTokens: safeNumber(usage.output_tokens),
|
outputTokens: safeNumber(usage.output_tokens),
|
||||||
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
|
cacheReadInputTokens: safeNumber(usage.cache_read_input_tokens),
|
||||||
cacheCreationInputTokens: safeNumber(usage.cache_creation_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).
|
* Legacy: parse Claude Code plain JSON output (non-streaming).
|
||||||
* Falls back when stream-json is not available.
|
* 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;
|
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", () => {
|
describe("HermesAcpClient", () => {
|
||||||
let client: HermesAcpClient;
|
let client: HermesAcpClient;
|
||||||
|
|
||||||
|
|||||||
@@ -245,72 +245,75 @@ export class HermesAcpClient {
|
|||||||
// ---- Session update → structured messages ----
|
// ---- Session update → structured messages ----
|
||||||
|
|
||||||
private handleSessionUpdate(update: Record<string, unknown>): void {
|
private handleSessionUpdate(update: Record<string, unknown>): void {
|
||||||
const updateType = update.sessionUpdate as string;
|
switch (update.sessionUpdate as string) {
|
||||||
|
case "agent_message_chunk":
|
||||||
switch (updateType) {
|
this.handleAgentMessageChunk(update);
|
||||||
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);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
case "agent_thought_chunk":
|
||||||
|
this.handleAgentThoughtChunk(update);
|
||||||
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);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
case "tool_call":
|
||||||
|
this.handleToolCall(update);
|
||||||
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();
|
|
||||||
break;
|
break;
|
||||||
}
|
case "tool_call_update":
|
||||||
|
this.handleToolCallUpdate(update);
|
||||||
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);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
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. */
|
/** Flush any accumulated text/reasoning into an assistant message. */
|
||||||
private flushAssistantMessage(): void {
|
private flushAssistantMessage(): void {
|
||||||
const text = this.messageChunks.join("");
|
const text = this.messageChunks.join("");
|
||||||
|
|||||||
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