diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md index 9f7312ac6d..ec582ddbce 100644 --- a/doc/architecture/runner-cursor-capabilities.md +++ b/doc/architecture/runner-cursor-capabilities.md @@ -20,10 +20,10 @@ The reference is the runner's Codex app-server integration and its closed thread |---|---|---|---| | Streaming assistant/reasoning | `agent_message_chunk`, `agent_thought_chunk` in live and replay presenters | Existing ACP streaming path | Authenticated event capture required | | Tool lifecycle/input/output | `tool_call` and `tool_call_update`, including raw input/output, locations and content | Existing canonical tool lifecycle; Cursor extensions add semantic activity | Standard content blocks/diffs must survive shared ACPX mapping; evaluate captured field coverage | -| Human questions | `cursor/ask_question`, native single/multiple selections | `normalizeCursorQuestionRequest`: all questions required; opaque UI IDs round-trip native IDs; forged/duplicate/missing selections rejected | Cancellation and skip outcomes must be routed by shared durable request lifecycle | +| Human questions | `cursor/ask_question`, native single/multiple selections | `normalizeCursorQuestionRequest`: all questions required; opaque UI IDs round-trip native IDs; forged/duplicate/missing selections rejected | Active-turn adapter maps canonical answers, decline to skipped, and cancellation; durable delivery still requires end-to-end evidence | | Proposed plans and decisions | `cursor/create_plan` with full Markdown, overview, todos, named phases and project flag | `normalizeCursorPlanRequest`: complete presentation; accept/reject/cancel; revision-derived question ID; rejection feedback | Foundation question descriptions allow 100,000 chars; this boundary also enforces the 196 KiB UTF-8 durable payload cap. Oversize rejects; it is never truncated. No generated `planUri`, since no uploaded plan artifact is fabricated | | Execution checklist | Standard ACP `plan` and `cursor/update_todos` (`merge`) | Per-turn reducer merges by native ID and emits complete snapshots with increasing revisions | Native cancelled status is visible as “Cancelled” with canonical blocked status, since canonical plan schema has no cancelled enum | -| Child activity | Optional `subagent_spawned`, `subagent_state_update`; metadata has tool-call/agent/model identity | Stateful child normalizer emits canonical delegation and retains role/task across state deltas; shared host must validate parent ownership | Requires client `_meta.subagents: true`. Nested transcript routing and child authority need end-to-end evidence | +| Child activity | Optional `subagent_spawned`, `subagent_state_update`; metadata has tool-call/agent/model identity | Stateful child normalizer emits canonical delegation and retains role/task across state deltas; active-turn adapter validates parent, known child spawn, and immutable tool/agent origin | Requires client `_meta.subagents: true`. ACPX SDK 1.4.0 lacks these two update variants; a closed pre-parser intercept is required. Nested transcript routing and child authority need end-to-end evidence | | Completed subagent tasks | `cursor/task`: description, prompt, subtype, model, ID, duration | Canonical delegation with role, model, task and duration in activity summary | Dedicated numeric duration field absent from canonical delegation schema | | Subagent questions | Pinned ACP subagent handler explicitly rejects interactive questions | No unsupported affordance advertised | Confirmed harness omission; requires upstream support | | Generated images | `cursor/generate_image`: description, path and reference-image paths | Canonical generated/viewed artifact events; description notice; realpath/regular-file containment checks; `registered:false` | Paths are references, not automatic uploads. Revalidate before artifact ingestion. No image bytes invented | @@ -61,7 +61,7 @@ Artifact paths are never automatically read or uploaded. References require a pr Passed in the provider worktree: -- 10 Cursor Vitest cases: questions, multi-selection, native ID round-trip, invalid answers, exact plan revisions, outcomes, todo delta merging, delegation metadata, path containment/symlink refusal, child identity retention, private launch defaults and project config gates. +- 13 Cursor Vitest cases: questions, multi-selection, native ID round-trip, invalid answers, exact plan revisions, outcomes, todo delta merging, delegation metadata, path containment/symlink refusal, child identity retention, private launch defaults and project config gates; active-turn canonical response mapping and immediate acknowledgements; stale/foreign/unknown child identity rejection. - 5 materializer Node tests: platform pins, entire-tree tampering, linked paths, archive tampering before extraction, and overwrite refusal. - Targeted strict TypeScript compilation for both provider modules and their imported contracts. - Credential-free wire initialization and auth/method probes, plus real macOS ARM64 materialization and archive/closure inventories for the other two platforms. @@ -71,3 +71,5 @@ The generic native-distribution lease now snapshots the pinned closure, launches Priority follow-ups before advertising support: (1) shared hook integration with durable response/recovery tests; (2) authenticated native lifetime and cleanup proof; (3) explicit credentials plus exact available model and measured dollar budget; (4) local and Daytona semantic tools, permissions, steering distinction, files/artifacts, questions/plans and settlement proof; (5) field-by-field captured trace audit to identify any additional unconsumed native fields. Remaining rich capabilities intentionally unused: session discovery, generic mode/config-option UI, provider session titles, command/skill picker metadata, nested child transcripts beyond delegation summaries, native numeric task duration, and optional planUri. They are listed above with reasons; neither “unsupported” nor “complete” should be inferred for unverified live behaviors. The final combined report must update this inventory with integration evidence and include any further events observed during authenticated qualification. + +Shared display-channel verification additionally covers exact rich-event schema validation, terminal/semantic/source injection rejection, active-turn fences, complete Unicode plan preservation through durable outbox persistence, secret redaction disclosure, and oversized UTF-8 rejection. The 4 KiB diagnostic preview cap remains active for diagnostics, never full approval documents. diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts index 439a5f0c83..2af0f6441d 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.test.ts @@ -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"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts index 0b6727a125..b508ac7122 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-extensions.ts @@ -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; + resolve(resolution: HarnessRuntimeRequestResolution): Record; + cancel(): Record; +}; +type CursorExtensionResult = { input: CursorExtensionInput } + | { events: CanonicalProviderEvent[]; response: Record }; + +/** 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(); + const assertSession = (params: Record) => { + if (params.sessionId !== context.sessionId) throw new Error("Cursor extension has a stale parent session"); + }; + return { + async request(method: string, params: Record): Promise { + 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): Promise { + 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[] }> }