diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index a58ed3a4e09b5a73db8d73d1925f823312fd67be..701417900ea2078ea31172946255c852283ba5ff 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -1,5 +1,6 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { + ApprovalRequestId, CommandId, EventId, MessageId, @@ -804,6 +805,64 @@ describe("ProviderRuntimeIngestion", () => { expect(completionEvents).toHaveLength(1); }); + it("maps canonical request events into approval activities with requestKind", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "request.opened", + eventId: asEventId("evt-request-opened"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: now, + requestId: ApprovalRequestId.makeUnsafe("req-open"), + payload: { + requestType: "command_execution_approval", + detail: "pwd", + }, + }); + + harness.emit({ + type: "request.resolved", + eventId: asEventId("evt-request-resolved"), + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: now, + requestId: ApprovalRequestId.makeUnsafe("req-open"), + payload: { + requestType: "command_execution_approval", + decision: "accept", + }, + }); + + await waitForThread( + harness.engine, + (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested") && + entry.activities.some((activity) => activity.kind === "approval.resolved"), + ); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread).toBeDefined(); + + const requested = thread?.activities.find((activity) => activity.id === "evt-request-opened"); + const requestedPayload = + requested?.payload && typeof requested.payload === "object" + ? (requested.payload as Record) + : undefined; + expect(requestedPayload?.requestKind).toBe("command"); + expect(requestedPayload?.requestType).toBe("command_execution_approval"); + + const resolved = thread?.activities.find((activity) => activity.id === "evt-request-resolved"); + const resolvedPayload = + resolved?.payload && typeof resolved.payload === "object" + ? (resolved.payload as Record) + : undefined; + expect(resolvedPayload?.requestKind).toBe("command"); + expect(resolvedPayload?.requestType).toBe("command_execution_approval"); + }); + it("maps runtime.error into errored session state", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 23208be111e7b1b90a309fc59a108905bdd822f5..fd47b3ba8de17bfb5888e4f9178635913b016c0e 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -133,6 +133,21 @@ function runtimeErrorMessageFromEvent(event: ProviderRuntimeEvent): string | und return payloadMessage ?? legacyMessage; } +function requestKindFromCanonicalRequestType( + requestType: string | undefined, +): "command" | "file-change" | undefined { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + return "command"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + default: + return undefined; + } +} + function isToolLifecycleItemType(itemType: string): boolean { return ( itemType === "command_execution" || @@ -151,6 +166,7 @@ function runtimeEventToActivities( const maybeSequence = event.sessionSequence !== undefined ? { sequence: event.sessionSequence } : {}; switch (event.type) { case "request.opened": { + const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); return [ { id: event.eventId, @@ -158,13 +174,14 @@ function runtimeEventToActivities( tone: "approval", kind: "approval.requested", summary: - event.payload.requestType === "command_execution_approval" + requestKind === "command" ? "Command approval requested" - : event.payload.requestType === "file_change_approval" + : requestKind === "file-change" ? "File-change approval requested" : "Approval requested", payload: { requestId: event.requestId, + ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), }, @@ -175,6 +192,7 @@ function runtimeEventToActivities( } case "request.resolved": { + const requestKind = requestKindFromCanonicalRequestType(event.payload.requestType); return [ { id: event.eventId, @@ -184,6 +202,7 @@ function runtimeEventToActivities( summary: "Approval resolved", payload: { requestId: event.requestId, + ...(requestKind ? { requestKind } : {}), requestType: event.payload.requestType, ...(event.payload.decision ? { decision: event.payload.decision } : {}), }, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 671fdc23cc83dd99fff4dedc7b4b05c8ee3c5575..00aa44350b8105c9c247f17f16bb1ce95f3d087d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -406,4 +406,39 @@ describe("CursorAdapterLive", () => { assert.equal(fake.lastPermissionSelection, "allow-always"); }).pipe(Effect.provide(layer)); }); + + it.effect("auto-approves cursor permission requests when approval policy is never", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + yield* adapter.startSession({ + provider: "cursor", + approvalPolicy: "never", + }); + + // consume startup events + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + fake.emitPermissionRequest(); + + const resolved = yield* Stream.runHead(adapter.streamEvents); + assert.equal(resolved._tag, "Some"); + if (resolved._tag !== "Some") { + return; + } + + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { + return; + } + + assert.equal(resolved.value.payload.decision, "acceptForSession"); + assert.equal(fake.lastPermissionSelection, "allow-always"); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 2013ef1de27d2957f5e70891c2516f9bdddfa7b6..2b76b6c965e4c173f9a078ba677900971aa61d13 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -15,6 +15,7 @@ import { EventId, type CanonicalItemType, type CanonicalRequestType, + type ProviderApprovalPolicy, ProviderSessionId, type ProviderRuntimeEvent, type ProviderSession, @@ -73,6 +74,7 @@ interface CursorTurnState { interface CursorSessionContext { session: ProviderSession; + approvalPolicy: ProviderApprovalPolicy; readonly child: ChildProcessWithoutNullStreams; readonly output: readline.Interface; readonly pending: Map; @@ -185,6 +187,43 @@ function normalizeRequestType(toolCall: unknown): CanonicalRequestType { return "unknown"; } +function selectCursorPermissionOption( + options: ReadonlyArray<{ optionId: string }>, + decision: "acceptForSession" | "accept" | "decline" | "cancel", +): string | undefined { + const allowAlways = options.find((option) => option.optionId === "allow-always"); + const allowOnce = options.find((option) => option.optionId === "allow-once"); + const rejectOnce = options.find((option) => option.optionId === "reject-once"); + + if (decision === "acceptForSession") { + return allowAlways?.optionId ?? allowOnce?.optionId; + } + if (decision === "accept") { + return allowOnce?.optionId ?? allowAlways?.optionId; + } + return rejectOnce?.optionId ?? options[0]?.optionId; +} + +function selectCursorAutoApprovalOption( + options: ReadonlyArray<{ optionId: string }>, +): { optionId: string; decision: "acceptForSession" | "accept" } | undefined { + const allowAlways = options.find((option) => option.optionId === "allow-always"); + if (allowAlways) { + return { + optionId: allowAlways.optionId, + decision: "acceptForSession", + }; + } + const allowOnce = options.find((option) => option.optionId === "allow-once"); + if (allowOnce) { + return { + optionId: allowOnce.optionId, + decision: "accept", + }; + } + return undefined; +} + function titleForItemType(itemType: CanonicalItemType): string { switch (itemType) { case "command_execution": @@ -459,14 +498,79 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return Effect.gen(function* () { const requestId = ApprovalRequestId.makeUnsafe(randomUUID()); const requestType = normalizeRequestType(decoded.params.toolCall); + const options = decoded.params.options.map((entry) => ({ optionId: entry.optionId })); + const detail = asString(asObject(decoded.params.toolCall)?.title); + + if (context.approvalPolicy === "never") { + const selection = + selectCursorAutoApprovalOption(options) ?? + (options[0] + ? { + optionId: options[0].optionId, + decision: "accept", + } + : undefined); + if (!selection) { + return yield* emitRuntimeWarning( + context, + "Cursor ACP permission request contained no selectable options.", + decoded.params, + ); + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: decoded.id, + result: { + outcome: { + outcome: "selected", + optionId: selection.optionId, + }, + }, + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: stamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: stamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + requestId, + payload: { + requestType, + decision: selection.decision, + resolution: { + optionId: selection.optionId, + autoApproved: true, + }, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerRequestId: String(decoded.id), + }, + raw: { + source: "cursor.acp.response", + method: "session/request_permission", + payload: { + optionId: selection.optionId, + autoApproved: true, + }, + }, + }); + return; + } context.pendingPermissions.set(requestId, { jsonRpcId: decoded.id, requestType, - options: decoded.params.options.map((entry) => ({ optionId: entry.optionId })), + options, }); - const detail = asString(asObject(decoded.params.toolCall)?.title); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "request.opened", @@ -891,6 +995,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const context: CursorSessionContext = { session, + approvalPolicy: input.approvalPolicy ?? "on-request", child, output, pending: new Map(), @@ -1323,16 +1428,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } - const allowAlways = pending.options.find((option) => option.optionId === "allow-always"); - const allowOnce = pending.options.find((option) => option.optionId === "allow-once"); - const rejectOnce = pending.options.find((option) => option.optionId === "reject-once"); - - const optionId = - decision === "acceptForSession" - ? allowAlways?.optionId ?? allowOnce?.optionId - : decision === "accept" - ? allowOnce?.optionId ?? allowAlways?.optionId - : rejectOnce?.optionId ?? pending.options[0]?.optionId; + const optionId = selectCursorPermissionOption(pending.options, decision); if (!optionId) { return yield* new ProviderAdapterRequestError({ @@ -1376,7 +1472,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { providerSessionId: context.session.sessionId, ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), - providerRequestId: requestId, + providerRequestId: String(pending.jsonRpcId), }, raw: { source: "cursor.acp.response", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 62f7c363bc4f3a883666f1470f3fdb966bbf7ad4..bcd289375fd6036feed1d6131e8db4bf8d5f1aa0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -520,6 +520,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImages = composerDraft.images; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); + const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); @@ -549,12 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [selectedProviderByThread, setSelectedProviderByThread] = useState< - Partial> - >({}); - const [selectedModelByThread, setSelectedModelByThread] = useState< - Partial> - >({}); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); @@ -679,9 +674,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ]); const sessionProvider = activeThread?.session?.provider ?? null; - const selectedProviderByThreadId = activeThread?.id - ? selectedProviderByThread[activeThread.id] - : undefined; + const selectedProviderByThreadId = composerDraft.provider; const hasThreadStarted = Boolean( activeThread && (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null), @@ -696,11 +689,7 @@ export default function ChatView({ threadId }: ChatViewProps) { : null; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, - (activeThread?.id ? selectedModelByThread[activeThread.id] : undefined) ?? - activeThread?.model ?? - activeProject?.model ?? - getDefaultModel(selectedProvider) ?? - DEFAULT_MODEL, + activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, ); const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel); const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); @@ -741,29 +730,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ), [lockedProvider], ); - useEffect(() => { - if (!activeThread?.id || !sessionProvider) return; - setSelectedProviderByThread((existing) => { - if (existing[activeThread.id] === sessionProvider) return existing; - return { ...existing, [activeThread.id]: sessionProvider }; - }); - }, [activeThread?.id, sessionProvider]); - useEffect(() => { - if (!activeThread?.id) return; - setSelectedModelByThread((existing) => { - if (existing[activeThread.id]) return existing; - return { - ...existing, - [activeThread.id]: resolveModelSlugForProvider( - selectedProvider, - activeThread.model ?? - activeProject?.model ?? - getDefaultModel(selectedProvider) ?? - DEFAULT_MODEL, - ), - }; - }); - }, [activeProject?.model, activeThread?.id, activeThread?.model, selectedProvider]); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; @@ -2348,14 +2314,8 @@ export default function ChatView({ threadId }: ChatViewProps) { provider === "cursor" ? resolveCursorModelFromSelection(parseCursorModelSelection(model)) : resolveModelSlugForProvider(provider, model); - setSelectedProviderByThread((existing) => ({ - ...existing, - [activeThread.id]: provider, - })); - setSelectedModelByThread((existing) => ({ - ...existing, - [activeThread.id]: resolvedModel, - })); + setComposerDraftProvider(activeThread.id, provider); + setComposerDraftModel(activeThread.id, resolvedModel); if (api && isServerThread) { void api.orchestration.dispatchCommand({ type: "thread.meta.update", @@ -2372,6 +2332,8 @@ export default function ChatView({ threadId }: ChatViewProps) { isServerThread, lockedProvider, scheduleComposerFocus, + setComposerDraftModel, + setComposerDraftProvider, ], ); const onCursorReasoningSelect = useCallback( @@ -2888,13 +2850,37 @@ export default function ChatView({ threadId }: ChatViewProps) { )} {selectedCursorModel && selectedCursorModelCapabilities && hasSelectedCursorTraits && ( - + <> + {cursorModelSelectionLockedReason ? ( + + + + + } + /> + + {cursorModelSelectionLockedReason} + + + ) : ( + + )} + )} ) : supportsReasoningEffort ? ( @@ -4238,10 +4224,12 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { selection: ReturnType; capabilities: ReturnType; + disabled?: boolean; onReasoningChange: (reasoning: CursorReasoningOption) => void; onFastModeChange: (enabled: boolean) => void; onThinkingModeChange: (enabled: boolean) => void; }) { + const [isMenuOpen, setIsMenuOpen] = useState(false); const reasoningLabelByOption: Record = { low: "Low", normal: "Normal", @@ -4258,9 +4246,25 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" ยท ") : "Traits"; return ( - + { + if (props.disabled) { + setIsMenuOpen(false); + return; + } + setIsMenuOpen(open); + }} + > } + render={ +