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
41 changes: 41 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ import {
createIosSimulatorService,
type IosSimulatorService,
} from "../../desktop/src/main/services/ios/iosSimulatorService";
import {
createAppControlService,
type AppControlService,
} from "../../desktop/src/main/services/appControl/appControlService";
import type { createFileService } from "../../desktop/src/main/services/files/fileService";
import {
createAutomationService,
Expand Down Expand Up @@ -168,6 +172,7 @@ export type AdeRuntime = {
automationPlannerService?: ReturnType<typeof createAutomationPlannerService> | null;
computerUseArtifactBrokerService: ComputerUseArtifactBrokerService;
iosSimulatorService?: IosSimulatorService | null;
appControlService?: AppControlService | null;
orchestratorService: ReturnType<typeof createOrchestratorService>;
aiOrchestratorService: ReturnType<typeof createAiOrchestratorService>;
missionBudgetService?: ReturnType<typeof createMissionBudgetService> | null;
Expand Down Expand Up @@ -471,6 +476,39 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo
projectRoot,
logger,
});
// Late-bound chat session lookup. agentChatService is created after
// appControlService below, so we capture a holder that the resolveLaneId
// closure reads at call time. The chat session store lives in agentChatService
// (getSessionSummary), not in sessionService (which holds terminal sessions).
const agentChatServiceHolder: { current: ReturnType<typeof createAgentChatService> | null } = { current: null };
const appControlService = createAppControlService({
projectRoot,
logger,
ptyService,
resolveLaneId: async ({ cwd, projectRoot: requestedProjectRoot, laneId, chatSessionId }) => {
const explicitLaneId = laneId?.trim();
if (explicitLaneId) return explicitLaneId;
const chatId = chatSessionId?.trim();
if (chatId && agentChatServiceHolder.current) {
const chatSession = await agentChatServiceHolder.current.getSessionSummary(chatId).catch(() => null);
if (chatSession?.laneId) return chatSession.laneId;
}
const targetRoot = path.resolve(cwd || requestedProjectRoot || projectRoot);
const lanes = await laneService.list({ includeArchived: false });
const matchingLane = lanes.find((lane) => {
const worktreePath = path.resolve(lane.worktreePath);
const attachedRootPath = lane.attachedRootPath ? path.resolve(lane.attachedRootPath) : null;
return (
targetRoot === worktreePath
|| targetRoot.startsWith(`${worktreePath}${path.sep}`)
|| (attachedRootPath !== null
&& (targetRoot === attachedRootPath
|| targetRoot.startsWith(`${attachedRootPath}${path.sep}`)))
);
});
Comment thread
arul28 marked this conversation as resolved.
return matchingLane?.id ?? lanes[0]?.id ?? null;
},
});

const aiOrchestratorService = createAiOrchestratorService({
db,
Expand Down Expand Up @@ -508,6 +546,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo
});

const agentChatService = headlessLinearServices.agentChatService as unknown as ReturnType<typeof createAgentChatService> | null;
agentChatServiceHolder.current = agentChatService;
const automationService = createAutomationService({
db,
logger,
Expand Down Expand Up @@ -573,6 +612,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo
automationPlannerService,
computerUseArtifactBrokerService,
iosSimulatorService,
appControlService,
orchestratorService,
aiOrchestratorService,
eventBuffer,
Expand All @@ -581,6 +621,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo
swallow(() => automationService.dispose());
swallow(() => processService.disposeAll());
swallow(() => iosSimulatorService.dispose());
swallow(() => appControlService.dispose());
swallow(() => headlessLinearServices.dispose());
swallow(() => aiOrchestratorService.dispose());
swallow(() => testService.disposeAll());
Expand Down
127 changes: 127 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,6 +1311,133 @@ describe("ADE CLI", () => {
}
});

it("app-control status maps to app_control actions", () => {
const status = buildCliPlan(["app-control", "status"]);
expect(status.kind).toBe("execute");
if (status.kind !== "execute") return;
expect(status.steps[0]?.params).toMatchObject({
name: "run_ade_action",
arguments: { domain: "app_control", action: "getStatus" },
});
});

it("app-control launch requires a command and supports aliases", () => {
const launch = buildCliPlan(["app-control", "launch", "--command", "npm run dev", "--debug-port", "9333", "--force"]);
expect(launch.kind).toBe("execute");
if (launch.kind !== "execute") return;
expect(launch.steps[0]?.params).toMatchObject({
arguments: {
domain: "app_control",
action: "launch",
args: {
command: "npm run dev",
debugPort: 9333,
force: true,
},
},
});

const command = buildCliPlan(["electron", "launch", "pnpm", "dev"]);
expect(command.kind).toBe("execute");
if (command.kind !== "execute") return;
expect(command.steps[0]?.params).toMatchObject({
arguments: {
domain: "app_control",
action: "launch",
args: { command: "pnpm dev" },
},
});
});

it("terminal read and write map to terminal actions", () => {
const read = buildCliPlan(["terminal", "read", "--chat-session", "chat-1", "--max-bytes", "500"]);
expect(read.kind).toBe("execute");
if (read.kind !== "execute") return;
expect(read.steps[0]?.params).toMatchObject({
arguments: {
domain: "terminal",
action: "read",
args: { chatSessionId: "chat-1", maxBytes: 500 },
},
});

const write = buildCliPlan(["terminal", "write", "--terminal", "term-1", "--data", "y\n"]);
expect(write.kind).toBe("execute");
if (write.kind !== "execute") return;
expect(write.steps[0]?.params).toMatchObject({
arguments: {
domain: "terminal",
action: "write",
args: { terminalId: "term-1", data: "y\n" },
},
});
});

it("app-control logs and terminal write use the active App Control terminal", () => {
const logs = buildCliPlan(["app-control", "logs", "--max-bytes", "1024"]);
expect(logs.kind).toBe("execute");
if (logs.kind !== "execute") return;
expect(logs.steps[0]?.params).toMatchObject({
arguments: {
domain: "app_control",
action: "readTerminal",
args: { maxBytes: 1024 },
},
});

const write = buildCliPlan(["app-control", "terminal", "write", "--data", "y\n"]);
expect(write.kind).toBe("execute");
if (write.kind !== "execute") return;
expect(write.steps[0]?.params).toMatchObject({
arguments: {
domain: "app_control",
action: "writeTerminal",
args: { data: "y\n" },
},
});
});

it("app-control connect, select, click, and type map to App Control actions", () => {
const connect = buildCliPlan(["app-control", "connect", "--cdp-port", "9222", "--force"]);
expect(connect.kind).toBe("execute");
if (connect.kind !== "execute") return;
expect(connect.steps[0]?.params).toMatchObject({
arguments: {
domain: "app_control",
action: "connect",
args: { cdpPort: 9222, force: true },
},
});

const positionalConnect = buildCliPlan(["app-control", "connect", "9333"]);
expect(positionalConnect.kind).toBe("execute");
if (positionalConnect.kind !== "execute") return;
expect(positionalConnect.steps[0]?.params).toMatchObject({
arguments: { domain: "app_control", action: "connect", args: { cdpPort: 9333 } },
});

const select = buildCliPlan(["app-control", "select", "--x", "120", "--y", "420"]);
expect(select.kind).toBe("execute");
if (select.kind !== "execute") return;
expect(select.steps[0]?.params).toMatchObject({
arguments: { domain: "app_control", action: "selectPoint", args: { x: 120, y: 420 } },
});

const click = buildCliPlan(["app", "click", "120", "420"]);
expect(click.kind).toBe("execute");
if (click.kind !== "execute") return;
expect(click.steps[0]?.params).toMatchObject({
arguments: { domain: "app_control", action: "click", args: { x: 120, y: 420 } },
});

const type = buildCliPlan(["app-control", "type", "--value", "hello", "--text"]);
expect(type.kind).toBe("execute");
if (type.kind !== "execute") return;
expect(type.steps[0]?.params).toMatchObject({
arguments: { domain: "app_control", action: "typeText", args: { text: "hello" } },
});
});

it("attaches a rendered lane graph when the plan has the lanes visualizer", () => {
const connection = {
mode: "headless" as const,
Expand Down
Loading
Loading