diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts index 5394dee2f86b1f861122513164516c3bd50d8690..5c9314de3f9983c6102a019a8414f4cb8d040338 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -229,7 +229,7 @@ describe("ClaudeCodeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( Stream.runCollect, Effect.forkChild, ); @@ -315,40 +315,43 @@ describe("ClaudeCodeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", + "session.configured", + "session.state.changed", "turn.started", "thread.started", - "message.delta", - "tool.started", - "tool.completed", - "message.completed", + "content.delta", + "item.started", + "item.completed", + "item.updated", + "item.completed", "turn.completed", ], ); - const turnStarted = runtimeEvents[1]; + const turnStarted = runtimeEvents[3]; assert.equal(turnStarted?.type, "turn.started"); if (turnStarted?.type === "turn.started") { assert.equal(turnStarted.turnId, turn.turnId); } - const deltaEvent = runtimeEvents[3]; - assert.equal(deltaEvent?.type, "message.delta"); - if (deltaEvent?.type === "message.delta") { - assert.equal(deltaEvent.delta, "Hi"); + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Hi"); assert.equal(deltaEvent.turnId, turn.turnId); } - const toolStarted = runtimeEvents[4]; - assert.equal(toolStarted?.type, "tool.started"); - if (toolStarted?.type === "tool.started") { - assert.equal(toolStarted.toolKind, "command"); + const toolStarted = runtimeEvents.find((event) => event.type === "item.started"); + assert.equal(toolStarted?.type, "item.started"); + if (toolStarted?.type === "item.started") { + assert.equal(toolStarted.payload.itemType, "command_execution"); } - const turnCompleted = runtimeEvents[7]; + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; assert.equal(turnCompleted?.type, "turn.completed"); if (turnCompleted?.type === "turn.completed") { assert.equal(turnCompleted.turnId, turn.turnId); - assert.equal(turnCompleted.status, "completed"); + assert.equal(turnCompleted.payload.state, "completed"); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -361,7 +364,7 @@ describe("ClaudeCodeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( Stream.runCollect, Effect.forkChild, ); @@ -416,22 +419,25 @@ describe("ClaudeCodeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", + "session.configured", + "session.state.changed", "turn.started", "thread.started", - "message.delta", - "message.completed", + "item.updated", + "content.delta", + "item.completed", "turn.completed", ], ); - const deltaIndex = runtimeEvents.findIndex((event) => event.type === "message.delta"); - const completedIndex = runtimeEvents.findIndex((event) => event.type === "message.completed"); + const deltaIndex = runtimeEvents.findIndex((event) => event.type === "content.delta"); + const completedIndex = runtimeEvents.findIndex((event) => event.type === "item.completed"); assert.equal(deltaIndex >= 0 && completedIndex >= 0 && deltaIndex < completedIndex, true); const deltaEvent = runtimeEvents[deltaIndex]; - assert.equal(deltaEvent?.type, "message.delta"); - if (deltaEvent?.type === "message.delta") { - assert.equal(deltaEvent.delta, "Late text"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Late text"); assert.equal(deltaEvent.turnId, turn.turnId); } }).pipe( @@ -445,7 +451,7 @@ describe("ClaudeCodeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( Stream.runCollect, Effect.forkChild, ); @@ -485,18 +491,21 @@ describe("ClaudeCodeAdapterLive", () => { runtimeEvents.map((event) => event.type), [ "session.started", + "session.configured", + "session.state.changed", "turn.started", "thread.started", - "message.delta", - "message.completed", + "item.updated", + "content.delta", + "item.completed", "turn.completed", ], ); - const deltaEvent = runtimeEvents.find((event) => event.type === "message.delta"); - assert.equal(deltaEvent?.type, "message.delta"); - if (deltaEvent?.type === "message.delta") { - assert.equal(deltaEvent.delta, "Fallback hello"); + const deltaEvent = runtimeEvents.find((event) => event.type === "content.delta"); + assert.equal(deltaEvent?.type, "content.delta"); + if (deltaEvent?.type === "content.delta") { + assert.equal(deltaEvent.payload.delta, "Fallback hello"); assert.equal(deltaEvent.turnId, turn.turnId); } }).pipe( @@ -552,7 +561,13 @@ describe("ClaudeCodeAdapterLive", () => { const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); assert.deepEqual( runtimeEvents.map((event) => event.type), - ["session.started", "turn.started", "thread.started", "message.completed", "turn.completed"], + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + ], ); const sessionStarted = runtimeEvents[0]; @@ -561,7 +576,7 @@ describe("ClaudeCodeAdapterLive", () => { assert.equal("threadId" in sessionStarted, false); } - const threadStarted = runtimeEvents[2]; + const threadStarted = runtimeEvents[4]; assert.equal(threadStarted?.type, "thread.started"); if (threadStarted?.type === "thread.started") { assert.equal(threadStarted.threadId, "sdk-thread-real"); @@ -582,8 +597,7 @@ describe("ClaudeCodeAdapterLive", () => { approvalPolicy: "on-request", }); - const consumeSessionStarted = yield* Stream.runHead(adapter.streamEvents); - assert.equal(consumeSessionStarted._tag, "Some"); + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); const createInput = harness.getLastCreateQueryInput(); const canUseTool = createInput?.options.canUseTool; @@ -613,8 +627,8 @@ describe("ClaudeCodeAdapterLive", () => { if (requested._tag !== "Some") { return; } - assert.equal(requested.value.type, "approval.requested"); - if (requested.value.type !== "approval.requested") { + assert.equal(requested.value.type, "request.opened"); + if (requested.value.type !== "request.opened") { return; } @@ -629,12 +643,12 @@ describe("ClaudeCodeAdapterLive", () => { if (resolved._tag !== "Some") { return; } - assert.equal(resolved.value.type, "approval.resolved"); - if (resolved.value.type !== "approval.resolved") { + assert.equal(resolved.value.type, "request.resolved"); + if (resolved.value.type !== "request.resolved") { return; } assert.equal(resolved.value.requestId, requested.value.requestId); - assert.equal(resolved.value.decision, "accept"); + assert.equal(resolved.value.payload.decision, "accept"); const permissionResult = yield* Effect.promise(() => permissionPromise); assert.equal((permissionResult as PermissionResult).behavior, "allow"); diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 8289a3e40ea9a05da86267b7552a52b488ed60f1..40736c18a9c5ffbfef7450e531406ba2bb958ebc 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -19,11 +19,12 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, EventId, type ProviderApprovalDecision, ProviderItemId, type ProviderRuntimeEvent, - type ProviderRuntimeToolKind, type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, ProviderSessionId, @@ -73,7 +74,7 @@ interface ClaudeTurnState { } interface PendingApproval { - readonly requestKind: "command" | "file-change"; + readonly requestType: CanonicalRequestType; readonly detail?: string; readonly suggestions?: ReadonlyArray; readonly decision: Deferred.Deferred; @@ -81,7 +82,8 @@ interface PendingApproval { interface ToolInFlight { readonly itemId: string; - readonly toolKind: ProviderRuntimeToolKind; + readonly itemType: CanonicalItemType; + readonly toolName: string; readonly title: string; readonly detail?: string; } @@ -188,7 +190,7 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef }; } -function classifyToolKind(toolName: string): ProviderRuntimeToolKind { +function classifyToolItemType(toolName: string): CanonicalItemType { const normalized = toolName.toLowerCase(); if ( normalized.includes("bash") || @@ -196,7 +198,7 @@ function classifyToolKind(toolName: string): ProviderRuntimeToolKind { normalized.includes("shell") || normalized.includes("terminal") ) { - return "command"; + return "command_execution"; } if ( normalized.includes("edit") || @@ -207,13 +209,18 @@ function classifyToolKind(toolName: string): ProviderRuntimeToolKind { normalized.includes("create") || normalized.includes("delete") ) { - return "file-change"; + return "file_change"; } - return "other"; + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + return "dynamic_tool_call"; } -function classifyRequestKind(toolName: string): "command" | "file-change" { - return classifyToolKind(toolName) === "command" ? "command" : "file-change"; +function classifyRequestType(toolName: string): CanonicalRequestType { + return classifyToolItemType(toolName) === "command_execution" + ? "command_execution_approval" + : "file_change_approval"; } function summarizeToolRequest(toolName: string, input: Record): string { @@ -230,14 +237,18 @@ function summarizeToolRequest(toolName: string, input: Record): return `${toolName}: ${serialized.slice(0, 397)}...`; } -function titleForTool(kind: ProviderRuntimeToolKind): string { - switch (kind) { - case "command": +function titleForTool(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": return "Command run"; - case "file-change": + case "file_change": return "File change"; - case "other": + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": return "Tool call"; + default: + return "Item"; } } @@ -284,6 +295,10 @@ function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStat return "failed"; } +function streamKindFromDeltaType(deltaType: string): "assistant_text" | "reasoning_text" { + return deltaType.includes("thinking") ? "reasoning_text" : "assistant_text"; +} + function extractAssistantText(message: SDKMessage): string { if (message.type !== "assistant") { return ""; @@ -540,6 +555,20 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { sessionId: context.session.sessionId, createdAt: stamp.createdAt, threadId: nextThreadId, + payload: { + providerThreadId: nextThreadId, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + providerThreadId: nextThreadId, + }, + raw: { + source: "claude.sdk.message", + method: "claude/thread/started", + payload: { + session_id: message.session_id, + }, + }, }); } }); @@ -563,7 +592,44 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(turnState ? { turnId: turnState.turnId } : {}), - message, + payload: { + message, + class: "provider_error", + ...(cause !== undefined ? { detail: cause } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(turnState ? { providerTurnId: turnState.turnId } : {}), + }, + }); + }); + + const emitRuntimeWarning = ( + context: ClaudeSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + eventId: stamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: stamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + ...(turnState ? { turnId: turnState.turnId } : {}), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(turnState ? { providerTurnId: turnState.turnId } : {}), + }, }); }); @@ -571,6 +637,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { context: ClaudeSessionContext, status: ProviderRuntimeTurnStatus, errorMessage?: string, + result?: SDKResultMessage, ): Effect.Effect => Effect.gen(function* () { const turnState = context.turnState; @@ -583,8 +650,20 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), - status, - ...(errorMessage ? { errorMessage } : {}), + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + }, }); return; } @@ -593,7 +672,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { if (!turnState.emittedTextDelta && turnState.fallbackAssistantText.length > 0) { const deltaStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "message.delta", + type: "content.delta", eventId: deltaStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -601,13 +680,22 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: turnState.turnId, itemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), - delta: turnState.fallbackAssistantText, + payload: { + streamKind: "assistant_text", + delta: turnState.fallbackAssistantText, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, }); } const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "message.completed", + type: "item.completed", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -615,6 +703,17 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { itemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: turnState.turnId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(turnState.assistantItemId), + }, }); } @@ -632,8 +731,21 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: turnState.turnId, - status, - ...(errorMessage ? { errorMessage } : {}), + payload: { + state: status, + ...(result?.stop_reason !== undefined ? { stopReason: result.stop_reason } : {}), + ...(result?.usage ? { usage: result.usage } : {}), + ...(result?.modelUsage ? { modelUsage: result.modelUsage } : {}), + ...(typeof result?.total_cost_usd === "number" + ? { totalCostUsd: result.total_cost_usd } + : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnState.turnId, + }, }); const updatedAt = yield* nowIso; @@ -673,7 +785,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "message.delta", + type: "content.delta", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -681,7 +793,21 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId: context.turnState.turnId, itemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), - delta: event.delta.text, + payload: { + streamKind: streamKindFromDeltaType(event.delta.type), + delta: event.delta.text, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_delta", + payload: message, + }, }); } return; @@ -698,7 +824,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } const toolName = block.name; - const toolKind = classifyToolKind(toolName); + const itemType = classifyToolItemType(toolName); const toolInput = typeof block.input === "object" && block.input !== null ? (block.input as Record) @@ -708,15 +834,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const tool: ToolInFlight = { itemId, - toolKind, - title: titleForTool(toolKind), + itemType, + toolName, + title: titleForTool(itemType), detail, }; context.inFlightTools.set(index, tool); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "tool.started", + type: "item.started", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -724,9 +851,27 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), itemId: ProviderItemId.makeUnsafe(tool.itemId), - toolKind: tool.toolKind, - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), + payload: { + itemType: tool.itemType, + status: "inProgress", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + data: { + toolName: tool.toolName, + input: toolInput, + }, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_start", + payload: message, + }, }); return; } @@ -741,7 +886,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "tool.completed", + type: "item.completed", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -749,9 +894,23 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), itemId: ProviderItemId.makeUnsafe(tool.itemId), - toolKind: tool.toolKind, - title: tool.title, - ...(tool.detail ? { detail: tool.detail } : {}), + payload: { + itemType: tool.itemType, + status: "completed", + title: tool.title, + ...(tool.detail ? { detail: tool.detail } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerItemId: ProviderItemId.makeUnsafe(tool.itemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/stream_event/content_block_stop", + payload: message, + }, }); } }); @@ -777,6 +936,35 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { fallbackAssistantText, }; } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.updated", + eventId: stamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: stamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + turnId: context.turnState.turnId, + itemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + data: message.message, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: context.turnState.turnId, + providerItemId: ProviderItemId.makeUnsafe(context.turnState.assistantItemId), + }, + raw: { + source: "claude.sdk.message", + method: "claude/assistant", + payload: message, + }, + }); } context.lastAssistantUuid = message.uuid; @@ -799,7 +987,251 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); } - yield* completeTurn(context, status, errorMessage); + yield* completeTurn(context, status, errorMessage, message); + }); + + const handleSystemMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + if (message.type !== "system") { + return; + } + + const stamp = yield* makeEventStamp(); + const base = { + 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 } : {}), + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: `${message.type}:${message.subtype}`, + payload: message, + }, + }; + + switch (message.subtype) { + case "init": + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + payload: { + config: message as Record, + }, + }); + return; + case "status": + yield* offerRuntimeEvent({ + ...base, + type: "session.state.changed", + payload: { + state: message.status === "compacting" ? "waiting" : "running", + reason: `status:${message.status ?? "active"}`, + detail: message, + }, + }); + return; + case "compact_boundary": + yield* offerRuntimeEvent({ + ...base, + type: "thread.state.changed", + payload: { + state: "compacted", + detail: message, + }, + }); + return; + case "hook_started": + yield* offerRuntimeEvent({ + ...base, + type: "hook.started", + payload: { + hookId: message.hook_id, + hookName: message.hook_name, + hookEvent: message.hook_event, + }, + }); + return; + case "hook_progress": + yield* offerRuntimeEvent({ + ...base, + type: "hook.progress", + payload: { + hookId: message.hook_id, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + }, + }); + return; + case "hook_response": + yield* offerRuntimeEvent({ + ...base, + type: "hook.completed", + payload: { + hookId: message.hook_id, + outcome: message.outcome, + output: message.output, + stdout: message.stdout, + stderr: message.stderr, + ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}), + }, + }); + return; + case "task_started": + yield* offerRuntimeEvent({ + ...base, + type: "task.started", + payload: { + taskId: message.task_id, + description: message.description, + ...(message.task_type ? { taskType: message.task_type } : {}), + }, + }); + return; + case "task_progress": + yield* offerRuntimeEvent({ + ...base, + type: "task.progress", + payload: { + taskId: message.task_id, + description: message.description, + ...(message.usage ? { usage: message.usage } : {}), + ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + }, + }); + return; + case "task_notification": + yield* offerRuntimeEvent({ + ...base, + type: "task.completed", + payload: { + taskId: message.task_id, + status: message.status, + ...(message.summary ? { summary: message.summary } : {}), + ...(message.usage ? { usage: message.usage } : {}), + }, + }); + return; + case "files_persisted": + yield* offerRuntimeEvent({ + ...base, + type: "files.persisted", + payload: { + files: Array.isArray(message.files) + ? message.files.map((file: { filename: string; file_id: string }) => ({ + filename: file.filename, + fileId: file.file_id, + })) + : [], + ...(Array.isArray(message.failed) + ? { + failed: message.failed.map((entry: { filename: string; error: string }) => ({ + filename: entry.filename, + error: entry.error, + })), + } + : {}), + }, + }); + return; + default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude system message subtype '${message.subtype}'.`, + message, + ); + return; + } + }); + + const handleSdkTelemetryMessage = ( + context: ClaudeSessionContext, + message: SDKMessage, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + const base = { + 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 } : {}), + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "claude.sdk.message" as const, + method: sdkNativeMethod(message), + messageType: message.type, + payload: message, + }, + }; + + if (message.type === "tool_progress") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.progress", + payload: { + toolUseId: message.tool_use_id, + toolName: message.tool_name, + elapsedSeconds: message.elapsed_time_seconds, + ...(message.task_id ? { summary: `task:${message.task_id}` } : {}), + }, + }); + return; + } + + if (message.type === "tool_use_summary") { + yield* offerRuntimeEvent({ + ...base, + type: "tool.summary", + payload: { + summary: message.summary, + ...(message.preceding_tool_use_ids.length > 0 + ? { precedingToolUseIds: message.preceding_tool_use_ids } + : {}), + }, + }); + return; + } + + if (message.type === "auth_status") { + yield* offerRuntimeEvent({ + ...base, + type: "auth.status", + payload: { + isAuthenticating: message.isAuthenticating, + output: message.output, + ...(message.error ? { error: message.error } : {}), + }, + }); + return; + } + + if (message.type === "rate_limit") { + yield* offerRuntimeEvent({ + ...base, + type: "account.rate-limits.updated", + payload: { + rateLimits: message, + }, + }); + return; + } }); const handleSdkMessage = ( @@ -820,7 +1252,21 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { case "result": yield* handleResultMessage(context, message); return; + case "system": + yield* handleSystemMessage(context, message); + return; + case "tool_progress": + case "tool_use_summary": + case "auth_status": + case "rate_limit": + yield* handleSdkTelemetryMessage(context, message); + return; default: + yield* emitRuntimeWarning( + context, + `Unhandled Claude SDK message type '${message.type}'.`, + message, + ); return; } }); @@ -854,7 +1300,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { yield* Deferred.succeed(pending.decision, "cancel"); const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "approval.resolved", + type: "request.resolved", eventId: stamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -862,8 +1308,16 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), requestId, - requestKind: pending.requestKind, - decision: "cancel", + payload: { + requestType: pending.requestType, + decision: "cancel", + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerRequestId: requestId, + }, }); } context.pendingApprovals.clear(); @@ -893,7 +1347,14 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { sessionId: context.session.sessionId, createdAt: stamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), - message: "Session stopped", + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + }, }); } @@ -972,11 +1433,11 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { } const requestId = ApprovalRequestId.makeUnsafe(yield* Random.nextUUIDv4); - const requestKind = classifyRequestKind(toolName); + const requestType = classifyRequestType(toolName); const detail = summarizeToolRequest(toolName, toolInput); const decisionDeferred = yield* Deferred.make(); const pendingApproval: PendingApproval = { - requestKind, + requestType, detail, decision: decisionDeferred, ...(callbackOptions.suggestions @@ -986,7 +1447,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const requestedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "approval.requested", + type: "request.opened", eventId: requestedStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -994,8 +1455,29 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), requestId, - requestKind, - detail, + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, }); pendingApprovals.set(requestId, pendingApproval); @@ -1017,7 +1499,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { const resolvedStamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ - type: "approval.resolved", + type: "request.resolved", eventId: resolvedStamp.eventId, provider: PROVIDER, sessionId: context.session.sessionId, @@ -1025,8 +1507,23 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { ...(context.session.threadId ? { threadId: context.session.threadId } : {}), ...(context.turnState ? { turnId: context.turnState.turnId } : {}), requestId, - requestKind, - decision, + payload: { + requestType, + decision, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerRequestId: requestId, + }, + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, }); if (decision === "accept" || decision === "acceptForSession") { @@ -1134,6 +1631,52 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { sessionId, createdAt: sessionStartedStamp.createdAt, ...(threadId ? { threadId } : {}), + payload: input.resumeCursor !== undefined ? { resume: input.resumeCursor } : {}, + providerRefs: { + providerSessionId: sessionId, + ...(threadId ? { providerThreadId: threadId } : {}), + }, + }); + + const configuredStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: configuredStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: configuredStamp.createdAt, + ...(threadId ? { threadId } : {}), + payload: { + config: { + ...(input.model ? { model: input.model } : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(providerOptions?.maxThinkingTokens !== undefined + ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + : {}), + }, + }, + providerRefs: { + providerSessionId: sessionId, + ...(threadId ? { providerThreadId: threadId } : {}), + }, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: readyStamp.createdAt, + ...(threadId ? { threadId } : {}), + payload: { + state: "ready", + }, + providerRefs: { + providerSessionId: sessionId, + ...(threadId ? { providerThreadId: threadId } : {}), + }, }); Effect.runFork(runSdkStream(context)); @@ -1191,6 +1734,15 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { createdAt: turnStartedStamp.createdAt, ...(context.session.threadId ? { threadId: context.session.threadId } : {}), turnId, + payload: { + ...(input.model ? { model: input.model } : {}), + ...(input.effort ? { effort: input.effort } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnId, + }, }); const message = buildUserMessage(input); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 2dd6062e603d4a33a9e767d527e6bddffcf5f886..b0cbc7d67907bae931b87e188c672e834f0aa6b8 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -214,7 +214,7 @@ const lifecycleLayer = it.layer( ); lifecycleLayer("CodexAdapterLive lifecycle", (it) => { - it.effect("maps completed agent message items to canonical message.completed events", () => + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); @@ -244,12 +244,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (firstEvent._tag !== "Some") { return; } - assert.equal(firstEvent.value.type, "message.completed"); - if (firstEvent.value.type !== "message.completed") { + assert.equal(firstEvent.value.type, "item.completed"); + if (firstEvent.value.type !== "item.completed") { return; } assert.equal(firstEvent.value.itemId, "msg_1"); assert.equal(firstEvent.value.turnId, "turn-1"); + assert.equal(firstEvent.value.payload.itemType, "assistant_message"); }), ); @@ -280,7 +281,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { return; } assert.equal(firstEvent.value.sessionId, "sess-1"); - assert.equal(firstEvent.value.message, "Session stopped"); + assert.equal(firstEvent.value.payload.reason, "Session stopped"); }), ); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8bec7388932d6ba7fa31869304926a7277e39f69..16aa9aa9016940de248c13044684bf202a0604b9 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -7,11 +7,10 @@ * @module CodexAdapterLive */ import { + type CanonicalItemType, + type CanonicalRequestType, type ProviderEvent, type ProviderRuntimeEvent, - type ProviderRuntimeTurnStatus, - type ProviderRuntimeToolKind, - ProviderItemId, ProviderApprovalDecision, ProviderSessionId, ProviderThreadId, @@ -100,7 +99,11 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function toTurnStatus(value: unknown): ProviderRuntimeTurnStatus | undefined { +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function toTurnStatus(value: unknown): "completed" | "failed" | "cancelled" | "interrupted" { switch (value) { case "completed": case "failed": @@ -108,7 +111,7 @@ function toTurnStatus(value: unknown): ProviderRuntimeTurnStatus | undefined { case "interrupted": return value; default: - return undefined; + return "completed"; } } @@ -123,29 +126,53 @@ function normalizeItemType(raw: unknown): string { .toLowerCase(); } -function shouldDropItemType(type: string): boolean { - if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { - return true; - } - return type === "work" || type.startsWith("work "); +function toCanonicalItemType(raw: unknown): CanonicalItemType { + const type = normalizeItemType(raw); + if (type.includes("user")) return "user_message"; + if (type.includes("agent message") || type.includes("assistant")) return "assistant_message"; + if (type.includes("reasoning") || type.includes("thought")) return "reasoning"; + if (type.includes("plan") || type.includes("todo")) return "plan"; + if (type.includes("command")) return "command_execution"; + if (type.includes("file change") || type.includes("patch") || type.includes("edit")) return "file_change"; + if (type.includes("mcp")) return "mcp_tool_call"; + if (type.includes("dynamic tool")) return "dynamic_tool_call"; + if (type.includes("collab")) return "collab_agent_tool_call"; + if (type.includes("web search")) return "web_search"; + if (type.includes("image")) return "image_view"; + if (type.includes("review entered")) return "review_entered"; + if (type.includes("review exited")) return "review_exited"; + if (type.includes("compact")) return "context_compaction"; + if (type.includes("error")) return "error"; + return "unknown"; } -function toolMeta(type: string): - | { - readonly toolKind: ProviderRuntimeToolKind; - readonly title: string; - } - | undefined { - if (type.includes("command")) { - return { toolKind: "command", title: "Command run" }; +function itemTitle(itemType: CanonicalItemType): string | undefined { + switch (itemType) { + case "assistant_message": + return "Assistant message"; + case "user_message": + return "User message"; + case "reasoning": + return "Reasoning"; + case "plan": + return "Plan"; + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + case "web_search": + return "Web search"; + case "image_view": + return "Image view"; + case "error": + return "Error"; + default: + return undefined; } - if (type.includes("file change")) { - return { toolKind: "file-change", title: "File change" }; - } - if (type.includes("tool")) { - return { toolKind: "other", title: "Tool call" }; - } - return undefined; } function itemDetail( @@ -174,58 +201,104 @@ function itemDetail( return undefined; } -function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { - if (event.method !== "item/completed") { - return undefined; +function toRequestTypeFromMethod(method: string): CanonicalRequestType { + switch (method) { + case "item/commandExecution/requestApproval": + return "command_execution_approval"; + case "item/fileChange/requestApproval": + return "file_change_approval"; + case "applyPatchApproval": + return "apply_patch_approval"; + case "execCommandApproval": + return "exec_command_approval"; + case "item/tool/requestUserInput": + return "tool_user_input"; + case "item/tool/call": + return "dynamic_tool_call"; + case "account/chatgptAuthTokens/refresh": + return "auth_tokens_refresh"; + default: + return "unknown"; } +} - const payload = asObject(event.payload); - const item = asObject(payload?.item); - if (!item) { - return undefined; +function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { + switch (kind) { + case "command": + return "command_execution_approval"; + case "file-change": + return "file_change_approval"; + default: + return "unknown"; } +} - const normalizedType = normalizeItemType(item.type ?? item.kind); - if (!normalizedType.includes("agent message")) { - return undefined; +function toThreadState(value: unknown): + | "active" + | "idle" + | "archived" + | "closed" + | "compacted" + | "error" { + switch (value) { + case "idle": + return "idle"; + case "archived": + return "archived"; + case "closed": + return "closed"; + case "compacted": + return "compacted"; + case "error": + case "failed": + return "error"; + default: + return "active"; } +} - const itemId = event.itemId ?? asString(item.id); - if (!itemId) { - return undefined; +function contentStreamKindFromMethod( + method: string, +): "assistant_text" | "reasoning_text" | "reasoning_summary_text" | "plan_text" | "command_output" | "file_change_output" { + switch (method) { + case "item/agentMessage/delta": + return "assistant_text"; + case "item/reasoning/textDelta": + return "reasoning_text"; + case "item/reasoning/summaryTextDelta": + return "reasoning_summary_text"; + case "item/plan/delta": + return "plan_text"; + case "item/commandExecution/outputDelta": + return "command_output"; + case "item/fileChange/outputDelta": + return "file_change_output"; + default: + return "assistant_text"; } +} - return { - type: "message.completed", - eventId: event.id, - provider: event.provider, - sessionId: event.sessionId, - createdAt: event.createdAt, - itemId: ProviderItemId.makeUnsafe(itemId), - ...(event.threadId ? { threadId: event.threadId } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), +function eventRawSource(event: ProviderEvent): ProviderRuntimeEvent["raw"]["source"] { + return event.kind === "request" ? "codex.app-server.request" : "codex.app-server.notification"; +} + +function providerRefsFromEvent(event: ProviderEvent): ProviderRuntimeEvent["providerRefs"] | undefined { + const refs: Record = { + providerSessionId: event.sessionId, }; + if (event.threadId) refs.providerThreadId = event.threadId; + if (event.turnId) refs.providerTurnId = event.turnId; + if (event.itemId) refs.providerItemId = event.itemId; + if (event.requestId) refs.providerRequestId = event.requestId; + + return Object.keys(refs).length > 0 + ? (refs as ProviderRuntimeEvent["providerRefs"]) + : undefined; } -function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { - if (event.method !== "item/started" && event.method !== "item/completed") { - return undefined; - } - const payload = asObject(event.payload); - const item = asObject(payload?.item); - if (!item) { - return undefined; - } - const normalizedType = normalizeItemType(item.type ?? item.kind); - if (shouldDropItemType(normalizedType)) { - return undefined; - } - const meta = toolMeta(normalizedType); - if (!meta) { - return undefined; - } - - const eventBase = { +function runtimeEventBase(event: ProviderEvent): Omit { + const refs = providerRefsFromEvent(event); + return { eventId: event.id, provider: event.provider, sessionId: event.sessionId, @@ -233,21 +306,50 @@ function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { ...(event.threadId ? { threadId: event.threadId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: event.itemId } : {}), - toolKind: meta.toolKind, - title: meta.title, - ...(payload ? { detail: itemDetail(item, payload) } : {}), - } as const; + ...(event.requestId ? { requestId: event.requestId } : {}), + ...(refs ? { providerRefs: refs } : {}), + raw: { + source: eventRawSource(event), + method: event.method, + payload: event.payload ?? {}, + }, + }; +} - if (event.method === "item/started") { - return { - type: "tool.started", - ...eventBase, - }; +function mapItemLifecycle( + event: ProviderEvent, + lifecycle: "item.started" | "item.updated" | "item.completed", +): ProviderRuntimeEvent | undefined { + const payload = asObject(event.payload); + const item = asObject(payload?.item); + const source = item ?? payload; + if (!source) { + return undefined; } + const itemType = toCanonicalItemType(source.type ?? source.kind); + if (itemType === "unknown" && lifecycle !== "item.updated") { + return undefined; + } + + const detail = itemDetail(source, payload ?? source); + const status = + lifecycle === "item.started" + ? "inProgress" + : lifecycle === "item.completed" + ? "completed" + : undefined; + return { - type: "tool.completed", - ...eventBase, + ...runtimeEventBase(event), + type: lifecycle, + payload: { + itemType, + ...(status ? { status } : {}), + ...(itemTitle(itemType) ? { title: itemTitle(itemType) } : {}), + ...(detail ? { detail } : {}), + ...(event.payload !== undefined ? { data: event.payload } : {}), + }, }; } @@ -261,78 +363,87 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray 0) { + if (event.method === "turn/aborted") { return [ { - type: "message.delta", - eventId: event.id, - provider: event.provider, - sessionId: event.sessionId, - createdAt: event.createdAt, - ...(event.threadId ? { threadId: event.threadId } : {}), - ...(event.turnId ? { turnId: event.turnId } : {}), - ...(event.itemId ? { itemId: event.itemId } : {}), - delta: event.textDelta, + ...runtimeEventBase(event), + type: "turn.aborted", + payload: { + reason: event.message ?? "Turn aborted", + }, }, ]; } - return []; + if (event.method === "turn/plan/updated") { + const steps = Array.isArray(payload?.plan) ? payload.plan : []; + return [ + { + ...runtimeEventBase(event), + type: "turn.plan.updated", + payload: { + ...(asString(payload?.explanation) ? { explanation: asString(payload?.explanation) } : {}), + plan: steps + .map((entry) => asObject(entry)) + .filter((entry): entry is Record => entry !== undefined) + .map((entry) => ({ + step: asString(entry.step) ?? "step", + status: + entry.status === "completed" || entry.status === "inProgress" + ? entry.status + : "pending", + })), + }, + }, + ]; + } + + if (event.method === "turn/diff/updated") { + return [ + { + ...runtimeEventBase(event), + type: "turn.diff.updated", + payload: { + unifiedDiff: + asString(payload?.unifiedDiff) ?? + asString(payload?.diff) ?? + asString(payload?.patch) ?? + "", + }, + }, + ]; + } + + if (event.method === "item/started") { + const started = mapItemLifecycle(event, "item.started"); + return started ? [started] : []; + } + + if (event.method === "item/completed") { + const completed = mapItemLifecycle(event, "item.completed"); + return completed ? [completed] : []; + } + + if ( + event.method === "item/reasoning/summaryPartAdded" || + event.method === "item/commandExecution/terminalInteraction" + ) { + const updated = mapItemLifecycle(event, "item.updated"); + return updated ? [updated] : []; + } + + if ( + event.method === "item/agentMessage/delta" || + event.method === "item/plan/delta" || + event.method === "item/commandExecution/outputDelta" || + event.method === "item/fileChange/outputDelta" || + event.method === "item/reasoning/summaryTextDelta" || + event.method === "item/reasoning/textDelta" + ) { + const delta = + event.textDelta ?? + asString(payload?.delta) ?? + asString(payload?.text) ?? + asString(asObject(payload?.content)?.text); + if (!delta || delta.length === 0) { + return []; + } + return [ + { + ...runtimeEventBase(event), + type: "content.delta", + payload: { + streamKind: contentStreamKindFromMethod(event.method), + delta, + ...(typeof payload?.contentIndex === "number" ? { contentIndex: payload.contentIndex } : {}), + ...(typeof payload?.summaryIndex === "number" ? { summaryIndex: payload.summaryIndex } : {}), + }, + }, + ]; + } + + if (event.method === "item/mcpToolCall/progress") { + return [ + { + ...runtimeEventBase(event), + type: "tool.progress", + payload: { + ...(asString(payload?.toolUseId) ? { toolUseId: asString(payload?.toolUseId) } : {}), + ...(asString(payload?.toolName) ? { toolName: asString(payload?.toolName) } : {}), + ...(asString(payload?.summary) ? { summary: asString(payload?.summary) } : {}), + ...(asNumber(payload?.elapsedSeconds) !== undefined + ? { elapsedSeconds: asNumber(payload?.elapsedSeconds) } + : {}), + }, + }, + ]; + } + + if (event.method === "serverRequest/resolved") { + return [ + { + ...runtimeEventBase(event), + type: "request.resolved", + payload: { + requestType: "unknown", + ...(event.payload !== undefined ? { resolution: event.payload } : {}), + }, + }, + ]; + } + + if (event.method === "model/rerouted") { + return [ + { + ...runtimeEventBase(event), + type: "model.rerouted", + payload: { + fromModel: asString(payload?.fromModel) ?? "unknown", + toModel: asString(payload?.toModel) ?? "unknown", + reason: asString(payload?.reason) ?? "unknown", + }, + }, + ]; + } + + if (event.method === "deprecationNotice") { + return [ + { + ...runtimeEventBase(event), + type: "deprecation.notice", + payload: { + summary: asString(payload?.summary) ?? "Deprecation notice", + ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), + }, + }, + ]; + } + + if (event.method === "configWarning") { + return [ + { + ...runtimeEventBase(event), + type: "config.warning", + payload: { + summary: asString(payload?.summary) ?? "Configuration warning", + ...(asString(payload?.details) ? { details: asString(payload?.details) } : {}), + ...(asString(payload?.path) ? { path: asString(payload?.path) } : {}), + ...(payload?.range !== undefined ? { range: payload.range } : {}), + }, + }, + ]; + } + + if (event.method === "account/updated") { + return [ + { + ...runtimeEventBase(event), + type: "account.updated", + payload: { + account: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "account/rateLimits/updated") { + return [ + { + ...runtimeEventBase(event), + type: "account.rate-limits.updated", + payload: { + rateLimits: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "mcpServer/oauthLogin/completed") { + return [ + { + ...runtimeEventBase(event), + type: "mcp.oauth.completed", + payload: { + success: payload?.success === true, + ...(asString(payload?.name) ? { name: asString(payload?.name) } : {}), + ...(asString(payload?.error) ? { error: asString(payload?.error) } : {}), + }, + }, + ]; + } + + if (event.method === "thread/realtime/started") { + const realtimeSessionId = asString(payload?.realtimeSessionId); + return [ + { + ...runtimeEventBase(event), + type: "thread.realtime.started", + payload: { + realtimeSessionId, + }, + }, + ]; + } + + if (event.method === "thread/realtime/itemAdded") { + return [ + { + ...runtimeEventBase(event), + type: "thread.realtime.item-added", + payload: { + item: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "thread/realtime/outputAudio/delta") { + return [ + { + ...runtimeEventBase(event), + type: "thread.realtime.audio.delta", + payload: { + audio: event.payload ?? {}, + }, + }, + ]; + } + + if (event.method === "thread/realtime/error") { + const message = asString(payload?.message) ?? event.message ?? "Realtime error"; + return [ + { + ...runtimeEventBase(event), + type: "thread.realtime.error", + payload: { + message, + }, + }, + ]; + } + + if (event.method === "thread/realtime/closed") { + return [ + { + ...runtimeEventBase(event), + type: "thread.realtime.closed", + payload: { + reason: event.message, + }, + }, + ]; + } + + if (event.method === "error") { + const message = + asString(asObject(payload?.error)?.message) ?? event.message ?? "Provider runtime error"; + return [ + { + ...runtimeEventBase(event), + type: "runtime.error", + payload: { + message, + class: "provider_error", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + + if (event.method === "windows/worldWritableWarning") { + return [ + { + ...runtimeEventBase(event), + type: "runtime.warning", + payload: { + message: event.message ?? "Windows world-writable warning", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + + if (event.method === "windowsSandbox/setupCompleted") { + return [ + { + ...runtimeEventBase(event), + type: "runtime.warning", + payload: { + message: event.message ?? "Windows sandbox setup completed", + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; + } + + return [ + { + ...runtimeEventBase(event), + type: "runtime.warning", + payload: { + message: `Unhandled Codex provider event method: ${event.method}`, + ...(event.payload !== undefined ? { detail: event.payload } : {}), + }, + }, + ]; } const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2daca1d9443921d1c80254b356f4b6dcc00a081a --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,350 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import readline from "node:readline"; + +import { ApprovalRequestId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import { Effect, Fiber, Stream } from "effect"; + +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "./CursorAdapter.ts"; + +class FakeCursorAcpProcess extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + killed = false; + + private readonly input = readline.createInterface({ input: this.stdin }); + private permissionRequestId = 700; + lastPermissionSelection: string | undefined; + + constructor() { + super(); + this.input.on("line", (line) => { + const message = JSON.parse(line) as Record; + if (typeof message.method === "string") { + this.handleRequest(message); + return; + } + + if (message.id === this.permissionRequestId) { + const optionId = + (message.result as { outcome?: { optionId?: unknown } } | undefined)?.outcome?.optionId; + if (typeof optionId === "string") { + this.lastPermissionSelection = optionId; + } + } + }); + } + + kill(): boolean { + if (this.killed) { + return true; + } + this.killed = true; + this.emit("exit", 0, null); + return true; + } + + emitPermissionRequest(): void { + this.emitServerMessage({ + jsonrpc: "2.0", + id: this.permissionRequestId, + method: "session/request_permission", + params: { + sessionId: "acp-session-1", + toolCall: { + toolCallId: "tool-perm-1", + kind: "execute", + title: "`pwd`", + }, + options: [ + { optionId: "allow-once", kind: "allow_once" }, + { optionId: "allow-always", kind: "allow_always" }, + { optionId: "reject-once", kind: "reject_once" }, + ], + }, + }); + } + + private handleRequest(message: Record): void { + const method = message.method; + const id = message.id; + if (typeof method !== "string" || (typeof id !== "string" && typeof id !== "number")) { + return; + } + + switch (method) { + case "initialize": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + }, + authMethods: [{ id: "cursor_login" }], + }, + }); + return; + case "authenticate": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/new": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + sessionId: "acp-session-1", + modes: { + currentModeId: "agent", + availableModes: [{ id: "agent" }], + }, + }, + }); + return; + case "session/load": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: {}, + }); + return; + case "session/prompt": { + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "thinking", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + kind: "execute", + title: "`pwd`", + rawInput: { command: "pwd" }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "acp-session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "/tmp/project", + stderr: "", + }, + }, + }, + }); + + this.emitServerMessage({ + jsonrpc: "2.0", + id, + result: { + stopReason: "end_turn", + }, + }); + return; + } + case "session/cancel": + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: "Method not found", + }, + }); + return; + default: + this.emitServerMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Unhandled method: ${method}`, + }, + }); + } + } + + private emitServerMessage(message: unknown): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } +} + +describe("CursorAdapterLive", () => { + it.effect("returns validation error for non-cursor provider on startSession", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "cursor", + operation: "startSession", + issue: "Expected provider 'cursor' but received 'codex'.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps ACP prompt/update events into canonical runtime events", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + cwd: "/tmp/project", + }); + + const turn = yield* adapter.sendTurn({ + sessionId: session.sessionId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.deepEqual( + events.map((event) => event.type), + [ + "session.configured", + "session.started", + "thread.started", + "session.state.changed", + "turn.started", + "content.delta", + "content.delta", + "item.started", + "item.completed", + "item.completed", + "turn.completed", + ], + ); + + const turnStarted = events[4]; + assert.equal(turnStarted?.type, "turn.started"); + if (turnStarted?.type === "turn.started") { + assert.equal(turnStarted.turnId, turn.turnId); + } + + const completion = events[10]; + assert.equal(completion?.type, "turn.completed"); + if (completion?.type === "turn.completed") { + assert.equal(completion.payload.state, "completed"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("bridges permission requests to request.opened/request.resolved", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const session = yield* adapter.startSession({ + provider: "cursor", + }); + + // consume startup events + yield* Stream.take(adapter.streamEvents, 4).pipe(Stream.runDrain); + + fake.emitPermissionRequest(); + + const opened = yield* Stream.runHead(adapter.streamEvents); + assert.equal(opened._tag, "Some"); + if (opened._tag !== "Some") { + return; + } + assert.equal(opened.value.type, "request.opened"); + if (opened.value.type !== "request.opened") { + return; + } + + yield* adapter.respondToRequest( + session.sessionId, + ApprovalRequestId.makeUnsafe(opened.value.requestId), + "acceptForSession", + ); + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..f9db742836db40a76b37977429f5c20109352cff --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1378 @@ +/** + * CursorAdapterLive - Scoped live implementation for the Cursor ACP provider adapter. + * + * Spawns `agent acp` over stdio, manages JSON-RPC session lifecycle, and maps + * ACP notifications/requests into canonical provider runtime events. + * + * @module CursorAdapterLive + */ +import { randomUUID } from "node:crypto"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import readline from "node:readline"; + +import { + ApprovalRequestId, + EventId, + type CanonicalItemType, + type CanonicalRequestType, + ProviderSessionId, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderThreadId, + ProviderTurnId, + type ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Queue, Random, Schema, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + CursorAdapter, + type CursorAdapterShape, + CursorAcpInitializeResult, + CursorAcpPermissionRequest, + CursorAcpSessionNewResult, + CursorAcpSessionPromptResult, + CursorAcpSessionUpdateNotification, +} from "../Services/CursorAdapter.ts"; +import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "cursor" as const; +const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; + +interface CursorResumeState { + readonly acpSessionId?: string; +} + +interface PendingRequest { + readonly method: string; + readonly timeout: ReturnType; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; +} + +interface PendingPermission { + readonly jsonRpcId: string | number; + readonly requestType: CanonicalRequestType; + readonly options: ReadonlyArray<{ optionId: string }>; +} + +interface CursorTurnState { + readonly turnId: ReturnType; + readonly assistantItemId: string; + readonly startedToolCalls: Set; + readonly items: Array; +} + +interface CursorSessionContext { + session: ProviderSession; + readonly child: ChildProcessWithoutNullStreams; + readonly output: readline.Interface; + readonly pending: Map; + readonly pendingPermissions: Map; + readonly turns: Array<{ + id: ReturnType; + items: Array; + }>; + turnState: CursorTurnState | undefined; + acpSessionId: string; + nextRpcId: number; + stopping: boolean; +} + +export interface CursorAdapterLiveOptions { + readonly createProcess?: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + }) => ChildProcessWithoutNullStreams; + readonly nativeEventLogPath?: string; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function toSessionError( + sessionId: ReturnType, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("not found")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + sessionId, + cause, + }); + } + if (normalized.includes("closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + sessionId, + cause, + }); + } + return undefined; +} + +function toRequestError( + sessionId: ReturnType, + method: string, + cause: unknown, +): ProviderAdapterError { + const sessionError = toSessionError(sessionId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function normalizeToolItemType(kind: unknown, title: unknown): CanonicalItemType { + const normalizedKind = asString(kind)?.toLowerCase(); + const normalizedTitle = asString(title)?.toLowerCase(); + + if (normalizedKind === "execute") { + return "command_execution"; + } + if (normalizedKind === "edit" || normalizedKind === "write") { + return "file_change"; + } + if (normalizedKind === "mcp") { + return "mcp_tool_call"; + } + if (normalizedTitle?.includes("terminal")) { + return "command_execution"; + } + return "dynamic_tool_call"; +} + +function normalizeRequestType(toolCall: unknown): CanonicalRequestType { + const record = asObject(toolCall); + const kind = asString(record?.kind)?.toLowerCase(); + if (kind === "execute") { + return "command_execution_approval"; + } + if (kind === "edit" || kind === "write") { + return "file_change_approval"; + } + return "unknown"; +} + +function titleForItemType(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Command run"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "dynamic_tool_call": + return "Tool call"; + default: + return "Item"; + } +} + +function summarizeToolOutput(rawOutput: unknown): string | undefined { + const output = asObject(rawOutput); + if (!output) return undefined; + + const stdout = asString(output.stdout); + if (stdout && stdout.trim().length > 0) { + return stdout.trim().slice(0, 400); + } + + const summary = JSON.stringify(output); + return summary.length > 400 ? `${summary.slice(0, 397)}...` : summary; +} + +function mapStopReasonToTurnState(stopReason: string | undefined): + | "completed" + | "failed" + | "interrupted" + | "cancelled" { + if (stopReason === "cancelled") return "cancelled"; + if (stopReason === "interrupted") return "interrupted"; + return "completed"; +} + +function readCursorResumeState(resumeCursor: unknown): CursorResumeState | undefined { + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + + const cursor = resumeCursor as { + acpSessionId?: unknown; + sessionId?: unknown; + }; + + const acpSessionId = + typeof cursor.acpSessionId === "string" + ? cursor.acpSessionId + : typeof cursor.sessionId === "string" + ? cursor.sessionId + : undefined; + + if (!acpSessionId) { + return {}; + } + return { acpSessionId }; +} + +function writeCursorMessage(context: CursorSessionContext, message: unknown): void { + if (!context.child.stdin.writable) { + throw new Error("Cannot write to Cursor ACP stdin."); + } + context.child.stdin.write(`${JSON.stringify(message)}\n`); +} + +function makeCursorAdapter(options?: CursorAdapterLiveOptions) { + return Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogPath !== undefined + ? makeEventNdjsonLogger(options.nativeEventLogPath) + : undefined; + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const spawnCursorAcp = (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + }): ChildProcessWithoutNullStreams => { + if (options?.createProcess) { + return options.createProcess(input); + } + return spawn(input.binaryPath, ["acp"], { + cwd: input.cwd, + env: input.env, + stdio: ["pipe", "pipe", "pipe"], + }); + }; + + const sendRequest = ( + context: CursorSessionContext, + method: string, + params: unknown, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise => { + const id = context.nextRpcId; + context.nextRpcId += 1; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + context.pending.delete(String(id)); + reject(new Error(`Timed out waiting for ${method}.`)); + }, timeoutMs); + + context.pending.set(String(id), { + method, + timeout, + resolve, + reject, + }); + + writeCursorMessage(context, { + jsonrpc: "2.0", + id, + method, + params, + }); + }); + }; + + const resolvePendingRequest = (context: CursorSessionContext, message: Record) => { + const id = message.id; + if (typeof id !== "string" && typeof id !== "number") { + return; + } + const pending = context.pending.get(String(id)); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + context.pending.delete(String(id)); + + const error = asObject(message.error); + if (error) { + pending.reject(new Error(`${pending.method} failed: ${JSON.stringify(error)}`)); + return; + } + + pending.resolve(message.result); + }; + + const decodePermissionRequest = Schema.decodeUnknownSync(CursorAcpPermissionRequest); + const decodeSessionUpdateNotification = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification); + + const emitRuntimeWarning = ( + context: CursorSessionContext, + message: string, + detail?: unknown, + ): Effect.Effect => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.warning", + 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 } : {}), + payload: { + message, + ...(detail !== undefined ? { detail } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + }); + }); + + const completeTurn = ( + context: CursorSessionContext, + state: "completed" | "failed" | "interrupted" | "cancelled", + errorMessage?: string, + stopReason?: string, + ): Effect.Effect => + Effect.gen(function* () { + const turnState = context.turnState; + if (!turnState) { + return; + } + + const itemStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "item.completed", + eventId: itemStamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: itemStamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + turnId: turnState.turnId, + itemId: turnState.assistantItemId, + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnState.turnId, + providerItemId: turnState.assistantItemId, + }, + }); + + context.turns.push({ + id: turnState.turnId, + items: [...turnState.items], + }); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + eventId: stamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: stamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + turnId: turnState.turnId, + payload: { + state, + ...(stopReason ? { stopReason } : {}), + ...(errorMessage ? { errorMessage } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnState.turnId, + }, + }); + + context.turnState = undefined; + context.session = { + ...context.session, + status: state === "failed" ? "error" : "ready", + activeTurnId: undefined, + ...(errorMessage ? { lastError: errorMessage } : {}), + updatedAt: yield* nowIso, + }; + }); + + const handlePermissionRequest = ( + context: CursorSessionContext, + request: unknown, + ): Effect.Effect => { + let decoded: ReturnType; + try { + decoded = decodePermissionRequest(request); + } catch (error) { + return emitRuntimeWarning( + context, + "Failed to decode Cursor ACP permission request.", + error, + ); + } + + return Effect.gen(function* () { + const requestId = ApprovalRequestId.makeUnsafe(randomUUID()); + const requestType = normalizeRequestType(decoded.params.toolCall); + + context.pendingPermissions.set(requestId, { + jsonRpcId: decoded.id, + requestType, + options: decoded.params.options.map((entry) => ({ optionId: entry.optionId })), + }); + + const detail = asString(asObject(decoded.params.toolCall)?.title); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + 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, + ...(detail ? { detail } : {}), + args: decoded.params, + }, + 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.request", + method: decoded.method, + payload: decoded, + }, + }); + }); + }; + + const handleSessionUpdateNotification = ( + context: CursorSessionContext, + notification: unknown, + ): Effect.Effect => { + let decoded: ReturnType; + try { + decoded = decodeSessionUpdateNotification(notification); + } catch (error) { + return emitRuntimeWarning( + context, + "Failed to decode Cursor ACP session/update notification.", + error, + ); + } + + return Effect.gen(function* () { + const update = decoded.params.update; + + const base = { + provider: PROVIDER, + sessionId: context.session.sessionId, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + }, + raw: { + source: "cursor.acp.notification" as const, + method: decoded.method, + messageType: update.sessionUpdate, + payload: decoded, + }, + }; + + switch (update.sessionUpdate) { + case "available_commands_update": { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "session.configured", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + payload: { + config: { + availableCommands: update.availableCommands, + }, + }, + }); + return; + } + + case "agent_thought_chunk": { + if (!context.turnState) return; + if (update.content.text.length === 0) return; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "content.delta", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: context.turnState.assistantItemId, + payload: { + streamKind: "reasoning_text", + delta: update.content.text, + }, + }); + return; + } + + case "agent_message_chunk": { + if (!context.turnState) return; + if (update.content.text.length === 0) return; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "content.delta", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: context.turnState.assistantItemId, + payload: { + streamKind: "assistant_text", + delta: update.content.text, + }, + }); + return; + } + + case "tool_call": { + if (!context.turnState) return; + const seen = context.turnState.startedToolCalls.has(update.toolCallId); + const itemType = normalizeToolItemType(update.kind, update.title); + const title = update.title ?? titleForItemType(itemType); + const detail = asString(asObject(update.rawInput)?.command); + + if (!seen) { + context.turnState.startedToolCalls.add(update.toolCallId); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "item.started", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: update.toolCallId, + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), + }, + }); + return; + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + ...base, + type: "item.updated", + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: update.toolCallId, + payload: { + itemType, + status: "inProgress", + title, + ...(detail ? { detail } : {}), + ...(update.rawInput !== undefined ? { data: update.rawInput } : {}), + }, + }); + return; + } + + case "tool_call_update": { + if (!context.turnState) return; + const status = update.status === "completed" ? "completed" : "inProgress"; + const stamp = yield* makeEventStamp(); + const eventType = update.status === "completed" ? "item.completed" : "item.updated"; + yield* offerRuntimeEvent({ + ...base, + type: eventType, + eventId: stamp.eventId, + createdAt: stamp.createdAt, + turnId: context.turnState.turnId, + itemId: update.toolCallId, + payload: { + itemType: "command_execution", + status, + title: "Tool call", + ...(summarizeToolOutput(update.rawOutput) + ? { detail: summarizeToolOutput(update.rawOutput) } + : {}), + ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}), + }, + }); + return; + } + } + }); + }; + + const handleStdoutLine = (context: CursorSessionContext, line: string): void => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + Effect.runFork( + emitRuntimeWarning(context, "Received invalid JSON from Cursor ACP.", { + line, + }), + ); + return; + } + + const message = asObject(parsed); + if (!message) { + Effect.runFork( + emitRuntimeWarning(context, "Received non-object protocol message from Cursor ACP."), + ); + return; + } + + if (nativeEventLogger) { + const nativeMethod = + typeof message.method === "string" + ? message.method + : (typeof message.id === "string" || typeof message.id === "number") + ? "cursor/acp/response" + : "cursor/acp/message"; + const nativeKind = + typeof message.method === "string" && + (typeof message.id === "string" || typeof message.id === "number") + ? "request" + : typeof message.method === "string" + ? "notification" + : "session"; + nativeEventLogger.write({ + observedAt: new Date().toISOString(), + event: { + id: EventId.makeUnsafe(randomUUID()), + kind: nativeKind, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: new Date().toISOString(), + method: nativeMethod, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + ...(context.turnState ? { turnId: context.turnState.turnId } : {}), + payload: message, + }, + }); + } + + if ( + (typeof message.id === "string" || typeof message.id === "number") && + typeof message.method === "string" + ) { + if (message.method === "session/request_permission") { + Effect.runFork(handlePermissionRequest(context, message)); + return; + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Unsupported server request: ${message.method}`, + }, + }); + return; + } + + if ( + (typeof message.id === "string" || typeof message.id === "number") && + ("result" in message || "error" in message) + ) { + resolvePendingRequest(context, message); + return; + } + + if (typeof message.method === "string") { + if (message.method === "session/update") { + Effect.runFork(handleSessionUpdateNotification(context, message)); + return; + } + + Effect.runFork( + emitRuntimeWarning( + context, + `Unhandled Cursor ACP notification '${message.method}'.`, + message, + ), + ); + return; + } + + Effect.runFork( + emitRuntimeWarning(context, "Received unrecognized protocol message from Cursor ACP.", { + message, + }), + ); + }; + + const stopSessionInternal = ( + context: CursorSessionContext, + options?: { readonly emitExitEvent?: boolean }, + ): Effect.Effect => + Effect.gen(function* () { + if (context.stopping) return; + context.stopping = true; + + for (const pending of context.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Cursor session stopped before request completion.")); + } + context.pending.clear(); + + if (context.turnState) { + yield* completeTurn(context, "interrupted", "Session stopped.", "cancelled"); + } + + context.output.close(); + if (!context.child.killed) { + context.child.kill(); + } + + context.session = { + ...context.session, + status: "closed", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + + if (options?.emitExitEvent !== false) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: stamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + payload: { + reason: "Session stopped", + exitKind: "graceful", + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + }, + }); + } + + sessions.delete(context.session.sessionId); + }); + + const requireSession = ( + sessionId: ReturnType, + ): Effect.Effect => { + const context = sessions.get(sessionId); + if (!context) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + sessionId, + }), + ); + } + if (context.stopping || context.session.status === "closed") { + return Effect.fail( + new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + sessionId, + }), + ); + } + return Effect.succeed(context); + }; + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const startedAt = yield* nowIso; + const sessionId = ProviderSessionId.makeUnsafe(`cursor-session-${yield* Random.nextUUIDv4}`); + const cwd = input.cwd ?? process.cwd(); + const cursorOptions = input.providerOptions?.cursor as + | { binaryPath?: string } + | undefined; + const binaryPath = cursorOptions?.binaryPath ?? "agent"; + const resumeState = readCursorResumeState(input.resumeCursor); + + const child = yield* Effect.try({ + try: () => + spawnCursorAcp({ + binaryPath, + cwd, + env: process.env, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + sessionId, + detail: toMessage(cause, "Failed to spawn Cursor ACP process."), + cause, + }), + }); + + const output = readline.createInterface({ input: child.stdout }); + + const session: ProviderSession = { + sessionId, + provider: PROVIDER, + status: "connecting", + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.model ? { model: input.model } : {}), + createdAt: startedAt, + updatedAt: startedAt, + }; + + const context: CursorSessionContext = { + session, + child, + output, + pending: new Map(), + pendingPermissions: new Map(), + turns: [], + turnState: undefined, + acpSessionId: resumeState?.acpSessionId ?? "", + nextRpcId: 1, + stopping: false, + }; + + output.on("line", (line) => { + handleStdoutLine(context, line); + }); + + child.stderr.on("data", (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (message.length === 0) { + return; + } + Effect.runFork( + emitRuntimeWarning(context, "Cursor ACP stderr output", { + message, + }), + ); + }); + + child.on("error", (error) => { + Effect.runFork( + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "runtime.error", + 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 } : {}), + payload: { + message: error.message || "Cursor ACP process error.", + class: "transport_error", + detail: error, + }, + }); + }), + ); + }); + + child.on("exit", (code, signal) => { + if (context.stopping) { + return; + } + Effect.runFork( + Effect.gen(function* () { + if (context.turnState) { + yield* completeTurn( + context, + "failed", + `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + ); + } + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + eventId: stamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: stamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + payload: { + reason: `Cursor ACP exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + exitKind: code === 0 ? "graceful" : "error", + recoverable: code === 0, + }, + }); + + sessions.delete(context.session.sessionId); + }), + ); + }); + + sessions.set(sessionId, context); + + const initializeResult = yield* Effect.tryPromise({ + try: async () => sendRequest(context, "initialize", {}), + catch: (cause) => toRequestError(sessionId, "initialize", cause), + }); + const decodedInitialize = Schema.decodeUnknownSync(CursorAcpInitializeResult)(initializeResult); + + const initStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.configured", + eventId: initStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: initStamp.createdAt, + payload: { + config: decodedInitialize, + }, + providerRefs: { + providerSessionId: sessionId, + }, + raw: { + source: "cursor.acp.response", + method: "initialize", + payload: initializeResult, + }, + }); + + yield* Effect.tryPromise({ + try: async () => sendRequest(context, "authenticate", { methodId: "cursor_login" }), + catch: () => undefined, + }); + + const acpSessionId = yield* Effect.tryPromise({ + try: async () => { + if (resumeState?.acpSessionId) { + await sendRequest(context, "session/load", { + sessionId: resumeState.acpSessionId, + cwd, + mcpServers: [], + }); + return resumeState.acpSessionId; + } + + const sessionNewParams: { + cwd: string; + mcpServers: []; + model?: string; + } = { + cwd, + mcpServers: [], + }; + if (input.model) { + sessionNewParams.model = input.model; + } + const result = await sendRequest(context, "session/new", sessionNewParams); + const decoded = Schema.decodeUnknownSync(CursorAcpSessionNewResult)(result); + return decoded.sessionId; + }, + catch: (cause) => toRequestError(sessionId, "session/new|session/load", cause), + }); + + const threadId = ProviderThreadId.makeUnsafe(acpSessionId); + context.acpSessionId = acpSessionId; + context.session = { + ...context.session, + status: "ready", + threadId, + resumeCursor: { + acpSessionId, + }, + updatedAt: yield* nowIso, + }; + + const sessionStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + eventId: sessionStartedStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: sessionStartedStamp.createdAt, + threadId, + payload: resumeState?.acpSessionId ? { resume: input.resumeCursor } : {}, + providerRefs: { + providerSessionId: sessionId, + providerThreadId: threadId, + }, + }); + + const threadStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "thread.started", + eventId: threadStartedStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: threadStartedStamp.createdAt, + threadId, + payload: { + providerThreadId: threadId, + }, + providerRefs: { + providerSessionId: sessionId, + providerThreadId: threadId, + }, + }); + + const readyStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.state.changed", + eventId: readyStamp.eventId, + provider: PROVIDER, + sessionId, + createdAt: readyStamp.createdAt, + threadId, + payload: { + state: "ready", + }, + providerRefs: { + providerSessionId: sessionId, + providerThreadId: threadId, + }, + }); + + return { + ...context.session, + }; + }); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.sessionId); + + if (context.turnState) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Session '${input.sessionId}' already has an active turn '${context.turnState.turnId}'.`, + }); + } + + const turnId = ProviderTurnId.makeUnsafe(`cursor-turn-${yield* Random.nextUUIDv4}`); + const turnState: CursorTurnState = { + turnId, + assistantItemId: `cursor-assistant-${yield* Random.nextUUIDv4}`, + startedToolCalls: new Set(), + items: [], + }; + + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const startedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: startedStamp.eventId, + provider: PROVIDER, + sessionId: context.session.sessionId, + createdAt: startedStamp.createdAt, + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + turnId, + payload: { + ...(input.model ? { model: input.model } : {}), + ...(input.effort ? { effort: input.effort } : {}), + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + providerTurnId: turnId, + }, + }); + + const promptText = input.input?.trim(); + if (!promptText || promptText.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }); + } + + const promptResultRaw = yield* Effect.tryPromise({ + try: async () => + sendRequest(context, "session/prompt", { + sessionId: context.acpSessionId, + prompt: [{ type: "text", text: promptText }], + }), + catch: (cause) => toRequestError(input.sessionId, "session/prompt", cause), + }); + + const promptResult = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)(promptResultRaw); + const turnStateValue = mapStopReasonToTurnState(promptResult.stopReason); + yield* completeTurn( + context, + turnStateValue, + turnStateValue === "failed" ? "Cursor prompt failed." : undefined, + promptResult.stopReason, + ); + + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + resumeCursor: { + acpSessionId: context.acpSessionId, + }, + }; + + return { + ...(context.session.threadId ? { threadId: context.session.threadId } : {}), + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (sessionId, _turnId) => + Effect.gen(function* () { + const context = yield* requireSession(sessionId); + if (!context.turnState) { + return; + } + + const cancelResult = yield* Effect.tryPromise({ + try: async () => sendRequest(context, "session/cancel", { sessionId: context.acpSessionId }, 15_000), + catch: (cause) => cause, + }); + + if (cancelResult instanceof Error) { + yield* emitRuntimeWarning( + context, + "Cursor ACP session/cancel is unavailable; marking turn as interrupted.", + cancelResult, + ); + } + + yield* completeTurn(context, "interrupted", "Turn interrupted by user.", "cancelled"); + }); + + const readThread: CursorAdapterShape["readThread"] = (sessionId) => + Effect.gen(function* () { + const context = yield* requireSession(sessionId); + return { + threadId: ProviderThreadId.makeUnsafe(context.acpSessionId), + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (sessionId, _numTurns) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: `Cursor ACP does not support thread rollback for session '${sessionId}'.`, + }), + ); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + sessionId, + requestId, + decision, + ) => + Effect.gen(function* () { + const context = yield* requireSession(sessionId); + const pending = context.pendingPermissions.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + + 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; + + if (!optionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `No selectable permission options for request: ${requestId}`, + }); + } + + writeCursorMessage(context, { + jsonrpc: "2.0", + id: pending.jsonRpcId, + result: { + outcome: { + outcome: "selected", + optionId, + }, + }, + }); + + context.pendingPermissions.delete(requestId); + + 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: pending.requestType, + decision, + resolution: { + optionId, + }, + }, + providerRefs: { + providerSessionId: context.session.sessionId, + ...(context.session.threadId ? { providerThreadId: context.session.threadId } : {}), + ...(context.turnState ? { providerTurnId: context.turnState.turnId } : {}), + providerRequestId: requestId, + }, + raw: { + source: "cursor.acp.response", + method: "session/request_permission", + payload: { + optionId, + }, + }, + }); + }); + + const stopSession: CursorAdapterShape["stopSession"] = (sessionId) => + Effect.gen(function* () { + const context = yield* requireSession(sessionId); + yield* stopSessionInternal(context, { + emitExitEvent: true, + }); + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), ({ session }) => ({ ...session }))); + + const hasSession: CursorAdapterShape["hasSession"] = (sessionId) => + Effect.sync(() => { + const context = sessions.get(sessionId); + return context !== undefined && !context.stopping; + }); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: true, + }), + { discard: true }, + ); + + yield* Effect.addFinalizer(() => + Effect.forEach( + sessions, + ([, context]) => + stopSessionInternal(context, { + emitExitEvent: false, + }), + { discard: true }, + ).pipe(Effect.tap(() => Queue.shutdown(runtimeEventQueue))), + ); + + return { + provider: PROVIDER, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CursorAdapterShape; + }); +} + +export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); + +export function makeCursorAdapterLive(options?: CursorAdapterLiveOptions) { + return Layer.effect(CursorAdapter, makeCursorAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index edd2a5c18a1bb42b79d4b5ddc4f4ab78a42f7d0e..75ed7576cb609b377cbe7351a0cd2c2350ca3e6a 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeCodeAdapter, ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -41,6 +42,21 @@ const fakeClaudeAdapter: ClaudeCodeAdapterShape = { streamEvents: Stream.empty, }; +const fakeCursorAdapter: CursorAdapterShape = { + provider: "cursor", + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -48,6 +64,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeCodeAdapter, fakeClaudeAdapter), + Layer.succeed(CursorAdapter, fakeCursorAdapter), ), ), NodeServices.layer, @@ -60,11 +77,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeCode"); + const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeCode"]); + assert.deepEqual(providers, ["codex", "claudeCode", "cursor"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 37c430951878ce80bcadb504459b6223801aa301..37b2e40d1202a9c427bf40ee7352f7cab6f420c7 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -27,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeCodeAdapter]; + : [yield* CodexAdapter, yield* ClaudeCodeAdapter, yield* CursorAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Services/CursorAdapter.test.ts b/apps/server/src/provider/Services/CursorAdapter.test.ts index 1284d0970f83f0f2ba0f6761e4e2f30c71111889..007a212e1577d63bf16b56305ff2151b2d5ad2d5 100644 --- a/apps/server/src/provider/Services/CursorAdapter.test.ts +++ b/apps/server/src/provider/Services/CursorAdapter.test.ts @@ -1,131 +1,130 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { CursorCliStreamEvent } from "./CursorAdapter.ts"; +import { + CursorAcpPermissionRequest, + CursorAcpSessionPromptResult, + CursorAcpSessionUpdateNotification, +} from "./CursorAdapter.ts"; -describe("CursorCliStreamEvent", () => { - it("decodes system/init events", () => { - const decoded = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "system", - subtype: "init", - session_id: "sess-1", - apiKeySource: "login", - cwd: "/tmp/project", - model: "Auto", - permissionMode: "default", +describe("Cursor ACP schemas", () => { + it("decodes session/update thought and message chunks", () => { + const thought = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: "thinking", + }, + }, + }, }); - expect(decoded.type).toBe("system"); - if (decoded.type !== "system") { - return; - } - expect(decoded.subtype).toBe("init"); - expect(decoded.session_id).toBe("sess-1"); - }); + expect(thought.params.update.sessionUpdate).toBe("agent_thought_chunk"); - it("decodes thinking delta/completed events", () => { - const delta = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "thinking", - subtype: "delta", - text: "draft", - session_id: "sess-1", - timestamp_ms: 123, + const message = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "hello", + }, + }, + }, }); - expect(delta.type).toBe("thinking"); - if (delta.type !== "thinking") { - return; - } - expect(delta.subtype).toBe("delta"); + expect(message.params.update.sessionUpdate).toBe("agent_message_chunk"); + }); - const completed = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "thinking", - subtype: "completed", - session_id: "sess-1", + it("decodes tool call lifecycle updates", () => { + const started = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { command: "pwd" }, + }, + }, }); - expect(completed.type).toBe("thinking"); - if (completed.type !== "thinking") { - return; - } - expect(completed.subtype).toBe("completed"); - }); + expect(started.params.update.sessionUpdate).toBe("tool_call"); - it("decodes tool_call completed events with rejected results", () => { - const decoded = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "tool_call", - subtype: "completed", - call_id: "tool-1\nline-2", - session_id: "sess-1", - tool_call: { - shellToolCall: { - args: { - command: "rm -rf /tmp/nope", - }, - result: { - rejected: { - reason: "approval denied", - }, + const completed = Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: "ok", + stderr: "", }, }, }, }); - expect(decoded.type).toBe("tool_call"); - if (decoded.type !== "tool_call") { - return; - } - expect(decoded.subtype).toBe("completed"); - expect(decoded.call_id).toContain("\n"); + expect(completed.params.update.sessionUpdate).toBe("tool_call_update"); }); - it("decodes result success events with usage payload", () => { - const decoded = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "result", - subtype: "success", - duration_ms: 100, - duration_api_ms: 90, - is_error: false, - result: "OK", - session_id: "sess-1", - request_id: "req-1", - usage: { - inputTokens: 10, - outputTokens: 5, - cacheReadTokens: 2, - cacheWriteTokens: 1, + it("decodes permission requests", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpPermissionRequest)({ + jsonrpc: "2.0", + id: 9, + method: "session/request_permission", + params: { + sessionId: "sess-1", + toolCall: { + toolCallId: "tool-1", + kind: "execute", + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], }, }); - expect(decoded.type).toBe("result"); - if (decoded.type !== "result") { - return; - } - expect(decoded.subtype).toBe("success"); - expect(decoded.is_error).toBe(false); + expect(decoded.method).toBe("session/request_permission"); + expect(decoded.params.options).toHaveLength(2); }); - it("decodes connection/retry lifecycle events", () => { - const reconnecting = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "connection", - subtype: "reconnecting", - session_id: "sess-1", + it("decodes prompt completion result payload", () => { + const decoded = Schema.decodeUnknownSync(CursorAcpSessionPromptResult)({ + stopReason: "end_turn", }); - expect(reconnecting.type).toBe("connection"); - const retry = Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "retry", - subtype: "resuming", - session_id: "sess-1", - }); - expect(retry.type).toBe("retry"); + expect(decoded.stopReason).toBe("end_turn"); }); - it("rejects unsupported stream event types", () => { + it("rejects unsupported update types", () => { expect(() => - Schema.decodeUnknownSync(CursorCliStreamEvent)({ - type: "bogus", - session_id: "sess-1", + Schema.decodeUnknownSync(CursorAcpSessionUpdateNotification)({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "sess-1", + update: { + sessionUpdate: "unknown_update", + }, + }, }), ).toThrow(); }); diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts index c7ee46d46a1e89e22dd0587ac4046d269a9d0ee9..405ea3689b6755a780c88dc35053ed6023d7fbee 100644 --- a/apps/server/src/provider/Services/CursorAdapter.ts +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -1,11 +1,7 @@ /** - * CursorAdapter - Cursor CLI implementation of the generic provider adapter contract. + * CursorAdapter - Cursor ACP implementation of the generic provider adapter contract. * - * This service will own Cursor CLI (`agent`) process / stream-json semantics and emit - * canonical provider runtime events via the shared provider adapter contract. - * - * This file intentionally defines raw Cursor stream-json schemas up front so the future - * layer implementation can decode/validate NDJSON lines in a single place. + * Defines ACP JSON-RPC schemas used by the Cursor adapter layer. * * @module CursorAdapter */ @@ -14,201 +10,101 @@ import { Schema, ServiceMap } from "effect"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; -export const CursorCliSessionId = Schema.String.check(Schema.isNonEmpty()); -export type CursorCliSessionId = typeof CursorCliSessionId.Type; +export const CursorAcpJsonRpcId = Schema.Union([Schema.String, Schema.Int]); +export type CursorAcpJsonRpcId = typeof CursorAcpJsonRpcId.Type; -export const CursorCliTextContentPart = Schema.Struct({ +export const CursorAcpTextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String, }); -export type CursorCliTextContentPart = typeof CursorCliTextContentPart.Type; +export type CursorAcpTextContent = typeof CursorAcpTextContent.Type; -export const CursorCliContentPart = Schema.Union([ - CursorCliTextContentPart, +export const CursorAcpSessionUpdate = Schema.Union([ Schema.Struct({ - type: Schema.String, + sessionUpdate: Schema.Literal("available_commands_update"), + availableCommands: Schema.Array( + Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + }), + ), + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_thought_chunk"), + content: CursorAcpTextContent, + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("agent_message_chunk"), + content: CursorAcpTextContent, + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call"), + toolCallId: Schema.String, + title: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), + status: Schema.optional(Schema.String), + rawInput: Schema.optional(Schema.Unknown), + }), + Schema.Struct({ + sessionUpdate: Schema.Literal("tool_call_update"), + toolCallId: Schema.String, + status: Schema.String, + rawOutput: Schema.optional(Schema.Unknown), }), ]); -export type CursorCliContentPart = typeof CursorCliContentPart.Type; - -export const CursorCliUserMessage = Schema.Struct({ - role: Schema.Literal("user"), - content: Schema.Array(CursorCliContentPart), -}); -export type CursorCliUserMessage = typeof CursorCliUserMessage.Type; - -export const CursorCliAssistantMessage = Schema.Struct({ - role: Schema.Literal("assistant"), - content: Schema.Array(CursorCliContentPart), -}); -export type CursorCliAssistantMessage = typeof CursorCliAssistantMessage.Type; - -export const CursorCliToolCallResult = Schema.Struct({ - success: Schema.optional(Schema.Unknown), - failure: Schema.optional(Schema.Unknown), - rejected: Schema.optional(Schema.Unknown), -}); -export type CursorCliToolCallResult = typeof CursorCliToolCallResult.Type; - -export const CursorCliToolCallEntry = Schema.Struct({ - args: Schema.optional(Schema.Unknown), - result: Schema.optional(CursorCliToolCallResult), -}); -export type CursorCliToolCallEntry = typeof CursorCliToolCallEntry.Type; - -export const CursorCliFunctionToolCall = Schema.Struct({ - name: Schema.String, - arguments: Schema.String, - result: Schema.optional(CursorCliToolCallResult), -}); -export type CursorCliFunctionToolCall = typeof CursorCliFunctionToolCall.Type; - -export const CursorCliToolCallPayload = Schema.Struct({ - readToolCall: Schema.optional(CursorCliToolCallEntry), - writeToolCall: Schema.optional(CursorCliToolCallEntry), - editToolCall: Schema.optional(CursorCliToolCallEntry), - shellToolCall: Schema.optional(CursorCliToolCallEntry), - grepToolCall: Schema.optional(CursorCliToolCallEntry), - globToolCall: Schema.optional(CursorCliToolCallEntry), - function: Schema.optional(CursorCliFunctionToolCall), -}); -export type CursorCliToolCallPayload = typeof CursorCliToolCallPayload.Type; - -export const CursorCliTokenUsage = Schema.Struct({ - inputTokens: Schema.optional(Schema.Number), - outputTokens: Schema.optional(Schema.Number), - cacheReadTokens: Schema.optional(Schema.Number), - cacheWriteTokens: Schema.optional(Schema.Number), -}); -export type CursorCliTokenUsage = typeof CursorCliTokenUsage.Type; - -const CursorCliTimestampMs = { - timestamp_ms: Schema.optional(Schema.Number), -} as const; - -export const CursorCliSystemInitEvent = Schema.Struct({ - type: Schema.Literal("system"), - subtype: Schema.Literal("init"), - session_id: CursorCliSessionId, - apiKeySource: Schema.optional(Schema.String), - cwd: Schema.optional(Schema.String), - model: Schema.optional(Schema.String), - permissionMode: Schema.optional(Schema.String), - ...CursorCliTimestampMs, -}); -export type CursorCliSystemInitEvent = typeof CursorCliSystemInitEvent.Type; - -export const CursorCliUserEvent = Schema.Struct({ - type: Schema.Literal("user"), - message: CursorCliUserMessage, - session_id: CursorCliSessionId, - ...CursorCliTimestampMs, -}); -export type CursorCliUserEvent = typeof CursorCliUserEvent.Type; - -export const CursorCliAssistantEvent = Schema.Struct({ - type: Schema.Literal("assistant"), - message: CursorCliAssistantMessage, - session_id: CursorCliSessionId, - ...CursorCliTimestampMs, -}); -export type CursorCliAssistantEvent = typeof CursorCliAssistantEvent.Type; - -export const CursorCliThinkingDeltaEvent = Schema.Struct({ - type: Schema.Literal("thinking"), - subtype: Schema.Literal("delta"), - text: Schema.String, - session_id: CursorCliSessionId, - ...CursorCliTimestampMs, -}); -export type CursorCliThinkingDeltaEvent = typeof CursorCliThinkingDeltaEvent.Type; - -export const CursorCliThinkingCompletedEvent = Schema.Struct({ - type: Schema.Literal("thinking"), - subtype: Schema.Literal("completed"), - session_id: CursorCliSessionId, - ...CursorCliTimestampMs, +export type CursorAcpSessionUpdate = typeof CursorAcpSessionUpdate.Type; + +export const CursorAcpSessionUpdateNotification = Schema.Struct({ + jsonrpc: Schema.optional(Schema.Literal("2.0")), + method: Schema.Literal("session/update"), + params: Schema.Struct({ + sessionId: Schema.String, + update: CursorAcpSessionUpdate, + }), }); -export type CursorCliThinkingCompletedEvent = typeof CursorCliThinkingCompletedEvent.Type; +export type CursorAcpSessionUpdateNotification = typeof CursorAcpSessionUpdateNotification.Type; -export const CursorCliToolCallStartedEvent = Schema.Struct({ - type: Schema.Literal("tool_call"), - subtype: Schema.Literal("started"), - call_id: Schema.String, - tool_call: CursorCliToolCallPayload, - session_id: CursorCliSessionId, - ...CursorCliTimestampMs, +export const CursorAcpPermissionOption = Schema.Struct({ + optionId: Schema.String, + name: Schema.optional(Schema.String), + kind: Schema.optional(Schema.String), }); -export type CursorCliToolCallStartedEvent = typeof CursorCliToolCallStartedEvent.Type; +export type CursorAcpPermissionOption = typeof CursorAcpPermissionOption.Type; -export const CursorCliToolCallCompletedEvent = Schema.Struct({ - type: Schema.Literal("tool_call"), - subtype: Schema.Literal("completed"), - call_id: Schema.String, - tool_call: CursorCliToolCallPayload, - session_id: CursorCliSessionId, - ...CursorCliTimestampMs, +export const CursorAcpPermissionRequest = Schema.Struct({ + jsonrpc: Schema.optional(Schema.Literal("2.0")), + id: CursorAcpJsonRpcId, + method: Schema.Literal("session/request_permission"), + params: Schema.Struct({ + sessionId: Schema.String, + toolCall: Schema.optional(Schema.Unknown), + options: Schema.Array(CursorAcpPermissionOption), + }), }); -export type CursorCliToolCallCompletedEvent = typeof CursorCliToolCallCompletedEvent.Type; +export type CursorAcpPermissionRequest = typeof CursorAcpPermissionRequest.Type; -export const CursorCliResultSuccessEvent = Schema.Struct({ - type: Schema.Literal("result"), - subtype: Schema.Literal("success"), - duration_ms: Schema.Number, - duration_api_ms: Schema.optional(Schema.Number), - is_error: Schema.Boolean, - result: Schema.String, - session_id: CursorCliSessionId, - request_id: Schema.optional(Schema.String), - usage: Schema.optional(CursorCliTokenUsage), - ...CursorCliTimestampMs, +export const CursorAcpInitializeResult = Schema.Struct({ + protocolVersion: Schema.optional(Schema.Int), + agentCapabilities: Schema.optional(Schema.Unknown), + authMethods: Schema.optional(Schema.Array(Schema.Unknown)), }); -export type CursorCliResultSuccessEvent = typeof CursorCliResultSuccessEvent.Type; +export type CursorAcpInitializeResult = typeof CursorAcpInitializeResult.Type; -export const CursorCliConnectionEvent = Schema.Struct({ - type: Schema.Literal("connection"), - subtype: Schema.Literals(["reconnecting", "reconnected"]), - session_id: Schema.optional(CursorCliSessionId), - ...CursorCliTimestampMs, +export const CursorAcpSessionNewResult = Schema.Struct({ + sessionId: Schema.String, + modes: Schema.optional(Schema.Unknown), }); -export type CursorCliConnectionEvent = typeof CursorCliConnectionEvent.Type; +export type CursorAcpSessionNewResult = typeof CursorAcpSessionNewResult.Type; -export const CursorCliRetryEvent = Schema.Struct({ - type: Schema.Literal("retry"), - subtype: Schema.Literals(["starting", "resuming"]), - session_id: Schema.optional(CursorCliSessionId), - ...CursorCliTimestampMs, +export const CursorAcpSessionPromptResult = Schema.Struct({ + stopReason: Schema.optional(Schema.String), }); -export type CursorCliRetryEvent = typeof CursorCliRetryEvent.Type; - -export const CursorCliStreamEvent = Schema.Union([ - CursorCliSystemInitEvent, - CursorCliUserEvent, - CursorCliAssistantEvent, - CursorCliThinkingDeltaEvent, - CursorCliThinkingCompletedEvent, - CursorCliToolCallStartedEvent, - CursorCliToolCallCompletedEvent, - CursorCliResultSuccessEvent, - CursorCliConnectionEvent, - CursorCliRetryEvent, -]); -export type CursorCliStreamEvent = typeof CursorCliStreamEvent.Type; +export type CursorAcpSessionPromptResult = typeof CursorAcpSessionPromptResult.Type; -/** - * CursorAdapterShape - Service API for the Cursor provider adapter. - * - * `provider` is intentionally narrowed to `"cursor"` here. Until contracts add - * Cursor to `ProviderKind`, this shape is defined via `Omit<...,"provider">`. - */ -export interface CursorAdapterShape - extends Omit, "provider"> { +export interface CursorAdapterShape extends ProviderAdapterShape { readonly provider: "cursor"; } -/** - * CursorAdapter - Service tag for Cursor provider adapter operations. - */ export class CursorAdapter extends ServiceMap.Service()( "t3/provider/Services/CursorAdapter", ) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 58b7354765d1f72e7b78287cf2181e0f38ec7f6a..dfbf38cff7d11c51bfa49c4d9a22b4cfb56b44be 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,6 +20,7 @@ import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRun import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -60,8 +61,11 @@ export function makeServerProviderLayer(): Layer.Layer< const claudeAdapterLayer = makeClaudeCodeAdapterLive({ nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), }); + const cursorAdapterLayer = makeCursorAdapterLive({ + nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), + }); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer)), + Layer.provide(Layer.mergeAll(codexAdapterLayer, claudeAdapterLayer, cursorAdapterLayer)), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 54d8916d44c8a00c135460cfcbd558b95de8bb39..640ea520442d756e4db01b736647741902b81b99 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -52,11 +52,17 @@ describe("ProviderSessionStartInput", () => { provider: "cursor", cwd: "/tmp/workspace", model: "composer-1.5", + providerOptions: { + cursor: { + binaryPath: "/usr/local/bin/agent", + }, + }, approvalPolicy: "on-request", sandboxMode: "workspace-write", }); expect(parsed.provider).toBe("cursor"); expect(parsed.model).toBe("composer-1.5"); + expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent"); expect(parsed.approvalPolicy).toBe("on-request"); expect(parsed.sandboxMode).toBe("workspace-write"); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 3307ad4dc0c5626682fd80000ef6996967ba2243..e763e101ae4eed5d45a490c15c0d64aa9f398edd 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -62,9 +62,15 @@ export const ClaudeCodeProviderStartOptions = Schema.Struct({ }); export type ClaudeCodeProviderStartOptions = typeof ClaudeCodeProviderStartOptions.Type; +export const CursorProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type CursorProviderStartOptions = typeof CursorProviderStartOptions.Type; + export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), + cursor: Schema.optional(CursorProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index ebe5b21bd807e17bf239db6e046208760cf806c0..88c937b500e7c5f925b09aab5b1272629fde7bd3 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -9,6 +9,7 @@ import { RuntimeItemId, RuntimeRequestId, RuntimeSessionId, + RuntimeTaskId, ThreadId, TrimmedNonEmptyString, TurnId, @@ -25,6 +26,9 @@ export const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "cursor.acp.notification", + "cursor.acp.request", + "cursor.acp.response", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; @@ -48,6 +52,26 @@ export const ProviderRefs = Schema.Struct({ }); export type ProviderRefs = typeof ProviderRefs.Type; +export const RuntimeSessionState = Schema.Literals([ + "starting", + "ready", + "running", + "waiting", + "stopped", + "error", +]); +export type RuntimeSessionState = typeof RuntimeSessionState.Type; + +export const RuntimeThreadState = Schema.Literals([ + "active", + "idle", + "archived", + "closed", + "compacted", + "error", +]); +export type RuntimeThreadState = typeof RuntimeThreadState.Type; + export const RuntimeTurnState = Schema.Literals([ "completed", "failed", @@ -94,6 +118,12 @@ export const CanonicalItemType = Schema.Literals([ "file_change", "mcp_tool_call", "dynamic_tool_call", + "collab_agent_tool_call", + "web_search", + "image_view", + "review_entered", + "review_exited", + "context_compaction", "error", "unknown", ]); @@ -106,17 +136,30 @@ export const CanonicalRequestType = Schema.Literals([ "exec_command_approval", "tool_user_input", "dynamic_tool_call", + "auth_tokens_refresh", "unknown", ]); export type CanonicalRequestType = typeof CanonicalRequestType.Type; export const ProviderRuntimeEventType = Schema.Literals([ "session.started", + "session.configured", + "session.state.changed", "session.exited", "thread.started", + "thread.state.changed", + "thread.metadata.updated", + "thread.token-usage.updated", + "thread.realtime.started", + "thread.realtime.item-added", + "thread.realtime.audio.delta", + "thread.realtime.error", + "thread.realtime.closed", "turn.started", "turn.completed", + "turn.aborted", "turn.plan.updated", + "turn.diff.updated", "item.started", "item.updated", "item.completed", @@ -125,17 +168,46 @@ export const ProviderRuntimeEventType = Schema.Literals([ "request.resolved", "user-input.requested", "user-input.resolved", + "task.started", + "task.progress", + "task.completed", + "hook.started", + "hook.progress", + "hook.completed", + "tool.progress", + "tool.summary", + "auth.status", + "account.updated", + "account.rate-limits.updated", + "mcp.status.updated", + "mcp.oauth.completed", + "model.rerouted", + "config.warning", + "deprecation.notice", + "files.persisted", "runtime.warning", "runtime.error", ]); export type ProviderRuntimeEventType = typeof ProviderRuntimeEventType.Type; export const SessionStartedType = Schema.Literal("session.started"); +export const SessionConfiguredType = Schema.Literal("session.configured"); +export const SessionStateChangedType = Schema.Literal("session.state.changed"); export const SessionExitedType = Schema.Literal("session.exited"); export const ThreadStartedType = Schema.Literal("thread.started"); +export const ThreadStateChangedType = Schema.Literal("thread.state.changed"); +export const ThreadMetadataUpdatedType = Schema.Literal("thread.metadata.updated"); +export const ThreadTokenUsageUpdatedType = Schema.Literal("thread.token-usage.updated"); +export const ThreadRealtimeStartedType = Schema.Literal("thread.realtime.started"); +export const ThreadRealtimeItemAddedType = Schema.Literal("thread.realtime.item-added"); +export const ThreadRealtimeAudioDeltaType = Schema.Literal("thread.realtime.audio.delta"); +export const ThreadRealtimeErrorType = Schema.Literal("thread.realtime.error"); +export const ThreadRealtimeClosedType = Schema.Literal("thread.realtime.closed"); export const TurnStartedType = Schema.Literal("turn.started"); export const TurnCompletedType = Schema.Literal("turn.completed"); +export const TurnAbortedType = Schema.Literal("turn.aborted"); export const TurnPlanUpdatedType = Schema.Literal("turn.plan.updated"); +export const TurnDiffUpdatedType = Schema.Literal("turn.diff.updated"); export const ItemStartedType = Schema.Literal("item.started"); export const ItemUpdatedType = Schema.Literal("item.updated"); export const ItemCompletedType = Schema.Literal("item.completed"); @@ -144,6 +216,23 @@ export const RequestOpenedType = Schema.Literal("request.opened"); export const RequestResolvedType = Schema.Literal("request.resolved"); export const UserInputRequestedType = Schema.Literal("user-input.requested"); export const UserInputResolvedType = Schema.Literal("user-input.resolved"); +export const TaskStartedType = Schema.Literal("task.started"); +export const TaskProgressType = Schema.Literal("task.progress"); +export const TaskCompletedType = Schema.Literal("task.completed"); +export const HookStartedType = Schema.Literal("hook.started"); +export const HookProgressType = Schema.Literal("hook.progress"); +export const HookCompletedType = Schema.Literal("hook.completed"); +export const ToolProgressType = Schema.Literal("tool.progress"); +export const ToolSummaryType = Schema.Literal("tool.summary"); +export const AuthStatusType = Schema.Literal("auth.status"); +export const AccountUpdatedType = Schema.Literal("account.updated"); +export const AccountRateLimitsUpdatedType = Schema.Literal("account.rate-limits.updated"); +export const McpStatusUpdatedType = Schema.Literal("mcp.status.updated"); +export const McpOauthCompletedType = Schema.Literal("mcp.oauth.completed"); +export const ModelReroutedType = Schema.Literal("model.rerouted"); +export const ConfigWarningType = Schema.Literal("config.warning"); +export const DeprecationNoticeType = Schema.Literal("deprecation.notice"); +export const FilesPersistedType = Schema.Literal("files.persisted"); export const RuntimeWarningType = Schema.Literal("runtime.warning"); export const RuntimeErrorType = Schema.Literal("runtime.error"); @@ -167,6 +256,18 @@ export const SessionStartedPayload = Schema.Struct({ }); export type SessionStartedPayload = typeof SessionStartedPayload.Type; +export const SessionConfiguredPayload = Schema.Struct({ + config: UnknownRecordSchema, +}); +export type SessionConfiguredPayload = typeof SessionConfiguredPayload.Type; + +export const SessionStateChangedPayload = Schema.Struct({ + state: RuntimeSessionState, + reason: Schema.optional(TrimmedNonEmptyStringSchema), + detail: Schema.optional(Schema.Unknown), +}); +export type SessionStateChangedPayload = typeof SessionStateChangedPayload.Type; + export const SessionExitedPayload = Schema.Struct({ reason: Schema.optional(TrimmedNonEmptyStringSchema), recoverable: Schema.optional(Schema.Boolean), @@ -179,6 +280,48 @@ export const ThreadStartedPayload = Schema.Struct({ }); export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; +export const ThreadStateChangedPayload = Schema.Struct({ + state: RuntimeThreadState, + detail: Schema.optional(Schema.Unknown), +}); +export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; + +export const ThreadMetadataUpdatedPayload = Schema.Struct({ + name: Schema.optional(TrimmedNonEmptyStringSchema), + metadata: Schema.optional(UnknownRecordSchema), +}); +export type ThreadMetadataUpdatedPayload = typeof ThreadMetadataUpdatedPayload.Type; + +export const ThreadTokenUsageUpdatedPayload = Schema.Struct({ + usage: Schema.Unknown, +}); +export type ThreadTokenUsageUpdatedPayload = typeof ThreadTokenUsageUpdatedPayload.Type; + +export const ThreadRealtimeStartedPayload = Schema.Struct({ + realtimeSessionId: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type ThreadRealtimeStartedPayload = typeof ThreadRealtimeStartedPayload.Type; + +export const ThreadRealtimeItemAddedPayload = Schema.Struct({ + item: Schema.Unknown, +}); +export type ThreadRealtimeItemAddedPayload = typeof ThreadRealtimeItemAddedPayload.Type; + +export const ThreadRealtimeAudioDeltaPayload = Schema.Struct({ + audio: Schema.Unknown, +}); +export type ThreadRealtimeAudioDeltaPayload = typeof ThreadRealtimeAudioDeltaPayload.Type; + +export const ThreadRealtimeErrorPayload = Schema.Struct({ + message: TrimmedNonEmptyStringSchema, +}); +export type ThreadRealtimeErrorPayload = typeof ThreadRealtimeErrorPayload.Type; + +export const ThreadRealtimeClosedPayload = Schema.Struct({ + reason: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type ThreadRealtimeClosedPayload = typeof ThreadRealtimeClosedPayload.Type; + export const TurnStartedPayload = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyStringSchema), effort: Schema.optional(TrimmedNonEmptyStringSchema), @@ -195,6 +338,11 @@ export const TurnCompletedPayload = Schema.Struct({ }); export type TurnCompletedPayload = typeof TurnCompletedPayload.Type; +export const TurnAbortedPayload = Schema.Struct({ + reason: TrimmedNonEmptyStringSchema, +}); +export type TurnAbortedPayload = typeof TurnAbortedPayload.Type; + export const RuntimePlanStep = Schema.Struct({ step: TrimmedNonEmptyStringSchema, status: RuntimePlanStepStatus, @@ -207,6 +355,11 @@ export const TurnPlanUpdatedPayload = Schema.Struct({ }); export type TurnPlanUpdatedPayload = typeof TurnPlanUpdatedPayload.Type; +export const TurnDiffUpdatedPayload = Schema.Struct({ + unifiedDiff: Schema.String, +}); +export type TurnDiffUpdatedPayload = typeof TurnDiffUpdatedPayload.Type; + export const ItemLifecyclePayload = Schema.Struct({ itemType: CanonicalItemType, status: Schema.optional(RuntimeItemStatus), @@ -262,6 +415,136 @@ export const UserInputResolvedPayload = Schema.Struct({ }); export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type; +export const TaskStartedPayload = Schema.Struct({ + taskId: RuntimeTaskId, + description: Schema.optional(TrimmedNonEmptyStringSchema), + taskType: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type TaskStartedPayload = typeof TaskStartedPayload.Type; + +export const TaskProgressPayload = Schema.Struct({ + taskId: RuntimeTaskId, + description: TrimmedNonEmptyStringSchema, + usage: Schema.optional(Schema.Unknown), + lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type TaskProgressPayload = typeof TaskProgressPayload.Type; + +export const TaskCompletedPayload = Schema.Struct({ + taskId: RuntimeTaskId, + status: Schema.Literals(["completed", "failed", "stopped"]), + summary: Schema.optional(TrimmedNonEmptyStringSchema), + usage: Schema.optional(Schema.Unknown), +}); +export type TaskCompletedPayload = typeof TaskCompletedPayload.Type; + +export const HookStartedPayload = Schema.Struct({ + hookId: TrimmedNonEmptyStringSchema, + hookName: TrimmedNonEmptyStringSchema, + hookEvent: TrimmedNonEmptyStringSchema, +}); +export type HookStartedPayload = typeof HookStartedPayload.Type; + +export const HookProgressPayload = Schema.Struct({ + hookId: TrimmedNonEmptyStringSchema, + output: Schema.optional(Schema.String), + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), +}); +export type HookProgressPayload = typeof HookProgressPayload.Type; + +export const HookCompletedPayload = Schema.Struct({ + hookId: TrimmedNonEmptyStringSchema, + outcome: Schema.Literals(["success", "error", "cancelled"]), + output: Schema.optional(Schema.String), + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Int), +}); +export type HookCompletedPayload = typeof HookCompletedPayload.Type; + +export const ToolProgressPayload = Schema.Struct({ + toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), + toolName: Schema.optional(TrimmedNonEmptyStringSchema), + summary: Schema.optional(TrimmedNonEmptyStringSchema), + elapsedSeconds: Schema.optional(Schema.Number), +}); +export type ToolProgressPayload = typeof ToolProgressPayload.Type; + +export const ToolSummaryPayload = Schema.Struct({ + summary: TrimmedNonEmptyStringSchema, + precedingToolUseIds: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), +}); +export type ToolSummaryPayload = typeof ToolSummaryPayload.Type; + +export const AuthStatusPayload = Schema.Struct({ + isAuthenticating: Schema.optional(Schema.Boolean), + output: Schema.optional(Schema.Array(Schema.String)), + error: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type AuthStatusPayload = typeof AuthStatusPayload.Type; + +export const AccountUpdatedPayload = Schema.Struct({ + account: Schema.Unknown, +}); +export type AccountUpdatedPayload = typeof AccountUpdatedPayload.Type; + +export const AccountRateLimitsUpdatedPayload = Schema.Struct({ + rateLimits: Schema.Unknown, +}); +export type AccountRateLimitsUpdatedPayload = typeof AccountRateLimitsUpdatedPayload.Type; + +export const McpStatusUpdatedPayload = Schema.Struct({ + status: Schema.Unknown, +}); +export type McpStatusUpdatedPayload = typeof McpStatusUpdatedPayload.Type; + +export const McpOauthCompletedPayload = Schema.Struct({ + success: Schema.Boolean, + name: Schema.optional(TrimmedNonEmptyStringSchema), + error: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type McpOauthCompletedPayload = typeof McpOauthCompletedPayload.Type; + +export const ModelReroutedPayload = Schema.Struct({ + fromModel: TrimmedNonEmptyStringSchema, + toModel: TrimmedNonEmptyStringSchema, + reason: TrimmedNonEmptyStringSchema, +}); +export type ModelReroutedPayload = typeof ModelReroutedPayload.Type; + +export const ConfigWarningPayload = Schema.Struct({ + summary: TrimmedNonEmptyStringSchema, + details: Schema.optional(TrimmedNonEmptyStringSchema), + path: Schema.optional(TrimmedNonEmptyStringSchema), + range: Schema.optional(Schema.Unknown), +}); +export type ConfigWarningPayload = typeof ConfigWarningPayload.Type; + +export const DeprecationNoticePayload = Schema.Struct({ + summary: TrimmedNonEmptyStringSchema, + details: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type DeprecationNoticePayload = typeof DeprecationNoticePayload.Type; + +export const FilesPersistedPayload = Schema.Struct({ + files: Schema.Array( + Schema.Struct({ + filename: TrimmedNonEmptyStringSchema, + fileId: TrimmedNonEmptyStringSchema, + }), + ), + failed: Schema.optional( + Schema.Array( + Schema.Struct({ + filename: TrimmedNonEmptyStringSchema, + error: TrimmedNonEmptyStringSchema, + }), + ), + ), +}); +export type FilesPersistedPayload = typeof FilesPersistedPayload.Type; + export const RuntimeWarningPayload = Schema.Struct({ message: TrimmedNonEmptyStringSchema, detail: Schema.optional(Schema.Unknown), @@ -282,6 +565,20 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({ }); export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; +export const ProviderRuntimeSessionConfiguredEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionConfiguredType, + payload: SessionConfiguredPayload, +}); +export type ProviderRuntimeSessionConfiguredEvent = typeof ProviderRuntimeSessionConfiguredEvent.Type; + +export const ProviderRuntimeSessionStateChangedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: SessionStateChangedType, + payload: SessionStateChangedPayload, +}); +export type ProviderRuntimeSessionStateChangedEvent = typeof ProviderRuntimeSessionStateChangedEvent.Type; + export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: SessionExitedType, @@ -296,6 +593,68 @@ export const ProviderRuntimeThreadStartedEvent = Schema.Struct({ }); export type ProviderRuntimeThreadStartedEvent = typeof ProviderRuntimeThreadStartedEvent.Type; +export const ProviderRuntimeThreadStateChangedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadStateChangedType, + payload: ThreadStateChangedPayload, +}); +export type ProviderRuntimeThreadStateChangedEvent = typeof ProviderRuntimeThreadStateChangedEvent.Type; + +export const ProviderRuntimeThreadMetadataUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadMetadataUpdatedType, + payload: ThreadMetadataUpdatedPayload, +}); +export type ProviderRuntimeThreadMetadataUpdatedEvent = typeof ProviderRuntimeThreadMetadataUpdatedEvent.Type; + +export const ProviderRuntimeThreadTokenUsageUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadTokenUsageUpdatedType, + payload: ThreadTokenUsageUpdatedPayload, +}); +export type ProviderRuntimeThreadTokenUsageUpdatedEvent = + typeof ProviderRuntimeThreadTokenUsageUpdatedEvent.Type; + +export const ProviderRuntimeThreadRealtimeStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeStartedType, + payload: ThreadRealtimeStartedPayload, +}); +export type ProviderRuntimeThreadRealtimeStartedEvent = + typeof ProviderRuntimeThreadRealtimeStartedEvent.Type; + +export const ProviderRuntimeThreadRealtimeItemAddedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeItemAddedType, + payload: ThreadRealtimeItemAddedPayload, +}); +export type ProviderRuntimeThreadRealtimeItemAddedEvent = + typeof ProviderRuntimeThreadRealtimeItemAddedEvent.Type; + +export const ProviderRuntimeThreadRealtimeAudioDeltaEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeAudioDeltaType, + payload: ThreadRealtimeAudioDeltaPayload, +}); +export type ProviderRuntimeThreadRealtimeAudioDeltaEvent = + typeof ProviderRuntimeThreadRealtimeAudioDeltaEvent.Type; + +export const ProviderRuntimeThreadRealtimeErrorEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeErrorType, + payload: ThreadRealtimeErrorPayload, +}); +export type ProviderRuntimeThreadRealtimeErrorEvent = + typeof ProviderRuntimeThreadRealtimeErrorEvent.Type; + +export const ProviderRuntimeThreadRealtimeClosedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ThreadRealtimeClosedType, + payload: ThreadRealtimeClosedPayload, +}); +export type ProviderRuntimeThreadRealtimeClosedEvent = + typeof ProviderRuntimeThreadRealtimeClosedEvent.Type; + export const ProviderRuntimeTurnStartedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: TurnStartedType, @@ -310,6 +669,13 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ }); export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; +export const ProviderRuntimeTurnAbortedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnAbortedType, + payload: TurnAbortedPayload, +}); +export type ProviderRuntimeTurnAbortedEvent = typeof ProviderRuntimeTurnAbortedEvent.Type; + export const ProviderRuntimeTurnPlanUpdatedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: TurnPlanUpdatedType, @@ -317,6 +683,13 @@ export const ProviderRuntimeTurnPlanUpdatedEvent = Schema.Struct({ }); export type ProviderRuntimeTurnPlanUpdatedEvent = typeof ProviderRuntimeTurnPlanUpdatedEvent.Type; +export const ProviderRuntimeTurnDiffUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TurnDiffUpdatedType, + payload: TurnDiffUpdatedPayload, +}); +export type ProviderRuntimeTurnDiffUpdatedEvent = typeof ProviderRuntimeTurnDiffUpdatedEvent.Type; + export const ProviderRuntimeItemStartedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: ItemStartedType, @@ -372,8 +745,127 @@ export const ProviderRuntimeUserInputResolvedEvent = Schema.Struct({ type: UserInputResolvedType, payload: UserInputResolvedPayload, }); -export type ProviderRuntimeUserInputResolvedEvent = - typeof ProviderRuntimeUserInputResolvedEvent.Type; +export type ProviderRuntimeUserInputResolvedEvent = typeof ProviderRuntimeUserInputResolvedEvent.Type; + +export const ProviderRuntimeTaskStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskStartedType, + payload: TaskStartedPayload, +}); +export type ProviderRuntimeTaskStartedEvent = typeof ProviderRuntimeTaskStartedEvent.Type; + +export const ProviderRuntimeTaskProgressEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskProgressType, + payload: TaskProgressPayload, +}); +export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type; + +export const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskCompletedType, + payload: TaskCompletedPayload, +}); +export type ProviderRuntimeTaskCompletedEvent = typeof ProviderRuntimeTaskCompletedEvent.Type; + +export const ProviderRuntimeHookStartedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: HookStartedType, + payload: HookStartedPayload, +}); +export type ProviderRuntimeHookStartedEvent = typeof ProviderRuntimeHookStartedEvent.Type; + +export const ProviderRuntimeHookProgressEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: HookProgressType, + payload: HookProgressPayload, +}); +export type ProviderRuntimeHookProgressEvent = typeof ProviderRuntimeHookProgressEvent.Type; + +export const ProviderRuntimeHookCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: HookCompletedType, + payload: HookCompletedPayload, +}); +export type ProviderRuntimeHookCompletedEvent = typeof ProviderRuntimeHookCompletedEvent.Type; + +export const ProviderRuntimeToolProgressEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ToolProgressType, + payload: ToolProgressPayload, +}); +export type ProviderRuntimeToolProgressEvent = typeof ProviderRuntimeToolProgressEvent.Type; + +export const ProviderRuntimeToolSummaryEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ToolSummaryType, + payload: ToolSummaryPayload, +}); +export type ProviderRuntimeToolSummaryEvent = typeof ProviderRuntimeToolSummaryEvent.Type; + +export const ProviderRuntimeAuthStatusEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: AuthStatusType, + payload: AuthStatusPayload, +}); +export type ProviderRuntimeAuthStatusEvent = typeof ProviderRuntimeAuthStatusEvent.Type; + +export const ProviderRuntimeAccountUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: AccountUpdatedType, + payload: AccountUpdatedPayload, +}); +export type ProviderRuntimeAccountUpdatedEvent = typeof ProviderRuntimeAccountUpdatedEvent.Type; + +export const ProviderRuntimeAccountRateLimitsUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: AccountRateLimitsUpdatedType, + payload: AccountRateLimitsUpdatedPayload, +}); +export type ProviderRuntimeAccountRateLimitsUpdatedEvent = + typeof ProviderRuntimeAccountRateLimitsUpdatedEvent.Type; + +export const ProviderRuntimeMcpStatusUpdatedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: McpStatusUpdatedType, + payload: McpStatusUpdatedPayload, +}); +export type ProviderRuntimeMcpStatusUpdatedEvent = typeof ProviderRuntimeMcpStatusUpdatedEvent.Type; + +export const ProviderRuntimeMcpOauthCompletedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: McpOauthCompletedType, + payload: McpOauthCompletedPayload, +}); +export type ProviderRuntimeMcpOauthCompletedEvent = typeof ProviderRuntimeMcpOauthCompletedEvent.Type; + +export const ProviderRuntimeModelReroutedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ModelReroutedType, + payload: ModelReroutedPayload, +}); +export type ProviderRuntimeModelReroutedEvent = typeof ProviderRuntimeModelReroutedEvent.Type; + +export const ProviderRuntimeConfigWarningEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: ConfigWarningType, + payload: ConfigWarningPayload, +}); +export type ProviderRuntimeConfigWarningEvent = typeof ProviderRuntimeConfigWarningEvent.Type; + +export const ProviderRuntimeDeprecationNoticeEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: DeprecationNoticeType, + payload: DeprecationNoticePayload, +}); +export type ProviderRuntimeDeprecationNoticeEvent = typeof ProviderRuntimeDeprecationNoticeEvent.Type; + +export const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: FilesPersistedType, + payload: FilesPersistedPayload, +}); +export type ProviderRuntimeFilesPersistedEvent = typeof ProviderRuntimeFilesPersistedEvent.Type; export const ProviderRuntimeWarningEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, @@ -391,11 +883,23 @@ export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeSessionStartedEvent, + ProviderRuntimeSessionConfiguredEvent, + ProviderRuntimeSessionStateChangedEvent, ProviderRuntimeSessionExitedEvent, ProviderRuntimeThreadStartedEvent, + ProviderRuntimeThreadStateChangedEvent, + ProviderRuntimeThreadMetadataUpdatedEvent, + ProviderRuntimeThreadTokenUsageUpdatedEvent, + ProviderRuntimeThreadRealtimeStartedEvent, + ProviderRuntimeThreadRealtimeItemAddedEvent, + ProviderRuntimeThreadRealtimeAudioDeltaEvent, + ProviderRuntimeThreadRealtimeErrorEvent, + ProviderRuntimeThreadRealtimeClosedEvent, ProviderRuntimeTurnStartedEvent, ProviderRuntimeTurnCompletedEvent, + ProviderRuntimeTurnAbortedEvent, ProviderRuntimeTurnPlanUpdatedEvent, + ProviderRuntimeTurnDiffUpdatedEvent, ProviderRuntimeItemStartedEvent, ProviderRuntimeItemUpdatedEvent, ProviderRuntimeItemCompletedEvent, @@ -404,6 +908,23 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeRequestResolvedEvent, ProviderRuntimeUserInputRequestedEvent, ProviderRuntimeUserInputResolvedEvent, + ProviderRuntimeTaskStartedEvent, + ProviderRuntimeTaskProgressEvent, + ProviderRuntimeTaskCompletedEvent, + ProviderRuntimeHookStartedEvent, + ProviderRuntimeHookProgressEvent, + ProviderRuntimeHookCompletedEvent, + ProviderRuntimeToolProgressEvent, + ProviderRuntimeToolSummaryEvent, + ProviderRuntimeAuthStatusEvent, + ProviderRuntimeAccountUpdatedEvent, + ProviderRuntimeAccountRateLimitsUpdatedEvent, + ProviderRuntimeMcpStatusUpdatedEvent, + ProviderRuntimeMcpOauthCompletedEvent, + ProviderRuntimeModelReroutedEvent, + ProviderRuntimeConfigWarningEvent, + ProviderRuntimeDeprecationNoticeEvent, + ProviderRuntimeFilesPersistedEvent, ProviderRuntimeWarningEvent, ProviderRuntimeErrorEvent, ]); @@ -412,6 +933,7 @@ export type ProviderRuntimeEventV2 = typeof ProviderRuntimeEventV2.Type; export const ProviderRuntimeEvent = ProviderRuntimeEventV2; export type ProviderRuntimeEvent = ProviderRuntimeEventV2; +// Compatibility aliases for call sites still importing legacy names. export const ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; export type ProviderRuntimeMessageDeltaEvent = ProviderRuntimeContentDeltaEvent; export const ProviderRuntimeMessageCompletedEvent = ProviderRuntimeItemCompletedEvent; @@ -425,13 +947,8 @@ export type ProviderRuntimeApprovalRequestedEvent = ProviderRuntimeRequestOpened export const ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; export type ProviderRuntimeApprovalResolvedEvent = ProviderRuntimeRequestResolvedEvent; -export const ProviderRuntimeToolKind = Schema.Literals([ - "command_execution", - "file_change", - "mcp_tool_call", - "dynamic_tool_call", - "other", -]); +// Legacy helper aliases retained for adapters/tests. +export const ProviderRuntimeToolKind = Schema.Literals(["command", "file-change", "other"]); export type ProviderRuntimeToolKind = typeof ProviderRuntimeToolKind.Type; export const ProviderRuntimeTurnStatus = RuntimeTurnState;