mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
bef319134e
* fix: detect project root via .argent dir instead of git Flow tools previously shelled out to 'git rev-parse --show-toplevel' to locate the project root, which failed outside git repos and pulled flows from the wrong place in nested worktrees. Walk up from cwd looking for an existing .argent/ directory; fall back to cwd so the first flow-start-recording still creates it in place. * fix: skip $HOME when walking up for .argent/ The MCP launcher stores its own state in ~/.argent/ (logs, tool-server pid). Without this guard, running any flow tool from anywhere under $HOME — but outside a real project with its own .argent/ — would stop at the home directory and treat it as the project root, mixing personal MCP state with flow files. * revert: restore getGitRoot in flow-utils The walk-up .argent approach is fundamentally broken because the tool-server is a singleton daemon — its process.cwd() is not the calling project's cwd. Reverting both882ed4cand83e6ae6so the next commit can replace the resolution with an AsyncLocalStorage context populated from an X-Argent-Project-Root header sent by the argent mcp proxy. This reverts commit882ed4c. This reverts commit83e6ae6. * feat: resolve project root via AsyncLocalStorage from mcp proxy header The tool-server is a singleton daemon shared by every MCP client, so its process.cwd() is never the calling project's cwd. The git- root and walk-up heuristics both ran inside the daemon and silently returned the wrong directory. Shift the source of truth: the argent mcp proxy is per-client, so it IS in the client's workspace. Stamp process.cwd() on every POST to the tool-server as X-Argent-Project-Root, run invokeTool inside an AsyncLocalStorage scope, and expose requireProjectRoot() to any tool that needs it. Missing/malformed headers surface a clear error from the getter instead of silently defaulting to the daemon's cwd. - packages/mcp/src/mcp-server.ts: callTool sends the header - packages/tool-server/src/request-context.ts: ALS module - packages/tool-server/src/http.ts: extract header, wrap invokeTool - packages/tool-server/src/tools/flows/flow-utils.ts: requireProjectRoot - packages/tool-server/test/http-project-root.test.ts: integration tests covering present/missing/empty/malformed header, unicode roundtrip, non-flow tools bypassing the getter, and concurrent-request isolation * refactor: profiler tools resolve project root via requireProjectRoot Drop the project_root input parameter from react-profiler-analyze and react-profiler-component-source. Both now pull the root from the AsyncLocalStorage context stamped by the argent mcp proxy, matching flow-utils. Second caller for requireProjectRoot so the mechanism is exercised beyond flows. If the ALS approach turns out flaky, these two tools break in the same way as the flow tools and fail loudly with the same clear error message. Update the profiler skill doc to drop the project_root arg since agents no longer pass it. * test: stress-test AsyncLocalStorage projectRoot mechanism Four comprehensive test files generated by a parallel swarm audit of the new request-context.ts / requireProjectRoot() approach, covering every realistic way it could break. - test/als-propagation.test.ts — 15 tests documenting what Node's AsyncLocalStorage actually does under every async boundary the tool-server uses. Split into SAFE baselines (Promise chains, microtasks, timers, streams, synchronous EventEmitter emits, concurrent frames) and LATENT TRAPS (listeners fired outside an active frame, worker_threads, child_process). Serves as the authoritative reference for "can I read projectRoot here?". - test/als-nested-invocation.test.ts — 10 tests proving propagation through registry.invokeTool nesting: two-level, deep (A→B→C→D→E), mixed sync/async, parallel fan-out, cross-request isolation, flow-execute style loops, error mid-chain, long chains, explicit runWithContext re-entry overrides. - test/als-http-edge-cases.test.ts — HTTP boundary stress tests: malformed percent-encoding, null-byte injection, header case variants, duplicate headers, unicode round-trip, abort mid-flight, relative/Windows/URL-shaped paths. - test/als-state-contamination.test.ts — adversarial audit that surfaced two PRE-EXISTING P0 bugs the ALS fix does NOT solve: * activeFlowName module-level singleton in flow-utils.ts leaks between concurrent projects on a shared daemon * profilerPathsCache in react-profiler-session.ts is keyed only by Metro port, not projectRoot — two projects sharing port 8081 see each other's cached profiler sessions Both are documented via it.fails() so vitest passes today; the day a fix lands the it.fails flips to an alert and the test becomes a regression guard. 449/449 tool-server tests pass. * revert: drop AsyncLocalStorage project-root approach Reverts the ALS-based project root resolution because the approach isn't working well in practice. Restores the previous getGitRoot implementation in flow-utils, the project_root input parameter on the profiler tools, and the skill doc that describes it. Reverts: -3932eb5test: stress-test AsyncLocalStorage projectRoot mechanism -5398cbcrefactor: profiler tools resolve project root via requireProjectRoot -b7ae895feat: resolve project root via AsyncLocalStorage from mcp proxy header A follow-up commit adds clearer error handling around the git call so the agent gets a useful message when the tool-server's cwd isn't inside a git repo. * fix: clearer error when getGitRoot fails outside a git repo Wrap the `git rev-parse --show-toplevel` call in getGitRoot so that the failure surfaces a useful message to the agent instead of the raw child_process stderr. The tool-server daemon's cwd is not the calling project's cwd, so if the daemon happens to be launched outside any git repository the git command fails with a terse "fatal: not a git repository" that tells the agent nothing about what to do next. The new error reports the daemon's cwd, explains that flows are anchored to a git root, and points the user at `git init`. * refactor: require project_root as a flow tool parameter The tool-server is a singleton daemon — its process.cwd() is never the calling project's cwd, so flow-utils cannot infer the project root on its own. Instead of guessing via git or walking the filesystem, ask the calling agent directly: every flow tool now takes project_root as a required parameter and the agent supplies the absolute path it is working in. This matches the existing pattern used by react-profiler-analyze, react-profiler-component-source, and gather-workspace-data, all of which already accept an agent-provided project/workspace path. Changes: - flow-utils.ts: drop getGitRoot/execFile; getFlowsDir and getFlowPath become pure functions that take projectRoot; throw on non-absolute paths at the boundary. - flow-start-recording, flow-add-step, flow-add-echo, flow-finish-recording, flow-read-prerequisite, flow-execute: add project_root to each zod schema and plumb it through. - argent-create-flow SKILL.md: document project_root on every example tool call; note that it must be passed every time. - flow-tools.test.ts: remove the flow-utils vi.mock (no longer needed) and pass project_root: tmpDir on every call; add a regression test rejecting relative project_root. * prettier * cleanup skill * refactor: store project_root on flow-start-recording, not on every tool The tool-server cannot infer the calling project's working directory on its own. The agent now provides it once — as project_root on flow-start-recording, which stores it alongside activeFlowName in module state. All other flow tools (flow-add-step, flow-add-echo, flow-finish-recording, flow-execute, flow-read-prerequisite) read the stored value and require no project_root parameter. This matches the existing "active flow" pattern: just as flow name is tracked automatically after flow-start-recording, so is the project root. * feat: require project_root on flow-execute and flow-read-prerequisite These tools are independent entry points that may be called without a prior recording session (e.g. after daemon restart), so they cannot rely on module state set by flow-start-recording. Adding project_root as an explicit parameter mirrors the pattern already used by flow-start-recording and react-profiler-analyze. * fix: make requireActiveProjectRoot error message tool-agnostic The previous message pointed only at flow-start-recording, but the check is now reached from flow-execute and flow-read-prerequisite as well. * refactor: store flows in .argent/flows/ instead of .argent/ Keeps the .argent/ directory clean for other argent-related artifacts and groups flow files under a dedicated subdirectory. * style: prettier --write
806 lines
28 KiB
TypeScript
806 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Registry } from "../../../registry/src/index";
|
|
|
|
import { flowStartRecordingTool } from "../../src/tools/flows/flow-start-recording";
|
|
import { flowInsertEchoTool } from "../../src/tools/flows/flow-insert-echo";
|
|
import { flowFinishRecordingTool } from "../../src/tools/flows/flow-finish-recording";
|
|
import { createFlowAddStepTool } from "../../src/tools/flows/flow-add-step";
|
|
import { createRunFlowTool } from "../../src/tools/flows/flow-run";
|
|
import { flowReadPrerequisiteTool } from "../../src/tools/flows/flow-read-prerequisite";
|
|
import {
|
|
clearActiveFlow,
|
|
setActiveProjectRoot,
|
|
clearActiveProjectRoot,
|
|
parseFlow,
|
|
serializeFlow,
|
|
} from "../../src/tools/flows/flow-utils";
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
function createMockRegistry(
|
|
tools: Record<string, { result: unknown; outputHint?: string; throws?: boolean }> = {}
|
|
) {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
const entry = tools[id];
|
|
if (!entry) throw new Error(`Tool "${id}" not found`);
|
|
if (entry.throws) throw new Error(`Tool "${id}" failed`);
|
|
return entry.result;
|
|
}),
|
|
getTool: vi.fn((id: string) => {
|
|
const entry = tools[id];
|
|
if (!entry) return undefined;
|
|
return { outputHint: entry.outputHint };
|
|
}),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function readFlowFile(name: string): Promise<string> {
|
|
return fs.readFile(path.join(tmpDir, ".argent", "flows", `${name}.yaml`), "utf8");
|
|
}
|
|
|
|
const PREREQ = "App on home screen";
|
|
|
|
// ── Setup / teardown ─────────────────────────────────────────────────
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-test-"));
|
|
setActiveProjectRoot(tmpDir);
|
|
clearActiveFlow();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
clearActiveFlow();
|
|
clearActiveProjectRoot();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── flow-start-recording ─────────────────────────────────────────────
|
|
|
|
describe("flow-start-recording", () => {
|
|
it("creates the .argent/flows dir and a .yaml file with header", async () => {
|
|
const result = await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "test-flow", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
expect(result.message).toContain("test-flow");
|
|
|
|
const content = await readFlowFile("test-flow");
|
|
const flow = parseFlow(content);
|
|
expect(flow.executionPrerequisite).toBe(PREREQ);
|
|
expect(flow.steps).toEqual([]);
|
|
});
|
|
|
|
it("sets the active flow", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "my-flow", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
const result = await flowInsertEchoTool.execute({}, { message: "test" });
|
|
expect(result.message).toContain("my-flow");
|
|
});
|
|
|
|
it("overwrites an existing flow file", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "overwrite", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowInsertEchoTool.execute({}, { message: "line1" });
|
|
|
|
// Start again with same name — should reset
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "overwrite", project_root: tmpDir, executionPrerequisite: "Different prereq" }
|
|
);
|
|
const content = await readFlowFile("overwrite");
|
|
const flow = parseFlow(content);
|
|
expect(flow.steps).toEqual([]);
|
|
expect(flow.executionPrerequisite).toBe("Different prereq");
|
|
});
|
|
|
|
it("rejects a relative project_root", async () => {
|
|
await expect(
|
|
flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "relative", project_root: "./not-absolute", executionPrerequisite: PREREQ }
|
|
)
|
|
).rejects.toThrow("project_root must be an absolute path");
|
|
});
|
|
});
|
|
|
|
// ── flow-start-recording edge cases ──────────────────────────────────
|
|
|
|
describe("flow-start-recording edge cases", () => {
|
|
it("starting a new flow while another is recording notifies about the switch", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "first-flow", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
const result = await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "second-flow", project_root: tmpDir, executionPrerequisite: "Different" }
|
|
);
|
|
|
|
// Should mention both the old and new flow
|
|
expect(result.message).toContain("first-flow");
|
|
expect(result.message).toContain("second-flow");
|
|
expect(result.previousFlow).toBe("first-flow");
|
|
|
|
// Adding a step should target second-flow, not first-flow
|
|
const echoResult = await flowInsertEchoTool.execute({}, { message: "goes to second" });
|
|
expect(echoResult.message).toContain("second-flow");
|
|
|
|
// first-flow should still exist on disk but be empty
|
|
const firstContent = await readFlowFile("first-flow");
|
|
const firstFlow = parseFlow(firstContent);
|
|
expect(firstFlow.steps).toEqual([]);
|
|
|
|
// second-flow should have the echo
|
|
const secondContent = await readFlowFile("second-flow");
|
|
const secondFlow = parseFlow(secondContent);
|
|
expect(secondFlow.steps).toEqual([{ kind: "echo", message: "goes to second" }]);
|
|
});
|
|
|
|
it("restarting the same flow does not report a switch", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "same-flow", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowInsertEchoTool.execute({}, { message: "will be reset" });
|
|
|
|
const result = await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "same-flow", project_root: tmpDir, executionPrerequisite: "Updated prereq" }
|
|
);
|
|
|
|
// Should NOT mention a switch — it's the same flow being restarted
|
|
expect(result.message).not.toContain("Switched");
|
|
expect(result.previousFlow).toBeUndefined();
|
|
expect(result.message).toContain("same-flow");
|
|
});
|
|
|
|
it("does not report a switch when no flow was previously active", async () => {
|
|
const result = await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "fresh-start", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
|
|
expect(result.message).not.toContain("Switched");
|
|
expect(result.previousFlow).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── flow-add-echo ────────────────────────────────────────────────────
|
|
|
|
describe("flow-add-echo", () => {
|
|
it("appends an echo entry to the flow file", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "echo-test", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
const result = await flowInsertEchoTool.execute({}, { message: "Hello world" });
|
|
|
|
expect(result.message).toContain("echo-test");
|
|
const flow = parseFlow(result.flowFile);
|
|
expect(flow.steps).toEqual([{ kind: "echo", message: "Hello world" }]);
|
|
});
|
|
|
|
it("appends multiple echo entries", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "multi-echo", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowInsertEchoTool.execute({}, { message: "First" });
|
|
const result = await flowInsertEchoTool.execute({}, { message: "Second" });
|
|
|
|
const flow = parseFlow(result.flowFile);
|
|
expect(flow.steps).toEqual([
|
|
{ kind: "echo", message: "First" },
|
|
{ kind: "echo", message: "Second" },
|
|
]);
|
|
});
|
|
|
|
it("throws when no active flow", async () => {
|
|
await expect(flowInsertEchoTool.execute({}, { message: "oops" })).rejects.toThrow(
|
|
"No active flow"
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── flow-add-step ────────────────────────────────────────────────────
|
|
|
|
describe("flow-add-step", () => {
|
|
it("executes the tool and records on success", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { tapped: true } },
|
|
});
|
|
const tool = createFlowAddStepTool(registry);
|
|
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "step-test", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
const result = await tool.execute({}, { command: "tap", args: '{"x":0.5,"y":0.3}' });
|
|
|
|
expect(result.toolResult).toEqual({ tapped: true });
|
|
const flow = parseFlow(result.flowFile);
|
|
expect(flow.steps).toEqual([{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }]);
|
|
expect(registry.invokeTool).toHaveBeenCalledWith("tap", {
|
|
x: 0.5,
|
|
y: 0.3,
|
|
});
|
|
});
|
|
|
|
it("does not record when tool fails", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: null, throws: true },
|
|
});
|
|
const tool = createFlowAddStepTool(registry);
|
|
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "fail-test", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await expect(tool.execute({}, { command: "tap", args: '{"x":0.5}' })).rejects.toThrow(
|
|
'Tool "tap" failed'
|
|
);
|
|
|
|
const content = await readFlowFile("fail-test");
|
|
const flow = parseFlow(content);
|
|
expect(flow.steps).toEqual([]);
|
|
});
|
|
|
|
it("handles omitted args", async () => {
|
|
const registry = createMockRegistry({
|
|
screenshot: { result: { url: "http://..." } },
|
|
});
|
|
const tool = createFlowAddStepTool(registry);
|
|
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "no-args", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await tool.execute({}, { command: "screenshot" });
|
|
|
|
const content = await readFlowFile("no-args");
|
|
const flow = parseFlow(content);
|
|
expect(flow.steps).toEqual([{ kind: "tool", name: "screenshot", args: {} }]);
|
|
expect(registry.invokeTool).toHaveBeenCalledWith("screenshot", {});
|
|
});
|
|
|
|
it("throws when no active flow", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { ok: true } },
|
|
});
|
|
const tool = createFlowAddStepTool(registry);
|
|
|
|
await expect(tool.execute({}, { command: "tap", args: '{"x":0.5}' })).rejects.toThrow(
|
|
"No active flow"
|
|
);
|
|
});
|
|
|
|
it("throws on invalid JSON in args", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { ok: true } },
|
|
});
|
|
const tool = createFlowAddStepTool(registry);
|
|
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "bad-json", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await expect(
|
|
tool.execute({}, { command: "tap", args: "not valid json {{{" })
|
|
).rejects.toThrow();
|
|
|
|
// Flow file should remain unchanged (no step recorded)
|
|
const content = await readFlowFile("bad-json");
|
|
const flow = parseFlow(content);
|
|
expect(flow.steps).toEqual([]);
|
|
});
|
|
|
|
it("propagates error when tool is not registered in the registry", async () => {
|
|
const registry = createMockRegistry({}); // no tools registered
|
|
const tool = createFlowAddStepTool(registry);
|
|
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "missing-tool", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await expect(tool.execute({}, { command: "nonexistent-tool", args: "{}" })).rejects.toThrow(
|
|
'Tool "nonexistent-tool" not found'
|
|
);
|
|
|
|
// Flow file should remain unchanged
|
|
const content = await readFlowFile("missing-tool");
|
|
const flow = parseFlow(content);
|
|
expect(flow.steps).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ── flow-finish-recording ────────────────────────────────────────────
|
|
|
|
describe("flow-finish-recording", () => {
|
|
it("returns summary with prerequisite and clears active flow", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "finish-test", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowInsertEchoTool.execute({}, { message: "Step 1" });
|
|
|
|
const result = await flowFinishRecordingTool.execute({}, {});
|
|
|
|
expect(result.message).toContain("finish-test");
|
|
expect(result.executionPrerequisite).toBe(PREREQ);
|
|
expect(result.steps).toBe(1);
|
|
expect(result.summary).toEqual(["1. echo: Step 1"]);
|
|
|
|
// Active flow should be cleared
|
|
await expect(flowInsertEchoTool.execute({}, { message: "after finish" })).rejects.toThrow(
|
|
"No active flow"
|
|
);
|
|
});
|
|
|
|
it("throws when no active flow", async () => {
|
|
await expect(flowFinishRecordingTool.execute({}, {})).rejects.toThrow("No active flow");
|
|
});
|
|
|
|
it("handles empty flow", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "empty", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
const result = await flowFinishRecordingTool.execute({}, {});
|
|
|
|
expect(result.steps).toBe(0);
|
|
expect(result.summary).toEqual([]);
|
|
});
|
|
|
|
it("calling finish twice throws on the second call", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "double-finish", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowFinishRecordingTool.execute({}, {});
|
|
|
|
// Second call should fail — active flow was cleared
|
|
await expect(flowFinishRecordingTool.execute({}, {})).rejects.toThrow("No active flow");
|
|
});
|
|
|
|
it("returns the file path so the agent knows where it was written", async () => {
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "path-check", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
const result = await flowFinishRecordingTool.execute({}, {});
|
|
|
|
expect(result.path).toContain(path.join(".argent", "flows"));
|
|
expect(result.path).toContain("path-check.yaml");
|
|
});
|
|
|
|
it("summary includes both echo and tool steps", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { ok: true } },
|
|
});
|
|
const addStep = createFlowAddStepTool(registry);
|
|
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "summary-test", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowInsertEchoTool.execute({}, { message: "Before tap" });
|
|
await addStep.execute({}, { command: "tap", args: '{"x":0.5}' });
|
|
|
|
const result = await flowFinishRecordingTool.execute({}, {});
|
|
expect(result.summary).toEqual(["1. echo: Before tap", '2. tool: tap {"x":0.5}']);
|
|
});
|
|
});
|
|
|
|
// ── flow-execute ─────────────────────────────────────────────────────
|
|
|
|
describe("flow-execute", () => {
|
|
it("executes all steps in order", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { tapped: true } },
|
|
screenshot: {
|
|
result: { url: "http://img", path: "/tmp/img.png" },
|
|
outputHint: "image",
|
|
},
|
|
});
|
|
const addStep = createFlowAddStepTool(registry);
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
// Build a flow
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "run-test", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
await flowInsertEchoTool.execute({}, { message: "Tap button" });
|
|
await addStep.execute({}, { command: "tap", args: '{"x":0.5}' });
|
|
await flowInsertEchoTool.execute({}, { message: "Take screenshot" });
|
|
await addStep.execute({}, { command: "screenshot", args: "{}" });
|
|
await flowFinishRecordingTool.execute({}, {});
|
|
|
|
// Reset mock call counts
|
|
vi.mocked(registry.invokeTool).mockClear();
|
|
|
|
// Run the flow
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "run-test", project_root: tmpDir, prerequisiteAcknowledged: true }
|
|
);
|
|
|
|
expect(result.flow).toBe("run-test");
|
|
expect(result.executionPrerequisite).toBe(PREREQ);
|
|
expect(result.steps).toHaveLength(4);
|
|
|
|
// Echoes
|
|
expect(result.steps[0]).toEqual({ kind: "echo", message: "Tap button" });
|
|
expect(result.steps[2]).toEqual({
|
|
kind: "echo",
|
|
message: "Take screenshot",
|
|
});
|
|
|
|
// Tool calls
|
|
expect(result.steps[1]).toEqual({
|
|
kind: "tool",
|
|
tool: "tap",
|
|
result: { tapped: true },
|
|
outputHint: undefined,
|
|
});
|
|
expect(result.steps[3]).toEqual({
|
|
kind: "tool",
|
|
tool: "screenshot",
|
|
result: { url: "http://img", path: "/tmp/img.png" },
|
|
outputHint: "image",
|
|
});
|
|
|
|
expect(registry.invokeTool).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("stops on first error", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: null, throws: true },
|
|
});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
// Manually write a flow file in YAML format
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
|
{ kind: "echo", message: "Should not reach" },
|
|
],
|
|
});
|
|
await fs.writeFile(path.join(dir, "error-test.yaml"), content);
|
|
|
|
const result = await runFlow.execute({}, { name: "error-test", project_root: tmpDir });
|
|
|
|
expect(result.steps).toHaveLength(1);
|
|
expect(result.steps[0]).toMatchObject({
|
|
kind: "tool",
|
|
tool: "tap",
|
|
error: expect.stringContaining("failed"),
|
|
});
|
|
});
|
|
|
|
it("throws when flow file does not exist", async () => {
|
|
const registry = createMockRegistry({});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
await expect(
|
|
runFlow.execute({}, { name: "nonexistent", project_root: tmpDir })
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("carries outputHint from tool definition", async () => {
|
|
const registry = createMockRegistry({
|
|
screenshot: {
|
|
result: { url: "http://img" },
|
|
outputHint: "image",
|
|
},
|
|
});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "Ready",
|
|
steps: [{ kind: "tool", name: "screenshot", args: { udid: "A" } }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "hint-test.yaml"), content);
|
|
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "hint-test", project_root: tmpDir, prerequisiteAcknowledged: true }
|
|
);
|
|
|
|
expect(result.steps[0]).toMatchObject({
|
|
kind: "tool",
|
|
outputHint: "image",
|
|
});
|
|
});
|
|
|
|
it("returns executionPrerequisite from the flow file", async () => {
|
|
const registry = createMockRegistry({});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "App freshly reloaded",
|
|
steps: [{ kind: "echo", message: "Start" }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "prereq-test.yaml"), content);
|
|
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "prereq-test", project_root: tmpDir, prerequisiteAcknowledged: true }
|
|
);
|
|
|
|
expect(result.executionPrerequisite).toBe("App freshly reloaded");
|
|
});
|
|
|
|
it("returns a notice when prerequisite exists but is not acknowledged", async () => {
|
|
const registry = createMockRegistry({});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "Device unlocked",
|
|
steps: [{ kind: "echo", message: "Hello" }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "gated.yaml"), content);
|
|
|
|
const result = await runFlow.execute({}, { name: "gated", project_root: tmpDir });
|
|
|
|
expect(result).toMatchObject({
|
|
flow: "gated",
|
|
notice: expect.stringContaining("prerequisite"),
|
|
executionPrerequisite: "Device unlocked",
|
|
});
|
|
// Should NOT have a steps array — it's a notice, not a run result
|
|
expect(result).not.toHaveProperty("steps");
|
|
});
|
|
|
|
it("runs normally when prerequisite exists and is acknowledged", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { ok: true } },
|
|
});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "Device unlocked",
|
|
steps: [{ kind: "tool", name: "tap", args: { x: 0.5 } }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "ack-test.yaml"), content);
|
|
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "ack-test", project_root: tmpDir, prerequisiteAcknowledged: true }
|
|
);
|
|
|
|
expect(result).toHaveProperty("steps");
|
|
expect((result as { steps: unknown[] }).steps).toHaveLength(1);
|
|
expect(registry.invokeTool).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("runs normally when prerequisite is empty and not acknowledged", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { ok: true } },
|
|
});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tool", name: "tap", args: { x: 0.5 } }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "no-gate.yaml"), content);
|
|
|
|
const result = await runFlow.execute({}, { name: "no-gate", project_root: tmpDir });
|
|
|
|
expect(result).toHaveProperty("steps");
|
|
expect((result as { steps: unknown[] }).steps).toHaveLength(1);
|
|
expect(registry.invokeTool).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns notice when prerequisiteAcknowledged is explicitly false", async () => {
|
|
const registry = createMockRegistry({});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "App on settings page",
|
|
steps: [{ kind: "echo", message: "Hello" }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "explicit-false.yaml"), content);
|
|
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "explicit-false", project_root: tmpDir, prerequisiteAcknowledged: false }
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
flow: "explicit-false",
|
|
notice: expect.stringContaining("prerequisite"),
|
|
executionPrerequisite: "App on settings page",
|
|
});
|
|
expect(result).not.toHaveProperty("steps");
|
|
});
|
|
|
|
it("executes an empty flow (zero steps) successfully", async () => {
|
|
const registry = createMockRegistry({});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [],
|
|
});
|
|
await fs.writeFile(path.join(dir, "empty-flow.yaml"), content);
|
|
|
|
const result = await runFlow.execute({}, { name: "empty-flow", project_root: tmpDir });
|
|
|
|
expect(result).toHaveProperty("steps");
|
|
expect((result as { steps: unknown[] }).steps).toEqual([]);
|
|
expect(registry.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("executes a flow with only echo steps (no registry calls)", async () => {
|
|
const registry = createMockRegistry({});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "echo", message: "First" },
|
|
{ kind: "echo", message: "Second" },
|
|
{ kind: "echo", message: "Third" },
|
|
],
|
|
});
|
|
await fs.writeFile(path.join(dir, "echo-only.yaml"), content);
|
|
|
|
const result = await runFlow.execute({}, { name: "echo-only", project_root: tmpDir });
|
|
|
|
expect(result).toHaveProperty("steps");
|
|
const steps = (result as { steps: unknown[] }).steps;
|
|
expect(steps).toHaveLength(3);
|
|
expect(steps).toEqual([
|
|
{ kind: "echo", message: "First" },
|
|
{ kind: "echo", message: "Second" },
|
|
{ kind: "echo", message: "Third" },
|
|
]);
|
|
expect(registry.invokeTool).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("error mid-flow reports preceding successful steps", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { tapped: true } },
|
|
swipe: { result: null, throws: true },
|
|
});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [
|
|
{ kind: "echo", message: "Start" },
|
|
{ kind: "tool", name: "tap", args: { x: 0.5 } },
|
|
{ kind: "tool", name: "swipe", args: { direction: "up" } },
|
|
{ kind: "echo", message: "Should not reach" },
|
|
],
|
|
});
|
|
await fs.writeFile(path.join(dir, "mid-error.yaml"), content);
|
|
|
|
const result = await runFlow.execute({}, { name: "mid-error", project_root: tmpDir });
|
|
|
|
expect(result).toHaveProperty("steps");
|
|
const steps = (result as { steps: { kind: string }[] }).steps;
|
|
// Should include: echo, tap success, swipe error — then stop
|
|
expect(steps).toHaveLength(3);
|
|
expect(steps[0]).toEqual({ kind: "echo", message: "Start" });
|
|
expect(steps[1]).toMatchObject({
|
|
kind: "tool",
|
|
tool: "tap",
|
|
result: { tapped: true },
|
|
});
|
|
expect(steps[2]).toMatchObject({
|
|
kind: "tool",
|
|
tool: "swipe",
|
|
error: expect.stringContaining("failed"),
|
|
});
|
|
});
|
|
|
|
it("does not interfere with active recording state", async () => {
|
|
const registry = createMockRegistry({
|
|
tap: { result: { ok: true } },
|
|
});
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
// Write a flow to run
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tool", name: "tap", args: { x: 0.1 } }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "side-effect.yaml"), content);
|
|
|
|
// Start recording a different flow
|
|
await flowStartRecordingTool.execute(
|
|
{},
|
|
{ name: "recording", project_root: tmpDir, executionPrerequisite: PREREQ }
|
|
);
|
|
|
|
// Execute a saved flow — this should NOT affect the active recording
|
|
await runFlow.execute({}, { name: "side-effect", project_root: tmpDir });
|
|
|
|
// We should still be able to add steps to the recording
|
|
const result = await flowInsertEchoTool.execute({}, { message: "still recording" });
|
|
expect(result.message).toContain("recording");
|
|
});
|
|
});
|
|
|
|
// ── flow-read-prerequisite ───────────────────────────────────────────
|
|
|
|
describe("flow-read-prerequisite", () => {
|
|
it("reads the prerequisite from a saved flow", async () => {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "App on home screen",
|
|
steps: [{ kind: "echo", message: "Step 1" }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "read-test.yaml"), content);
|
|
|
|
const result = await flowReadPrerequisiteTool.execute(
|
|
{},
|
|
{ name: "read-test", project_root: tmpDir }
|
|
);
|
|
|
|
expect(result.flow).toBe("read-test");
|
|
expect(result.executionPrerequisite).toBe("App on home screen");
|
|
});
|
|
|
|
it("returns empty string when flow has no prerequisite", async () => {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const content = serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "echo", message: "Hello" }],
|
|
});
|
|
await fs.writeFile(path.join(dir, "empty-prereq.yaml"), content);
|
|
|
|
const result = await flowReadPrerequisiteTool.execute(
|
|
{},
|
|
{ name: "empty-prereq", project_root: tmpDir }
|
|
);
|
|
|
|
expect(result.flow).toBe("empty-prereq");
|
|
expect(result.executionPrerequisite).toBe("");
|
|
});
|
|
|
|
it("throws when the flow file does not exist", async () => {
|
|
await expect(
|
|
flowReadPrerequisiteTool.execute({}, { name: "nonexistent", project_root: tmpDir })
|
|
).rejects.toThrow();
|
|
});
|
|
});
|