Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions packages/argent-cli/src/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,16 @@ export function renderSummary(report: FlowReport, opts: { withDevice?: boolean }
const warnings = report.steps.filter((s) => s.warning).length;
const warningsNote = warnings ? `, ${warnings} warning${warnings === 1 ? "" : "s"}` : "";
// The live renderer prints its header before the runner has resolved a
// device, so its summary carries the device instead.
const where = opts.withDevice ? ` on ${report.device}` : "";
return `${report.ok ? "PASS" : "FAIL"}${where} — ${report.passed} passed, ${report.failed} failed, ${report.errored} errored, ${report.skipped} skipped${warningsNote}`;
// device, so its summary carries the device instead. Empty when the flow
// needed none.
const where = opts.withDevice && report.device ? ` on ${report.device}` : "";
// Four zeros on a passing run read as though nothing happened. Say why:
// narration is not counted, so a flow of only narration counts nothing.
// Only on a pass — on a failure the counts are not what needs explaining.
const nothingCounted =
report.ok && report.passed + report.failed + report.errored + report.skipped === 0;
const note = nothingCounted ? " (no test steps)" : "";
return `${report.ok ? "PASS" : "FAIL"}${where} — ${report.passed} passed, ${report.failed} failed, ${report.errored} errored, ${report.skipped} skipped${warningsNote}${note}`;
}

/**
Expand Down Expand Up @@ -370,7 +377,7 @@ export function exitAfterFlush(

export function renderReport(report: FlowReport): string {
const lines: string[] = [];
lines.push(`Flow "${report.flow}" on ${report.device}`);
lines.push(`Flow "${report.flow}"${report.device ? ` on ${report.device}` : ""}`);
// A fragment runs against the device's current state — remind the operator
// what it assumes was already set up.
if (report.executionPrerequisite) {
Expand Down
63 changes: 63 additions & 0 deletions packages/argent-cli/test/flow-deviceless-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { renderReport, renderSummary, type FlowReport } from "../src/flow.js";

function report(overrides: Partial<FlowReport> = {}): FlowReport {
return {
flow: "echo-only",
device: "",
ok: true,
passed: 0,
failed: 0,
skipped: 0,
errored: 0,
steps: [],
...overrides,
} as FlowReport;
}

describe("rendering a run that resolved no device", () => {
it("does not claim the run happened on a device", () => {
const line = renderSummary(report(), { withDevice: true });

expect(line).not.toContain(" on ");
expect(line).toBe("PASS — 0 passed, 0 failed, 0 errored, 0 skipped (no test steps)");
});

it("omits the device from the report header too", () => {
const lines = renderReport(report()).split("\n");

expect(lines[0]).toBe('Flow "echo-only"');
});

it("still names a device when the run had one", () => {
const line = renderSummary(report({ device: "UDID-1", passed: 2 }), { withDevice: true });

expect(line).toBe("PASS on UDID-1 — 2 passed, 0 failed, 0 errored, 0 skipped");
});
});

describe("the no-test-steps note", () => {
it("explains a passing run whose counters are all zero", () => {
expect(renderSummary(report())).toContain("(no test steps)");
});

it("is absent whenever anything was counted", () => {
expect(renderSummary(report({ passed: 1 }))).not.toContain("(no test steps)");
expect(renderSummary(report({ skipped: 1 }))).not.toContain("(no test steps)");
});

it("is absent on a failure, where the counts are not what needs explaining", () => {
// A cancelled run can be a FAIL with every counter still zero; calling that
// "no test steps" would read as though the failure had no cause.
const line = renderSummary(report({ ok: false }));

expect(line).toBe("FAIL — 0 passed, 0 failed, 0 errored, 0 skipped");
expect(line).not.toContain("(no test steps)");
});

it("leaves an ordinary summary byte-for-byte unchanged", () => {
const line = renderSummary(report({ ok: false, passed: 2, failed: 1, skipped: 1 }));

expect(line).toBe("FAIL — 2 passed, 1 failed, 0 errored, 1 skipped");
});
});
7 changes: 6 additions & 1 deletion packages/argent-mcp/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,14 @@ export async function flowRunToMcpContent(
}

if (result.ok !== undefined) {
// Narration steps are not counted, so a flow of only narration counts
// nothing — say so rather than reporting four zeros on a passing run.
const counted =
(result.passed ?? 0) + (result.failed ?? 0) + (result.errored ?? 0) + (result.skipped ?? 0);
const note = result.ok && counted === 0 ? " (no test steps)" : "";
blocks.push({
type: "text",
text: `${result.ok ? "PASS" : "FAIL"} — ${result.passed ?? 0} passed, ${result.failed ?? 0} failed, ${result.errored ?? 0} errored, ${result.skipped ?? 0} skipped`,
text: `${result.ok ? "PASS" : "FAIL"} — ${result.passed ?? 0} passed, ${result.failed ?? 0} failed, ${result.errored ?? 0} errored, ${result.skipped ?? 0} skipped${note}`,
});
} else {
blocks.push({ type: "text", text: `Flow "${result.flow}" complete.` });
Expand Down
72 changes: 71 additions & 1 deletion packages/tool-server/src/tools/flows/flow-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { DeviceInfo, Registry, ToolContext } from "@argent/registry";
import { FAILURE_CODES, FailureError } from "@argent/registry";
import { resolveDevice } from "../../utils/device-info";
import { invokeSubTool } from "../../utils/sub-invoke";
import type { WhenPlatform } from "./flow-utils";
import type { FlowStep, WhenPlatform } from "./flow-utils";

/**
* Device resolution + binding for the flow runner. Flows store no device id
Expand All @@ -17,6 +17,14 @@ export type FlowPlatform = WhenPlatform;

const DEVICE_BIND_KEYS = ["udid", "device_id"] as const;

/**
* Keys that mean a tool acts on a device. A superset of the keys the runner
* injects: `device` names one without receiving the run's own (a nested flow
* takes it that way), and a step that drives a device must count as needing one
* even when the runner does not hand it over.
*/
const DEVICE_ARG_KEYS = [...DEVICE_BIND_KEYS, "device"] as const;

interface RawDevice {
platform: FlowPlatform;
state?: string;
Expand Down Expand Up @@ -109,6 +117,68 @@ export function stripDeviceKeys(args: Record<string, unknown>): Record<string, u
* for the device-id keys the tool's input schema declares (so `.strict()`
* schemas stay valid).
*/
/**
* Whether a step acts on a device.
*
* Answered per kind rather than by trying and failing, so a flow that touches no
* device never has to have one. The default is that a step DOES need one: a kind
* added later inherits today's behaviour instead of silently running against no
* device, and the `never` binding makes leaving it unclassified a compile error.
*
* Two of the classifications are worth stating outright:
*
* - `when` needs a device whatever its body contains, because the guard itself
* reads one — the device's platform, or its view tree.
* - `run` needs one without the fragment being read here. The flow it names is
* resolved at run time; resolving it a second time would duplicate that lookup
* and could disagree with it if the file changed in between. The cost is that
* composing a narration-only fragment still resolves a device.
*/
export function stepRequiresDevice(registry: Registry, step: FlowStep): boolean {
switch (step.kind) {
case "echo":
case "wait":
return false;
case "tool":
return toolRequiresDevice(registry, step.name);
case "when":
case "run":
case "launch":
case "tap":
case "long-press":
case "type":
case "await":
case "assert":
case "scroll-to":
case "pinch":
case "rotate":
case "snapshot":
return true;
default: {
const unclassified: never = step;
void unclassified;
return true;
}
}
}

/** Whether any step in a flow acts on a device. */
export function flowRequiresDevice(registry: Registry, steps: FlowStep[]): boolean {
return steps.some((step) => stepRequiresDevice(registry, step));
}

function toolRequiresDevice(registry: Registry, toolName: string): boolean {
const toolDef = registry.getTool(toolName);
// An unknown tool is assumed to need a device: the step is going to fail
// either way, and it fails more usefully with one resolved.
if (!toolDef) return true;
const props = (toolDef.inputSchema as { properties?: Record<string, unknown> } | undefined)
?.properties;
// A tool with no declared input takes no device.
if (!props) return false;
return DEVICE_ARG_KEYS.some((k) => k in props);
}

export function bindDeviceArgs(
registry: Registry,
toolName: string,
Expand Down
Loading