Compare commits

...

6 Commits

Author SHA1 Message Date
xingyue 94fa964b84 fix(workflow): add typecheck script and fix remaining type errors
- Add typecheck script (bunx tsc --build) to package.json
- Update check script to run typecheck before biome
- Fix mock fetch casts in test files (bun-types preconnect)
- Fix RequestInfo → Request | string | URL in llm-extract test
- Fix ThreadContext generic cast in solve-issue-template test
- Fix git-exec.ts missing return and module resolution
- Remove @types/node from workflow-role-committer
- Add exports field to workflow-util-agent/package.json
2026-05-06 18:10:25 +08:00
xiaoju 2c642b1a53 refactor: committer as pure agent role with discriminated union meta (#17)
- Remove hardcoded git commands (git-exec.ts deleted)
- CommitterMeta is now a discriminated union: committed | failed
- Agent handles git operations, committer extracts structured result
- Moderator can route on meta.status for retry logic

Closes #17
2026-05-06 10:06:27 +00:00
xiaoju c15a5554c0 refactor: replace gitExec with spawnCli from workflow-util-agent
Remove hand-rolled execFile + promisify, delegate to spawnCli.
Same throw-on-error interface, better error messages.
2026-05-06 09:51:06 +00:00
xiaoju e38852a761 Merge pull request 'fix(workflow): resolve type errors across all packages' (#16) from fix/type-errors-and-tsbuildinfo into main 2026-05-06 09:44:49 +00:00
xiaoju fd8f1f2491 refactor: move createRole to workflow-util-role
workflow-role-llm now only contains createLlmAdapter (OpenAI chat
completions → AgentFn). All role infrastructure lives in util-role.
2026-05-06 09:42:49 +00:00
xingyue 98b6153070 fix(workflow): resolve type errors across all packages and remove tsbuildinfo from tracking
- Add bun-types and @types/xxhashjs to root devDependencies
- Add types: ['bun-types'] to root and package tsconfigs
- Fix AST Node type narrowing in bundle-validator.ts with AcornNode type and narrowNode helper
- Fix generic ThreadContext variance in create-role-moderator.ts
- Add explicit parameter types in worker.ts
- Fix ChildProcess type in worker-spawn.ts to match spawn() stdio config
- Remove all tsconfig.tsbuildinfo from git tracking
- Add tsconfig.tsbuildinfo to .gitignore
2026-05-06 17:41:11 +08:00
34 changed files with 238 additions and 248 deletions
+1
View File
@@ -2,3 +2,4 @@ node_modules/
dist/
bun.lock
*.tgz
tsconfig.tsbuildinfo
+5 -2
View File
@@ -7,11 +7,14 @@
],
"scripts": {
"build": "bun run --filter '*' build",
"check": "biome check .",
"check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
"test": "bun run --filter '*' test"
},
"devDependencies": {
"@biomejs/biome": "^2.4.14"
"@biomejs/biome": "^2.4.14",
"@types/xxhashjs": "^0.2.4",
"bun-types": "^1.3.13"
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { type ChildProcess, spawn } from "node:child_process";
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { createConnection } from "node:net";
import { join } from "node:path";
@@ -23,7 +23,7 @@ function isProcessAlive(pid: number): boolean {
async function waitForReadyLine(
childStdout: NodeJS.ReadableStream,
child: ChildProcessWithoutNullStreams,
child: ChildProcess,
): Promise<Result<number, string>> {
return await new Promise((resolve) => {
let buf = "";
+2 -1
View File
@@ -14,7 +14,8 @@
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["bun-types"]
},
"references": [{ "path": "../workflow" }],
"include": ["src/**/*.ts"]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,40 +1,10 @@
import { describe, expect, spyOn, test } from "bun:test";
import { execFile } from "node:child_process";
import { appendFile, mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { AgentFn, ThreadContext } from "@uncaged/workflow";
import { START } from "@uncaged/workflow";
import * as utilRole from "@uncaged/workflow-util-role";
import { createCommitterRole } from "../src/committer.js";
import { gitExec } from "../src/git-exec.js";
const execFileAsync = promisify(execFile);
async function git(repo: string, args: string[]): Promise<void> {
await gitExec(repo, args);
}
async function setupRepoWithRemote(): Promise<{ repo: string }> {
const base = await mkdtemp(join(tmpdir(), "wf-committer-"));
const bare = join(base, "origin.git");
const repo = join(base, "work");
await mkdir(repo, { recursive: true });
await mkdir(bare, { recursive: true });
await execFileAsync("git", ["init"], { cwd: repo, encoding: "utf8" });
await git(repo, ["config", "user.email", "t@t"]);
await git(repo, ["config", "user.name", "t"]);
await writeFile(join(repo, "README.md"), "# hi\n", "utf8");
await git(repo, ["add", "README.md"]);
await git(repo, ["commit", "-m", "init"]);
await execFileAsync("git", ["init", "--bare"], { cwd: bare, encoding: "utf8" });
await git(repo, ["remote", "add", "origin", bare]);
await git(repo, ["push", "-u", "origin", "HEAD"]);
return { repo };
}
function makeCtx(): ThreadContext {
return {
@@ -50,21 +20,13 @@ function makeCtx(): ThreadContext {
const provider = { baseUrl: "https://example.com/v1", apiKey: "k", model: "m" };
describe("createCommitterRole", () => {
test("returns committed false when working tree clean", async () => {
const { repo } = await setupRepoWithRemote();
const agent: AgentFn = async () => {
throw new Error("agent should not run");
};
const role = createCommitterRole(
agent,
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
{ cwd: repo, remote: "origin", threadId: null },
);
const out = await role(makeCtx());
expect(out.meta.committed).toBe(false);
});
const dryRunMeta = {
status: "committed" as const,
branch: "dry-run/placeholder",
commitSha: "0000000",
};
describe("createCommitterRole", () => {
test("dry-run skips pipeline", async () => {
const agent: AgentFn = async () => {
throw new Error("agent should not run");
@@ -72,39 +34,101 @@ describe("createCommitterRole", () => {
const role = createCommitterRole(agent, {
provider,
dryRun: true,
dryRunMeta: { branch: "dry-run", message: "chore: dry run" },
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.content).toBe("[dry-run] committer skipped");
expect(out.meta).toEqual({ committed: true });
expect(out.meta).toEqual(dryRunMeta);
});
test("commits and pushes when changes exist", async () => {
const { repo } = await setupRepoWithRemote();
await appendFile(join(repo, "README.md"), "\nmore\n", "utf8");
test("returns committed meta when extraction succeeds", async () => {
const committed = {
status: "committed" as const,
branch: "feat/widget",
commitSha: "deadbeef".repeat(5).slice(0, 40),
};
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue({
branch: "feat/test-commit",
message: "feat: add more",
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(committed);
const agent: AgentFn = async (_ctx, prompt) =>
`Created branch ${committed.branch}, pushed. SHA ${committed.commitSha}.\n${prompt.slice(0, 80)}`;
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const agent: AgentFn = async () => "plan text";
const role = createCommitterRole(
agent,
{ provider, dryRun: null, dryRunMeta: { branch: "dry-run", message: "chore: dry run" } },
{ cwd: repo, remote: "origin", threadId: null },
);
const out = await role(makeCtx());
expect(out.meta.committed).toBe(true);
expect(out.meta).toEqual(committed);
expect(spy).toHaveBeenCalled();
const branches = await gitExec(repo, ["branch", "--list", "feat/test-commit"]);
expect(branches).toContain("feat/test-commit");
const remoteRefs = await gitExec(repo, ["ls-remote", "--heads", "origin", "feat/test-commit"]);
expect(remoteRefs.trim().length).toBeGreaterThan(0);
spy.mockRestore();
});
test("returns failed meta when extraction reports failure", async () => {
const failed = {
status: "failed" as const,
error: "working tree clean; nothing to commit",
logRef: null as string | null,
};
const spy = spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
const agent: AgentFn = async () => "git status shows no changes; skipping branch and commit.";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(failed);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns failed meta with logRef when extraction includes it", async () => {
const failed = {
status: "failed" as const,
error: "push rejected",
logRef: "LOGREF01",
};
spyOn(utilRole, "extractMetaOrThrow").mockResolvedValue(failed);
const agent: AgentFn = async () => "Remote rejected non-fast-forward.";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual(failed);
});
test("onFail wraps extraction errors", async () => {
spyOn(utilRole, "extractMetaOrThrow").mockRejectedValue(
new Error("structured extraction failed"),
);
const agent: AgentFn = async () => "opaque agent output";
const role = createCommitterRole(agent, {
provider,
dryRun: null,
dryRunMeta,
});
const out = await role(makeCtx());
expect(out.meta).toEqual({
status: "failed",
error: "committer role threw before structured result",
logRef: null,
});
expect(out.content).toContain("committer failed:");
expect(out.content).toContain("structured extraction failed");
});
});
@@ -1,29 +1,28 @@
import type { AgentFn, Role, RoleResult, ThreadContext } from "@uncaged/workflow";
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import {
createRole,
decorateRole,
extractMetaOrThrow,
type LlmProvider,
onFail,
withDryRun,
} from "@uncaged/workflow-util-role";
import * as z from "zod/v4";
import { gitExec } from "./git-exec.js";
export const committerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("committed"),
branch: z.string(),
commitSha: z.string(),
}),
z.object({
status: z.literal("failed"),
error: z.string(),
logRef: z.string().nullable(),
}),
]);
export const committerMetaSchema = z.object({
committed: z
.boolean()
.describe("true if branch created, changes committed, and pushed successfully"),
});
export type CommitterMeta = z.infer<typeof committerMetaSchema>;
const committerPlanSchema = z.object({
branch: z.string().describe("Feature branch name, e.g. feat/slug or fix/slug"),
message: z.string().describe("Single-line conventional commit subject"),
});
export type CommitterPlanMeta = z.infer<typeof committerPlanSchema>;
export type CommitterGitConfig = {
cwd: string;
remote: string;
@@ -37,6 +36,12 @@ export const DEFAULT_COMMITTER_GIT_CONFIG: CommitterGitConfig = {
threadId: null,
};
const DRY_RUN_COMMITTED_META: CommitterMeta = {
status: "committed",
branch: "dry-run/placeholder",
commitSha: "0000000",
};
function resolveExtractDryRun(extractDryRun: boolean | null): boolean {
return extractDryRun === true;
}
@@ -50,37 +55,18 @@ function summarizeThreadContext(ctx: ThreadContext): string {
return lines.join("\n");
}
function sanitizeBranch(branch: string): string {
const t = branch.trim();
if (
t === "" ||
t.includes("..") ||
t.includes(" ") ||
t.startsWith("-") ||
t.includes("\n") ||
t.includes("\t")
) {
throw new Error(`invalid branch name: ${branch}`);
}
return t;
}
function sanitizeCommitMessage(message: string): string {
const line = message.trim().split(/\r?\n/)[0] ?? "";
if (line === "") {
throw new Error("commit message is empty");
}
return line;
}
function committerPlanPrompt(ctx: ThreadContext, gitConfig: CommitterGitConfig): string {
function committerSystemPrompt(ctx: ThreadContext, gitConfig: CommitterGitConfig): string {
const threadLine =
gitConfig.threadId !== null
? `Optional CLI context: run \`uncaged-workflow thread ${gitConfig.threadId}\` if available.\n`
: "";
return `You plan a git branch and a single-line conventional commit message for the following workflow thread.
return `You are the **git committer** for this workflow. Prior roles planned, implemented, and reviewed the change; your job is to perform git operations in the repository and report the outcome.
## Repository context
- Working directory (run git commands here): \`${gitConfig.cwd}\`
- Remote name for push: \`${gitConfig.remote}\`
${threadLine}
## Thread context
@@ -88,66 +74,44 @@ ${summarizeThreadContext(ctx)}
## Your task
Infer a good branch name (\`feat/<slug>\` or \`fix/<slug>\`) and a conventional commit **subject** (one line, no body).
1. Inspect the working tree (e.g. \`git status\`). If there is nothing to commit, stop and explain why in your reply.
2. Create a new branch using **conventional** naming (\`feat/<slug>\`, \`fix/<slug>\`, or \`chore/<slug>\` as appropriate).
3. Stage all intended changes, commit with a **single-line conventional commit subject**, and push the branch to \`${gitConfig.remote}\` (e.g. \`git push -u ${gitConfig.remote} <branch>\`).
4. In your reply, state clearly whether the push succeeded, the **exact branch name** used, and the **full commit SHA** from \`git rev-parse HEAD\` (or explain the failure).
Reply with enough detail that a maintainer understands the change; structured extraction will read \`branch\` and \`message\` from your answer.`;
}
async function runCommitterPipeline(
ctx: ThreadContext,
agent: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
gitConfig: CommitterGitConfig,
): Promise<RoleResult<CommitterMeta>> {
const cwd = gitConfig.cwd;
const porcelain = await gitExec(cwd, ["status", "--porcelain"]);
if (porcelain.trim() === "") {
return {
content: "Working tree clean; nothing to commit.",
meta: { committed: false },
};
}
const prompt = committerPlanPrompt(ctx, gitConfig);
const raw = await agent(ctx, prompt);
const plan = await extractMetaOrThrow("committer-plan", raw, committerPlanSchema, {
provider: extract.provider,
dryRun: resolveExtractDryRun(extract.dryRun),
dryRunMeta: extract.dryRunMeta,
});
const branch = sanitizeBranch(plan.branch);
const message = sanitizeCommitMessage(plan.message);
await gitExec(cwd, ["checkout", "-b", branch]);
await gitExec(cwd, ["add", "-A"]);
await gitExec(cwd, ["commit", "-m", message]);
await gitExec(cwd, ["push", "-u", gitConfig.remote, branch]);
return {
content: raw,
meta: { committed: true },
};
Structured extraction will read \`status\`, branch, commit SHA, or error details from your answer.`;
}
/**
* Git committer role: LLM proposes branch + message; this package runs git via `child_process`.
* Decorators match nerve semantics: dry-run skips work with `committed: true`; failures yield `committed: false`.
* Git committer role: the agent runs git (branch, commit, push); structured extraction yields {@link CommitterMeta}.
* Dry-run skips the agent and returns a stable committed placeholder; unexpected throws yield \`status: "failed"\`.
*/
export function createCommitterRole(
adapter: AgentFn,
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterPlanMeta },
extract: { provider: LlmProvider; dryRun: boolean | null; dryRunMeta: CommitterMeta },
gitConfig: CommitterGitConfig = DEFAULT_COMMITTER_GIT_CONFIG,
): Role<CommitterMeta> {
const inner: Role<CommitterMeta> = async (ctx) =>
runCommitterPipeline(ctx, adapter, extract, gitConfig);
const inner: Role<CommitterMeta> = createRole({
name: "committer",
schema: committerMetaSchema,
systemPrompt: async (ctx) => committerSystemPrompt(ctx, gitConfig),
agent: adapter,
extract,
});
return decorateRole(inner, [
withDryRun<CommitterMeta>({
label: "committer",
meta: { committed: true },
meta: DRY_RUN_COMMITTED_META,
dryRun: resolveExtractDryRun(extract.dryRun),
}),
onFail<CommitterMeta>({ label: "committer", meta: { committed: false } }),
onFail<CommitterMeta>({
label: "committer",
meta: {
status: "failed",
error: "committer role threw before structured result",
logRef: null,
},
}),
]);
}
@@ -1,26 +0,0 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/** Runs `git` with args in `cwd`; throws if git exits non-zero. */
export async function gitExec(cwd: string, args: readonly string[]): Promise<string> {
try {
const r = await execFileAsync("git", [...args], {
cwd,
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
});
return r.stdout;
} catch (e) {
const stderr =
typeof e === "object" &&
e !== null &&
"stderr" in e &&
typeof (e as { stderr: unknown }).stderr === "string"
? (e as { stderr: string }).stderr
: "";
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`git ${args.join(" ")} failed: ${msg}${stderr ? ` (${stderr.trim()})` : ""}`);
}
}
@@ -1,9 +1,7 @@
export {
type CommitterGitConfig,
type CommitterMeta,
type CommitterPlanMeta,
committerMetaSchema,
createCommitterRole,
DEFAULT_COMMITTER_GIT_CONFIG,
} from "./committer.js";
export { gitExec } from "./git-exec.js";
@@ -3,8 +3,13 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
"composite": true,
"types": ["bun-types"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-role" }]
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-util-role" }
]
}
File diff suppressed because one or more lines are too long
@@ -19,13 +19,13 @@ describe("createLlmAdapter", () => {
const originalFetch = globalThis.fetch;
test("posts system + user (start.content) and returns assistant text", async () => {
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ choices: [{ message: { content: "model reply" } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
)) as unknown as typeof fetch;
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
@@ -37,13 +37,13 @@ describe("createLlmAdapter", () => {
});
test("throws on non-ok fetch response", async () => {
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response("Internal Server Error", {
status: 500,
headers: { "Content-Type": "text/plain" },
}),
);
)) as unknown as typeof fetch;
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
@@ -53,7 +53,7 @@ describe("createLlmAdapter", () => {
});
test("throws on fetch network failure", async () => {
globalThis.fetch = () => Promise.reject(new Error("ECONNREFUSED"));
globalThis.fetch = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
const provider = { baseUrl: "https://api.example/v1", apiKey: "k", model: "m" };
const adapter = createLlmAdapter(provider);
+2 -1
View File
@@ -1,5 +1,7 @@
export {
buildDescriptorFromRoles,
type CreateRoleArgs,
createRole,
decorateRole,
extractMetaOrThrow,
type LlmError,
@@ -18,4 +20,3 @@ export {
withDryRun,
} from "@uncaged/workflow-util-role";
export { chatCompletionText, createLlmAdapter, type LlmChatError } from "./create-llm-adapter.js";
export { type CreateRoleArgs, createRole } from "./create-role.js";
File diff suppressed because one or more lines are too long
@@ -50,7 +50,10 @@ describe("createReviewerRole", () => {
});
test("runs reviewer extract", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ approved: true })));
globalThis.fetch = (() =>
Promise.resolve(
toolCallResponse(JSON.stringify({ approved: true })),
)) as unknown as typeof fetch;
const agent: AgentFn = async (_ctx, prompt) => {
expect(prompt).toContain("git diff");
@@ -68,7 +71,10 @@ describe("createReviewerRole", () => {
});
test("includes uncaged-workflow thread hint when threadId set", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ approved: false })));
globalThis.fetch = (() =>
Promise.resolve(
toolCallResponse(JSON.stringify({ approved: false })),
)) as unknown as typeof fetch;
let seen = "";
const agent: AgentFn = async (_ctx, prompt) => {
File diff suppressed because one or more lines are too long
@@ -61,7 +61,7 @@ function committerStep(): RoleStep<SolveIssueMeta> {
return {
role: "committer",
content: "commit",
meta: { committed: true },
meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" },
timestamp: 4,
};
}
@@ -115,7 +115,7 @@ describe("createSolveIssueRoles", () => {
expect(typeof roles.committer).toBe("function");
const ctx = makeCtx(10, []);
const plannerOut = await roles.planner(ctx);
const plannerOut = await roles.planner(ctx as unknown as ThreadContext);
expect(plannerOut.meta.plan).toBe("");
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
});
@@ -1,9 +1,5 @@
import type { AgentFn, Role } from "@uncaged/workflow";
import {
type CommitterMeta,
type CommitterPlanMeta,
createCommitterRole,
} from "@uncaged/workflow-role-committer";
import { type CommitterMeta, createCommitterRole } from "@uncaged/workflow-role-committer";
import { createRole } from "@uncaged/workflow-role-llm";
import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer";
import type { LlmProvider } from "@uncaged/workflow-util-role";
@@ -53,9 +49,10 @@ const REVIEWER_DRY_RUN_META: ReviewerMeta = {
approved: true,
};
const COMMITTER_PLAN_DRY_RUN_META: CommitterPlanMeta = {
branch: "dry-run",
message: "chore: dry run",
const COMMITTER_DRY_RUN_META: CommitterMeta = {
status: "committed",
branch: "dry-run/placeholder",
commitSha: "0000000",
};
export type SolveIssueMeta = {
@@ -142,7 +139,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
{
provider: extract.provider,
dryRun: extract.dryRun,
dryRunMeta: COMMITTER_PLAN_DRY_RUN_META,
dryRunMeta: COMMITTER_DRY_RUN_META,
},
committerGit,
);
File diff suppressed because one or more lines are too long
@@ -4,6 +4,12 @@
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
File diff suppressed because one or more lines are too long
@@ -55,7 +55,8 @@ describe("createRole", () => {
});
test("runs AgentFn then structured extract", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })));
globalThis.fetch = (() =>
Promise.resolve(toolCallResponse(JSON.stringify({ n: 3 })))) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const agent: AgentFn = async (_ctx, prompt) => prompt;
@@ -73,7 +74,8 @@ describe("createRole", () => {
});
test("passes ThreadContext to AgentFn", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })));
globalThis.fetch = (() =>
Promise.resolve(toolCallResponse(JSON.stringify({ n: 0 })))) as unknown as typeof fetch;
const seen: ThreadContext[] = [];
const agent: AgentFn = async (ctx, _prompt) => {
@@ -94,7 +96,8 @@ describe("createRole", () => {
});
test("resolves dynamic systemPrompt functions before AgentFn", async () => {
globalThis.fetch = () => Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })));
globalThis.fetch = (() =>
Promise.resolve(toolCallResponse(JSON.stringify({ n: 99 })))) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const agent: AgentFn = async (_ctx, prompt) => prompt;
@@ -14,10 +14,10 @@ describe("extractMetaOrThrow", () => {
test("dryRun returns dryRunMeta without calling fetch", async () => {
let calls = 0;
globalThis.fetch = () => {
globalThis.fetch = (() => {
calls += 1;
return Promise.resolve(new Response("{}", { status: 200 }));
};
}) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const out = await extractMetaOrThrow("r", "raw", schema, {
@@ -33,7 +33,7 @@ describe("extractMetaOrThrow", () => {
});
test("throws when extraction fails after retry", async () => {
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
@@ -49,7 +49,7 @@ describe("extractMetaOrThrow", () => {
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
)) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
@@ -61,7 +61,7 @@ describe("extractMetaOrThrow", () => {
});
test("returns validated meta on successful tool call", async () => {
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
@@ -82,7 +82,7 @@ describe("extractMetaOrThrow", () => {
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
)) as unknown as typeof fetch;
const schema = z.object({
branch: z.string(),
@@ -17,7 +17,7 @@ describe("llmExtract", () => {
let capturedUrl: string | null = null;
let capturedInit: RequestInit | null = null;
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
globalThis.fetch = ((input: Request | string | URL, init?: RequestInit) => {
capturedUrl = typeof input === "string" ? input : input.toString();
capturedInit = init ?? null;
return Promise.resolve(
@@ -44,7 +44,7 @@ describe("llmExtract", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
};
}) as unknown as typeof fetch;
const result = await llmExtract({
text: "some plan",
@@ -66,13 +66,13 @@ describe("llmExtract", () => {
}
expect(result.value).toEqual({ name: "cpu-usage", description: "CPU load" });
expect(capturedUrl).toBe("https://example.com/v1/chat/completions");
expect(capturedInit?.method).toBe("POST");
expect(capturedInit?.headers).toMatchObject({
expect(capturedUrl!).toBe("https://example.com/v1/chat/completions");
expect(capturedInit!.method).toBe("POST");
expect(capturedInit!.headers).toMatchObject({
Authorization: "Bearer k",
"Content-Type": "application/json",
});
const body = JSON.parse(capturedInit?.body as string) as {
const body = JSON.parse(capturedInit!.body as string) as {
model: string;
tool_choice: { function: { name: string } };
};
@@ -83,7 +83,7 @@ describe("llmExtract", () => {
test("returns schema_validation_failed when arguments do not match the schema", async () => {
const schema = z.object({ n: z.number() });
globalThis.fetch = () =>
globalThis.fetch = (() =>
Promise.resolve(
new Response(
JSON.stringify({
@@ -99,7 +99,7 @@ describe("llmExtract", () => {
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
)) as unknown as typeof fetch;
const result = await llmExtract({
text: "x",
@@ -120,10 +120,10 @@ describe("llmExtract", () => {
test("dryRun skips fetch and returns dryRunMeta", async () => {
let calls = 0;
globalThis.fetch = () => {
globalThis.fetch = (() => {
calls += 1;
return Promise.resolve(new Response("{}", { status: 200 }));
};
}) as unknown as typeof fetch;
const schema = z.object({ n: z.number() });
const result = await llmExtract({
@@ -1,6 +1,7 @@
import type { AgentFn, Role, ThreadContext } from "@uncaged/workflow";
import { extractMetaOrThrow, type LlmProvider } from "@uncaged/workflow-util-role";
import type * as z from "zod/v4";
import { extractMetaOrThrow } from "./extract-meta.js";
import type { LlmProvider } from "./types.js";
export type CreateRoleArgs<M extends Record<string, unknown>> = {
name: string;
+1
View File
@@ -1,4 +1,5 @@
export { buildDescriptorFromRoles, type RoleDescriptorInput } from "./build-descriptor.js";
export { type CreateRoleArgs, createRole } from "./create-role.js";
export {
decorateRole,
type OnFailOptions,
+19 -7
View File
@@ -10,6 +10,18 @@ import type {
Program,
VariableDeclaration,
} from "acorn";
/** Acorn Node with index-access for property traversal. */
type AcornNode = Node & { [key: string]: unknown };
/**
* Narrow an Acorn Node to a specific AST subtype after a `.type` guard.
* Avoids double-cast (`as unknown as T`) by going through AcornNode.
*/
function narrowNode<T extends Node>(node: Node): T {
return node as unknown as T;
}
import * as acorn from "acorn";
import { err, ok, type Result } from "./result.js";
@@ -55,7 +67,7 @@ function pushNestedAstNodes(value: unknown, out: Node[]): void {
function collectChildNodes(node: Node): Node[] {
const children: Node[] = [];
for (const key of Object.keys(node)) {
const val = (node as Record<string, unknown>)[key];
const val = (node as AcornNode)[key];
pushNestedAstNodes(val, children);
}
return children;
@@ -273,10 +285,10 @@ function descriptorExportExists(program: Program): boolean {
}
function stringLiteralModuleSpecifier(src: Node): string | null {
if (src.type !== "Literal" || typeof src.value !== "string") {
if (src.type !== "Literal" || typeof (src as AcornNode).value !== "string") {
return null;
}
return src.value;
return (src as AcornNode).value as string;
}
function validateImportDeclaration(node: ImportDeclaration): string | null {
@@ -337,16 +349,16 @@ function bundleConstraintViolationForNode(node: Node): string | null {
return "dynamic import() is not allowed in workflow bundles";
}
if (node.type === "ImportDeclaration") {
return validateImportDeclaration(node);
return validateImportDeclaration(narrowNode<ImportDeclaration>(node));
}
if (node.type === "ExportNamedDeclaration") {
return validateExportNamedDeclaration(node);
return validateExportNamedDeclaration(narrowNode<ExportNamedDeclaration>(node));
}
if (node.type === "ExportAllDeclaration") {
return validateExportAllDeclaration(node);
return validateExportAllDeclaration(narrowNode<ExportAllDeclaration>(node));
}
if (node.type === "CallExpression") {
return validateRequireCall(node);
return validateRequireCall(narrowNode<CallExpression>(node));
}
return null;
}
@@ -69,7 +69,7 @@ export function createRoleModerator<M extends RoleMeta>(
return { returnCode: 1, summary: `unknown role: ${next}` };
}
const result = await roleFn(ctx);
const result = await roleFn(ctx as unknown as ThreadContext);
const ts = Date.now();
const step = {
role: next,
+2 -2
View File
@@ -423,7 +423,7 @@ async function main(): Promise<void> {
});
}
const server = createServer((socket) => {
const server = createServer((socket: Socket) => {
void (async () => {
const line = await readLineFromSocket(socket);
if (line === null) {
@@ -439,7 +439,7 @@ async function main(): Promise<void> {
})();
});
server.on("error", (errObj) => {
server.on("error", (errObj: Error) => {
bootLog("W8YK4NPX", `worker server error: ${errObj.message}`);
process.exit(1);
});
+2 -1
View File
@@ -14,7 +14,8 @@
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src"
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src/**/*.ts", "xxhashjs.d.ts"]
}
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -13,7 +13,8 @@
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist"
"outDir": "dist",
"types": ["bun-types"]
},
"references": [
{ "path": "packages/workflow" },