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
84 changes: 83 additions & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit } from "./adeRpcServer";
import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit, resolveComputerUseOwners } from "./adeRpcServer";
import { JsonRpcError, JsonRpcErrorCode } from "./jsonrpc";

type RuntimeFixture = ReturnType<typeof createRuntime>;
const originalPlatform = process.platform;
Expand Down Expand Up @@ -4481,4 +4482,85 @@ describe("adeRpcServer", () => {
expect(response?.isError).toBeUndefined();
expect(fixture.runtime.eventBuffer.drain).toHaveBeenCalledWith(0, 100);
});

describe("resolveComputerUseOwners explicit ownerKind/ownerId", () => {
function makeSession(): any {
return {
initialized: true,
protocolVersion: "2025-06-18",
identity: {
callerId: "caller-1",
role: "external",
chatSessionId: null,
standaloneChatSession: false,
missionId: null,
runId: null,
stepId: null,
attemptId: null,
ownerId: null,
},
askUserEvents: [],
askUserRateLimit: { maxCalls: 1, windowMs: 1000 },
memoryAddEvents: [],
memoryAddRateLimit: { maxCalls: 1, windowMs: 1000 },
memorySearchEvents: [],
memorySearchRateLimit: { maxCalls: 1, windowMs: 1000 },
};
}

it("includes an explicit lane owner", () => {
const owners = resolveComputerUseOwners(makeSession(), { ownerKind: "lane", ownerId: "lane-1" });
expect(owners).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "lane", id: "lane-1", relation: "attached_to" }),
]),
);
// Explicit owner is prepended.
expect(owners[0]).toEqual(expect.objectContaining({ kind: "lane", id: "lane-1" }));
});

it("normalizes alias 'chat' to 'chat_session'", () => {
const owners = resolveComputerUseOwners(makeSession(), { ownerKind: "chat", ownerId: "c1" });
expect(owners[0]).toEqual(expect.objectContaining({ kind: "chat_session", id: "c1" }));
expect(owners.some((o) => (o as any).kind === "chat")).toBe(false);
});

it("normalizes alias 'pr' to 'github_pr'", () => {
const owners = resolveComputerUseOwners(makeSession(), { ownerKind: "pr", ownerId: "p1" });
expect(owners[0]).toEqual(expect.objectContaining({ kind: "github_pr", id: "p1" }));
});

it("throws JsonRpcError with invalidParams for an unsupported ownerKind", () => {
let caught: unknown = null;
try {
resolveComputerUseOwners(makeSession(), { ownerKind: "bogus", ownerId: "x" });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(JsonRpcError);
expect((caught as JsonRpcError).code).toBe(JsonRpcErrorCode.invalidParams);
});

it("rejects when ownerKind is provided without ownerId", () => {
let caught: unknown = null;
try {
resolveComputerUseOwners(makeSession(), { ownerKind: "lane" });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(JsonRpcError);
expect((caught as JsonRpcError).code).toBe(JsonRpcErrorCode.invalidParams);
});

it("rejects when ownerId is provided without ownerKind", () => {
let caught: unknown = null;
try {
resolveComputerUseOwners(makeSession(), { ownerId: "lane-123" });
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(JsonRpcError);
expect((caught as JsonRpcError).code).toBe(JsonRpcErrorCode.invalidParams);
});
});
});
54 changes: 49 additions & 5 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,33 +433,37 @@ const TOOL_SPECS: ToolSpec[] = [
},
{
name: "screenshot_environment",
description: "Fallback-only: capture a local screenshot and store it in ADE artifacts for proof attachment.",
description: "Fallback-only: capture a local screenshot/image and store it as visual ADE proof.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
displayId: { type: "number" },
ownerKind: { type: "string" },
ownerId: { type: "string" },
format: { type: "string", enum: ["png", "jpg"], default: "png" }
}
}
},
{
name: "record_environment",
description: "Fallback-only: record a short local screen video and store it in ADE artifacts for proof attachment.",
description: "Fallback-only: record a short local screen video and store it as visual ADE proof.",
inputSchema: {
type: "object",
additionalProperties: false,
properties: {
name: { type: "string" },
displayId: { type: "number" },
ownerKind: { type: "string" },
ownerId: { type: "string" },
durationSec: { type: "number", minimum: 1, maximum: 120, default: 10 }
}
}
},
{
name: "ingest_computer_use_artifacts",
description: "Register externally-produced computer-use proof artifacts into ADE for ownership, closeout, and publishing.",
description: "Register externally-produced visual proof artifacts into ADE for ownership, closeout, and publishing. Console logs are supporting diagnostics and should not be the only proof unless explicitly requested.",
inputSchema: {
type: "object",
additionalProperties: false,
Expand Down Expand Up @@ -521,12 +525,14 @@ const TOOL_SPECS: ToolSpec[] = [
automationRunId: { type: "string" },
prUrl: { type: "string" },
linearIssueId: { type: "string" },
ownerKind: { type: "string" },
ownerId: { type: "string" },
}
}
},
{
name: "list_computer_use_artifacts",
description: "List ADE-managed computer-use artifacts by owner or canonical proof type.",
description: "List ADE-managed proof artifacts by owner or canonical type, including visual proof and supporting diagnostics.",
inputSchema: {
type: "object",
additionalProperties: false,
Expand Down Expand Up @@ -2056,7 +2062,7 @@ function assertNonEmptyString(value: unknown, field: string): string {
return text;
}

function resolveComputerUseOwners(session: SessionState, toolArgs: Record<string, unknown>): ComputerUseArtifactOwner[] {
export function resolveComputerUseOwners(session: SessionState, toolArgs: Record<string, unknown>): ComputerUseArtifactOwner[] {
const owners: ComputerUseArtifactOwner[] = [];
const add = (
kind: ComputerUseArtifactOwner["kind"],
Expand All @@ -2066,7 +2072,45 @@ function resolveComputerUseOwners(session: SessionState, toolArgs: Record<string
if (!id || !id.trim().length) return;
owners.push({ kind, id: id.trim(), relation });
};
const addExplicitOwner = () => {
const rawKind = asOptionalTrimmedString(toolArgs.ownerKind);
const ownerId = asOptionalTrimmedString(toolArgs.ownerId);
if (Boolean(rawKind) !== Boolean(ownerId)) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"ownerKind and ownerId must be provided together",
);
}
if (!rawKind || !ownerId) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let normalizedKind = rawKind;
if (rawKind === "chat") normalizedKind = "chat_session";
else if (rawKind === "pr") normalizedKind = "github_pr";
switch (normalizedKind) {
case "lane":
case "mission":
case "orchestrator_run":
case "orchestrator_step":
case "orchestrator_attempt":
case "chat_session":
case "automation_run":
case "github_pr":
case "linear_issue":
add(
normalizedKind,
ownerId,
normalizedKind === "github_pr" || normalizedKind === "linear_issue"
? "published_to"
: normalizedKind === "orchestrator_attempt"
? "produced_by"
: "attached_to",
);
break;
default:
throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `Unsupported proof ownerKind: ${rawKind}`);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

addExplicitOwner();
add("mission", session.identity.missionId);
add("orchestrator_run", session.identity.runId);
add("orchestrator_step", session.identity.stepId);
Expand Down
49 changes: 49 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,55 @@ describe("ADE CLI", () => {
expect(joined.command).toEqual(["lanes", "list"]);
});

it("prefers headless mode for local proof capture commands", () => {
const screenshot = buildCliPlan(["proof", "screenshot"]);
const capture = buildCliPlan(["proof", "capture", "--caption", "Done", "--owner-kind", "chat", "--owner-id", "chat-1"]);
const record = buildCliPlan(["proof", "record", "--seconds", "3"]);
const list = buildCliPlan(["proof", "list"]);

expect(screenshot.kind).toBe("execute");
expect(capture.kind).toBe("execute");
expect(record.kind).toBe("execute");
expect(list.kind).toBe("execute");
if (screenshot.kind !== "execute" || capture.kind !== "execute" || record.kind !== "execute" || list.kind !== "execute") return;

expect(screenshot.preferHeadless).toBe(true);
expect(capture.preferHeadless).toBe(true);
expect(capture.steps[0]?.params).toMatchObject({
name: "screenshot_environment",
arguments: {
name: "Done",
ownerKind: "chat",
ownerId: "chat-1",
},
});
expect(record.preferHeadless).toBe(true);
expect(list.preferHeadless).toBeUndefined();
});

it("maps proof attach to visual artifact ingestion", () => {
const plan = buildCliPlan(["proof", "attach", "/tmp/done.png", "--caption", "Checkout complete", "--owner-kind", "chat", "--owner-id", "chat-1"]);
expect(plan.kind).toBe("execute");
if (plan.kind !== "execute") return;

expect(plan.steps[0]?.params).toMatchObject({
name: "ingest_computer_use_artifacts",
arguments: {
backendStyle: "manual",
backendName: "ade-cli",
toolName: "proof attach",
ownerKind: "chat",
ownerId: "chat-1",
inputs: [{
kind: "screenshot",
title: "Checkout complete",
description: "Checkout complete",
path: "/tmp/done.png",
}],
},
});
});

it("rejects invalid --role values", () => {
expect(() => parseCliArgs(["--role", "bogus", "lanes", "list"])).toThrow(
/--role must be one of/,
Expand Down
77 changes: 62 additions & 15 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type FormatterId =

type CliPlan =
| { kind: "help"; text: string }
| { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId };
| { kind: "execute"; label: string; steps: InvocationStep[]; visualizer?: "lanes"; summary?: "status" | "doctor" | "auth"; formatter?: FormatterId; preferHeadless?: boolean };

type CliConnection = {
mode: "desktop-socket" | "headless";
Expand Down Expand Up @@ -429,16 +429,20 @@ const HELP_BY_COMMAND: Record<string, string> = {
proof: `${ADE_BANNER}
Proof and computer use

Proof commands capture or ingest artifacts that ADE can attach to work.
Local screenshot/video fallback is macOS-only; desktop socket mode has the
best parity with the app.
Proof commands capture or ingest reviewer-visible evidence for ADE work.
Prefer screenshots/images, screen recordings, and browser captures/traces.
Console logs are supporting diagnostics, not a replacement for visual proof.
Local screenshot/video fallback is macOS-only and runs headless by default
unless --socket is explicitly requested. Desktop socket mode has the best
parity for UI-owned proof state.

$ ade proof status --text Show proof backend capabilities
$ ade proof list --text List captured artifacts
$ ade proof screenshot Capture a screenshot artifact
$ ade proof capture --caption "Done" Capture a screenshot artifact
$ ade proof attach /tmp/proof.png --caption "Done" Attach an existing image/video
$ ade proof record --seconds 20 Capture a short video proof
$ ade proof launch --app "ADE" Launch an app for proof capture
$ ade proof ingest --input-json '{"artifacts":[]}' Ingest external proof artifacts
$ ade proof ingest --input-json '{"artifacts":[]}' Ingest external visual proof artifacts
`,
tests: `${ADE_BANNER}
Tests
Expand Down Expand Up @@ -1569,15 +1573,54 @@ function buildFilesPlan(args: string[]): CliPlan {

function buildProofPlan(args: string[]): CliPlan {
const sub = firstPositional(args) ?? "status";
const proofOwnerBase = () => {
const ownerKind = readValue(args, ["--owner-kind", "--owner"]);
const ownerId = readValue(args, ["--owner-id"]);
return {
...(ownerKind ? { ownerKind } : {}),
...(ownerId ? { ownerId } : {}),
};
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const inferAttachedProofKind = (filePath: string): string => {
const ext = path.extname(filePath).replace(/^\./, "").toLowerCase();
if (["png", "jpg", "jpeg", "webp", "gif", "heic", "heif", "tif", "tiff"].includes(ext)) return "screenshot";
if (["mov", "mp4", "m4v", "webm"].includes(ext)) return "video_recording";
if (["zip", "har"].includes(ext)) return "browser_trace";
return "browser_verification";
};
if (sub === "actions") return { kind: "execute", label: "proof actions", steps: [listActionsStep("actions", "computer_use_artifacts")] };
if (sub === "status" || sub === "backends") return { kind: "execute", label: "proof backend status", steps: [actionCallStep("result", "get_computer_use_backend_status", collectGenericObjectArgs(args))] };
if (sub === "environment") return { kind: "execute", label: "computer-use environment", steps: [actionCallStep("result", "get_environment_info", collectGenericObjectArgs(args))] };
if (sub === "environment") return { kind: "execute", label: "computer-use environment", steps: [actionCallStep("result", "get_environment_info", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true };
if (sub === "list" || sub === "ls") return { kind: "execute", label: "proof list", steps: [actionCallStep("result", "list_computer_use_artifacts", collectGenericObjectArgs(args))] };
if (sub === "ingest") return { kind: "execute", label: "proof ingest", steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args))] };
if (sub === "screenshot") return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args))] };
if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))] };
if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))] };
if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args))] };
if (sub === "attach") {
const caption = readValue(args, ["--caption", "--description", "--desc"]);
const attachedPath = requireValue(readValue(args, ["--path"]) ?? firstPositional(args), "path");
const title = readValue(args, ["--title", "--name"]) ?? caption ?? path.basename(attachedPath);
return {
kind: "execute",
label: "proof attach",
steps: [actionCallStep("result", "ingest_computer_use_artifacts", collectGenericObjectArgs(args, {
backendStyle: "manual",
backendName: "ade-cli",
toolName: "proof attach",
...proofOwnerBase(),
inputs: [{
kind: inferAttachedProofKind(attachedPath),
title,
...(caption ? { description: caption } : {}),
path: attachedPath,
}],
}))],
};
}
if (sub === "screenshot" || sub === "capture") {
const caption = readValue(args, ["--caption", "--description", "--desc"]);
return { kind: "execute", label: "computer-use screenshot", steps: [actionCallStep("result", "screenshot_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? caption }))], preferHeadless: true };
}
if (sub === "record") return { kind: "execute", label: "computer-use record", steps: [actionCallStep("result", "record_environment", collectGenericObjectArgs(args, { ...proofOwnerBase(), name: readValue(args, ["--name", "--title"]) ?? readValue(args, ["--caption", "--description", "--desc"]), durationSec: readNumberOption(args, ["--seconds", "--duration-sec"]) }))], preferHeadless: true };
if (sub === "launch") return { kind: "execute", label: "computer-use launch", steps: [actionCallStep("result", "launch_app", collectGenericObjectArgs(args, { app: readValue(args, ["--app"]) ?? firstPositional(args) }))], preferHeadless: true };
if (sub === "interact") return { kind: "execute", label: "computer-use interact", steps: [actionCallStep("result", "interact_gui", collectGenericObjectArgs(args, proofOwnerBase()))], preferHeadless: true };
return { kind: "execute", label: `proof ${sub}`, steps: [actionStep("result", "computer_use_artifacts", sub, collectGenericObjectArgs(args))] };
}

Expand Down Expand Up @@ -1867,7 +1910,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet<string> = new Set([
"--automation", "--base", "--base-branch", "--body", "--branch",
"--branch-name", "--branch-ref", "--category", "--color", "--cols",
"--command", "--comment", "--comment-id", "--commit", "--compare-ref",
"--compare-to", "--content", "--context-file", "--cwd", "--data",
"--caption", "--compare-to", "--content", "--context-file", "--cwd", "--data",
"--depth", "--desc",
"--description", "--domain", "--duration-sec", "--enabled", "--event",
"--from-file", "--group", "--group-id", "--head", "--icon", "--id",
Expand All @@ -1876,7 +1919,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet<string> = new Set([
"--max-log-bytes", "--max-prompt-chars", "--max-rounds", "--memory",
"--memory-id", "--merge-method", "--message", "--method", "--mode", "--model",
"--model-id", "--name", "--new", "--new-path", "--number", "--old",
"--old-path", "--params-json", "--parent", "--parent-lane", "--parent-lane-id",
"--old-path", "--owner", "--owner-id", "--owner-kind",
"--params-json", "--parent", "--parent-lane", "--parent-lane-id",
"--path", "--permission-mode", "--permissions", "--pr", "--pr-id",
"--pr-number", "--pr-url", "--process", "--process-id", "--project-root",
"--prompt", "--provider", "--pty", "--pty-id", "--query", "--question",
Expand Down Expand Up @@ -3108,8 +3152,11 @@ function summarizeExecution(args: {

async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalOptions): Promise<unknown> {
let connection: CliConnection;
const connectionOptions = plan.preferHeadless && !options.requireSocket
? { ...options, headless: true }
: options;
try {
connection = await createConnection(options);
connection = await createConnection(connectionOptions);
} catch (error) {
const roots = resolveRoots(options);
let socketPath = path.join(roots.projectRoot, ".ade", "ade.sock");
Expand All @@ -3119,7 +3166,7 @@ async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalO
} catch {
// Keep the conventional Unix fallback if shared layout loading fails.
}
const requestedMode = options.requireSocket ? "desktop-socket" : options.headless ? "headless" : "auto";
const requestedMode = connectionOptions.requireSocket ? "desktop-socket" : connectionOptions.headless ? "headless" : "auto";
const cause = error instanceof Error ? error.message : String(error);
const sourceRuntimeInterop = isSourceRuntimeInteropError(cause);
throw new CliExecutionError(`Failed to initialize ADE CLI connection for ${plan.label}.`, {
Expand Down
Loading
Loading