diff --git a/packages/cli-workflow/src/__tests__/moderator-evaluate.test.ts b/packages/cli-workflow/src/__tests__/moderator-evaluate.test.ts index 81a7e32..eab29fc 100644 --- a/packages/cli-workflow/src/__tests__/moderator-evaluate.test.ts +++ b/packages/cli-workflow/src/__tests__/moderator-evaluate.test.ts @@ -5,17 +5,17 @@ import { evaluate } from "../moderator/evaluate.js"; const solveIssueGraph: WorkflowPayload["graph"] = { $START: { - _: { role: "planner", prompt: "Start planning from the issue in the task." }, + _: { role: "planner", prompt: "Start planning from the issue in the task.", location: null }, }, planner: { - _: { role: "developer", prompt: "Implement the plan: {{plan}}" }, + _: { role: "developer", prompt: "Implement the plan: {{plan}}", location: null }, }, developer: { - _: { role: "reviewer", prompt: "Review the changes: {{summary}}" }, + _: { role: "reviewer", prompt: "Review the changes: {{summary}}", location: null }, }, reviewer: { - approved: { role: "$END", prompt: "Done." }, - rejected: { role: "developer", prompt: "Fix: {{comments}}" }, + approved: { role: "$END", prompt: "Done.", location: null }, + rejected: { role: "developer", prompt: "Fix: {{comments}}", location: null }, }, }; @@ -24,7 +24,11 @@ describe("evaluate", () => { const result = evaluate(solveIssueGraph, "$START", { $status: "_" }); expect(result).toEqual({ ok: true, - value: { role: "planner", prompt: "Start planning from the issue in the task." }, + value: { + role: "planner", + prompt: "Start planning from the issue in the task.", + location: null, + }, }); }); @@ -35,7 +39,7 @@ describe("evaluate", () => { }); expect(result).toEqual({ ok: true, - value: { role: "developer", prompt: "Fix: missing tests" }, + value: { role: "developer", prompt: "Fix: missing tests", location: null }, }); }); @@ -43,7 +47,7 @@ describe("evaluate", () => { const result = evaluate(solveIssueGraph, "reviewer", { $status: "approved" }); expect(result).toEqual({ ok: true, - value: { role: "$END", prompt: "Done." }, + value: { role: "$END", prompt: "Done.", location: null }, }); }); @@ -70,7 +74,11 @@ describe("evaluate", () => { }); expect(result).toEqual({ ok: true, - value: { role: "developer", prompt: "Implement the plan: Add auth middleware" }, + value: { + role: "developer", + prompt: "Implement the plan: Add auth middleware", + location: null, + }, }); }); @@ -81,14 +89,14 @@ describe("evaluate", () => { }); expect(result).toEqual({ ok: true, - value: { role: "developer", prompt: 'Fix: use & "Result" types' }, + value: { role: "developer", prompt: 'Fix: use & "Result" types', location: null }, }); }); test("triple mustache also works for unescaped output", () => { const graph: Record> = { reviewer: { - _: { role: "developer", prompt: "Fix: {{{comments}}}" }, + _: { role: "developer", prompt: "Fix: {{{comments}}}", location: null }, }, }; const result = evaluate(graph, "reviewer", { @@ -97,7 +105,7 @@ describe("evaluate", () => { }); expect(result).toEqual({ ok: true, - value: { role: "developer", prompt: "Fix: " }, + value: { role: "developer", prompt: "Fix: ", location: null }, }); }); @@ -107,7 +115,11 @@ describe("evaluate", () => { }); expect(result).toEqual({ ok: true, - value: { role: "developer", prompt: "Implement the plan: Add auth middleware" }, + value: { + role: "developer", + prompt: "Implement the plan: Add auth middleware", + location: null, + }, }); }); @@ -117,6 +129,7 @@ describe("evaluate", () => { _: { role: "developer", prompt: "Address: {{review.comments}}", + location: null, }, }, }; @@ -126,7 +139,7 @@ describe("evaluate", () => { }); expect(result).toEqual({ ok: true, - value: { role: "developer", prompt: "Address: refactor the handler" }, + value: { role: "developer", prompt: "Address: refactor the handler", location: null }, }); }); }); diff --git a/packages/cli-workflow/src/__tests__/step-timing.test.ts b/packages/cli-workflow/src/__tests__/step-timing.test.ts index 37970b0..aec9cd4 100644 --- a/packages/cli-workflow/src/__tests__/step-timing.test.ts +++ b/packages/cli-workflow/src/__tests__/step-timing.test.ts @@ -85,6 +85,7 @@ describe("protocol types", () => { edgePrompt: "", startedAtMs: 1000, completedAtMs: 2000, + cwd: "/test/path", }; expect(record.startedAtMs).toBe(1000); expect(record.completedAtMs).toBe(2000); @@ -239,8 +240,8 @@ describe("thread read timing", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "go" } }, - worker: { _: { role: "$END", prompt: "" } }, + $START: { _: { role: "worker", prompt: "go", location: null } }, + worker: { _: { role: "$END", prompt: "", location: null } }, }, }); @@ -305,8 +306,8 @@ describe("thread read timing", () => { }, }, graph: { - $START: { _: { role: "worker", prompt: "go" } }, - worker: { _: { role: "$END", prompt: "" } }, + $START: { _: { role: "worker", prompt: "go", location: null } }, + worker: { _: { role: "$END", prompt: "", location: null } }, }, }); diff --git a/packages/cli-workflow/src/__tests__/thread-location.test.ts b/packages/cli-workflow/src/__tests__/thread-location.test.ts new file mode 100644 index 0000000..10f5230 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/thread-location.test.ts @@ -0,0 +1,174 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CasRef, StartNodePayload, ThreadId } from "@uncaged/workflow-protocol"; +import { describe, expect, test } from "vitest"; +import { cmdThreadStart } from "../commands/thread.js"; +import { createUwfStore } from "../store.js"; + +describe("Thread and edge location integration", () => { + let tmpDir: string; + let storageRoot: string; + + async function setupTestEnv() { + tmpDir = join(tmpdir(), `uwf-test-location-${Date.now()}`); + storageRoot = join(tmpDir, "storage"); + await mkdir(storageRoot, { recursive: true }); + } + + async function teardown() { + if (tmpDir) { + await rm(tmpDir, { recursive: true, force: true }); + } + } + + test("thread start captures cwd in StartNode", async () => { + await setupTestEnv(); + + const workflowYaml = ` +name: test-location +description: Test workflow for location feature +roles: + planner: + description: Plans the work + goal: Plan implementation + capabilities: ["planning"] + procedure: Plan + output: | + $status: "ready" + frontmatter: + type: object + required: ["$status"] + properties: + $status: { type: string } +graph: + $START: + _: + role: planner + prompt: "Plan the work" + location: null + planner: + _: + role: $END + prompt: "Done" + location: null +`; + + const workflowPath = join(tmpDir, "test-location.yaml"); + await writeFile(workflowPath, workflowYaml, "utf8"); + + const testCwd = "/test/project/path"; + const result = await cmdThreadStart(storageRoot, workflowPath, "test prompt", tmpDir, testCwd); + + expect(result.thread).toBeDefined(); + expect(result.workflow).toBeDefined(); + + // Verify StartNode has the cwd field + const uwf = await createUwfStore(storageRoot); + const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot)); + const headHash = index[result.thread as ThreadId]; + expect(headHash).toBeDefined(); + + const startNode = uwf.store.get(headHash as CasRef); + expect(startNode).not.toBe(null); + expect(startNode?.type).toBe(uwf.schemas.startNode); + + const startPayload = startNode?.payload as StartNodePayload; + expect(startPayload.cwd).toBe(testCwd); + + await teardown(); + }); + + test("thread start validates cwd is absolute path", async () => { + await setupTestEnv(); + + const workflowYaml = ` +name: test-location +description: Test workflow +roles: + planner: + description: Plans + goal: Plan + capabilities: ["planning"] + procedure: Plan + output: | + $status: "ready" + frontmatter: + type: object + required: ["$status"] + properties: + $status: { type: string } +graph: + $START: + _: + role: planner + prompt: "Plan" + location: null + planner: + _: + role: $END + prompt: "Done" + location: null +`; + + const workflowPath = join(tmpDir, "test-location.yaml"); + await writeFile(workflowPath, workflowYaml, "utf8"); + + // Relative path should fail (process.exit is wrapped by vitest) + await expect( + cmdThreadStart(storageRoot, workflowPath, "test", tmpDir, "relative/path"), + ).rejects.toThrow(); + + await teardown(); + }); + + test("thread start uses process.cwd() as default", async () => { + await setupTestEnv(); + + const workflowYaml = ` +name: test-default-cwd +description: Test default cwd +roles: + planner: + description: Plans + goal: Plan + capabilities: ["planning"] + procedure: Plan + output: | + $status: "ready" + frontmatter: + type: object + required: ["$status"] + properties: + $status: { type: string } +graph: + $START: + _: + role: planner + prompt: "Plan" + location: null + planner: + _: + role: $END + prompt: "Done" + location: null +`; + + const workflowPath = join(tmpDir, "test-default-cwd.yaml"); + await writeFile(workflowPath, workflowYaml, "utf8"); + + const result = await cmdThreadStart(storageRoot, workflowPath, "test", tmpDir); + + const uwf = await createUwfStore(storageRoot); + const index = await import("../store.js").then((m) => m.loadThreadsIndex(storageRoot)); + const headHash = index[result.thread as ThreadId]; + + const startNode = uwf.store.get(headHash as CasRef); + const startPayload = startNode?.payload as StartNodePayload; + + // Should default to process.cwd() + expect(startPayload.cwd).toBe(process.cwd()); + + await teardown(); + }); +}); diff --git a/packages/cli-workflow/src/__tests__/validate-semantic.test.ts b/packages/cli-workflow/src/__tests__/validate-semantic.test.ts index 52d3137..021a8b9 100644 --- a/packages/cli-workflow/src/__tests__/validate-semantic.test.ts +++ b/packages/cli-workflow/src/__tests__/validate-semantic.test.ts @@ -51,11 +51,11 @@ function makeWorkflow(overrides?: Partial): WorkflowPayload { }, }, graph: { - $START: { _: { role: "writer", prompt: "Begin writing" } }, - writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}" } }, + $START: { _: { role: "writer", prompt: "Begin writing", location: null } }, + writer: { _: { role: "reviewer", prompt: "Review this: {{{plan}}}", location: null } }, reviewer: { - approved: { role: "$END", prompt: "Done: {{{summary}}}" }, - rejected: { role: "writer", prompt: "Fix: {{{reason}}}" }, + approved: { role: "$END", prompt: "Done: {{{summary}}}", location: null }, + rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null }, }, }, }; @@ -67,7 +67,7 @@ function makeWorkflow(overrides?: Partial): WorkflowPayload { describe("Suite 1: Role Reference Integrity", () => { test("1.1 graph references unknown role", () => { const wf = makeWorkflow(); - wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } }; + wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes('unknown role "nonexistent"'))).toBe(true); }); @@ -138,8 +138,8 @@ describe("Suite 2: Graph Structure", () => { test("2.2 $START has multiple status keys", () => { const wf = makeWorkflow(); wf.graph.$START = { - _: { role: "writer", prompt: "Begin" }, - other: { role: "reviewer", prompt: "Also" }, + _: { role: "writer", prompt: "Begin", location: null }, + other: { role: "reviewer", prompt: "Also", location: null }, }; const errors = validateWorkflow(wf); expect( @@ -149,7 +149,7 @@ describe("Suite 2: Graph Structure", () => { test("2.3 $START edge uses non-_ status", () => { const wf = makeWorkflow(); - wf.graph.$START = { ready: { role: "writer", prompt: "Begin" } }; + wf.graph.$START = { ready: { role: "writer", prompt: "Begin", location: null } }; const errors = validateWorkflow(wf); expect( errors.some((e) => e.includes('$START must have exactly one edge with status "_"')), @@ -158,7 +158,7 @@ describe("Suite 2: Graph Structure", () => { test("2.4 $END has outgoing edges", () => { const wf = makeWorkflow(); - wf.graph.$END = { _: { role: "writer", prompt: "Loop" } }; + wf.graph.$END = { _: { role: "writer", prompt: "Loop", location: null } }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes("$END must not have outgoing edges"))).toBe(true); }); @@ -177,7 +177,7 @@ describe("Suite 2: Graph Structure", () => { required: ["$status"], } as unknown as string, }; - wf.graph.isolated = { _: { role: "$END", prompt: "done" } }; + wf.graph.isolated = { _: { role: "$END", prompt: "done", location: null } }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes('role "isolated" is not reachable from $START'))).toBe( true, @@ -186,7 +186,7 @@ describe("Suite 2: Graph Structure", () => { test("2.6 edge target references invalid role", () => { const wf = makeWorkflow(); - wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost" } }; + wf.graph.writer = { _: { role: "ghost", prompt: "Go to ghost", location: null } }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes('unknown target role "ghost"'))).toBe(true); }); @@ -196,8 +196,8 @@ describe("Suite 3: Status-Edge Consistency", () => { test("3.1 single-exit role with multiple graph keys", () => { const wf = makeWorkflow(); wf.graph.writer = { - _: { role: "reviewer", prompt: "Review" }, - extra: { role: "$END", prompt: "Done" }, + _: { role: "reviewer", prompt: "Review", location: null }, + extra: { role: "$END", prompt: "Done", location: null }, }; const errors = validateWorkflow(wf); expect( @@ -209,7 +209,7 @@ describe("Suite 3: Status-Edge Consistency", () => { test("3.2 single-exit role missing _ key", () => { const wf = makeWorkflow(); - wf.graph.writer = { done: { role: "reviewer", prompt: "Review" } }; + wf.graph.writer = { done: { role: "reviewer", prompt: "Review", location: null } }; const errors = validateWorkflow(wf); expect( errors.some((e) => e.includes('role "writer" is single-exit but graph has no "_" key')), @@ -219,9 +219,9 @@ describe("Suite 3: Status-Edge Consistency", () => { test("3.3 multi-exit role with extra statuses", () => { const wf = makeWorkflow(); wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done" }, - rejected: { role: "writer", prompt: "Fix" }, - timeout: { role: "$END", prompt: "Timed out" }, + approved: { role: "$END", prompt: "Done", location: null }, + rejected: { role: "writer", prompt: "Fix", location: null }, + timeout: { role: "$END", prompt: "Timed out", location: null }, }; const errors = validateWorkflow(wf); expect( @@ -232,7 +232,7 @@ describe("Suite 3: Status-Edge Consistency", () => { test("3.4 multi-exit role missing a status", () => { const wf = makeWorkflow(); wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done" }, + approved: { role: "$END", prompt: "Done", location: null }, }; const errors = validateWorkflow(wf); expect( @@ -242,7 +242,7 @@ describe("Suite 3: Status-Edge Consistency", () => { test("3.5 multi-exit role with _ key", () => { const wf = makeWorkflow(); - wf.graph.reviewer = { _: { role: "$END", prompt: "Done" } }; + wf.graph.reviewer = { _: { role: "$END", prompt: "Done", location: null } }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes('role "reviewer" is multi-exit but graph uses "_"'))).toBe( true, @@ -265,8 +265,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => { } as unknown as string, }; wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done" }, - rejected: { role: "writer", prompt: "Fix: {{{comments}}}" }, + approved: { role: "$END", prompt: "Done", location: null }, + rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null }, }; const errors = validateWorkflow(wf); expect(errors).toEqual([]); @@ -286,9 +286,9 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => { } as unknown as string, }; wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done" }, - rejected: { role: "writer", prompt: "Fix" }, - timeout: { role: "$END", prompt: "Timed out" }, + approved: { role: "$END", prompt: "Done", location: null }, + rejected: { role: "writer", prompt: "Fix", location: null }, + timeout: { role: "$END", prompt: "Timed out", location: null }, }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true); @@ -308,7 +308,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => { } as unknown as string, }; wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done" }, + approved: { role: "$END", prompt: "Done", location: null }, }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true); @@ -327,7 +327,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => { required: ["$status", "plan"], } as unknown as string, }; - wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}" } }; + wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } }; const errors = validateWorkflow(wf); expect(errors).toEqual([]); }); @@ -346,8 +346,8 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => { } as unknown as string, }; wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done: {{{nonexistent}}}" }, - rejected: { role: "writer", prompt: "Fix: {{{comments}}}" }, + approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null }, + rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null }, }; const errors = validateWorkflow(wf); expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true); @@ -357,7 +357,7 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => { describe("Suite 4: Mustache Template Variable Existence", () => { test("4.1 prompt references nonexistent variable (single-exit)", () => { const wf = makeWorkflow(); - wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}" } }; + wf.graph.writer = { _: { role: "reviewer", prompt: "Review: {{{branch}}}", location: null } }; const errors = validateWorkflow(wf); expect( errors.some((e) => @@ -369,8 +369,8 @@ describe("Suite 4: Mustache Template Variable Existence", () => { test("4.2 prompt references nonexistent variable (multi-exit)", () => { const wf = makeWorkflow(); wf.graph.reviewer = { - approved: { role: "$END", prompt: "Done: {{{branch}}}" }, - rejected: { role: "writer", prompt: "Fix: {{{reason}}}" }, + approved: { role: "$END", prompt: "Done: {{{branch}}}", location: null }, + rejected: { role: "writer", prompt: "Fix: {{{reason}}}", location: null }, }; const errors = validateWorkflow(wf); expect( @@ -388,7 +388,7 @@ describe("Suite 4: Mustache Template Variable Existence", () => { test("4.4 $status variable is always valid", () => { const wf = makeWorkflow(); - wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}" } }; + wf.graph.writer = { _: { role: "reviewer", prompt: "Status: {{$status}}", location: null } }; const errors = validateWorkflow(wf); expect(errors).toEqual([]); }); @@ -461,9 +461,9 @@ describe("Suite 6: Multiple Errors Collection", () => { } as unknown as string, }; // unknown graph reference - wf.graph.nonexistent = { _: { role: "$END", prompt: "done" } }; + wf.graph.nonexistent = { _: { role: "$END", prompt: "done", location: null } }; // bad mustache var - wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}" } }; + wf.graph.writer = { _: { role: "reviewer", prompt: "{{{badvar}}}", location: null } }; const errors = validateWorkflow(wf); expect(errors.length).toBeGreaterThanOrEqual(3); }); diff --git a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts index 011e347..d3c5d12 100644 --- a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts +++ b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts @@ -41,8 +41,8 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload }, }, graph: { - $START: { _: { role: "worker", prompt: "start working" } }, - worker: { _: { role: "$END", prompt: "done" } }, + $START: { _: { role: "worker", prompt: "start working", location: null } }, + worker: { _: { role: "$END", prompt: "done", location: null } }, }, }; } diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index e1083c4..a5c6b3c 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -266,7 +266,13 @@ export async function cmdThreadStart( workflowId: string, prompt: string, projectRoot: string, + cwd: string = process.cwd(), ): Promise { + // Validate cwd is an absolute path + if (!isAbsolute(cwd)) { + fail("cwd must be an absolute path"); + } + const uwf = await createUwfStore(storageRoot); const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot); @@ -278,6 +284,7 @@ export async function cmdThreadStart( const startPayload: StartNodePayload = { workflow: workflowHash, prompt, + cwd, }; const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload); @@ -772,6 +779,7 @@ function spawnAgent( threadId: ThreadId, role: string, edgePrompt: string, + cwd: string, ): CasRef { const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt]; let stdout: string; @@ -780,6 +788,7 @@ function spawnAgent( encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large + cwd, }); } catch (e) { const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null }; @@ -987,6 +996,11 @@ async function cmdThreadStepOnce( const role = nextResult.value.role; const edgePrompt = nextResult.value.prompt; + + // Resolve cwd: use edge location if provided, otherwise inherit thread.cwd + const threadCwd = chain.start.cwd; + const effectiveCwd = nextResult.value.location !== null ? nextResult.value.location : threadCwd; + const config = await loadWorkflowConfig(storageRoot); const agent = resolveAgentConfig(config, workflow, role, agentOverride); @@ -995,7 +1009,7 @@ async function cmdThreadStepOnce( }); loadDotenv({ path: getEnvPath(storageRoot) }); - const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt); + const newHead = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd); plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null); diff --git a/packages/cli-workflow/src/commands/workflow.ts b/packages/cli-workflow/src/commands/workflow.ts index 88b370c..791fd09 100644 --- a/packages/cli-workflow/src/commands/workflow.ts +++ b/packages/cli-workflow/src/commands/workflow.ts @@ -61,6 +61,7 @@ function normalizeGraph( normalized[status] = { role: target.role, prompt: target.prompt, + location: target.location ?? null, }; } result[node] = normalized; diff --git a/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts b/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts new file mode 100644 index 0000000..e929481 --- /dev/null +++ b/packages/cli-workflow/src/moderator/__tests__/evaluate.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "vitest"; +import { evaluate } from "../evaluate.js"; + +describe("Moderator location resolution", () => { + test("returns null location when edge has no location field", () => { + const graph = { + planner: { + ready: { + role: "coder", + prompt: "Implement the code", + location: null, + }, + }, + }; + + const result = evaluate(graph, "planner", { $status: "ready" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.location).toBe(null); + } + }); + + test("resolves static location string", () => { + const graph = { + planner: { + ready: { + role: "coder", + prompt: "Implement the code", + location: "/static/path", + }, + }, + }; + + const result = evaluate(graph, "planner", { $status: "ready" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.location).toBe("/static/path"); + } + }); + + test("resolves mustache template location", () => { + const graph = { + planner: { + ready: { + role: "coder", + prompt: "Implement the code", + location: "{{{repoPath}}}", + }, + }, + }; + + const result = evaluate(graph, "planner", { + $status: "ready", + repoPath: "/home/user/repo", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.location).toBe("/home/user/repo"); + } + }); + + test("resolves mustache template with multiple variables", () => { + const graph = { + planner: { + ready: { + role: "coder", + prompt: "Implement the code", + location: "{{{basePath}}}/{{{projectName}}}", + }, + }, + }; + + const result = evaluate(graph, "planner", { + $status: "ready", + basePath: "/home/user", + projectName: "myproject", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.location).toBe("/home/user/myproject"); + } + }); + + test("handles missing template variable gracefully", () => { + const graph = { + planner: { + ready: { + role: "coder", + prompt: "Implement the code", + location: "{{{repoPath}}}", + }, + }, + }; + + const result = evaluate(graph, "planner", { $status: "ready" }); + + expect(result.ok).toBe(true); + if (result.ok) { + // Mustache renders missing variables as empty string + expect(result.value.location).toBe(""); + } + }); +}); diff --git a/packages/cli-workflow/src/moderator/evaluate.ts b/packages/cli-workflow/src/moderator/evaluate.ts index ae82e4a..09d93fd 100644 --- a/packages/cli-workflow/src/moderator/evaluate.ts +++ b/packages/cli-workflow/src/moderator/evaluate.ts @@ -43,7 +43,8 @@ export function evaluate( try { const prompt = mustache.render(target.prompt, lastOutput); - return { ok: true, value: { role: target.role, prompt } }; + const location = target.location !== null ? mustache.render(target.location, lastOutput) : null; + return { ok: true, value: { role: target.role, prompt, location } }; } catch (error) { return { ok: false, diff --git a/packages/cli-workflow/src/moderator/types.ts b/packages/cli-workflow/src/moderator/types.ts index 77599f0..b9c533a 100644 --- a/packages/cli-workflow/src/moderator/types.ts +++ b/packages/cli-workflow/src/moderator/types.ts @@ -4,4 +4,6 @@ export type Result = { ok: true; value: T } | { ok: false; error: E }; export type EvaluateResult = { role: string; prompt: string; + /** Resolved working directory from edge location field (null = inherit thread cwd). */ + location: string | null; }; diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index 0a59632..bf94022 100644 --- a/packages/cli-workflow/src/validate.ts +++ b/packages/cli-workflow/src/validate.ts @@ -36,8 +36,13 @@ function isTarget(value: unknown): boolean { if (!isRecord(value)) { return false; } + const hasValidLocation = + value.location === undefined || value.location === null || typeof value.location === "string"; return ( - typeof value.role === "string" && typeof value.prompt === "string" && value.prompt.trim() !== "" + typeof value.role === "string" && + typeof value.prompt === "string" && + value.prompt.trim() !== "" && + hasValidLocation ); } @@ -95,5 +100,22 @@ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null { if (!isStringRecord(raw.roles, isRoleDefinition) || !isGraph(raw.graph)) { return null; } - return raw as WorkflowPayload; + + // Normalize location field: undefined → null + const normalized = { ...raw } as WorkflowPayload; + for (const roleName of Object.keys(normalized.graph)) { + const statusMap = normalized.graph[roleName]; + if (statusMap !== undefined) { + for (const status of Object.keys(statusMap)) { + const target = statusMap[status]; + if (target !== undefined) { + if (target.location === undefined) { + target.location = null; + } + } + } + } + } + + return normalized; } diff --git a/packages/workflow-protocol/src/__tests__/types.test.ts b/packages/workflow-protocol/src/__tests__/types.test.ts new file mode 100644 index 0000000..2c1a63e --- /dev/null +++ b/packages/workflow-protocol/src/__tests__/types.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import type { StartNodePayload, StepRecord, Target } from "../types.js"; + +describe("Protocol types for thread/edge location", () => { + describe("StartNodePayload", () => { + test("has required cwd field", () => { + const payload: StartNodePayload = { + workflow: "0123456789ABC", + prompt: "Test prompt", + cwd: "/home/user/project", + }; + + expect(payload.cwd).toBe("/home/user/project"); + expect(typeof payload.cwd).toBe("string"); + }); + }); + + describe("StepRecord", () => { + test("has required cwd field", () => { + const record: StepRecord = { + role: "planner", + output: "0123456789ABC", + detail: "DEF0123456789", + agent: "uwf-hermes", + edgePrompt: "Plan the implementation", + startedAtMs: Date.now(), + completedAtMs: Date.now() + 1000, + cwd: "/home/user/project", + }; + + expect(record.cwd).toBe("/home/user/project"); + expect(typeof record.cwd).toBe("string"); + }); + }); + + describe("Target", () => { + test("has location field that accepts string", () => { + const target: Target = { + role: "coder", + prompt: "Implement the code", + location: "/custom/path", + }; + + expect(target.location).toBe("/custom/path"); + expect(typeof target.location).toBe("string"); + }); + + test("has location field that accepts null", () => { + const target: Target = { + role: "coder", + prompt: "Implement the code", + location: null, + }; + + expect(target.location).toBe(null); + }); + + test("location supports mustache template syntax", () => { + const target: Target = { + role: "coder", + prompt: "Implement the code", + location: "{{{repoPath}}}", + }; + + expect(target.location).toBe("{{{repoPath}}}"); + }); + }); +}); diff --git a/packages/workflow-protocol/src/schemas.ts b/packages/workflow-protocol/src/schemas.ts index 252b6c1..088f859 100644 --- a/packages/workflow-protocol/src/schemas.ts +++ b/packages/workflow-protocol/src/schemas.ts @@ -20,6 +20,9 @@ const TARGET: JSONSchema = { properties: { role: { type: "string" }, prompt: { type: "string" }, + location: { + anyOf: [{ type: "string" }, { type: "null" }], + }, }, additionalProperties: false, }; @@ -49,10 +52,11 @@ export const WORKFLOW_SCHEMA: JSONSchema = { export const START_NODE_SCHEMA: JSONSchema = { title: "StartNode", type: "object", - required: ["workflow", "prompt"], + required: ["workflow", "prompt", "cwd"], properties: { workflow: { type: "string", format: "cas_ref" }, prompt: { type: "string" }, + cwd: { type: "string" }, }, additionalProperties: false, }; @@ -60,7 +64,17 @@ export const START_NODE_SCHEMA: JSONSchema = { export const STEP_NODE_SCHEMA: JSONSchema = { title: "StepNode", type: "object", - required: ["start", "prev", "role", "output", "detail", "agent", "startedAtMs", "completedAtMs"], + required: [ + "start", + "prev", + "role", + "output", + "detail", + "agent", + "startedAtMs", + "completedAtMs", + "cwd", + ], properties: { start: { type: "string", format: "cas_ref" }, prev: { @@ -73,6 +87,7 @@ export const STEP_NODE_SCHEMA: JSONSchema = { edgePrompt: { type: "string" }, startedAtMs: { type: "integer" }, completedAtMs: { type: "integer" }, + cwd: { type: "string" }, }, additionalProperties: false, }; diff --git a/packages/workflow-protocol/src/types.ts b/packages/workflow-protocol/src/types.ts index 65de96c..db80654 100644 --- a/packages/workflow-protocol/src/types.ts +++ b/packages/workflow-protocol/src/types.ts @@ -18,6 +18,8 @@ export type StepRecord = { startedAtMs: number; /** Date.now() after agent returns */ completedAtMs: number; + /** Working directory where the agent executed. Missing in legacy nodes → "". */ + cwd: string; }; // ── 4.2 Workflow 定义 ─────────────────────────────────────────────── @@ -34,6 +36,8 @@ export type RoleDefinition = { export type Target = { role: string; prompt: string; + /** Optional working directory override via mustache template. */ + location: string | null; }; export type WorkflowPayload = { @@ -48,6 +52,8 @@ export type WorkflowPayload = { export type StartNodePayload = { workflow: CasRef; prompt: string; + /** Working directory where the thread was created. */ + cwd: string; }; export type StepNodePayload = StepRecord & { diff --git a/packages/workflow-util-agent/src/context.ts b/packages/workflow-util-agent/src/context.ts index 4f306d7..4c23dd0 100644 --- a/packages/workflow-util-agent/src/context.ts +++ b/packages/workflow-util-agent/src/context.ts @@ -130,6 +130,7 @@ async function buildHistory( edgePrompt: step.edgePrompt ?? "", startedAtMs: step.startedAtMs, completedAtMs: step.completedAtMs, + cwd: step.cwd ?? "", content, }); } diff --git a/packages/workflow-util-agent/src/run.ts b/packages/workflow-util-agent/src/run.ts index b930cec..bcb1106 100644 --- a/packages/workflow-util-agent/src/run.ts +++ b/packages/workflow-util-agent/src/run.ts @@ -72,6 +72,7 @@ async function writeStepNode(options: { edgePrompt: options.edgePrompt, startedAtMs: options.startedAtMs, completedAtMs: options.completedAtMs, + cwd: process.cwd(), }; const hash = await options.store.put(options.schemas.stepNode, payload); const node = options.store.get(hash);