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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
{"type":"emit_inbound","label":"thinking-delta","frame":{"type":"interaction.update","runId":"run-046488f0-f621-4d59-976c-c4c935ad1282","update":{"type":"thinking-delta","text":"The user requested the"}}}
{"type":"expect_outbound","label":"run.cancel:1","frame":{"type":"run.cancel","runId":"run-046488f0-f621-4d59-976c-c4c935ad1282"}}
{"type":"emit_inbound","label":"run.completed:1","frame":{"type":"run.completed","result":{"id":"run-046488f0-f621-4d59-976c-c4c935ad1282","requestId":"d3b97820-1cf3-448d-a4b1-5fa7edc21460","status":"cancelled","model":{"id":"composer-2.5"},"durationMs":3177}}}
{"type":"expect_outbound","label":"run.start:2","frame":{"type":"run.start","message":"Actually, respond with exactly: steering fixture observed","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}}
{"type":"expect_outbound","label":"run.start:2","frame":{"type":"run.start","message":"Your previous turn was interrupted and is missing from your history. Some of its work may already be done. Its request was:\n\nRespond with exactly: steering fixture initial response\n\nThe new message, which may continue or change that request:\n\nActually, respond with exactly: steering fixture observed","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}}
{"type":"emit_inbound","label":"run.started:2","frame":{"type":"run.started","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","agentId":"agent-575fd75e-3fd7-412e-9b74-36f3a5aa5371"}}
{"type":"emit_inbound","label":"thinking-delta","frame":{"type":"interaction.update","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","update":{"type":"thinking-delta","text":"The response will be"}}}
{"type":"emit_inbound","label":"token-delta","frame":{"type":"interaction.update","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","update":{"type":"token-delta","tokens":5}}}
Expand Down
146 changes: 146 additions & 0 deletions packages/provider-cursor/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -812,6 +813,151 @@ describe("CursorAdapterV2", () => {
),
);

it.effect("carries an interrupted prompt into the steer that restarts the turn", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-steer-" });
const sentMessages: Array<string> = [];
const runEnds: Array<Deferred.Deferred<"finished" | "cancelled">> = [];
const instanceId = ProviderInstanceId.make("cursor");
const threadId = ThreadId.make("cursor-steer-thread");
const modelSelection = { instanceId, model: "composer-2.5" };
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: workspace,
});
const adapter = yield* makeCursorAdapterV2({
instanceId,
environment: { HOME: workspace },
}).pipe(
Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
assertComplete: Effect.void,
open: () =>
Effect.succeed({
agentId: "native-cursor-steer",
listMessages: Effect.succeed([]),
close: Effect.void,
send: (input) =>
Effect.gen(function* () {
sentMessages.push(
typeof input.message === "string" ? input.message : input.message.text,
);
const runId = `native-cursor-run-${sentMessages.length}`;
const ended = yield* Deferred.make<"finished" | "cancelled">();
runEnds.push(ended);
return {
agentId: "native-cursor-steer",
runId,
// Cursor drops a cancelled run's prompt from the agent's conversation.
wait: Deferred.await(ended).pipe(
Effect.map((status) => ({
id: runId,
requestId: `native-request-${sentMessages.length}`,
status,
model: { id: "composer-2.5" },
durationMs: 1,
})),
),
cancel: Deferred.succeed(ended, "cancelled"),
};
}),
}),
}),
Effect.provide(TestProviderHost.layer({ cwd: workspace })),
);
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("cursor-steer-session"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
const now = yield* DateTime.now;
const appThread = {
id: threadId,
projectId: ProjectId.make("cursor-steer-project"),
createdBy: "user" as const,
creationSource: "web" as const,
title: "Cursor steer",
providerInstanceId: instanceId,
modelSelection,
runtimeMode: "full-access" as const,
interactionMode: "default" as const,
branch: null,
worktreePath: null,
activeProviderThreadId: providerThread.id,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
forkedFrom: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
};
const turnInput = (runOrdinal: number, text: string) => ({
threadId,
providerThread,
modelSelection,
runtimePolicy,
runId: RunId.make(`cursor-steer-run-${runOrdinal}`),
runOrdinal,
providerTurnOrdinal: runOrdinal,
attemptId: RunAttemptId.make(`cursor-steer-attempt-${runOrdinal}`),
rootNodeId: NodeId.make(`cursor-steer-root-${runOrdinal}`),
appThread,
message: {
messageId: MessageId.make(`cursor-steer-message-${runOrdinal}`),
createdBy: "user" as const,
creationSource: "web" as const,
text,
attachments: [],
},
});
const firstPrompt = "Write me a 1000 word story about a dog.";
const steerText = "No wait, a pig please.";

yield* runtime.startTurn(turnInput(1, firstPrompt));
const started = Option.getOrThrow(
yield* runtime.events.pipe(
Stream.filter((event) => event.type === "provider_turn.updated"),
Stream.runHead,
),
);
assert(started.type === "provider_turn.updated");
yield* runtime.interruptTurn({ providerThread, providerTurnId: started.providerTurn.id });
yield* runtime.startTurn(turnInput(2, steerText));

assert.lengthOf(sentMessages, 2);
const steered = sentMessages[1]!;
assert.include(steered, steerText);
assert.include(
steered,
firstPrompt,
"the interrupted prompt is missing from the run that replaces it",
);

yield* Deferred.succeed(runEnds[1]!, "finished");
yield* runtime.events.pipe(
Stream.filter((event) => event.type === "turn.terminal" && event.status === "completed"),
Stream.runHead,
);
yield* runtime.startTurn(turnInput(3, "Now a cow."));
assert.notInclude(sentMessages[2]!, firstPrompt, "a finished turn carries nothing forward");
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer),
),
),
);

it.effect("projects Cursor directory trees and lint diagnostics as file search results", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
42 changes: 32 additions & 10 deletions packages/provider-cursor/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,8 @@ interface ActiveCursorTurn {
readonly assistant: ActiveCursorTextStream;
assistantReply: CursorTransportFailure;
readonly reasoning: ActiveCursorTextStream;
/** The user text this run sent, before runtime instructions; absent for /compress. */
readonly prompt: string | undefined;
interrupted: boolean;
finalized: boolean;
/** The agent's latest turn ended and no new work has started since. */
Expand Down Expand Up @@ -1004,6 +1006,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
readonly orchestration: boolean;
readonly device: boolean;
} | null>(null);
// Cursor drops a cancelled run from the conversation, so the next message carries its prompt.
const interruptedPrompt = yield* Ref.make<{
readonly agent: string;
readonly text: string;
} | null>(null);
const warnedUnsupportedSandbox = yield* Ref.make(false);
const activeTurn = yield* Ref.make<ActiveCursorTurn | null>(null);
const planIds = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact["id"]>());
Expand Down Expand Up @@ -2192,6 +2199,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
threadDisposition,
},
);
if (input.status === "interrupted" && input.context.prompt !== undefined) {
yield* Ref.set(interruptedPrompt, {
agent: input.context.run.agentId,
text: input.context.prompt,
});
}
yield* Ref.update(activeTurn, (current) =>
current?.providerTurnId === input.context.providerTurnId ? null : current,
);
Expand Down Expand Up @@ -2284,10 +2297,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
mcpServers: Record<string, McpServerConfig> | undefined,
hasDeviceTools: boolean,
delivered: { readonly orchestration: boolean; readonly device: boolean },
interrupted: string | undefined,
) {
const rawText = turnInput.message.text;
if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) {
return "/compress";
return { message: "/compress", prompt: undefined };
}
if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) {
const skills = yield* discoverCursorSkills(
Expand All @@ -2303,7 +2317,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
.map((skill) => skill.name),
);
}
const userText = t3OrchestrationPrompt({
const newText = t3OrchestrationPrompt({
prompt: yield* providerMessageTextWithAttachmentPaths({
text:
cursorSkillNames === undefined
Expand All @@ -2318,6 +2332,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
orchestrationDelivered: delivered.orchestration,
deviceInstructionsDelivered: delivered.device,
});
const userText =
interrupted === undefined
? newText
: `Your previous turn was interrupted and is missing from your history. Some of its work may already be done. Its request was:\n\n${interrupted}\n\nThe new message, which may continue or change that request:\n\n${newText}`;
const images = yield* Effect.forEach(
turnInput.message.attachments.filter(isProviderNativeImageAttachment),
(attachment: ChatAttachment) =>
Expand Down Expand Up @@ -2354,12 +2372,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
});
}
const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`;
return images.length === 0
? text
: ({
text,
images,
} satisfies SDKUserMessage);
return {
message: images.length === 0 ? text : ({ text, images } satisfies SDKUserMessage),
prompt: userText,
};
});

const startTurn = Effect.fn("CursorAdapterV2.startTurn")(
Expand Down Expand Up @@ -2390,13 +2406,15 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
known?.agent === agent.nativeThreadId
? known
: { orchestration: turnInput.runOrdinal > 1, device: false };
const message = yield* resolveUserMessage(
const interrupted = yield* Ref.get(interruptedPrompt);
const { message, prompt } = yield* resolveUserMessage(
turnInput,
mcpServers,
hasDeviceTools,
delivered,
interrupted?.agent === agent.nativeThreadId ? interrupted.text : undefined,
);
const compacted = message === "/compress";
const compacted = prompt === undefined;
const pendingUpdates: Array<InteractionUpdate> = [];
let context: ActiveCursorTurn | null = null;
// Create-time and send-time mcpServers merge with settingSources (SDK >= 1.0.39).
Expand Down Expand Up @@ -2424,6 +2442,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
orchestration: !compacted && (delivered.orchestration || mcpServers !== undefined),
device: !compacted && hasDeviceTools,
});
if (!compacted) {
yield* Ref.set(interruptedPrompt, null);
}
const startedAt = yield* DateTime.now;
const completed = yield* Deferred.make<void, never>();
const providerTurnId = idAllocator.derive.providerTurn({
Expand All @@ -2448,6 +2469,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
current: null,
nextSegment: 0,
},
prompt,
interrupted: false,
finalized: false,
turnEnded: false,
Expand Down
Loading