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
43 changes: 31 additions & 12 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,7 @@ import {
writeSessionLinearIssueContextFile,
createAgentChatService,
CURSOR_SDK_FIRST_EVENT_WATCHDOG_MS,
CURSOR_SDK_RECYCLE_CANCEL_TIMEOUT_MS,
} from "./agentChatService";
import { createChatRuntimeBudget } from "./chatRuntimeBudget";
import { readThreadPointerLedger } from "./threadPointerLedger";
Expand Down Expand Up @@ -2434,6 +2435,22 @@ describe("buildLinearSessionDirective", () => {
/** Just past the real watchdog budget, derived rather than mirrored. */
const CURSOR_SILENCE_WATCHDOG_TRIP_MS = CURSOR_SDK_FIRST_EVENT_WATCHDOG_MS + 1;

/**
* Recycle races `cancel()` against a 3s timeout. Advancing only the 90s
* watchdog schedules that timer; it does not flush it. Nested timers created
* during the watchdog callback are not included in the same advance.
*/
const flushCursorSdkSilenceRecycle = async (): Promise<void> => {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(CURSOR_SDK_RECYCLE_CANCEL_TIMEOUT_MS);
await Promise.resolve();
};

const tripCursorSdkSilenceWatchAndRecycle = async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await flushCursorSdkSilenceRecycle();
};

/**
* Real async setup work still needs event-loop turns while the clock is faked,
* so pump the fake clock instead of assuming a fixed number of ticks.
Expand All @@ -2450,6 +2467,8 @@ const pumpUntil = async (label: string, ready: () => boolean): Promise<void> =>
await vi.advanceTimersByTimeAsync(tripWatchdog
? CURSOR_SILENCE_WATCHDOG_TRIP_MS
: 1);
if (tripWatchdog) await flushCursorSdkSilenceRecycle();
await Promise.resolve();
}
if (!ready()) throw new Error(`pumpUntil timed out waiting for: ${label}`);
};
Expand Down Expand Up @@ -17025,7 +17044,7 @@ describe("createAgentChatService", () => {
event.event.type === "user_message" && event.event.deliveryState === "queued"));

mockState.cursorSendPromptGate = null;
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
// Re-send, then the carried steer delivered as its own turn.
await pumpUntil("steer delivered after recovery", () => mockState.cursorSdkSendCalls.length >= 3);

Expand Down Expand Up @@ -17440,9 +17459,9 @@ describe("createAgentChatService", () => {
await pumpUntil("queued steer", () => events.some((event) =>
event.event.type === "user_message" && event.event.deliveryState === "queued"));

await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("recovery re-send", () => mockState.cursorSdkSendCalls.length >= 2);
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("terminal failure", () => events.some((event) => event.event.type === "error"));

// Settled, and settled once: the attempt body and the wrapper cover
Expand Down Expand Up @@ -17537,7 +17556,7 @@ describe("createAgentChatService", () => {
await pumpUntil("carried steer queued", () => events.filter((event) =>
event.event.type === "user_message" && event.event.deliveryState === "queued").length >= 1);

await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("recovery re-send", () => mockState.cursorSdkSendCalls.length >= 2);

// Queued during attempt 2 — lands on the rebuilt runtime, which the
Expand All @@ -17546,7 +17565,7 @@ describe("createAgentChatService", () => {
await pumpUntil("second steer queued", () => events.filter((event) =>
event.event.type === "user_message" && event.event.deliveryState === "queued").length >= 2);

await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("terminal failure", () => events.some((event) => event.event.type === "error"));

const cancelNotices = events.filter((event) =>
Expand Down Expand Up @@ -17603,11 +17622,11 @@ describe("createAgentChatService", () => {

// Attempt 2 succeeds and delivers the carried steer.
mockState.cursorSendPromptGate = null;
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("steer delivered", () => mockState.cursorSdkSendCalls.length >= 3);
// The delivered steer's turn is silent in turn; its recovery re-send
// (send 4) succeeds and leaves a different runtime on the session.
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("nested recovery", () => mockState.cursorSdkSendCalls.length >= 4);
// Let the nested chain unwind fully, so the outer wrapper's finally
// runs while the session is on a different runtime than it re-queued on.
Expand Down Expand Up @@ -17698,9 +17717,9 @@ describe("createAgentChatService", () => {
text: "Both attempts go silent.",
}, { awaitDispatch: true }).catch(() => undefined);
await pumpUntil("first cursor send", () => mockState.cursorSdkSendCalls.length >= 1);
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("second cursor send", () => mockState.cursorSdkSendCalls.length >= 2);
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("terminal failure", () => mockState.cursorSdkPoisonCalls.length >= 2);
} finally {
vi.useRealTimers();
Expand Down Expand Up @@ -17948,7 +17967,7 @@ describe("createAgentChatService", () => {
// The retry must run against a healthy worker.
mockState.cursorSendPromptGate = null;

await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("recovery re-send", () => mockState.cursorSdkSendCalls.length >= 3);

expect(mockState.cursorSdkPoisonCalls).toHaveLength(1);
Expand Down Expand Up @@ -17995,9 +18014,9 @@ describe("createAgentChatService", () => {
}, { awaitDispatch: true }).catch(() => undefined);
await pumpUntil("first cursor send", () => mockState.cursorSdkSendCalls.length >= 1);

await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("second cursor send", () => mockState.cursorSdkSendCalls.length >= 2);
await vi.advanceTimersByTimeAsync(CURSOR_SILENCE_WATCHDOG_TRIP_MS);
await tripCursorSdkSilenceWatchAndRecycle();
await pumpUntil("terminal error event", () => events.some((event) => event.event.type === "error"));

const errorEvent = events.find((event) =>
Expand Down
111 changes: 80 additions & 31 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,7 @@ import {
type CursorSdkHookRequest,
type CursorSdkPermissionPolicy,
} from "./cursorSdkProtocol";
import { workerPathImagesFromAttachments, type WorkerIpcImage } from "./workerAttachmentImages";
import { resolveCursorCloudCreateCloudExtras } from "./cursorCloudCreateOptions";
import type {
DroidSdkAskUserRequest,
Expand Down Expand Up @@ -3391,7 +3392,7 @@ const CURSOR_SDK_AGENT_PROTOCOL_VERSION = 2;
*/
export const CURSOR_SDK_FIRST_EVENT_WATCHDOG_MS = 90_000;
/** Upper bound on the best-effort cancel issued while recycling a wedged thread. */
const CURSOR_SDK_RECYCLE_CANCEL_TIMEOUT_MS = 3_000;
export const CURSOR_SDK_RECYCLE_CANCEL_TIMEOUT_MS = 3_000;
const CURSOR_SDK_SILENT_RUN_MESSAGE =
"Cursor stopped responding. ADE opened a fresh Cursor thread — try sending again.";
const CLAUDE_WARMUP_WAIT_TIMEOUT_MS = 20_000;
Expand Down Expand Up @@ -23810,14 +23811,10 @@ export function createAgentChatService(args: {
const guidance = buildAdeGuidanceForLane(managed.laneWorktreePath, managed.session);
if (guidance.trim()) prompt = `${guidance}\n\n${prompt}`;
}
const promptBlocks = await buildAgentPromptBlocks(prompt, args.resolvedAttachments ?? []);
const promptText = promptBlocks
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n\n");
const images = promptBlocks
.filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image")
.map(({ data, mimeType }) => ({ data, mimeType }));
const { promptText, images } = await buildPiWorkerPrompt(
prompt,
args.resolvedAttachments ?? [],
);
args.onDispatched?.();
const accepted = runtime.sdk.sendPrompt({
prompt: promptText,
Expand Down Expand Up @@ -36722,6 +36719,69 @@ export function createAgentChatService(args: {
return blocks;
};

const promptTextWithoutInlineImages = async (
promptText: string,
resolvedAttachments: ResolvedAgentChatFileRef[],
): Promise<string> => {
const promptBlocks = await buildAgentPromptBlocks(
promptText,
resolvedAttachments.filter((attachment) => attachment.type !== "image" && attachment.type !== "image-url"),
);
return promptBlocks
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n\n");
};

const pathImagesFromResolved = (
resolvedAttachments: ResolvedAgentChatFileRef[],
): Array<{ path: string; mimeType: string; rootPath: string }> => (
workerPathImagesFromAttachments(
resolvedAttachments.flatMap((attachment) => {
if (attachment.type !== "image") return [];
const rootPath = attachment._rootPath.trim();
if (!rootPath) return [];
return [{
path: attachment.path,
resolvedPath: attachment._resolvedPath,
rootPath,
}];
}),
)
);

const buildCursorWorkerPrompt = async (
promptText: string,
resolvedAttachments: ResolvedAgentChatFileRef[],
): Promise<{ promptText: string; images: WorkerIpcImage[] }> => ({
promptText: await promptTextWithoutInlineImages(promptText, resolvedAttachments),
images: [
...pathImagesFromResolved(resolvedAttachments),
...resolvedAttachments.flatMap((attachment) => {
if (attachment.type !== "image-url") return [];
const url = attachment.url?.trim();
if (!url) return [];
return [{ url }];
}),
],
});

const buildPiWorkerPrompt = async (
promptText: string,
resolvedAttachments: ResolvedAgentChatFileRef[],
): Promise<{ promptText: string; images: Array<{ path: string; mimeType: string; rootPath: string }> }> => {
const text = await promptTextWithoutInlineImages(promptText, resolvedAttachments);
const urlHints = resolvedAttachments.flatMap((attachment) => {
if (attachment.type !== "image-url") return [];
const url = attachment.url?.trim();
return url ? [`Image URL: ${url}`] : [];
});
return {
promptText: [...(text ? [text] : []), ...urlHints].join("\n\n"),
images: pathImagesFromResolved(resolvedAttachments),
};
};

const mapChatDecisionToDroidPermission = (
decision: AgentChatApprovalDecision | undefined,
request: DroidSdkPermissionRequest,
Expand Down Expand Up @@ -37562,14 +37622,7 @@ export function createAgentChatService(args: {
}
}

const promptBlocks = await buildAgentPromptBlocks(composed, args.resolvedAttachments);
const promptText = promptBlocks
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n\n");
const images = promptBlocks
.filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image")
.map((block) => ({ data: block.data, mimeType: block.mimeType }));
const { promptText, images } = await buildCursorWorkerPrompt(composed, args.resolvedAttachments);

const modelParams = resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId);
persistChatState(managed);
Expand All @@ -37583,6 +37636,7 @@ export function createAgentChatService(args: {
approvalPolicy: approvalPolicyLabel(policy.approvalPolicy),
fullAuto: policy.fullAuto,
transport: "sdk",
imageCount: images.length,
});

if (args.onDispatched) {
Expand Down Expand Up @@ -37630,7 +37684,7 @@ export function createAgentChatService(args: {
const { watch: silenceWatch, guard: silenceGuard } = armCursorSdkSilenceWatch(runtime, turnId);
const sendPromise = runtime.sdk.sendPrompt({
promptText,
images,
...(images.length ? { images } : {}),
modelSdkId: runtime.modelSdkId,
...(modelParams?.length ? { modelParams } : {}),
// Set only when ADE deliberately abandoned the previous run (recovery
Expand Down Expand Up @@ -38325,11 +38379,7 @@ export function createAgentChatService(args: {
cloudComposed = `${injected}\n\n${cloudComposed}`;
}
}
const promptBlocks = await buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments);
const promptText = promptBlocks
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n\n");
const { promptText, images } = await buildCursorWorkerPrompt(cloudComposed, args.resolvedAttachments);

const cloudLogModelParams = runtime.modelSdkId
? resolveCursorSdkModelParamsForSession(managed.session, runtime.modelSdkId)
Expand All @@ -38342,6 +38392,7 @@ export function createAgentChatService(args: {
hasAgentId: Boolean(managed.session.cursorCloudAgentId),
...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}),
...(cloudLogModelParams?.length ? { modelParams: cursorModelParamsForLog(cloudLogModelParams) } : {}),
imageCount: images.length,
});

if (args.onDispatched) {
Expand All @@ -38366,6 +38417,7 @@ export function createAgentChatService(args: {
apiKey,
agentId: managed.session.cursorCloudAgentId,
promptText,
...(images.length ? { images } : {}),
idempotencyKey: cursorCloudIdempotencyKey(managed, turnId, "followup"),
mode: sdkMode,
...(runtime.modelSdkId ? { modelSdkId: runtime.modelSdkId } : {}),
Expand Down Expand Up @@ -38411,6 +38463,7 @@ export function createAgentChatService(args: {
const payload: CursorSdkCloudSendStreamPayload = {
apiKey,
promptText,
...(images.length ? { images } : {}),
repoUrl,
idempotencyKey: cursorCloudIdempotencyKey(managed, turnId, "create"),
mode: sdkMode,
Expand Down Expand Up @@ -40556,14 +40609,10 @@ export function createAgentChatService(args: {
allowActiveSession: true,
});
if (!preparedSteer) return { steerId, queued: false };
const promptBlocks = await buildAgentPromptBlocks(preparedSteer.submittedText, preparedSteer.resolvedAttachments);
const promptText = promptBlocks
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("\n\n");
const images = promptBlocks
.filter((block): block is { type: "image"; data: string; mimeType: string } => block.type === "image")
.map(({ data, mimeType }) => ({ data, mimeType }));
const { promptText, images } = await buildPiWorkerPrompt(
preparedSteer.submittedText,
preparedSteer.resolvedAttachments,
);
await runtime.sdk.steer(promptText, images);
preparedSteer.onDispatched?.();
options?.onAcceptedDispatch?.();
Expand Down
Loading
Loading