mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Bind Cursor rich input and activity adapters to an active parent turn
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
72d7bbc9b8
commit
e2000ca283
3 files changed
+136
-4
No files matched your search
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { PAPERCLIP_QUESTION_RESPONSE_SCHEMA } from "../../contracts/question-set.js";
|
||||
import {
|
||||
createCursorNotificationNormalizer, createCursorSubagentNormalizer, cursorWorkspaceArtifactReference,
|
||||
createCursorNotificationNormalizer, createCursorProfileExtensionAdapter, createCursorSubagentNormalizer, cursorWorkspaceArtifactReference,
|
||||
normalizeCursorPlanRequest, normalizeCursorQuestionRequest, normalizeCursorSubagentUpdate,
|
||||
} from "./cursor-extensions.js";
|
||||
|
||||
@@ -94,3 +94,53 @@ describe("Cursor rich notifications", () => {
|
||||
expect(normalize({ ...base, sessionUpdate: "subagent_state_update", state: "completed" })).toMatchObject({ payload: { children: [{ role: "Explore", model: "small", summary: "Find tests", status: "completed" }] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cursor active-turn extension adapter", () => {
|
||||
it("roundtrips canonical answers and plan decisions while rejecting implicit acceptance", async () => {
|
||||
const adapter = createCursorProfileExtensionAdapter({ workspacePath: await workspace(), sessionId: "parent", turnId: "turn" });
|
||||
const question = await adapter.request("cursor/ask_question", { sessionId: "parent", toolCallId: "ask", questions: [
|
||||
{ id: "native", prompt: "Choose", options: [{ id: "native-option", label: "One" }] },
|
||||
] });
|
||||
if (!("input" in question)) throw new Error("question was not blocking");
|
||||
expect(question.input.resolve({ action: "submit", response: response({ "question-1": { selectedOptionIds: ["option-1"] } }) })).toEqual({ outcome: { outcome: "answered", answers: [{ questionId: "native", selectedOptionIds: ["native-option"] }] } });
|
||||
expect(question.input.resolve({ action: "decline" })).toEqual({ outcome: { outcome: "skipped" } });
|
||||
expect(question.input.resolve({ action: "cancel" })).toEqual(question.input.cancel());
|
||||
expect(() => question.input.resolve({ action: "accept" })).toThrow();
|
||||
expect(() => question.input.resolve({ action: "submit", answers: { "question-1": { answers: ["option-1"] } } })).toThrow();
|
||||
|
||||
const plan = await adapter.request("cursor/create_plan", { sessionId: "parent", toolCallId: "plan", plan: "# Complete plan\n".repeat(1_000), todos: [] });
|
||||
if (!("input" in plan)) throw new Error("plan was not blocking");
|
||||
expect(plan.input.details.revision).toMatch(/^[a-f0-9]{64}$/);
|
||||
const id = plan.input.questionSet.questions[0]!.id;
|
||||
expect(plan.input.resolve({ action: "submit", response: response({ [id]: { selectedOptionIds: ["accept"] } }) })).toEqual({ outcome: { outcome: "accepted" } });
|
||||
expect(() => plan.input.resolve({ action: "accept" })).toThrow("displayed revision");
|
||||
expect(plan.input.resolve({ action: "decline" })).toEqual({ outcome: { outcome: "rejected" } });
|
||||
expect(plan.input.cancel()).toEqual({ outcome: { outcome: "cancelled" } });
|
||||
});
|
||||
|
||||
it("acknowledges native request-shaped activity and preserves its reducer order", async () => {
|
||||
const adapter = createCursorProfileExtensionAdapter({ workspacePath: await workspace(), sessionId: "parent", turnId: "turn" });
|
||||
const first = await adapter.request("cursor/update_todos", { sessionId: "parent", toolCallId: "a", merge: false, todos: [{ id: "a", content: "Read", status: "pending" }] });
|
||||
expect(first).toMatchObject({ response: {}, events: [{ eventType: "plan.updated", payload: { revision: 1 } }] });
|
||||
const second = await adapter.notification("cursor/update_todos", { sessionId: "parent", toolCallId: "b", merge: true, todos: [{ id: "b", content: "Write", status: "pending" }] });
|
||||
expect(second).toMatchObject([{ payload: { revision: 2, steps: [{ body: "Read" }, { body: "Write" }] } }]);
|
||||
await expect(adapter.request("cursor/update_todos", { sessionId: "other" })).rejects.toThrow("stale parent");
|
||||
await expect(adapter.notification("cursor/task", { sessionId: "other" })).rejects.toThrow("stale parent");
|
||||
await expect(adapter.request("cursor/unknown", { sessionId: "parent" })).rejects.toThrow("Unsupported");
|
||||
});
|
||||
|
||||
it("requires a known child spawn and immutable origin within the active parent", async () => {
|
||||
const context = { workspacePath: await workspace(), sessionId: "parent", turnId: "turn" };
|
||||
const adapter = createCursorProfileExtensionAdapter(context);
|
||||
const update = { subagentSessionId: "child", _meta: { cursor: { toolCallId: "task", agentId: "native-child", model: "small" } } };
|
||||
const state = { ...update, sessionUpdate: "subagent_state_update", state: "completed" };
|
||||
await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", update: state })).rejects.toThrow("no spawn");
|
||||
const spawned = { ...update, sessionUpdate: "subagent_spawned", name: "Research", task: "Inspect source" };
|
||||
expect(await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: spawned })).toMatchObject([{ eventType: "delegation.started", payload: { children: [{ role: "Research", model: "small" }] } }]);
|
||||
expect(await adapter.notification("cursor/subagent_update", { sessionId: "parent", update: state })).toMatchObject([{ eventType: "delegation.completed", payload: { children: [{ role: "Research", summary: "Inspect source" }] } }]);
|
||||
await expect(adapter.notification("cursor/subagent_update", { sessionId: "other", update: state })).rejects.toThrow("stale parent");
|
||||
await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", update: { ...state, _meta: { cursor: { ...update._meta.cursor, toolCallId: "forged" } } } })).rejects.toThrow("origin identity");
|
||||
await expect(adapter.notification("cursor/subagent_update", { sessionId: "parent", update: spawned })).rejects.toThrow("duplicated");
|
||||
await expect(createCursorProfileExtensionAdapter({ ...context, turnId: "next" }).notification("cursor/subagent_update", { sessionId: "parent", update: state })).rejects.toThrow("no spawn");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PaperclipQuestionSet,
|
||||
} from "../../contracts/question-set.js";
|
||||
import type { CanonicalProviderEvent } from "../../provider-events.js";
|
||||
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
|
||||
|
||||
export const CURSOR_EXTENSION_REQUEST_METHODS = [
|
||||
"cursor/ask_question", "cursor/create_plan",
|
||||
@@ -18,6 +19,85 @@ export const CURSOR_EXTENSION_REQUEST_METHODS = [
|
||||
export const CURSOR_EXTENSION_NOTIFICATION_METHODS = [
|
||||
"cursor/update_todos", "cursor/task", "cursor/generate_image",
|
||||
] as const;
|
||||
export const CURSOR_CLIENT_CAPABILITIES = { _meta: { subagents: true } } as const;
|
||||
|
||||
type CursorExtensionInput = {
|
||||
method: string;
|
||||
questionSet: PaperclipQuestionSet;
|
||||
details: Record<string, unknown>;
|
||||
resolve(resolution: HarnessRuntimeRequestResolution): Record<string, unknown>;
|
||||
cancel(): Record<string, unknown>;
|
||||
};
|
||||
type CursorExtensionResult = { input: CursorExtensionInput }
|
||||
| { events: CanonicalProviderEvent[]; response: Record<string, unknown> };
|
||||
|
||||
/** One adapter belongs to exactly one active session/turn, never a warm process. */
|
||||
export function createCursorProfileExtensionAdapter(context: {
|
||||
workspacePath: string; sessionId: string; turnId: string;
|
||||
}) {
|
||||
const normalizeNotification = createCursorNotificationNormalizer(context);
|
||||
const normalizeSubagent = createCursorSubagentNormalizer();
|
||||
const children = new Map<string, { toolCallId: string; agentId?: string }>();
|
||||
const assertSession = (params: Record<string, unknown>) => {
|
||||
if (params.sessionId !== context.sessionId) throw new Error("Cursor extension has a stale parent session");
|
||||
};
|
||||
return {
|
||||
async request(method: string, params: Record<string, unknown>): Promise<CursorExtensionResult> {
|
||||
assertSession(params);
|
||||
if (method === "cursor/ask_question") {
|
||||
const question = normalizeCursorQuestionRequest(params);
|
||||
return { input: {
|
||||
method, questionSet: question.questionSet, details: { toolCallId: question.toolCallId },
|
||||
cancel: question.cancel,
|
||||
resolve(resolution) {
|
||||
if (resolution.action === "cancel") return question.cancel();
|
||||
if (resolution.action === "decline") return question.skip();
|
||||
if (resolution.action !== "submit" || !("response" in resolution)) throw new Error("Cursor questions require a complete canonical answer");
|
||||
return question.resolve(resolution.response);
|
||||
},
|
||||
} };
|
||||
}
|
||||
if (method === "cursor/create_plan") {
|
||||
const plan = normalizeCursorPlanRequest(params);
|
||||
return { input: {
|
||||
method, questionSet: plan.questionSet, details: { toolCallId: plan.toolCallId, revision: plan.revision },
|
||||
cancel: plan.cancel,
|
||||
resolve(resolution) {
|
||||
if (resolution.action === "cancel") return plan.cancel();
|
||||
if (resolution.action === "decline") return { outcome: { outcome: "rejected" } };
|
||||
if (resolution.action !== "submit" || !("response" in resolution)) throw new Error("Cursor plan decisions require the displayed revision");
|
||||
return plan.resolve(resolution.response);
|
||||
},
|
||||
} };
|
||||
}
|
||||
if (!(CURSOR_EXTENSION_NOTIFICATION_METHODS as readonly string[]).includes(method)) throw new Error("Unsupported Cursor extension request");
|
||||
return { events: await normalizeNotification(method, params), response: {} };
|
||||
},
|
||||
async notification(method: string, params: Record<string, unknown>): Promise<CanonicalProviderEvent[]> {
|
||||
assertSession(params);
|
||||
if (method !== "cursor/subagent_update") return normalizeNotification(method, params);
|
||||
// The transport synthesizes this method only from the two pinned native
|
||||
// session/update variants, after checking the active parent connection.
|
||||
const update = object(params.update);
|
||||
const id = requiredText(update.subagentSessionId, "subagentSessionId", 1_000);
|
||||
if (id === context.sessionId) throw new Error("Cursor child cannot impersonate its parent session");
|
||||
const metadata = object(object(update._meta).cursor);
|
||||
const toolCallId = requiredText(metadata.toolCallId, "subagent toolCallId", 1_000);
|
||||
const agentId = optionalText(metadata.agentId, "subagent agentId", 1_000);
|
||||
const previous = children.get(id);
|
||||
if (update.sessionUpdate === "subagent_spawned") {
|
||||
if (previous || children.size >= 256) throw new Error("Cursor child session identity is duplicated or over capacity");
|
||||
} else if (update.sessionUpdate !== "subagent_state_update" || !previous) {
|
||||
throw new Error("Cursor child update has no spawn in this active turn");
|
||||
}
|
||||
if (previous && (previous.toolCallId !== toolCallId || previous.agentId !== agentId)) throw new Error("Cursor child update changed its origin identity");
|
||||
const event = normalizeSubagent(update);
|
||||
if (!event) throw new Error("Unsupported Cursor child update");
|
||||
children.set(id, { toolCallId, agentId });
|
||||
return [event];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type CursorQuestionResult = { outcome:
|
||||
| { outcome: "answered"; answers: Array<{ questionId: string; selectedOptionIds: string[] }> }
|
||||
|
||||
Reference in new issue
Block a user