Skip to content
Merged
2 changes: 2 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ ade prs pipeline pr-id save --conflict-strategy rebase --no-early-merge-on-green
ade run defs --text
ade run start web --lane lane-id
ade shell start --lane lane-id -- npm test
ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests"
ade shell start-cli --provider claude --lane lane-id --permission-mode default
ade chat create --lane lane-id --model gpt-5.5
ade tests run --lane lane-id --suite unit --wait
ade proof list --arg ownerKind=chat --arg ownerId=session-id
Expand Down
234 changes: 233 additions & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,13 @@ function createRuntime() {
},
ptyService: {
create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })),
dispose: vi.fn()
dispose: vi.fn(),
writeBySessionId: vi.fn((sessionId: string, data: string): boolean => {
void sessionId;
void data;
return true;
}),
enrichSessions: vi.fn((sessions: unknown[]) => sessions),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
testService: {
run: vi.fn(async () => ({ id: "test-run-1", status: "running" })),
Expand Down Expand Up @@ -1167,6 +1173,7 @@ describe("adeRpcServer", () => {
"screenshot_environment",
"record_environment",
"run_tests",
"start_cli_session",
"get_lane_status",
"list_lanes",
"commit_changes",
Expand Down Expand Up @@ -2143,6 +2150,231 @@ describe("adeRpcServer", () => {
expect(response.structuredContent.contextRef?.path).toBeNull();
});

it("routes start_cli_session through shared provider launch helpers", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-1",
laneId: "lane-1",
ptyId: "pty-1",
tracked: true,
toolType: "codex",
title: "Codex",
status: "running",
resumeCommand: null,
resumeMetadata: null,
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
permissionMode: "edit",
initialInput: "fix failing tests",
cols: 90,
rows: 24,
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
laneId: "lane-1",
title: "Codex",
toolType: "codex",
cols: 90,
rows: 24,
command: "codex",
startupCommand: expect.stringContaining("codex --no-alt-screen"),
}),
);
expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledWith("session-1", "fix failing tests\r");
expect(response.structuredContent).toMatchObject({
provider: "codex",
laneId: "lane-1",
ptyId: "pty-1",
sessionId: "session-1",
initialInputWritten: true,
});
});

it("preserves the initial input write error if cleanup fails", async () => {
const fixture = createRuntime();
fixture.runtime.ptyService.writeBySessionId.mockReturnValueOnce(false);
fixture.runtime.ptyService.dispose.mockImplementationOnce(() => {
throw new Error("already disposed");
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
initialInput: "fix failing tests",
});

expect(response.isError).toBe(true);
expect(fixture.runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" });
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain(
"Created terminal session could not receive the initial input.",
);
});

it("preassigns Claude session ids for start_cli_session launches", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "claude",
permissionMode: "default",
});

expect(response?.isError).toBeUndefined();
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(createCall.allowNewSessionId).toBe(true);
expect(createCall.startupCommand).toContain("--session-id");
expect(createCall.startupCommand).toContain(createCall.sessionId);
expect(createCall.toolType).toBe("claude");
});

it("resumes start_cli_session from stored terminal metadata", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-existing",
laneId: "lane-1",
ptyId: "pty-existing",
tracked: true,
toolType: "codex",
title: "Codex",
status: "exited",
resumeCommand: "codex resume picker",
resumeMetadata: {
provider: "codex",
targetKind: "thread",
targetId: "thread-77",
launch: { permissionMode: "edit" },
},
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
resumeSessionId: "session-existing",
});

expect(response?.isError).toBeUndefined();
expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-existing",
startupCommand: "codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-77",
}),
);
});

it("sanitizes start_cli_session resume target ids before building commands", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
resumeTargetId: "thread-1\n--danger",
});

expect(response?.isError).toBeUndefined();
const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0];
expect(createCall.startupCommand).toContain("thread-1 --danger");
expect(createCall.startupCommand).not.toContain("\n");
});

it("rejects start_cli_session resume when the session id is missing", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue(null);
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
resumeSessionId: "missing-session",
});

expect(response.isError).toBe(true);
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("missing-session");
expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled();
});

it("rejects start_cli_session resume for a different provider", async () => {
const fixture = createRuntime();
fixture.runtime.sessionService.get.mockReturnValue({
id: "session-existing",
laneId: "lane-1",
ptyId: "pty-existing",
tracked: true,
toolType: "claude",
title: "Claude",
status: "exited",
resumeCommand: "claude --resume old",
resumeMetadata: {
provider: "claude",
targetKind: "session",
targetId: "old",
launch: { permissionMode: "default" },
},
});
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
resumeSessionId: "session-existing",
});

expect(response.isError).toBe(true);
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("belongs to claude, not codex");
expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled();
});

it("rejects invalid start_cli_session permission modes", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "codex",
permissionMode: "surprise-me",
});

expect(response.isError).toBe(true);
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain(
"permissionMode must be one of default, plan, edit, full-auto, or config-toml",
);
expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled();
});

it("rejects unsupported start_cli_session permission/provider combinations", async () => {
const fixture = createRuntime();
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });

await initialize(handler, { role: "orchestrator" });
const response = await callTool(handler, "start_cli_session", {
laneId: "lane-1",
provider: "claude",
permissionMode: "config-toml",
});

expect(response.isError).toBe(true);
expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("config-toml is only supported for Codex");
expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled();
});

it("starts spawn_agent without writing an attached ADE server config", async () => {
const fixture = createRuntime();
fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-"));
Expand Down
Loading
Loading