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
69 changes: 69 additions & 0 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,35 @@ describe("createSyncRemoteCommandService", () => {
expect(restoreCancelledQueue).toHaveBeenCalledTimes(1);
});

it("forwards only a Cursor Cloud reasoning effort the caller actually sent", async () => {
const openCursorCloudChat = vi.fn(async (_args: Record<string, unknown>) => (
{ sessionId: "chat-1", session: {} }
));
const { service } = createService({ agentChatService: { openCursorCloudChat } });

await service.execute(makePayload("ai.openCursorCloudChat", {
cloudAgentId: "agt_1",
laneId: "lane-1",
reasoningEffort: "high",
}));
// An omitted control must leave the session default alone: forwarding it as
// null clears the reasoning effort on every mobile, web, and relay call.
await service.execute(makePayload("ai.openCursorCloudChat", {
cloudAgentId: "agt_1",
laneId: "lane-1",
}));

expect(openCursorCloudChat.mock.calls[0]?.[0]).toEqual({
cloudAgentId: "agt_1",
laneId: "lane-1",
reasoningEffort: "high",
});
expect(openCursorCloudChat.mock.calls[1]?.[0]).toEqual({
cloudAgentId: "agt_1",
laneId: "lane-1",
});
});

it("routes all Codex recovery actions through the mobile sync command", async () => {
const recoverCodexTurn = vi.fn(async (args) => ({
action: args.action,
Expand Down Expand Up @@ -1929,6 +1958,46 @@ describe("createSyncRemoteCommandService", () => {
}));
});

it("refuses work.updateSessionMeta title writes when Cursor owns the chat name", async () => {
const updateMeta = vi.fn();
const getSessionSummary = vi.fn().mockResolvedValue({
sessionId: "cloud-session-1",
cursorCloudAgentId: "cloud-agent-1",
});
const { service } = createService({
sessionService: { updateMeta },
agentChatService: { getSessionSummary },
});

await expect(service.execute(makePayload("work.updateSessionMeta", {
sessionId: "cloud-session-1",
title: "ADE-owned title",
manuallyNamed: true,
}))).rejects.toThrow("agent names are managed by Cursor");
expect(updateMeta).not.toHaveBeenCalled();
});

it("still pins a Cursor Cloud chat through work.updateSessionMeta", async () => {
const updateMeta = vi.fn();
const getSessionSummary = vi.fn().mockResolvedValue({
sessionId: "cloud-session-1",
cursorCloudAgentId: "cloud-agent-1",
});
const { service } = createService({
sessionService: { updateMeta },
agentChatService: { getSessionSummary },
});

await expect(service.execute(makePayload("work.updateSessionMeta", {
sessionId: "cloud-session-1",
pinned: true,
}))).resolves.toEqual({ ok: true });
expect(updateMeta).toHaveBeenCalledWith(expect.objectContaining({
sessionId: "cloud-session-1",
pinned: true,
}));
});

it("delegates PR merge contexts to the injected service", async () => {
const getMergeContexts = vi.fn().mockResolvedValue({ "pr-1": { prId: "pr-1", mergeable: true } });
const { service } = createService({
Expand Down
19 changes: 16 additions & 3 deletions apps/ade-cli/src/services/sync/syncRemoteCommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../../../../desktop/src/shared/types/chat";
import { runWithAbortSignal } from "./abortSignal";
import { projectAttachmentsDir } from "../../../../desktop/src/shared/chatAttachmentStagingFs";
import { assertCursorCloudRenameAllowed } from "../../../../desktop/src/shared/cursorCloudNaming";
import type { AttachmentUploadRegistry, AttachmentUploadTicket } from "./attachmentUploadService";
import type {
AgentChatCreateArgs,
Expand Down Expand Up @@ -4206,7 +4207,14 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio
args.sessionDeltaService?.getSessionDelta(parseSessionIdArgs(payload, "work.getSessionDelta").sessionId) ?? null);
register("work.listSessions", { viewerAllowed: true }, async (payload) => listRemoteWorkSessions(args, parseListSessionsArgs(payload)));
register("work.updateSessionMeta", { viewerAllowed: true, queueable: true }, async (payload) => {
args.sessionService.updateMeta(parseUpdateSessionMetaArgs(payload));
const parsed = parseUpdateSessionMetaArgs(payload);
await assertCursorCloudRenameAllowed(
args.agentChatService
? (sessionId) => args.agentChatService!.getSessionSummary(sessionId)
: null,
parsed,
);
args.sessionService.updateMeta(parsed);
return { ok: true };
});
// ---------------------------------------------------------------------
Expand Down Expand Up @@ -5536,15 +5544,20 @@ function registerMiscRemoteCommands({ args, register }: RemoteCommandRegistratio
return { ok: true };
});
register("ai.openCursorCloudChat", { viewerAllowed: true, queueable: false }, async (payload) => {
const agentName = asTrimmedString(payload.agentName);
const sessionId = asTrimmedString(payload.sessionId);
const modelId = asTrimmedString(payload.modelId);
const reasoningEffort = asTrimmedString(payload.reasoningEffort);
const fastMode = asOptionalBoolean(payload.fastMode);
return requireService(args.agentChatService, "Agent chat service not available.").openCursorCloudChat({
cloudAgentId: requireString(payload.cloudAgentId, "ai.openCursorCloudChat requires cloudAgentId."),
laneId: requireString(payload.laneId, "ai.openCursorCloudChat requires laneId."),
...(agentName ? { agentName } : {}),
...(sessionId ? { sessionId } : {}),
...(modelId ? { modelId } : {}),
// `asTrimmedString` returns null, never undefined, so this has to test
// for null: forwarding it would clear the session's reasoning effort on
// every mobile, web, and relay call that omits the field.
...(reasoningEffort !== null ? { reasoningEffort } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
});
});
register("ai.watchCursorCloudMirror", { viewerAllowed: true, queueable: false }, async (payload) => {
Expand Down
20 changes: 19 additions & 1 deletion apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ import {
formatSystemDetails,
CURSOR_CLOUD_PANE_NOTE,
} from "./rightPaneFormatters";
import { cursorCloudRenameBlockedReason } from "./cursorCloudChatRename";
import {
buildFeedbackDraftInput,
buildFeedbackEnvironment,
Expand Down Expand Up @@ -7523,6 +7524,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
focusDetails();
return;
}
const blocked = cursorCloudRenameBlockedReason(session);
if (blocked) {
addNotice(blocked, "error");
return;
}
openForm({
kind: "form",
title: "Rename chat",
Expand All @@ -7532,7 +7538,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
{ name: "title", label: "Title", required: true, initialValue: session.title ?? "" },
],
});
}, [activeSession, focusDetails, openForm, sessions]);
}, [activeSession, addNotice, focusDetails, openForm, sessions]);

const openFeedbackForm = useCallback(() => {
// Seed the multiline feedback form's serializable state (feedbackForm.ts)
Expand Down Expand Up @@ -11007,6 +11013,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
setRightPane({ kind: "details", title: "Rename chat", body: "No active chat is selected." });
return;
}
const renameTarget = sessions.find((entry) => entry.sessionId === sessionId) ?? activeSession;
const blocked = cursorCloudRenameBlockedReason(renameTarget);
if (blocked) {
addNotice(blocked, "error");
return;
}
if (!args) {
openChatRenameForm(sessionId);
return;
Expand Down Expand Up @@ -12560,6 +12572,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
if (!targetSessionId) return;
const title = requireField("title", "Title");
if (!title) return;
const renameTarget = sessions.find((entry) => entry.sessionId === targetSessionId) ?? activeSession;
const blocked = cursorCloudRenameBlockedReason(renameTarget);
if (blocked) {
addNotice(blocked, "error");
return;
}
await renameChat(conn, targetSessionId, title);
setRightOpen(false);
setRightPane({ kind: "empty" });
Expand Down
16 changes: 16 additions & 0 deletions apps/ade-cli/src/tuiClient/cursorCloudChatRename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE } from "../../../desktop/src/shared/cursorCloudNaming";
import { cursorCloudRenameBlockedReason } from "./cursorCloudChatRename";

describe("cursorCloudRenameBlockedReason", () => {
it("returns the shared blocked sentence for a Cursor Cloud chat", () => {
expect(cursorCloudRenameBlockedReason({ cursorCloudAgentId: "cloud-agent-1" }))
.toBe(CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE);
});

it("lets a local chat rename proceed", () => {
expect(cursorCloudRenameBlockedReason({ cursorCloudAgentId: null })).toBeNull();
expect(cursorCloudRenameBlockedReason({ cursorCloudAgentId: " " })).toBeNull();
expect(cursorCloudRenameBlockedReason(null)).toBeNull();
});
});
17 changes: 17 additions & 0 deletions apps/ade-cli/src/tuiClient/cursorCloudChatRename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE,
cursorOwnsSessionName,
} from "../../../desktop/src/shared/cursorCloudNaming";

/**
* ADE Code's copy of the Cursor-owns-name rule. Returns the blocked sentence
* when Rename must not open, otherwise null so the form / hotkey / slash
* command can proceed.
*/
export function cursorCloudRenameBlockedReason(
session: { cursorCloudAgentId?: string | null } | null | undefined,
): string | null {
return cursorOwnsSessionName(session?.cursorCloudAgentId)
? CURSOR_CLOUD_RENAME_BLOCKED_MESSAGE
: null;
}
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"ade:typecheck": "npm --prefix ../ade-cli run typecheck",
"ade:test": "npm --prefix ../ade-cli run test",
"regen-cli-help": "node ./scripts/regen-ade-cli-help.cjs",
"lint": "node ./node_modules/eslint/bin/eslint.js \"src/**/*.{ts,tsx}\"",
"lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js \"src/**/*.{ts,tsx}\"",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"version:ci": "node ./scripts/set-ci-version.mjs",
"version:release": "node ./scripts/set-release-version.mjs"
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,59 @@
expect(runtime.sessionDeltaService?.getSessionDelta).toHaveBeenCalledWith("session-1");
});

it("refuses session.updateMeta title writes when Cursor owns the chat name", async () => {
const updateMeta = vi.fn();
const getSessionSummary = vi.fn().mockResolvedValue({
sessionId: "cloud-session-1",
cursorCloudAgentId: "cloud-agent-1",
});
const runtime = {
sessionService: {
get: vi.fn(),
list: vi.fn(),
updateMeta,
},
agentChatService: { getSessionSummary },
} as unknown as Parameters<typeof getAdeActionDomainServices>[0];
const sessionService = getAdeActionDomainServices(runtime).session as {
updateMeta: (args: unknown) => Promise<unknown>;
} & Record<string, unknown>;

await expect(sessionService.updateMeta({
sessionId: "cloud-session-1",
title: "ADE-owned title",
})).rejects.toThrow("agent names are managed by Cursor");
expect(updateMeta).not.toHaveBeenCalled();
});

it("still pins a Cursor Cloud chat through session.updateMeta", async () => {
const updateMeta = vi.fn().mockReturnValue({ id: "cloud-session-1", pinned: true });
const getSessionSummary = vi.fn().mockResolvedValue({
sessionId: "cloud-session-1",
cursorCloudAgentId: "cloud-agent-1",
});
const runtime = {
sessionService: {
get: vi.fn(),
list: vi.fn(),
updateMeta,
},
agentChatService: { getSessionSummary },
} as unknown as Parameters<typeof getAdeActionDomainServices>[0];
const sessionService = getAdeActionDomainServices(runtime).session as {
updateMeta: (args: unknown) => Promise<unknown>;
} & Record<string, unknown>;

await expect(sessionService.updateMeta({
sessionId: "cloud-session-1",
pinned: true,
})).resolves.toEqual({ id: "cloud-session-1", pinned: true });
expect(updateMeta).toHaveBeenCalledWith(expect.objectContaining({
sessionId: "cloud-session-1",
pinned: true,
}));
});

// The sync remote-command path honours `dismissPendingInput` for a single
// session. This bulk action never has, and used to drop the key silently — so
// the same argument meant "dismiss the prompt" over sync and nothing at all
Expand Down Expand Up @@ -1999,7 +2052,7 @@

it("does not pretend a native CLI prompt was dismissed while its process is still blocked", async () => {
const settleSession = vi.fn(() => true);
const settleSessionReportingAbort = vi.fn(() => ({ found: true, settled: true }));

Check warning on line 2055 in apps/desktop/src/main/services/adeActions/registry.test.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'settleSessionReportingAbort' is assigned a value but never used. Allowed unused vars must match /^_/u
const setSessionRuntimeState = vi.fn(() => true);
const runtime = {
sessionService: {
Expand Down
22 changes: 20 additions & 2 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import fs from "node:fs";
import path from "node:path";

Check warning on line 2 in apps/desktop/src/main/services/adeActions/registry.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'path' is defined but never used. Allowed unused vars must match /^_/u
import { randomUUID } from "node:crypto";
import type { AdeRuntime } from "../../../../../ade-cli/src/bootstrap";
import {
Expand Down Expand Up @@ -92,6 +92,7 @@
ListLanesArgs,
SessionSettleOverride,
SessionWakeReason,
UpdateSessionMetaArgs,
PrAgentPermissionMode,
PrAiResolutionContext,
PrAiResolutionEventPayload,
Expand Down Expand Up @@ -141,6 +142,7 @@
import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot } from "../shared/utils";
import { parseLinearGraphQLInput } from "../cto/linearGraphQLInput";
import { launchAgentChatCli } from "../chat/agentChatCliLaunch";
import { assertCursorCloudRenameAllowed } from "../../../shared/cursorCloudNaming";
import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession";
import {
parseSettleOverrideArg,
Expand Down Expand Up @@ -2118,6 +2120,20 @@
if (!sessionService) return null;
return {
...(sessionService as unknown as OpaqueService),
async updateMeta(args?: unknown) {
// Preload prefers this runtime action over IPC, so the IPC rename guard
// never runs in a connected desktop. Override the spread `updateMeta`.
const record = (args && typeof args === "object" && !Array.isArray(args)
? args
: {}) as UpdateSessionMetaArgs;
await assertCursorCloudRenameAllowed(
runtime.agentChatService
? (sessionId) => runtime.agentChatService!.getSessionSummary(sessionId)
: null,
record,
);
return sessionService.updateMeta(record);
},
async list(args?: ListSessionsArgs | null) {
return listSessionsWithChatProjection(runtime, args ?? {});
},
Expand Down Expand Up @@ -2964,16 +2980,18 @@
openCursorCloudChat: (args?: {
cloudAgentId?: string;
laneId?: string;
agentName?: string;
sessionId?: string;
modelId?: string;
reasoningEffort?: string | null;
fastMode?: boolean | null;
}) =>
requireService(runtime.agentChatService, "Agent chat service not available.").openCursorCloudChat({
cloudAgentId: requireNonEmptyString(args?.cloudAgentId, "cloudAgentId"),
laneId: requireNonEmptyString(args?.laneId, "laneId"),
...(args?.agentName ? { agentName: args.agentName } : {}),
...(args?.sessionId ? { sessionId: args.sessionId } : {}),
...(args?.modelId ? { modelId: args.modelId } : {}),
...(args?.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}),
...(args?.fastMode !== undefined ? { fastMode: args.fastMode } : {}),
}),
watchCursorCloudMirror: (args?: { sessionId?: string; watching?: boolean }) => {
if (typeof args?.watching !== "boolean") {
Expand Down
Loading
Loading