Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2,325 changes: 100 additions & 2,225 deletions apps/ade-cli/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/ade-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@anthropic-ai/sdk": "^0.103.0",
"@cursor/sdk": "^1.0.13",
"@cursor/sdk": "^1.0.23",
Comment thread
arul28 marked this conversation as resolved.
"@factory/droid-sdk": "^0.2.0",
"@linear/sdk": "^84.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
Expand Down
34 changes: 32 additions & 2 deletions apps/ade-cli/src/cursorCloud.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildCliPlan } from "./cli";
import { CursorCloudUsageError, parseCursorCloudCommand } from "./cursorCloud";
import { CursorCloudUsageError, parseCursorCloudCommand, runCursorCloud } from "./cursorCloud";

const cursorModelsListMock = vi.hoisted(() => vi.fn());

vi.mock("@cursor/sdk", () => ({
Cursor: {
models: {
list: (...args: unknown[]) => cursorModelsListMock(...args),
},
},
}));

afterEach(() => {
cursorModelsListMock.mockReset();
});

describe("ADE CLI cursor cloud surface", () => {
it("routes 'cursor cloud' to a cursor-cloud plan", () => {
Expand Down Expand Up @@ -63,3 +77,19 @@ describe("parseCursorCloudCommand", () => {
expect(() => parseCursorCloudCommand(["bogus", "list"])).toThrow(CursorCloudUsageError);
});
});

describe("runCursorCloud", () => {
it("renders current Cursor SDK model list entries in text mode", async () => {
cursorModelsListMock.mockResolvedValue([
{ id: "cursor/claude-sonnet-5", displayName: "Claude Sonnet 5" },
{ model: { id: "legacy/composer" }, displayName: "Legacy Composer" },
]);

const result = await runCursorCloud(["models", "list"], "text");

expect(result.exitCode).toBe(0);
expect(result.output).toContain("Claude Sonnet 5 (cursor/claude-sonnet-5)");
expect(result.output).toContain("Legacy Composer (legacy/composer)");
expect(cursorModelsListMock).toHaveBeenCalledWith({ apiKey: undefined });
});
});
14 changes: 11 additions & 3 deletions apps/ade-cli/src/cursorCloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@ function trimWhitespace(value: string | null | undefined): string | undefined {
return trimmed.length ? trimmed : undefined;
}

function readCursorModelId(value: unknown): string {
if (!isRecord(value)) return "";
if (typeof value.id === "string" && value.id.trim()) return value.id.trim();
if (isRecord(value.model) && typeof value.model.id === "string") return value.model.id.trim();
return "";
}

/** Top-level entry point. Dispatches to the right group/sub handler. */
export async function runCursorCloud(args: Args, outputMode: CursorOutputMode): Promise<CursorCloudExecutionResult> {
const cleaned = [...args];
Expand Down Expand Up @@ -453,7 +460,7 @@ async function runModelsGroup(sub: string, rest: Args, opts: CursorCloudOptions)
const lines = ["Cursor cloud models"];
if (!items.length) lines.push(" (none)");
else for (const m of items) {
const id = isRecord(m) && isRecord(m.model) ? String(m.model.id ?? "") : "";
const id = readCursorModelId(m);
const display = isRecord(m) && typeof m.displayName === "string" ? m.displayName : id;
lines.push(` ${display}${id && display !== id ? ` (${id})` : ""}`);
}
Expand Down Expand Up @@ -664,8 +671,9 @@ export const CURSOR_CLOUD_HELP: Record<string, string> = {

$ ade cursor cloud models list

Lists models available for cloud agents. Use the model.id field as --model
on "agents create" / "agents resume".
Lists models available for cloud agents. Use the top-level id field as
--model on "agents create" / "agents resume". Older nested "model.id"
rows are still accepted for compatibility.
`,
me: ` Cursor Cloud: me

Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/services/agentRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,11 @@ describe("classifyAgentCliError", () => {
authCommand: "claude auth login",
});
});

it("does not mistake Cursor SDK agent resume misses for a missing Cursor CLI", () => {
expect(classifyAgentCliError(
"Cursor SDK init failed: Agent agent-5db8305e-086a-4f01-adff-5bfb8420ce32 not found (operation=Agent.resume)",
"cursor",
)).toBeNull();
});
});
8 changes: 6 additions & 2 deletions apps/ade-cli/src/services/agentRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [
installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash',
authCommand: "cursor-agent login",
missingErrorPatterns: [
/\bcursor(?:-agent)?\b.*\b(command not found|not recognized|not found|enoent)\b/i,
/\bcursor-agent\b.*\b(command not found|not recognized|not found|enoent)\b/i,
/\bcursor\b.*\b(command not found|not recognized|enoent)\b/i,
/\bspawn\s+cursor(?:-agent)?\s+enoent\b/i,
],
notAuthErrorPatterns: [
Expand Down Expand Up @@ -131,7 +132,10 @@ export function classifyAgentCliError(message: string, preferredAgent?: string |
}

if (preferred) {
if (/\b(command not found|not recognized|enoent|executable file not found|no such file or directory)\b/i.test(text)) {
if (
/\b(command not found|not recognized|enoent|executable file not found|no such file or directory)\b/i.test(text)
|| /\b(?:spawn|exec(?:ute)?|binary|command|executable)\b.*\bnot found\b/i.test(text)
) {
return toMatch(preferred, "missing");
}
if (/\b(not logged in|not authenticated|unauthorized|authentication failed|login required|invalid api key|401|403)\b/i.test(text)) {
Expand Down
8 changes: 4 additions & 4 deletions apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,13 +785,13 @@ describe("ChatView", () => {
},
];
const frame = renderEvents(events, { width: 100 });
// Typed split: the command group and the file-change group each get their
// own collapsible header (in event order). Each single-entry group previews
// its call/file inline, so the collapsed headers still carry the signal.
// Typed split: tool calls and file changes each get their own collapsible
// header. The collapsed tool-call header previews the latest call so live
// progress stays visible without stacking every command by default.
expect(frame).toContain("Tool calls");
expect(frame).toContain("Files changed");
expect(frame).toContain("npm test");
expect(frame).toContain("npm run typecheck");
expect(frame).not.toContain("npm test");
expect(frame).toContain("auth.ts");
// The collapsed file header keeps the badge + diff stats format.
expect(frame).toContain("TS");
Expand Down
37 changes: 25 additions & 12 deletions apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,15 @@ describe("aggregateChatBlocks typed groups", () => {
.filter((b) => b.kind === "tool-calls-group" || b.kind === "files-changed-group")
.map((b) => b.kind);

// Tool calls first, then files (file_changes interrupted the run), then a fresh
// tool-calls-group continues with the trailing command.
expect(groupKinds).toEqual(["tool-calls-group", "files-changed-group", "tool-calls-group"]);
// Tool calls first, then files (file_changes interrupted the run). Activity
// phase collapse merges the trailing command back into the first tool group.
expect(groupKinds).toEqual(["tool-calls-group", "files-changed-group"]);

const toolGroups = blocks.filter((b) => b.kind === "tool-calls-group") as Array<Extract<AggregatedBlock, { kind: "tool-calls-group" }>>;
const fileGroup = blocks.find((b) => b.kind === "files-changed-group") as Extract<AggregatedBlock, { kind: "files-changed-group" }> | undefined;

expect(toolGroups[0]!.entries.map((e) => e.tool)).toEqual(["read", "read", "grep"]);
// The trailing command event lands in a fresh tool-calls-group as tool="shell".
expect(toolGroups[1]!.entries).toHaveLength(1);
expect(toolGroups[1]!.entries[0]).toMatchObject({
tool: "shell",
arg: "npm test",
status: "ok",
durationMs: 1500,
});
expect(toolGroups[0]!.entries.map((e) => e.tool)).toEqual(["read", "read", "grep", "shell"]);
expect(toolGroups).toHaveLength(1);

expect(fileGroup!.entries).toHaveLength(2);
expect(fileGroup!.entries[0]).toMatchObject({
Expand Down Expand Up @@ -512,6 +505,26 @@ describe("aggregateChatBlocks desktop work-log parity", () => {
expect(reasoning).toHaveLength(1);
expect(reasoning[0]).toMatchObject({ text: "part one part two", live: false });
});

it("collapses alternating reasoning and tool bursts into merged activity rows", () => {
const events: AgentChatEventEnvelope[] = [
env("2026-01-01T12:00:00.000Z", { type: "reasoning", text: "First thought.", itemId: "r1", turnId: "turn-1" }),
env("2026-01-01T12:00:01.000Z", { type: "tool_call", tool: "Read", args: { path: "a.ts" }, itemId: "t1", turnId: "turn-1" }),
env("2026-01-01T12:00:02.000Z", { type: "reasoning", text: "Second thought.", itemId: "r2", turnId: "turn-1" }),
env("2026-01-01T12:00:03.000Z", { type: "tool_call", tool: "Edit", args: { path: "b.ts" }, itemId: "t2", turnId: "turn-1" }),
env("2026-01-01T12:00:04.000Z", { type: "reasoning", text: "Third thought.", itemId: "r3", turnId: "turn-1" }),
env("2026-01-01T12:00:05.000Z", { type: "tool_call", tool: "Shell", args: { cmd: "pwd" }, itemId: "t3", turnId: "turn-1" }),
];
const blocks = aggregate(events);
const reasoning = blocks.filter((b) => b.kind === "reasoning") as Extract<AggregatedBlock, { kind: "reasoning" }>[];
const toolGroups = blocks.filter((b) => b.kind === "tool-calls-group") as Extract<AggregatedBlock, { kind: "tool-calls-group" }>[];
expect(reasoning).toHaveLength(1);
expect(toolGroups).toHaveLength(1);
expect(reasoning[0]!.text).toContain("First thought.");
expect(reasoning[0]!.text).toContain("Second thought.");
expect(reasoning[0]!.text).toContain("Third thought.");
expect(toolGroups[0]!.entries).toHaveLength(3);
});
});

// Realistic Claude history-replay shapes (distilled from real ended-session
Expand Down
101 changes: 99 additions & 2 deletions apps/ade-cli/src/tuiClient/aggregate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
collapseActivityPhaseRows,
mergeReasoningTextFragments,
type ActivityPhaseMergeMeta,
} from "../../../desktop/src/shared/chatActivityPhase";
import type {
AgentChatEvent,
AgentChatEventEnvelope,
Expand Down Expand Up @@ -921,7 +926,99 @@ export function aggregateChatBlocks(args: {
}

if (args.maxBlocks && blocks.length > args.maxBlocks) {
return blocks.slice(-args.maxBlocks);
return collapseAggregatedActivityPhaseBlocks(blocks.slice(-args.maxBlocks));
}
return collapseAggregatedActivityPhaseBlocks(blocks);
}

function classifyAggregatedActivityBlock(
block: AggregatedBlock,
): { kind: "reasoning" | "work"; turnId: string | null } | null {
if (block.kind === "reasoning") {
return { kind: "reasoning", turnId: block.turnId };
}
return blocks;
if (block.kind === "tool-calls-group" || block.kind === "files-changed-group") {
return { kind: "work", turnId: block.turnId };
}
return null;
}

function mergeAggregatedActivityPhase(
phase: readonly AggregatedBlock[],
meta: ActivityPhaseMergeMeta,
): AggregatedBlock[] {
const reasoningBlocks = phase.filter((block): block is Extract<AggregatedBlock, { kind: "reasoning" }> => block.kind === "reasoning");
const toolGroups = phase.filter((block): block is Extract<AggregatedBlock, { kind: "tool-calls-group" }> => block.kind === "tool-calls-group");
const fileGroups = phase.filter((block): block is Extract<AggregatedBlock, { kind: "files-changed-group" }> => block.kind === "files-changed-group");
const merged: AggregatedBlock[] = [];

const pushReasoning = () => {
if (reasoningBlocks.length === 0) return;
if (reasoningBlocks.length === 1) {
merged.push(reasoningBlocks[0]!);
return;
}
const first = reasoningBlocks[0]!;
merged.push({
...first,
id: `activity-phase-reasoning:${first.id}`,
text: mergeReasoningTextFragments(reasoningBlocks.map((block) => block.text)),
live: reasoningBlocks.some((block) => block.live),
});
};

const pushToolGroups = () => {
if (toolGroups.length === 0) return;
if (toolGroups.length === 1) {
merged.push(toolGroups[0]!);
return;
}
const first = toolGroups[0]!;
merged.push({
...first,
id: `activity-phase-tools:${first.id}`,
entries: toolGroups.flatMap((block) => block.entries),
live: toolGroups.some((block) => block.live),
});
};

const pushFileGroups = () => {
if (fileGroups.length === 0) return;
if (fileGroups.length === 1) {
merged.push(fileGroups[0]!);
return;
}
const first = fileGroups[0]!;
merged.push({
...first,
id: `activity-phase-files:${first.id}`,
entries: fileGroups.flatMap((block) => block.entries),
live: fileGroups.some((block) => block.live),
});
};

const firstWorkBlock = phase.find((block) => block.kind === "tool-calls-group" || block.kind === "files-changed-group");

const pushWork = () => {
if (firstWorkBlock?.kind === "files-changed-group") {
pushFileGroups();
pushToolGroups();
} else {
pushToolGroups();
pushFileGroups();
}
};

if (meta.workFirst) {
pushWork();
pushReasoning();
} else {
pushReasoning();
pushWork();
}
return merged;
}

export function collapseAggregatedActivityPhaseBlocks(blocks: AggregatedBlock[]): AggregatedBlock[] {
return collapseActivityPhaseRows(blocks, classifyAggregatedActivityBlock, mergeAggregatedActivityPhase);
}
Loading