Compare commits

...

12 Commits

Author SHA1 Message Date
xingyue 221919448e refactor: reduce cognitive complexity in session-detail and acp-client
Extract helper functions to bring parseClaudeCodeStreamOutput (37→≤15)
and handleSessionUpdate (24→≤15) within complexity limits. Add tests.

Fixes #448
2026-05-24 00:41:39 +08:00
xingyue 68b82c9574 style: use dot notation for process.env.CLAUDE_MODEL 2026-05-24 00:25:08 +08:00
xiaomo 335b8a4ae6 Merge pull request 'refactor(cli): reduce cognitive complexity in setup.ts' (#453) from fix/445-reduce-setup-complexity into main 2026-05-23 16:18:10 +00:00
xingyue bf31fa0d03 refactor(cli): reduce cognitive complexity in setup.ts
Extracts inline logic into focused helper functions to bring
each function under the complexity threshold.

Fixes #445
2026-05-24 00:14:15 +08:00
xiaomo c39f2f3e63 Merge pull request 'refactor(cli): reduce cognitive complexity in thread.ts' (#452) from fix/446-reduce-thread-complexity into main 2026-05-23 15:55:03 +00:00
xingyue 6481fc0cc5 refactor(cli): reduce cognitive complexity in thread.ts
Extract helper functions (resolveThreadId, getThreadHead, listThreadSteps,
displayStepDetails, displayThreadRead) to reduce nesting and improve
readability. Also adds test coverage for the refactored functions.

Fixes #446
2026-05-23 23:47:54 +08:00
xiaoju 3190e06ebe docs: add sync-readme rule for consistent README updates
小橘 🍊(NEKO Team)
2026-05-23 15:09:25 +00:00
xiaomo f8ae2fe25b Merge pull request 'docs: sync all README.md files with current codebase' (#451) from docs/sync-readme into main 2026-05-23 15:03:56 +00:00
xingyue 48a274685b fix(builtin): nudge budget + deadline warning
- Nudge turns don't consume turn budget (up to MAX_NUDGES=3), prevents
  wasting agent work capacity on bookkeeping
- Inject deadline warning when 3 turns remain, telling agent to wrap up
- Agent can use status:failed to gracefully exit if it can't finish
2026-05-23 22:58:09 +08:00
xingyue 5b68359dfc fix #447: extract shouldNudge and export executeTurnTools from loop.ts, add tests 2026-05-23 22:45:09 +08:00
xingyue c2ddfb8558 fix(builtin): deadline warning + graceful exit on turn limit
- Inject user message when 3 turns remain, telling agent to wrap up
- Prompt tells agent to use status:failed if it can't finish in time
- Prevents wasting all turns without producing any frontmatter output
- Remove stale test file from dogfood agent run
2026-05-23 22:44:42 +08:00
xingyue 603018caf2 fix(builtin): force-strip tool_calls when noTools is set
copilot-api returns tool_calls even when tools field is omitted from
the request (infers from message history). Now the loop explicitly
nullifies tool_calls when noTools=true.
2026-05-23 22:35:20 +08:00
14 changed files with 1659 additions and 407 deletions
+67
View File
@@ -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();
});
});
+259 -162
View File
@@ -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');
+148 -97
View File
@@ -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);
});
});
+46 -9
View File
@@ -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("");
+89
View File
@@ -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