From 0367407ba1f672494d6f5f0723fbe94e35028956 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:54:07 +0200 Subject: [PATCH 1/7] feat(acp): support serverless remote sessions --- .../content/docs/reference/config-and-env.md | 13 +- .../content/docs/reference/handler-surface.md | 6 +- packages/junior/src/api/acp/README.md | 35 +- packages/junior/src/api/acp/route.ts | 1156 +++++++++++------ packages/junior/src/api/acp/transport.ts | 846 ++++++++++++ .../junior/src/chat/api-turns/cancellation.ts | 414 ++++-- packages/junior/src/chat/api-turns/mailbox.ts | 34 + packages/junior/src/chat/api-turns/work.ts | 64 +- .../junior/src/chat/app/conversation-work.ts | 6 +- .../junior/src/chat/task-execution/state.ts | 3 + .../junior/src/chat/task-execution/store.ts | 5 + packages/junior/tests/fixtures/acp-http.ts | 129 ++ .../integration/acp-http-recovery.test.ts | 183 +++ .../junior/tests/integration/acp-http.test.ts | 366 ++++-- .../tests/integration/api-turn-work.test.ts | 25 +- .../tests/unit/api/acp-transport.test.ts | 75 ++ .../unit/chat/api-turn-cancellation.test.ts | 163 ++- 17 files changed, 2883 insertions(+), 640 deletions(-) create mode 100644 packages/junior/src/api/acp/transport.ts create mode 100644 packages/junior/src/chat/api-turns/mailbox.ts create mode 100644 packages/junior/tests/fixtures/acp-http.ts create mode 100644 packages/junior/tests/integration/acp-http-recovery.test.ts create mode 100644 packages/junior/tests/unit/api/acp-transport.test.ts diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 0a3436918f..de121b9913 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -139,7 +139,7 @@ import { createApp } from "@sentry/junior"; const app = await createApp({ experimental: { - // ACP v1 Streamable HTTP for one-process development and testing. + // ACP v1 Streamable HTTP backed by the configured shared state. acp: true, // Model-facing spawnAgent for durable child agent work. Incomplete; keep off // unless you are testing the #879 runtime. @@ -152,10 +152,13 @@ const app = await createApp({ local createApp-equivalent entrypoint and already wires the child-worker path. `acp` mounts `GET`, `POST`, and `DELETE /api/acp`. Every request needs a Junior -personal token in the bearer authorization header. The current transport keeps -connection state in one Node process. Use it only for local or single-process -testing. Run `pnpm acp:local` in this repository for a loopback test with the -official ACP SDK client. +personal token in the bearer authorization header. The route stores transport +records in the configured `StateAdapter`. The production Redis adapter lets +requests reach different app instances. It does not need process affinity. The +memory adapter remains local to one process. A client must reconnect and call +`session/load` when its live SSE request reaches the deployment request limit. +Run `pnpm acp:local` in this repository for a loopback test with the official +ACP SDK client. ## Install-wide config defaults diff --git a/packages/docs/src/content/docs/reference/handler-surface.md b/packages/docs/src/content/docs/reference/handler-surface.md index 0eddc2f625..0830e68e3b 100644 --- a/packages/docs/src/content/docs/reference/handler-surface.md +++ b/packages/docs/src/content/docs/reference/handler-surface.md @@ -32,8 +32,10 @@ Handled `POST` routes: When `createApp({ experimental: { acp: true } })` is set, `GET`, `POST`, and `DELETE /api/acp` expose ACP v1 Streamable HTTP. Every request requires a Junior personal token in the bearer authorization header. This experimental route -keeps connection state in one Node process. Do not enable it on a multi-process -deployment. +stores transport records in the configured `StateAdapter`. The production +Redis adapter supports requests from different app instances. Live SSE +requests still end at the deployment request limit. Clients must reconnect and +call `session/load` after that limit. ## Expected behavior diff --git a/packages/junior/src/api/acp/README.md b/packages/junior/src/api/acp/README.md index 3fac02191d..7904bc80c1 100644 --- a/packages/junior/src/api/acp/README.md +++ b/packages/junior/src/api/acp/README.md @@ -12,11 +12,36 @@ links, media, filesystem callbacks, and terminal callbacks are not supported. `session/cancel` stops the active Turn and returns the ACP `cancelled` stop reason. -The ACP SDK keeps connection state in the Node process. This prototype supports -one process only. Do not use it on a multi-process deployment until the -transport has proven affinity or the SDK provides a released distributed state -backend. Direct tests with T3 Code or Zed and agreed resource-link and tool -behavior are also required before promotion. +## Runtime Design + +The ACP `sessionId` is the Junior `conversationId`. The Conversation remains +the source for history, Turn state, and replay. The normal mailbox, queue, +lease, checkpoint, and event paths run the work. + +The ACP transport stores connection ownership, retry receipts, pending stream +items, stream cursors, and stream leases in the Junior `StateAdapter`. +Production uses the existing Redis adapter. Any ACP request can reach any app +instance. The route does not need process affinity or a separate ACP service. +The memory adapter remains process-local and is suitable only for local use. +Each stream preserves up to 1,024 undelivered items. It returns `503` instead +of dropping an item when that limit is full. + +## Live HTTP Limit + +ACP v1 delivers server messages over live SSE requests. The official client +opens one connection stream and one stream for each active session. Junior can +restore a Conversation after either stream disconnects, but ACP v1 does not +replay an in-flight transport response on a new connection. + +`juniorNitro()` sets the default Vercel function duration to 300 seconds. A +client must create a new ACP connection and call `session/load` after a live +stream reaches that limit. Junior keeps running accepted work after a client +disconnects, and a later load replays its stored Messages. A prompt that is +still in flight when the stream closes cannot resolve transparently in the old +client connection. + +ACP remains an opt-in experimental surface. Test client-specific session, +resource, and tool behavior before enabling it for general use. Run the official-SDK smoke client against a single local process through the existing tunnel: diff --git a/packages/junior/src/api/acp/route.ts b/packages/junior/src/api/acp/route.ts index de3a9b7d22..8d7b463629 100644 --- a/packages/junior/src/api/acp/route.ts +++ b/packages/junior/src/api/acp/route.ts @@ -1,45 +1,158 @@ /** - * Own the remote ACP HTTP edge. + * Own the remote ACP v1 Streamable HTTP edge. * - * Every request authenticates one Actor. Connections stay in this app process, - * and sessions map only to that Actor's private Conversations. This prototype - * accepts text prompts and durable replay. It does not accept client tools. + * StateAdapter transport records route short-lived JSON-RPC messages. Junior's + * Conversation log remains the source for session replay and Turn output. No + * request relies on another request reaching the same app process. */ -import { randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import type { StateAdapter } from "chat"; import type { User } from "@sentry/junior-plugin-api"; import * as acp from "@agentclientprotocol/sdk"; -import { AcpServer } from "@agentclientprotocol/sdk/experimental/server"; +import { z } from "zod"; import { readConversationAccessFromSql } from "@/api/conversations/access"; +import type { WebActor } from "@/chat/actor"; +import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; import { + apiConversationMessageExists, + apiConversationMessageIsPending, +} from "@/chat/api-turns/mailbox"; +import { + apiConversationMessageId, apiTurnIdForMessage, appendAndEnqueueApiConversationMessage, recordApiConversationActivity, webActorFromEmail, } from "@/chat/api-turns/work"; -import type { WebActor } from "@/chat/actor"; import type { ConversationEventStore } from "@/chat/conversations/history"; -import { projectConversationMessages } from "@/chat/conversations/message-projection"; import type { ConversationStore } from "@/chat/conversations/store"; import { getConversationEventStore, getConversationStore, getDb, } from "@/chat/db"; +import { + createRequestContext, + logException, + withContext, + withSpan, +} from "@/chat/logging"; import { resolveViewerUser } from "@/chat/plugins/viewer"; -import { logException, withSpan } from "@/chat/logging"; -import { sleep } from "@/chat/sleep"; +import { getStateAdapter } from "@/chat/state/adapter"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { ensureConversationWake } from "@/chat/task-execution/store"; -import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; import { authenticatePersonalToken } from "@/personal-tokens/store"; import { JUNIOR_VERSION } from "@/version"; +import { + acceptAcpRequest, + createAcpConnection, + deleteAcpConnection, + openAcpSse, + readAcpConnection, + type AcpConnection, + type AcpPromptStreamOutput, + type AcpRequestReceipt, + type AcpStreamRoute, +} from "./transport"; const ACP_CONNECTION_ID_HEADER = "Acp-Connection-Id"; +const ACP_SESSION_ID_HEADER = "Acp-Session-Id"; const ACP_CONVERSATION_PREFIX = "local:acp:"; +const ACP_EVENT_STREAM_MIME_TYPE = "text/event-stream"; +const ACP_JSON_MIME_TYPE = "application/json"; const EVENT_PAGE_SIZE = 50; -const EVENT_POLL_INTERVAL_MS = 25; const MAX_PROMPT_TEXT_LENGTH = 32_000; +const ACP_SESSION_ID_PATTERN = /^local:acp:[a-f0-9]{32}$/; + +const SESSION_HEADER_METHODS = new Set([ + acp.methods.agent.session.cancel, + acp.methods.agent.session.close, + acp.methods.agent.session.load, + acp.methods.agent.session.prompt, + acp.methods.agent.session.resume, + acp.methods.agent.session.setConfigOption, + acp.methods.agent.session.setMode, +]); + +const metaSchema = z.record(z.string(), z.unknown()).nullable(); +const jsonRpcIdSchema = z.union([z.string(), z.number().finite(), z.null()]); +const acpConnectionIdSchema = z.string().uuid(); +const acpSessionIdSchema = z.string().regex(ACP_SESSION_ID_PATTERN); +const acpCallSchema = z + .object({ + id: jsonRpcIdSchema.optional(), + jsonrpc: z.literal("2.0"), + method: z.string().min(1), + params: z.unknown().optional(), + }) + .strict(); + +const implementationSchema = z + .object({ + _meta: metaSchema.optional(), + name: z.string().min(1), + title: z.string().nullable().optional(), + version: z.string(), + }) + .strict(); + +const initializeParamsSchema = z + .object({ + _meta: metaSchema.optional(), + clientCapabilities: z.record(z.string(), z.unknown()).optional(), + clientInfo: implementationSchema.nullable().optional(), + protocolVersion: z.number().int().min(0).max(65_535), + }) + .strict(); + +const newSessionParamsSchema = z + .object({ + _meta: metaSchema.optional(), + additionalDirectories: z.array(z.string()).optional(), + cwd: z.string().min(1), + mcpServers: z.array(z.unknown()), + }) + .strict(); + +const loadSessionParamsSchema = newSessionParamsSchema.extend({ + sessionId: acpSessionIdSchema, +}); + +const annotationsSchema = z + .object({ + _meta: metaSchema.optional(), + audience: z + .array(z.enum(["assistant", "user"])) + .nullable() + .optional(), + lastModified: z.string().nullable().optional(), + priority: z.number().finite().nullable().optional(), + }) + .strict(); + +const textContentBlockSchema = z + .object({ + _meta: metaSchema.optional(), + annotations: annotationsSchema.nullable().optional(), + text: z.string(), + type: z.literal("text"), + }) + .strict(); + +const promptParamsSchema = z + .object({ + _meta: metaSchema.optional(), + prompt: z.array(textContentBlockSchema), + sessionId: acpSessionIdSchema, + }) + .strict(); + +const cancelParamsSchema = z + .object({ + _meta: metaSchema.optional(), + sessionId: acpSessionIdSchema, + }) + .strict(); interface AcpRouteOptions { cancellation: ApiTurnCancellation; @@ -53,6 +166,12 @@ interface AuthenticatedAcpActor { user: User; } +type AcpCall = z.output; +type SessionParams = z.output; +type LoadSessionParams = z.output; +type PromptParams = z.output; +type CancelParams = z.output; + type AcpOperation = | "initialize" | "session_cancel" @@ -60,6 +179,10 @@ type AcpOperation = | "session_new" | "session_prompt"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function bearerToken(request: Request): string | undefined { const authorization = request.headers.get("Authorization"); const match = authorization?.match(/^Bearer ([^\s]+)$/); @@ -85,6 +208,18 @@ async function authenticateRequest( }; } +function parseParams(schema: z.ZodType, params: unknown): T { + const parsed = schema.safeParse(params); + if (parsed.success) return parsed.data; + const firstPath = parsed.error.issues[0]?.path[0]; + throw acp.RequestError.invalidParams({ + field: + typeof firstPath === "string" || typeof firstPath === "number" + ? String(firstPath) + : "params", + }); +} + /** Reject client MCP servers because Junior does not use the client workspace. */ function rejectUnsupportedMcpServers(mcpServers: readonly unknown[]): void { if (mcpServers.length > 0) { @@ -100,7 +235,7 @@ async function requireOwnedSession( sessionId: string, user: User, ): Promise { - if (!sessionId.startsWith(ACP_CONVERSATION_PREFIX)) { + if (!acpSessionIdSchema.safeParse(sessionId).success) { throw acp.RequestError.resourceNotFound(sessionId); } const access = ( @@ -112,7 +247,7 @@ async function requireOwnedSession( } /** Convert supported ACP text blocks to one bounded API Turn message. */ -function promptText(prompt: readonly acp.ContentBlock[]): string { +function promptText(prompt: PromptParams["prompt"]): string { if (prompt.length === 0) { throw acp.RequestError.invalidParams( { field: "prompt" }, @@ -121,12 +256,6 @@ function promptText(prompt: readonly acp.ContentBlock[]): string { } const blocks: string[] = []; for (const block of prompt) { - if (block.type !== "text") { - throw acp.RequestError.invalidParams( - { field: "prompt" }, - "Junior accepts one or more text blocks only", - ); - } if (!block.text.trim()) { throw acp.RequestError.invalidParams( { field: "prompt" }, @@ -145,10 +274,122 @@ function promptText(prompt: readonly acp.ContentBlock[]): string { return text; } -/** Preserve the JSON-RPC id type in one connection-scoped mailbox key. */ -function requestIdKey(requestId: acp.JsonRpcId): string { - if (requestId === null) return "null"; - return `${typeof requestId}:${requestId}`; +function stableHex(value: string, length = 32): string { + return createHash("sha256").update(value).digest("hex").slice(0, length); +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalJson(child)]), + ); +} + +/** Preserve the JSON-RPC id type and request payload in durable retry keys. */ +function requestKey(call: AcpCall & { id: acp.JsonRpcId }): string { + const id = + call.id === null + ? "null" + : `${typeof call.id}:${stableHex(String(call.id))}`; + const payload = JSON.stringify( + canonicalJson({ method: call.method, params: call.params ?? null }), + ); + return `${id}:${stableHex(payload)}`; +} + +function sessionIdFromCall(call: AcpCall | undefined): string | undefined { + if (!call) return undefined; + const schema = + call.method === acp.methods.agent.session.load + ? loadSessionParamsSchema + : call.method === acp.methods.agent.session.prompt + ? promptParamsSchema + : call.method === acp.methods.agent.session.cancel + ? cancelParamsSchema + : undefined; + if (!schema) return undefined; + const parsed = schema.safeParse(call.params); + return parsed.success ? parsed.data.sessionId : undefined; +} + +/** Route session methods only from a canonical header and matching strict params. */ +function determineRoute( + connectionId: string, + call: AcpCall, + headerSessionId: string | undefined, +): AcpStreamRoute | Response { + const paramsSessionId = sessionIdFromCall(call); + if (SESSION_HEADER_METHODS.has(call.method) && !headerSessionId) { + return textResponse("Missing Acp-Session-Id", 400); + } + if ( + headerSessionId !== undefined && + paramsSessionId !== undefined && + headerSessionId !== paramsSessionId + ) { + return textResponse("Mismatched Acp-Session-Id", 400); + } + return { + connectionId, + ...(headerSessionId + ? { sessionId: headerSessionId } + : paramsSessionId + ? { sessionId: paramsSessionId } + : {}), + }; +} + +/** Keep load failures on the connection stream until session ownership succeeds. */ +function responseSessionId( + call: AcpCall, + route: AcpStreamRoute, +): string | undefined { + return call.method === acp.methods.agent.session.load + ? undefined + : route.sessionId; +} + +function resultMessage( + requestId: acp.JsonRpcId, + result: unknown, +): acp.AnyResponse { + return { jsonrpc: "2.0", id: requestId, result }; +} + +function errorMessage( + requestId: acp.JsonRpcId, + error: acp.RequestError, +): acp.AnyResponse { + return { + jsonrpc: "2.0", + id: requestId, + error: error.toErrorResponse(), + }; +} + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "Content-Type": "text/plain" }, + }); +} + +function jsonResponse( + value: acp.AnyResponse, + status: number, + headers?: HeadersInit, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": ACP_JSON_MIME_TYPE, ...headers }, + }); +} + +function emptyResponse(status: number): Response { + return new Response(null, { status }); } async function latestEventSeq( @@ -159,103 +400,24 @@ async function latestEventSeq( return page.events.at(-1)?.seq ?? 0; } -/** Replay durable user and assistant Messages in their stored order. */ -async function replaySession( +async function turnHasEnded( eventStore: ConversationEventStore, - sessionId: string, - client: acp.AgentContext, -): Promise { - const history = await eventStore.loadMessageHistory(sessionId); - for (const message of projectConversationMessages(history)) { - if (message.role === "system") continue; - await client.notify(acp.methods.client.session.update, { - sessionId, - update: { - sessionUpdate: - message.role === "user" - ? "user_message_chunk" - : "agent_message_chunk", - content: { type: "text", text: message.text }, - messageId: message.id, - }, - }); - } -} - -/** Stream durable assistant Messages until the matching Turn ends or fails. */ -async function waitForTurn(args: { - afterSeq: number; - cancellation: ApiTurnCancellation; - cancellationSignal: AbortSignal; - client: acp.AgentContext; - eventStore: ConversationEventStore; - sessionId: string; - signal: AbortSignal; - turnId: string; -}): Promise { - let cursor = args.afterSeq; - const assistantPrefix = `${args.turnId}:assistant:`; - const sentMessageIds = new Set(); - - while (true) { - if (args.signal.aborted) { - throw acp.RequestError.requestCancelled(); - } - const page = await args.eventStore.query(args.sessionId, { - afterSeq: cursor, - limit: EVENT_PAGE_SIZE, - types: ["message", "turn_completed", "turn_failed"], - }); - if (page.events.length === 0) { - try { - await sleep(EVENT_POLL_INTERVAL_MS, args.signal); - } catch (error) { - if (args.signal.aborted) { - throw acp.RequestError.requestCancelled(); - } - throw error; - } - continue; - } - - for (const event of page.events) { - cursor = event.seq; - const data = event.data; - if ( - data.type === "message" && - data.role === "assistant" && - data.messageId.startsWith(assistantPrefix) && - !sentMessageIds.has(data.messageId) - ) { - sentMessageIds.add(data.messageId); - await args.client.notify(acp.methods.client.session.update, { - sessionId: args.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: data.text }, - messageId: data.messageId, - }, - }); - continue; - } - if (data.type === "turn_completed" && data.turnId === args.turnId) { - args.cancellation.finish(args.sessionId, args.cancellationSignal); - return { - stopReason: data.outcome === "cancelled" ? "cancelled" : "end_turn", - }; - } - if (data.type === "turn_failed" && data.turnId === args.turnId) { - args.cancellation.finish(args.sessionId, args.cancellationSignal); - throw acp.RequestError.internalError( - { failureCode: data.failureCode }, - "Junior Turn failed", - ); - } - } - } + conversationId: string, + turnId: string, +): Promise { + const page = await eventStore.query(conversationId, { + limit: EVENT_PAGE_SIZE, + types: ["turn_completed", "turn_failed"], + }); + return page.events.some( + (event) => + (event.data.type === "turn_completed" || + event.data.type === "turn_failed") && + event.data.turnId === turnId, + ); } -/** Trace one protocol operation and capture only unexpected edge failures. */ +/** Trace one protocol operation; the HTTP or SSE boundary owns error capture. */ async function runAcpOperation( authenticated: AuthenticatedAcpActor, operation: AcpOperation, @@ -272,282 +434,540 @@ async function runAcpOperation( platform: "acp", userId: authenticated.actor.userId, }, + async () => await callback(), + ); +} + +/** Create a deterministic private Conversation for one session/new request. */ +async function createSession(args: { + authenticated: AuthenticatedAcpActor; + call: AcpCall; + connection: AcpConnection; + conversationStore: ConversationStore; + requestKey: string; +}): Promise { + return await runAcpOperation( + args.authenticated, + "session_new", + undefined, async () => { - try { - return await callback(); - } catch (error) { - if (!(error instanceof acp.RequestError)) { - logException(error, `${name}.failed`); - } - throw error; - } + const params = parseParams( + newSessionParamsSchema, + args.call.params, + ); + rejectUnsupportedMcpServers(params.mcpServers); + const sessionId = `${ACP_CONVERSATION_PREFIX}${stableHex( + `${args.connection.nonce}:${args.requestKey}`, + )}`; + await recordApiConversationActivity({ + actor: args.authenticated.actor, + conversationId: sessionId, + conversationStore: args.conversationStore, + nowMs: Date.now(), + rootVisibility: "private", + }); + return sessionId; }, ); } -/** Build the ACP Agent whose session handlers run as one authenticated Actor. */ -function createActorAgent( - authenticated: AuthenticatedAcpActor, - connectionNonce: string, - options: AcpRouteOptions, -) { - const conversationStore = options.conversationStore ?? getConversationStore(); - const eventStore = getConversationEventStore(); - - return acp - .agent({ name: "junior" }) - .onRequest(acp.methods.agent.initialize, () => - runAcpOperation(authenticated, "initialize", undefined, () => ({ - protocolVersion: acp.PROTOCOL_VERSION, - agentCapabilities: { - loadSession: true, - promptCapabilities: { - image: false, - audio: false, - embeddedContext: false, - }, - }, - authMethods: [], - agentInfo: { name: "junior", version: JUNIOR_VERSION }, - })), - ) - .onRequest(acp.methods.agent.session.new, async (context) => { - return await runAcpOperation( - authenticated, - "session_new", - undefined, - async () => { - rejectUnsupportedMcpServers(context.params.mcpServers); - const sessionId = `${ACP_CONVERSATION_PREFIX}${randomUUID()}`; - await recordApiConversationActivity({ - actor: authenticated.actor, - conversationId: sessionId, - conversationStore, - nowMs: Date.now(), - rootVisibility: "private", - }); - return { sessionId }; - }, - ); - }) - .onRequest(acp.methods.agent.session.load, async (context) => { - return await runAcpOperation( - authenticated, - "session_load", - context.params.sessionId, - async () => { - rejectUnsupportedMcpServers(context.params.mcpServers); - await requireOwnedSession( - context.params.sessionId, - authenticated.user, - ); - await replaySession( - eventStore, - context.params.sessionId, - context.client, - ); - return {}; - }, +/** Accept one prompt into the existing durable Conversation mailbox. */ +async function acceptPrompt(args: { + authenticated: AuthenticatedAcpActor; + call: AcpCall; + cancellation: ApiTurnCancellation; + connection: AcpConnection; + conversationStore: ConversationStore; + eventStore: ConversationEventStore; + queue: ConversationWorkQueue; + requestId: acp.JsonRpcId; + requestKey: string; + state: StateAdapter; +}): Promise<{ output: AcpPromptStreamOutput; sessionId: string }> { + const params = parseParams( + promptParamsSchema, + args.call.params, + ); + return await runAcpOperation( + args.authenticated, + "session_prompt", + params.sessionId, + async () => { + await requireOwnedSession(params.sessionId, args.authenticated.user); + const text = promptText(params.prompt); + const currentSeq = await latestEventSeq( + args.eventStore, + params.sessionId, ); - }) - .onRequest(acp.methods.agent.session.prompt, async (context) => { - return await runAcpOperation( - authenticated, - "session_prompt", - context.params.sessionId, - async () => { - await requireOwnedSession( - context.params.sessionId, - authenticated.user, - ); - const text = promptText(context.params.prompt); - const currentSeq = await latestEventSeq( - eventStore, - context.params.sessionId, - ); - const cancellationSignal = options.cancellation.begin( - context.params.sessionId, - ); - if (!cancellationSignal) { - throw acp.RequestError.invalidParams( - { field: "sessionId" }, - "This ACP session already has an active prompt", - ); - } - const recordDisconnect = () => - options.cancellation.disconnect( - context.params.sessionId, - cancellationSignal, - ); - if (context.signal.aborted) { - recordDisconnect(); - } else { - context.signal.addEventListener("abort", recordDisconnect, { - once: true, - }); - } - let accepted: Awaited< - ReturnType - >; - try { - accepted = await appendAndEnqueueApiConversationMessage( - { - actor: authenticated.actor, - conversationId: context.params.sessionId, - idempotencyKey: `${connectionNonce}:${requestIdKey(context.requestId)}`, - message: text, - }, - { - conversationStore, - queue: options.queue, - state: options.state, - }, - ); - } catch (error) { - options.cancellation.finish( - context.params.sessionId, - cancellationSignal, - ); - throw error; - } - // A duplicate request can refer to a Turn that ended before currentSeq. - const afterSeq = accepted.status === "duplicate" ? 0 : currentSeq; - return await waitForTurn({ - afterSeq, - cancellation: options.cancellation, - cancellationSignal, - client: context.client, - eventStore, - sessionId: context.params.sessionId, - signal: context.signal, - turnId: apiTurnIdForMessage(accepted.messageId), - }); + const idempotencyKey = `${args.connection.nonce}:${args.requestKey}`; + const messageId = apiConversationMessageId({ + conversationId: params.sessionId, + idempotencyKey, + }); + const turnId = apiTurnIdForMessage(messageId); + const admit = async () => + await args.cancellation.admit( + { + conversationId: params.sessionId, + messageId, + turnId, + }, + { + messageExists: async (candidateMessageId) => + await apiConversationMessageExists({ + conversationId: params.sessionId, + messageId: candidateMessageId, + state: args.state, + }), + run: async (fence) => + await appendAndEnqueueApiConversationMessage( + { + actor: args.authenticated.actor, + conversationId: params.sessionId, + idempotencyKey, + message: text, + }, + { + conversationStore: args.conversationStore, + fence, + queue: args.queue, + state: args.state, + }, + ), + }, + ); + + let admission = await admit(); + if ( + admission.status === "active" && + (await turnHasEnded( + args.eventStore, + params.sessionId, + admission.turnId, + )) && + !(await apiConversationMessageIsPending({ + conversationId: params.sessionId, + messageId: admission.messageId, + state: args.state, + })) + ) { + await args.cancellation.finish(params.sessionId, admission.turnId); + admission = await admit(); + } + if (admission.status === "active") { + throw acp.RequestError.invalidParams( + { field: "sessionId" }, + "This ACP session already has an active prompt", + ); + } + + if ( + (await turnHasEnded(args.eventStore, params.sessionId, turnId)) && + !(await apiConversationMessageIsPending({ + conversationId: params.sessionId, + messageId, + state: args.state, + })) + ) { + await args.cancellation.finish(params.sessionId, turnId); + } + return { + output: { + afterSeq: admission.value.status === "duplicate" ? 0 : currentSeq, + kind: "prompt", + requestId: args.requestId, + turnId, }, - ); - }) - .onNotification(acp.methods.agent.session.cancel, async (context) => { - await runAcpOperation( - authenticated, - "session_cancel", - context.params.sessionId, - async () => { - await requireOwnedSession( - context.params.sessionId, - authenticated.user, - ); - if (!options.cancellation.cancel(context.params.sessionId)) { - return; - } - const nowMs = Date.now(); - await ensureConversationWake({ - conversationId: context.params.sessionId, - conversationStore, - idempotencyKey: `acp-cancel:${context.params.sessionId}:${nowMs}`, - nowMs, - queue: options.queue, - replaceExistingWake: true, - state: options.state, - }); + sessionId: params.sessionId, + }; + }, + ); +} + +/** Dispatch one connected JSON-RPC request into a durable outbound receipt. */ +async function createRequestReceipt(args: { + authenticated: AuthenticatedAcpActor; + call: AcpCall & { id: acp.JsonRpcId }; + cancellation: ApiTurnCancellation; + connection: AcpConnection; + conversationStore: ConversationStore; + eventStore: ConversationEventStore; + queue: ConversationWorkQueue; + requestKey: string; + route: AcpStreamRoute; + state: StateAdapter; +}): Promise { + if (args.call.method === acp.methods.agent.session.new) { + const sessionId = await createSession(args); + return { + outputs: [ + { + kind: "message", + message: resultMessage(args.call.id, { sessionId }), }, - ); + ], + }; + } + + if (args.call.method === acp.methods.agent.session.load) { + const params = parseParams( + loadSessionParamsSchema, + args.call.params, + ); + await runAcpOperation( + args.authenticated, + "session_load", + params.sessionId, + async () => { + rejectUnsupportedMcpServers(params.mcpServers); + await requireOwnedSession(params.sessionId, args.authenticated.user); + }, + ); + return { + outputs: [ + { kind: "replay", sessionId: params.sessionId }, + { kind: "message", message: resultMessage(args.call.id, {}) }, + ], + }; + } + + if (args.call.method === acp.methods.agent.session.prompt) { + const prompt = await acceptPrompt({ + ...args, + requestId: args.call.id, }); + return { + outputs: [ + { + kind: "prompt", + output: prompt.output, + sessionId: prompt.sessionId, + }, + ], + }; + } + + throw acp.RequestError.methodNotFound(args.call.method); } -function isJsonRpcId(value: unknown): value is acp.JsonRpcId { - return ( - value === null || - typeof value === "string" || - (typeof value === "number" && Number.isFinite(value)) +/** Handle a session/cancel notification through durable Turn control. */ +async function handleCancelNotification(args: { + authenticated: AuthenticatedAcpActor; + call: AcpCall; + cancellation: ApiTurnCancellation; + conversationStore: ConversationStore; + queue: ConversationWorkQueue; + state: StateAdapter; +}): Promise { + const params = parseParams( + cancelParamsSchema, + args.call.params, + ); + await runAcpOperation( + args.authenticated, + "session_cancel", + params.sessionId, + async () => { + await requireOwnedSession(params.sessionId, args.authenticated.user); + if (!(await args.cancellation.cancel(params.sessionId))) return; + const nowMs = Date.now(); + await ensureConversationWake({ + conversationId: params.sessionId, + conversationStore: args.conversationStore, + idempotencyKey: `acp-cancel:${params.sessionId}:${nowMs}`, + nowMs, + queue: args.queue, + replaceExistingWake: true, + state: args.state, + }); + }, ); } -/** Reject object-shaped messages that would make the SDK log their raw body. */ -async function hasSafeAcpEnvelope(request: Request): Promise { - if (request.method !== "POST") return true; - let value: unknown; - try { - value = await request.clone().json(); - } catch { - return true; - } - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return true; +/** Require an actor-bound connection without revealing another actor's ID. */ +async function requireConnection(args: { + authenticated: AuthenticatedAcpActor; + connectionId: string; + state: StateAdapter; +}): Promise { + const value = await readAcpConnection(args.state, args.connectionId); + if (!value || value.actorId !== args.authenticated.actor.userId) { + return undefined; } - if (!("jsonrpc" in value) || value.jsonrpc !== "2.0") { - return false; + return value; +} + +function isJsonContentType(contentType: string | null): boolean { + return ( + contentType?.split(";", 1)[0]?.trim().toLowerCase() === ACP_JSON_MIME_TYPE + ); +} + +async function handleInitialize(args: { + authenticated: AuthenticatedAcpActor; + call: AcpCall; + state: StateAdapter; +}): Promise { + const requestId = args.call.id; + if (requestId === undefined || requestId === null) { + return textResponse("Initialize request must include an ID", 400); } - if (!("method" in value) || typeof value.method !== "string") { - return false; + try { + const { connectionId, result } = await runAcpOperation( + args.authenticated, + "initialize", + undefined, + async () => { + parseParams(initializeParamsSchema, args.call.params); + const result = { + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: false, + audio: false, + embeddedContext: false, + }, + }, + authMethods: [], + agentInfo: { name: "junior", version: JUNIOR_VERSION }, + } satisfies acp.InitializeResponse; + const connection = await createAcpConnection( + args.state, + args.authenticated.actor.userId, + ); + return { connectionId: connection.connectionId, result }; + }, + ); + return jsonResponse(resultMessage(requestId, result), 200, { + [ACP_CONNECTION_ID_HEADER]: connectionId, + }); + } catch (error) { + if (!(error instanceof acp.RequestError)) throw error; + return jsonResponse(errorMessage(requestId, error), 200); } - return !("id" in value) || isJsonRpcId(value.id); } -/** Return whether an SDK initialization response contains a protocol result. */ -async function initializationSucceeded(response: Response): Promise { - try { - const value: unknown = await response.clone().json(); - return typeof value === "object" && value !== null && "result" in value; - } catch { - return false; +async function handleConnectedPost(args: { + authenticated: AuthenticatedAcpActor; + call: AcpCall; + connection: AcpConnection; + connectionId: string; + options: AcpRouteOptions; + route: AcpStreamRoute; + state: StateAdapter; +}): Promise { + const conversationStore = + args.options.conversationStore ?? getConversationStore(); + const eventStore = getConversationEventStore(); + + const requestId = args.call.id; + if (requestId === undefined) { + if (args.call.method === acp.methods.agent.session.cancel) { + try { + await handleCancelNotification({ + authenticated: args.authenticated, + call: args.call, + cancellation: args.options.cancellation, + conversationStore, + queue: args.options.queue, + state: args.state, + }); + } catch (error) { + if (!(error instanceof acp.RequestError)) throw error; + } + } + return emptyResponse(202); + } + + const identifiedCall = { ...args.call, id: requestId }; + const identifiedRequestKey = requestKey(identifiedCall); + const status = await acceptAcpRequest({ + connectionId: args.connectionId, + requestKey: identifiedRequestKey, + state: args.state, + createReceipt: async (): Promise => { + try { + return await createRequestReceipt({ + authenticated: args.authenticated, + call: identifiedCall, + cancellation: args.options.cancellation, + connection: args.connection, + conversationStore, + eventStore, + queue: args.options.queue, + requestKey: identifiedRequestKey, + route: args.route, + state: args.state, + }); + } catch (error) { + if (!(error instanceof acp.RequestError)) throw error; + const sessionId = responseSessionId(args.call, args.route); + return { + outputs: [ + { + kind: "message", + message: errorMessage(requestId, error), + ...(sessionId ? { sessionId } : {}), + }, + ], + }; + } + }, + }); + if (status === "busy") { + return textResponse("ACP request is already being accepted", 503); + } + if (status === "full") { + return textResponse("ACP stream has too much undelivered output", 503); } + return emptyResponse(202); } -/** Create one app-scoped remote ACP v1 HTTP handler. */ +/** Create a serverless-safe remote ACP v1 HTTP handler. */ export function createAcpHttpHandler( options: AcpRouteOptions, ): (request: Request) => Promise { - const server = new AcpServer({ agent: acp.agent({ name: "junior" }) }); - const connectionActors = new Map(); + const state = options.state ?? getStateAdapter(); - return async (request) => { - const authenticated = await authenticateRequest(request); - if (!authenticated) { - return new Response("Unauthorized", { status: 401 }); - } + return async (request) => + await withContext( + createRequestContext(request, { platform: "acp" }), + async () => { + let authenticated: AuthenticatedAcpActor | undefined; + let call: AcpCall | undefined; + let connectionId: string | undefined; + let sessionId: string | undefined; + try { + authenticated = await authenticateRequest(request); + if (!authenticated) { + return new Response("Unauthorized", { status: 401 }); + } - const connectionId = request.headers.get(ACP_CONNECTION_ID_HEADER); - const connectionActorId = connectionId - ? connectionActors.get(connectionId) - : undefined; - if (connectionId && connectionActorId === undefined) { - return new Response("Unknown Acp-Connection-Id", { status: 404 }); - } - if ( - connectionActorId !== undefined && - connectionActorId !== authenticated.actor.userId - ) { - return new Response("Unknown Acp-Connection-Id", { status: 404 }); - } - if (!(await hasSafeAcpEnvelope(request))) { - return new Response("Invalid JSON-RPC message", { status: 400 }); - } + const connectionHeader = request.headers.get( + ACP_CONNECTION_ID_HEADER, + ); + if (connectionHeader !== null) { + const parsed = acpConnectionIdSchema.safeParse(connectionHeader); + if (!parsed.success) { + return textResponse("Invalid Acp-Connection-Id", 400); + } + connectionId = parsed.data; + } + const sessionHeader = request.headers.get(ACP_SESSION_ID_HEADER); + if (sessionHeader !== null) { + const parsed = acpSessionIdSchema.safeParse(sessionHeader); + if (!parsed.success) { + return textResponse("Invalid Acp-Session-Id", 400); + } + sessionId = parsed.data; + } - const response = await server.handleRequest(request, { - agent: createActorAgent(authenticated, randomUUID(), options), - }); - const responseConnectionId = response.headers.get(ACP_CONNECTION_ID_HEADER); - if (response.ok && responseConnectionId && !connectionId) { - if (await initializationSucceeded(response)) { - connectionActors.set(responseConnectionId, authenticated.actor.userId); - } else { - await server.handleRequest( - new Request(request.url, { - method: "DELETE", - headers: { [ACP_CONNECTION_ID_HEADER]: responseConnectionId }, - }), - ); - const headers = new Headers(response.headers); - headers.delete(ACP_CONNECTION_ID_HEADER); - return new Response(response.body, { - headers, - status: response.status, - statusText: response.statusText, - }); - } - } - if (request.method === "DELETE" && response.ok && connectionId) { - connectionActors.delete(connectionId); - } - return response; - }; + if (request.method === "POST") { + if (!isJsonContentType(request.headers.get("Content-Type"))) { + return textResponse("Unsupported Media Type", 415); + } + let value: unknown; + try { + value = await request.json(); + } catch { + return textResponse("Invalid JSON", 400); + } + if (Array.isArray(value)) { + return textResponse( + "Batch JSON-RPC requests are not implemented", + 501, + ); + } + const parsedCall = acpCallSchema.safeParse(value); + if (!parsedCall.success) { + return textResponse("Invalid JSON-RPC message", 400); + } + call = parsedCall.data; + + if (call.method === acp.methods.agent.initialize) { + if (connectionId) { + return textResponse( + "Initialize not allowed on existing connection", + 400, + ); + } + return await handleInitialize({ authenticated, call, state }); + } + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + const connection = await requireConnection({ + authenticated, + connectionId, + state, + }); + if (!connection) { + return textResponse("Unknown Acp-Connection-Id", 404); + } + const route = determineRoute(connectionId, call, sessionId); + if (route instanceof Response) return route; + return await handleConnectedPost({ + authenticated, + call, + connection, + connectionId, + options, + route, + state, + }); + } + + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + const connection = await requireConnection({ + authenticated, + connectionId, + state, + }); + if (!connection) { + return textResponse("Unknown Acp-Connection-Id", 404); + } + + if (request.method === "GET") { + if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + return textResponse("WebSocket upgrade is not implemented", 426); + } + const accept = request.headers.get("Accept")?.toLowerCase(); + if (!accept?.includes(ACP_EVENT_STREAM_MIME_TYPE)) { + return textResponse("Not Acceptable", 406); + } + return await openAcpSse({ + actorId: authenticated.actor.userId, + eventStore: getConversationEventStore(), + requestSignal: request.signal, + route: { connectionId, ...(sessionId ? { sessionId } : {}) }, + state, + }); + } + + if (request.method === "DELETE") { + await deleteAcpConnection(state, connectionId); + return emptyResponse(202); + } + + return textResponse("Method Not Allowed", 405); + } catch (error) { + const conversationId = sessionIdFromCall(call) ?? sessionId; + logException(error, "acp.transport.exception", { + ...(authenticated + ? { + actorId: authenticated.actor.userId, + userId: authenticated.actor.userId, + } + : {}), + ...(connectionId ? { connectionId } : {}), + ...(conversationId ? { conversationId } : {}), + platform: "acp", + }); + return textResponse("ACP transport failed", 500); + } + }, + ); } diff --git a/packages/junior/src/api/acp/transport.ts b/packages/junior/src/api/acp/transport.ts new file mode 100644 index 0000000000..43534662eb --- /dev/null +++ b/packages/junior/src/api/acp/transport.ts @@ -0,0 +1,846 @@ +/** + * Short-lived ACP transport state. + * + * StateAdapter records replace process-local connection maps and stream + * buffers. Session history and Turn results remain in the Conversation log. + */ +import { createHash, randomUUID } from "node:crypto"; +import type { Lock, StateAdapter } from "chat"; +import * as acp from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { ConversationEventStore } from "@/chat/conversations/history"; +import { projectConversationMessages } from "@/chat/conversations/message-projection"; +import { logException } from "@/chat/logging"; +import { sleep } from "@/chat/sleep"; +import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "@/chat/state/locks"; + +// Transport records can expire because the Conversation log owns durable history. +const ACP_STATE_TTL_MS = 24 * 60 * 60 * 1000; +const ACP_SSE_LEASE_TTL_MS = 60_000; +const ACP_SSE_LEASE_RENEW_INTERVAL_MS = 20_000; +const EVENT_PAGE_SIZE = 50; +const EVENT_POLL_INTERVAL_MS = 100; +const LOCK_WAIT_MS = 5_000; +// Bound undelivered transport output without truncating Conversation history. +const MAX_STREAM_ITEMS = 1_024; +const SSE_KEEP_ALIVE_MS = 15_000; + +const jsonRpcIdSchema = z.union([z.string(), z.number().finite(), z.null()]); + +const jsonRpcErrorSchema = z + .object({ + code: z.number().int(), + data: z.unknown().optional(), + message: z.string(), + }) + .strict(); + +const acpMessageSchema = z.union([ + z + .object({ + id: jsonRpcIdSchema.optional(), + jsonrpc: z.literal("2.0"), + method: z.string().min(1), + params: z.unknown().optional(), + }) + .strict(), + z + .object({ + id: jsonRpcIdSchema, + jsonrpc: z.literal("2.0"), + result: z.unknown(), + }) + .strict(), + z + .object({ + error: jsonRpcErrorSchema, + id: jsonRpcIdSchema, + jsonrpc: z.literal("2.0"), + }) + .strict(), +]); + +const acpConnectionSchema = z + .object({ + actorId: z.string().min(1), + nonce: z.string().min(1), + }) + .strict(); + +const acpStreamRouteSchema = z + .object({ + connectionId: z.string().min(1), + sessionId: z.string().min(1).optional(), + }) + .strict(); + +const acpStreamDependencySchema = z + .object({ + itemId: z.string().min(1), + sessionId: z.string().min(1), + }) + .strict(); + +const acpPromptStreamOutputSchema = z + .object({ + afterSeq: z.number().int().nonnegative(), + kind: z.literal("prompt"), + requestId: jsonRpcIdSchema, + turnId: z.string().min(1), + }) + .strict(); + +const acpStreamOutputSchema = z.discriminatedUnion("kind", [ + z + .object({ + afterStreamItem: acpStreamDependencySchema.optional(), + kind: z.literal("message"), + message: acpMessageSchema, + }) + .strict(), + z.object({ kind: z.literal("replay") }).strict(), + acpPromptStreamOutputSchema, +]); + +const storedAcpStreamItemSchema = z + .object({ + id: z.string().min(1), + output: acpStreamOutputSchema, + }) + .strict(); + +const acpReceiptOutputSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("message"), + message: acpMessageSchema, + sessionId: z.string().min(1).optional(), + }) + .strict(), + z + .object({ + kind: z.literal("replay"), + sessionId: z.string().min(1), + }) + .strict(), + z + .object({ + kind: z.literal("prompt"), + output: acpPromptStreamOutputSchema, + sessionId: z.string().min(1), + }) + .strict(), +]); + +const acpRequestReceiptSchema = z + .object({ outputs: z.array(acpReceiptOutputSchema) }) + .strict(); + +const acpStreamCursorSchema = z.object({ itemId: z.string().min(1) }).strict(); + +export type AcpConnection = z.output; +export type AcpStreamRoute = z.output; + +type AcpStreamDependency = z.output; +export type AcpPromptStreamOutput = z.output< + typeof acpPromptStreamOutputSchema +>; +type AcpStreamOutput = z.output; +type StoredAcpStreamItem = z.output; +export type AcpReceiptOutput = z.output; +export type AcpRequestReceipt = z.output; + +function stableHex(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +function connectionKey(connectionId: string): string { + return `junior:acp:v1:connection:${connectionId}`; +} + +function receiptKey(connectionId: string, requestKey: string): string { + return `${connectionKey(connectionId)}:request:${requestKey}`; +} + +function receiptLockKey(connectionId: string, requestKey: string): string { + return `${receiptKey(connectionId, requestKey)}:lock`; +} + +function streamKey(route: AcpStreamRoute): string { + const suffix = route.sessionId + ? `session:${stableHex(route.sessionId)}` + : "connection"; + return `${connectionKey(route.connectionId)}:stream:${suffix}`; +} + +function streamListKey(route: AcpStreamRoute): string { + return `${streamKey(route)}:items`; +} + +function streamCursorKey(route: AcpStreamRoute): string { + return `${streamKey(route)}:cursor`; +} + +function streamLockKey(route: AcpStreamRoute): string { + return `${streamKey(route)}:subscriber`; +} + +function streamAppendLockKey(route: AcpStreamRoute): string { + return `${streamKey(route)}:append`; +} + +function streamItemCompletionKey( + connectionId: string, + dependency: AcpStreamDependency, +): string { + const route = { connectionId, sessionId: dependency.sessionId }; + return `${streamKey(route)}:complete:${stableHex(dependency.itemId)}`; +} + +function requestStreamItemId(requestKey: string, index: number): string { + return `acp-item:${stableHex(`${requestKey}:${index}`)}`; +} + +function parseStreamItem(value: unknown): StoredAcpStreamItem { + return storedAcpStreamItemSchema.parse(value); +} + +function parseReceipt(value: unknown): AcpRequestReceipt { + return acpRequestReceiptSchema.parse(value); +} + +async function readStreamItems( + state: StateAdapter, + route: AcpStreamRoute, +): Promise { + return (await state.getList(streamListKey(route))).map(parseStreamItem); +} + +async function readStreamCursor( + state: StateAdapter, + route: AcpStreamRoute, +): Promise { + const value = await state.get(streamCursorKey(route)); + return value === null || value === undefined + ? undefined + : acpStreamCursorSchema.parse(value).itemId; +} + +async function connectionIsLive( + state: StateAdapter, + connectionId: string, +): Promise { + return Boolean(await readAcpConnection(state, connectionId)); +} + +/** Create one actor-bound ACP transport connection. */ +export async function createAcpConnection( + state: StateAdapter, + actorId: string, +): Promise<{ connection: AcpConnection; connectionId: string }> { + const connectionId = randomUUID(); + const connection = acpConnectionSchema.parse({ + actorId, + nonce: randomUUID(), + }); + await state.set(connectionKey(connectionId), connection, ACP_STATE_TTL_MS); + return { connection, connectionId }; +} + +/** Read one live ACP transport connection. */ +export async function readAcpConnection( + state: StateAdapter, + connectionId: string, +): Promise { + const value = await state.get(connectionKey(connectionId)); + return value === null || value === undefined + ? undefined + : acpConnectionSchema.parse(value); +} + +/** Remove one ACP connection so all of its live streams terminate. */ +export async function deleteAcpConnection( + state: StateAdapter, + connectionId: string, +): Promise { + await state.delete(connectionKey(connectionId)); + await state.delete(streamCursorKey({ connectionId })); +} + +/** Reuse pending output IDs and fence stream capacity before each new delivery. */ +async function appendStreamOutput(args: { + id: string; + output: AcpStreamOutput; + route: AcpStreamRoute; + state: StateAdapter; +}): Promise { + const route = acpStreamRouteSchema.parse(args.route); + const output = acpStreamOutputSchema.parse(args.output); + const result = await withLock( + args.state, + streamAppendLockKey(route), + async (lock) => { + let existing = await readStreamItems(args.state, route); + const cursor = await readStreamCursor(args.state, route); + const cursorIndex = cursor + ? existing.findIndex((item) => item.id === cursor) + : -1; + const pending = existing.slice(cursorIndex >= 0 ? cursorIndex + 1 : 0); + const sameReceiptItem = (item: StoredAcpStreamItem): boolean => + item.id === args.id || item.id.startsWith(`${args.id}:delivery:`); + const pendingItem = pending.find(sameReceiptItem); + if (pendingItem) return pendingItem.id; + + if (existing.length >= MAX_STREAM_ITEMS) { + if (cursorIndex !== existing.length - 1) { + throw new AcpStreamFullError(); + } + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.delete(streamListKey(route)); + await args.state.delete(streamCursorKey(route)); + existing = []; + } + + const existingIds = new Set(existing.map((item) => item.id)); + let id = args.id; + for (let delivery = 1; existingIds.has(id); delivery += 1) { + id = `${args.id}:delivery:${delivery}`; + } + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + if (output.kind === "replay") { + const sessionId = route.sessionId; + if (!sessionId) { + throw new Error("ACP replay output requires a session stream"); + } + await args.state.delete( + streamItemCompletionKey(route.connectionId, { + itemId: id, + sessionId, + }), + ); + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + } + await args.state.appendToList( + streamListKey(route), + { id, output } satisfies StoredAcpStreamItem, + { ttlMs: ACP_STATE_TTL_MS }, + ); + return id; + }, + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: LOCK_WAIT_MS, + }, + ); + if (!result.acquired) { + throw new Error("Timed out acquiring ACP stream append control"); + } + return result.value; +} + +class AcpStreamFullError extends Error { + constructor() { + super("ACP stream has too much undelivered output"); + this.name = "AcpStreamFullError"; + } +} + +type EmitAcpMessage = (message: acp.AnyMessage) => Promise; + +/** Replay durable Conversation Messages as ACP session updates. */ +async function streamSessionReplay(args: { + emit: EmitAcpMessage; + eventStore: ConversationEventStore; + sessionId: string; + signal: AbortSignal; +}): Promise { + const history = await args.eventStore.loadMessageHistory(args.sessionId); + for (const message of projectConversationMessages(history)) { + if (args.signal.aborted) return false; + if (message.role === "system") continue; + const emitted = await args.emit({ + jsonrpc: "2.0", + method: acp.methods.client.session.update, + params: { + sessionId: args.sessionId, + update: { + sessionUpdate: + message.role === "user" + ? "user_message_chunk" + : "agent_message_chunk", + content: { type: "text", text: message.text }, + messageId: message.id, + }, + }, + }); + if (!emitted) return false; + } + return true; +} + +/** Queue one request receipt on its connection and session streams. */ +async function queueReceipt(args: { + connectionId: string; + receipt: AcpRequestReceipt; + requestKey: string; + state: StateAdapter; +}): Promise { + const receipt = parseReceipt(args.receipt); + let dependency: AcpStreamDependency | undefined; + for (const [index, output] of receipt.outputs.entries()) { + const id = requestStreamItemId(args.requestKey, index); + if (output.kind === "replay") { + const itemId = await appendStreamOutput({ + id, + output: { kind: "replay" }, + route: { connectionId: args.connectionId, sessionId: output.sessionId }, + state: args.state, + }); + dependency = { itemId, sessionId: output.sessionId }; + continue; + } + if (output.kind === "message") { + await appendStreamOutput({ + id, + output: { + ...(dependency ? { afterStreamItem: dependency } : {}), + kind: "message", + message: output.message, + }, + route: { + connectionId: args.connectionId, + ...(output.sessionId ? { sessionId: output.sessionId } : {}), + }, + state: args.state, + }); + dependency = undefined; + continue; + } + await appendStreamOutput({ + id, + output: output.output, + route: { connectionId: args.connectionId, sessionId: output.sessionId }, + state: args.state, + }); + } +} + +/** Store a receipt once and redeliver it when a client retries after delivery. */ +export async function acceptAcpRequest(args: { + connectionId: string; + createReceipt: () => Promise; + requestKey: string; + state: StateAdapter; +}): Promise<"accepted" | "busy" | "full"> { + try { + const result = await withLock( + args.state, + receiptLockKey(args.connectionId, args.requestKey), + async (lock) => { + const stored = await args.state.get( + receiptKey(args.connectionId, args.requestKey), + ); + let receipt = + stored === null || stored === undefined + ? undefined + : parseReceipt(stored); + if (!receipt) { + receipt = parseReceipt(await args.createReceipt()); + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.set( + receiptKey(args.connectionId, args.requestKey), + receipt, + ACP_STATE_TTL_MS, + ); + } + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await queueReceipt({ + connectionId: args.connectionId, + receipt, + requestKey: args.requestKey, + state: args.state, + }); + }, + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: LOCK_WAIT_MS, + }, + ); + return result.acquired ? "accepted" : "busy"; + } catch (error) { + if (error instanceof AcpStreamFullError) return "full"; + throw error; + } +} + +function resultMessage( + requestId: acp.JsonRpcId, + result: unknown, +): acp.AnyResponse { + return { jsonrpc: "2.0", id: requestId, result }; +} + +function errorMessage( + requestId: acp.JsonRpcId, + error: acp.RequestError, +): acp.AnyResponse { + return { + jsonrpc: "2.0", + id: requestId, + error: error.toErrorResponse(), + }; +} + +/** Emit assistant Messages and the final response for one durable Turn. */ +async function streamPrompt(args: { + connectionId: string; + emit: EmitAcpMessage; + eventStore: ConversationEventStore; + output: AcpPromptStreamOutput; + sessionId: string; + signal: AbortSignal; + state: StateAdapter; +}): Promise { + let cursor = args.output.afterSeq; + const assistantPrefix = `${args.output.turnId}:assistant:`; + const sentMessageIds = new Set(); + + while (!args.signal.aborted) { + if (!(await connectionIsLive(args.state, args.connectionId))) return false; + const page = await args.eventStore.query(args.sessionId, { + afterSeq: cursor, + limit: EVENT_PAGE_SIZE, + types: ["message", "turn_completed", "turn_failed"], + }); + if (page.events.length === 0) { + await sleep(EVENT_POLL_INTERVAL_MS, args.signal); + continue; + } + + for (const event of page.events) { + cursor = event.seq; + const data = event.data; + if ( + data.type === "message" && + data.role === "assistant" && + data.messageId.startsWith(assistantPrefix) && + !sentMessageIds.has(data.messageId) + ) { + sentMessageIds.add(data.messageId); + if ( + !(await args.emit({ + jsonrpc: "2.0", + method: acp.methods.client.session.update, + params: { + sessionId: args.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: data.text }, + messageId: data.messageId, + }, + }, + })) + ) { + return false; + } + continue; + } + if ( + data.type === "turn_completed" && + data.turnId === args.output.turnId + ) { + return await args.emit( + resultMessage(args.output.requestId, { + stopReason: data.outcome === "cancelled" ? "cancelled" : "end_turn", + } satisfies acp.PromptResponse), + ); + } + if (data.type === "turn_failed" && data.turnId === args.output.turnId) { + return await args.emit( + errorMessage( + args.output.requestId, + acp.RequestError.internalError( + { failureCode: data.failureCode }, + "Junior Turn failed", + ), + ), + ); + } + } + } + return false; +} + +function serializeSseMessage(message: acp.AnyMessage): Uint8Array { + return new TextEncoder().encode(`data: ${JSON.stringify(message)}\n\n`); +} + +function serializeSseKeepAlive(): Uint8Array { + return new TextEncoder().encode(":\n\n"); +} + +/** Wait until another stream has delivered its required item. */ +async function waitForStreamDependency(args: { + connectionId: string; + dependency: AcpStreamDependency; + signal: AbortSignal; + state: StateAdapter; +}): Promise { + while (!args.signal.aborted) { + if (!(await connectionIsLive(args.state, args.connectionId))) return false; + const value = await args.state.get( + streamItemCompletionKey(args.connectionId, args.dependency), + ); + if (value !== null && value !== undefined && z.literal(true).parse(value)) { + return true; + } + await sleep(EVENT_POLL_INTERVAL_MS, args.signal); + } + return false; +} + +/** Open one connection or session SSE reader backed by shared state. */ +export async function openAcpSse(args: { + actorId: string; + eventStore: ConversationEventStore; + requestSignal: AbortSignal; + route: AcpStreamRoute; + state: StateAdapter; +}): Promise { + const route = acpStreamRouteSchema.parse(args.route); + const lock = await args.state.acquireLock( + streamLockKey(route), + ACP_SSE_LEASE_TTL_MS, + ); + if (!lock) { + return new Response("ACP SSE stream already connected", { status: 409 }); + } + return new Response(createSseBody({ ...args, lock, route }), { + status: 200, + headers: { + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Content-Type": "text/event-stream", + }, + }); +} + +/** Keep one SSE body alive while it consumes durable stream items in order. */ +function createSseBody(args: { + actorId: string; + eventStore: ConversationEventStore; + lock: Lock; + requestSignal: AbortSignal; + route: AcpStreamRoute; + state: StateAdapter; +}): ReadableStream { + const abort = new AbortController(); + let cleaned = false; + let heartbeat: ReturnType | undefined; + let keepAlive: ReturnType | undefined; + let leaseExpiresAt = args.lock.expiresAt; + let retainingLease: Promise | undefined; + + const correlation = { + actorId: args.actorId, + connectionId: args.route.connectionId, + ...(args.route.sessionId ? { conversationId: args.route.sessionId } : {}), + platform: "acp", + userId: args.actorId, + }; + const cleanup = async (): Promise => { + if (cleaned) return; + cleaned = true; + abort.abort(); + if (keepAlive) clearInterval(keepAlive); + if (heartbeat) clearInterval(heartbeat); + args.requestSignal.removeEventListener("abort", onRequestAbort); + try { + await args.state.releaseLock(args.lock); + } catch (error) { + logException(error, "acp.sse.lock_cleanup.exception", correlation); + } + }; + const onRequestAbort = (): void => void cleanup(); + const retainLease = async (): Promise => { + if (retainingLease) return await retainingLease; + retainingLease = (async () => { + let extended: boolean; + try { + extended = await args.state.extendLock(args.lock, ACP_SSE_LEASE_TTL_MS); + } catch (error) { + if (Date.now() < leaseExpiresAt) return; + throw new Error("ACP SSE stream lease could not be renewed", { + cause: error, + }); + } + if (!extended) { + throw new Error("ACP SSE stream lease expired"); + } + leaseExpiresAt = Date.now() + ACP_SSE_LEASE_TTL_MS; + })(); + try { + await retainingLease; + } finally { + retainingLease = undefined; + } + }; + + return new ReadableStream({ + start(controller) { + let failureCaptured = false; + const fail = (error: unknown): void => { + if (cleaned) return; + const failure = + error instanceof Error + ? error + : new Error("ACP SSE stream failed", { cause: error }); + if (!failureCaptured) { + failureCaptured = true; + logException(failure, "acp.sse.exception", correlation); + } + abort.abort(failure); + try { + controller.error(failure); + } catch { + // The response stream may already be closed or cancelled. + } + void cleanup(); + }; + + args.requestSignal.addEventListener("abort", onRequestAbort, { + once: true, + }); + if (args.requestSignal.aborted) { + void cleanup(); + return; + } + keepAlive = setInterval(() => { + if (cleaned || (controller.desiredSize ?? 0) <= 0) return; + try { + controller.enqueue(serializeSseKeepAlive()); + } catch (error) { + fail(error); + } + }, SSE_KEEP_ALIVE_MS); + keepAlive.unref?.(); + + heartbeat = setInterval(() => { + if (cleaned) return; + void retainLease().catch(fail); + }, ACP_SSE_LEASE_RENEW_INTERVAL_MS); + heartbeat.unref?.(); + + const emit: EmitAcpMessage = async (message) => { + while (!abort.signal.aborted && (controller.desiredSize ?? 0) <= 0) { + await sleep(EVENT_POLL_INTERVAL_MS, abort.signal); + } + if ( + abort.signal.aborted || + !(await connectionIsLive(args.state, args.route.connectionId)) + ) { + abort.abort(); + return false; + } + await retainLease(); + controller.enqueue(serializeSseMessage(message)); + return true; + }; + + const pump = async (): Promise => { + let cursor = await readStreamCursor(args.state, args.route); + while (!abort.signal.aborted) { + if (!(await connectionIsLive(args.state, args.route.connectionId))) { + return; + } + + const items = await readStreamItems(args.state, args.route); + const cursorIndex = cursor + ? items.findIndex((item) => item.id === cursor) + : -1; + const pending = items.slice(cursorIndex >= 0 ? cursorIndex + 1 : 0); + if (pending.length === 0) { + await sleep(EVENT_POLL_INTERVAL_MS, abort.signal); + continue; + } + + for (const item of pending) { + if (abort.signal.aborted) return; + let delivered: boolean; + if (item.output.kind === "message") { + if ( + item.output.afterStreamItem && + !(await waitForStreamDependency({ + connectionId: args.route.connectionId, + dependency: item.output.afterStreamItem, + signal: abort.signal, + state: args.state, + })) + ) { + return; + } + delivered = await emit(item.output.message); + } else { + const sessionId = args.route.sessionId; + if (!sessionId) { + throw new Error( + `ACP ${item.output.kind} output requires a session stream`, + ); + } + delivered = + item.output.kind === "replay" + ? await streamSessionReplay({ + emit, + eventStore: args.eventStore, + sessionId, + signal: abort.signal, + }) + : await streamPrompt({ + connectionId: args.route.connectionId, + emit, + eventStore: args.eventStore, + output: item.output, + sessionId, + signal: abort.signal, + state: args.state, + }); + } + if (!delivered || abort.signal.aborted) return; + await retainLease(); + if (item.output.kind === "replay") { + await args.state.set( + streamItemCompletionKey(args.route.connectionId, { + itemId: item.id, + sessionId: args.route.sessionId!, + }), + true, + ACP_STATE_TTL_MS, + ); + await retainLease(); + } + await args.state.set( + streamCursorKey(args.route), + acpStreamCursorSchema.parse({ itemId: item.id }), + ACP_STATE_TTL_MS, + ); + cursor = item.id; + } + } + }; + + void pump() + .then(() => { + if (!cleaned) controller.close(); + }) + .catch((error: unknown) => { + if (!cleaned && !args.requestSignal.aborted) fail(error); + }) + .finally(cleanup); + }, + async cancel() { + await cleanup(); + }, + }); +} diff --git a/packages/junior/src/chat/api-turns/cancellation.ts b/packages/junior/src/chat/api-turns/cancellation.ts index f7893b2af2..86d8550270 100644 --- a/packages/junior/src/chat/api-turns/cancellation.ts +++ b/packages/junior/src/chat/api-turns/cancellation.ts @@ -1,73 +1,297 @@ -/** App-scoped control for one active API Turn per Conversation. */ +import type { StateAdapter } from "chat"; +import { z } from "zod"; +import { deleteWebAuthorization } from "@/chat/api-turns/authorization"; +import type { ConversationTurnLifecycle } from "@/chat/conversations/turn-lifecycle"; +import { logException, runBestEffort } from "@/chat/logging"; +import { persistThreadStateById } from "@/chat/runtime/thread-state"; +import { markTurnClosed } from "@/chat/runtime/turn"; +import type { SandboxRef } from "@/chat/sandbox/ref"; +import { markConversationMessage } from "@/chat/services/conversation-memory"; +import { clearPendingAuth } from "@/chat/services/pending-auth"; +import { getStateAdapter } from "@/chat/state/adapter"; +import type { ThreadConversationState } from "@/chat/state/conversation"; +import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "@/chat/state/locks"; +import { abandonTurnRecord } from "@/chat/task-execution/checkpoint"; + +const API_TURN_CONTROL_TTL_MS = 24 * 60 * 60 * 1000; +const CANCELLATION_POLL_INTERVAL_MS = 100; +const CONTROL_LOCK_WAIT_MS = 5_000; + +const activeApiTurnSchema = z + .object({ + accepted: z.boolean(), + cancelled: z.boolean(), + messageId: z.string().min(1), + turnId: z.string().min(1), + }) + .strict(); + +type ActiveApiTurn = z.output; + +interface LocalApiTurn { + controller: AbortController; + polling: boolean; + timer?: ReturnType; + turnId: string; +} + +export type ApiTurnAdmission = + | { status: "accepted"; value: T } + | { messageId: string; status: "active"; turnId: string }; + +/** Durable control and invocation-local abort signals for cancellable API Turns. */ export interface ApiTurnCancellation { - begin(conversationId: string): AbortSignal | undefined; - cancel(conversationId: string): boolean; - disconnect(conversationId: string, signal: AbortSignal): void; - finish(conversationId: string, signal: AbortSignal): void; - park(conversationId: string, signal: AbortSignal): void; - signal(conversationId: string): AbortSignal | undefined; + /** Admit one retry-stable mailbox message under the Conversation control lock. */ + admit( + input: { + conversationId: string; + messageId: string; + turnId: string; + }, + callbacks: { + messageExists: (messageId: string) => Promise; + /** Invoke the fence inside the mailbox mutation, before its write or duplicate decision. */ + run: (fence: () => Promise) => Promise; + }, + ): Promise>; + /** Create the abort signal used by the execution slice that owns the Turn. */ + begin( + conversationId: string, + turnId: string, + ): Promise; + /** Persist cancellation and abort a matching local execution slice. */ + cancel(conversationId: string): Promise; + /** Finish the Turn and remove its durable control state. */ + finish( + conversationId: string, + turnId: string, + signal?: AbortSignal, + ): Promise; + /** Stop local polling without removing durable Turn control. */ + release(conversationId: string, signal: AbortSignal): void; } -interface ActiveApiTurnCancellation { - connected: boolean; - controller: AbortController; - parked: boolean; +function controlKey(conversationId: string): string { + return `junior:api-turn:v1:control:${conversationId}`; +} + +function controlLockKey(conversationId: string): string { + return `${controlKey(conversationId)}:lock`; +} + +/** Parse one canonical durable API Turn control record. */ +async function readControl( + state: StateAdapter, + conversationId: string, +): Promise { + const value = await state.get(controlKey(conversationId)); + return value === null || value === undefined + ? undefined + : activeApiTurnSchema.parse(value); +} + +/** Run one durable API Turn control mutation under its Conversation lock. */ +async function mutateControl(args: { + conversationId: string; + run: (fence: () => Promise) => Promise; + state: StateAdapter; +}): Promise { + const result = await withLock( + args.state, + controlLockKey(args.conversationId), + async (lock) => + await args.run(async () => + fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS), + ), + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: CONTROL_LOCK_WAIT_MS, + }, + ); + if (!result.acquired) { + throw new Error( + `Timed out acquiring API Turn control for ${args.conversationId}`, + ); + } + return result.value; } -/** Create in-process cancellation state for active API Turns. */ -export function createApiTurnCancellation(): ApiTurnCancellation { - const active = new Map(); +/** Create state-backed cancellation that works across worker invocations. */ +export function createApiTurnCancellation( + options: { pollIntervalMs?: number; state?: StateAdapter } = {}, +): ApiTurnCancellation { + const state = options.state ?? getStateAdapter(); + const pollIntervalMs = + options.pollIntervalMs ?? CANCELLATION_POLL_INTERVAL_MS; + const local = new Map(); + + const release = (conversationId: string, signal: AbortSignal): void => { + const entry = local.get(conversationId); + if (entry?.controller.signal !== signal) return; + if (entry.timer) clearInterval(entry.timer); + local.delete(conversationId); + }; return { - begin(conversationId) { - if (active.has(conversationId)) { - return undefined; - } - const controller = new AbortController(); - active.set(conversationId, { - connected: true, - controller, - parked: false, + async admit(input, callbacks) { + return await mutateControl({ + conversationId: input.conversationId, + state, + run: async (fence) => { + let existing = await readControl(state, input.conversationId); + if (existing && existing.turnId !== input.turnId) { + if ( + !existing.accepted && + !(await callbacks.messageExists(existing.messageId)) + ) { + await fence(); + await state.delete(controlKey(input.conversationId)); + existing = undefined; + } else { + if (!existing.accepted) { + existing = { ...existing, accepted: true }; + await fence(); + await state.set( + controlKey(input.conversationId), + existing, + API_TURN_CONTROL_TTL_MS, + ); + } + return { + messageId: existing.messageId, + status: "active" as const, + turnId: existing.turnId, + }; + } + } + + if (!existing) { + await fence(); + await state.set( + controlKey(input.conversationId), + { + accepted: false, + cancelled: false, + messageId: input.messageId, + turnId: input.turnId, + } satisfies ActiveApiTurn, + API_TURN_CONTROL_TTL_MS, + ); + } + + const value = await callbacks.run(fence); + const current = await readControl(state, input.conversationId); + if (current && current.turnId !== input.turnId) { + throw new Error( + `API Turn control changed during admission for ${input.conversationId}`, + ); + } + if (current && !current.accepted) { + await fence(); + await state.set( + controlKey(input.conversationId), + { ...current, accepted: true } satisfies ActiveApiTurn, + API_TURN_CONTROL_TTL_MS, + ); + } + return { status: "accepted" as const, value }; + }, }); - return controller.signal; }, - cancel(conversationId) { - const entry = active.get(conversationId); - if (!entry) { - return false; + + async begin(conversationId, turnId) { + const active = await readControl(state, conversationId); + if (!active || active.turnId !== turnId) { + return undefined; } - entry.controller.abort(new Error("API Turn cancelled")); - return true; - }, - disconnect(conversationId, signal) { - const entry = active.get(conversationId); - if (entry?.controller.signal !== signal) { - return; + + const previous = local.get(conversationId); + if (previous?.turnId === turnId) { + return previous.controller.signal; } - entry.connected = false; - if (entry.parked) { - active.delete(conversationId); + if (previous) { + if (previous.timer) clearInterval(previous.timer); + local.delete(conversationId); } - }, - finish(conversationId, signal) { - const entry = active.get(conversationId); - if (entry?.controller.signal === signal) { - active.delete(conversationId); + + const controller = new AbortController(); + let invalidStateCaptured = false; + const entry: LocalApiTurn = { + controller, + polling: false, + turnId, + }; + const poll = async (): Promise => { + if (entry.polling || controller.signal.aborted) return; + entry.polling = true; + try { + const current = await readControl(state, conversationId); + if (current && current.turnId === turnId && current.cancelled) { + controller.abort(new Error("API Turn cancelled")); + } + } catch (error) { + if (error instanceof z.ZodError && !invalidStateCaptured) { + invalidStateCaptured = true; + logException(error, "api.turn.cancellation_state.exception", { + conversationId, + turnId, + }); + } + // A later poll can recover while the execution slice stays active. + } finally { + entry.polling = false; + } + }; + entry.timer = setInterval(() => void poll(), pollIntervalMs); + entry.timer.unref?.(); + local.set(conversationId, entry); + if (active.cancelled) { + controller.abort(new Error("API Turn cancelled")); } + return controller.signal; }, - park(conversationId, signal) { - const entry = active.get(conversationId); - if (entry?.controller.signal !== signal) { - return; - } - entry.parked = true; - if (!entry.connected) { - active.delete(conversationId); + + async cancel(conversationId) { + const turnId = await mutateControl({ + conversationId, + state, + run: async (fence) => { + const active = await readControl(state, conversationId); + if (!active) return undefined; + await fence(); + await state.set( + controlKey(conversationId), + { ...active, cancelled: true } satisfies ActiveApiTurn, + API_TURN_CONTROL_TTL_MS, + ); + return active.turnId; + }, + }); + if (!turnId) return false; + const entry = local.get(conversationId); + if (entry?.turnId === turnId) { + entry.controller.abort(new Error("API Turn cancelled")); } + return true; }, - signal(conversationId) { - return active.get(conversationId)?.controller.signal; + + async finish(conversationId, turnId, signal) { + if (signal) release(conversationId, signal); + await mutateControl({ + conversationId, + state, + run: async (fence) => { + const active = await readControl(state, conversationId); + if (active?.turnId === turnId) { + await fence(); + await state.delete(controlKey(conversationId)); + } + }, + }); }, + + release, }; } @@ -84,49 +308,47 @@ export async function completeCancelledApiTurn(args: { turnId: string; userMessageId: string; }): Promise { - try { - await abandonTurnRecord({ - conversationId: args.conversationId, - turnId: args.turnId, - errorMessage: "API Turn cancelled", - }); - clearPendingAuth(args.conversation, args.turnId); - markConversationMessage(args.conversation, args.userMessageId, { - replied: false, - skippedReason: "turn cancelled", - }); - markTurnClosed({ - conversation: args.conversation, - nowMs: Date.now(), - sessionId: args.turnId, - }); - await deleteWebAuthorization({ - actorId: args.actorId, - conversationId: args.conversationId, - }); - await persistThreadStateById(args.conversationId, { - conversation: args.conversation, - sandboxRef: args.sandboxRef, - }); - await args.lifecycle.complete({ + await abandonTurnRecord({ + conversationId: args.conversationId, + turnId: args.turnId, + errorMessage: "API Turn cancelled", + }); + clearPendingAuth(args.conversation, args.turnId); + markConversationMessage(args.conversation, args.userMessageId, { + replied: false, + skippedReason: "turn cancelled", + }); + markTurnClosed({ + conversation: args.conversation, + nowMs: Date.now(), + sessionId: args.turnId, + }); + await deleteWebAuthorization({ + actorId: args.actorId, + conversationId: args.conversationId, + }); + await persistThreadStateById(args.conversationId, { + conversation: args.conversation, + sandboxRef: args.sandboxRef, + }); + await args.lifecycle.complete({ + conversationId: args.conversationId, + createdAtMs: Date.now(), + outcome: "cancelled", + turnId: args.turnId, + }); + await args.acknowledge(); + await runBestEffort( + async () => + await args.cancellation?.finish( + args.conversationId, + args.turnId, + args.signal, + ), + "api.turn.cancellation_cleanup.exception", + { conversationId: args.conversationId, - createdAtMs: Date.now(), - outcome: "cancelled", turnId: args.turnId, - }); - } finally { - if (args.signal) { - args.cancellation?.finish(args.conversationId, args.signal); - } - } - await args.acknowledge(); + }, + ); } -import type { ConversationTurnLifecycle } from "@/chat/conversations/turn-lifecycle"; -import { deleteWebAuthorization } from "@/chat/api-turns/authorization"; -import { persistThreadStateById } from "@/chat/runtime/thread-state"; -import { markTurnClosed } from "@/chat/runtime/turn"; -import type { SandboxRef } from "@/chat/sandbox/ref"; -import { markConversationMessage } from "@/chat/services/conversation-memory"; -import { clearPendingAuth } from "@/chat/services/pending-auth"; -import type { ThreadConversationState } from "@/chat/state/conversation"; -import { abandonTurnRecord } from "@/chat/task-execution/checkpoint"; diff --git a/packages/junior/src/chat/api-turns/mailbox.ts b/packages/junior/src/chat/api-turns/mailbox.ts new file mode 100644 index 0000000000..6dcaa9e0e3 --- /dev/null +++ b/packages/junior/src/chat/api-turns/mailbox.ts @@ -0,0 +1,34 @@ +import type { StateAdapter } from "chat"; +import { getConversation } from "@/chat/task-execution/store"; + +/** Return whether the durable Conversation has accepted one API Message id. */ +export async function apiConversationMessageExists(args: { + conversationId: string; + messageId: string; + state?: StateAdapter; +}): Promise { + const conversation = await getConversation({ + conversationId: args.conversationId, + state: args.state, + }); + return Boolean( + conversation?.execution.inboundMessageIds.includes(args.messageId), + ); +} + +/** Return whether one API Message still waits for mailbox acknowledgement. */ +export async function apiConversationMessageIsPending(args: { + conversationId: string; + messageId: string; + state?: StateAdapter; +}): Promise { + const conversation = await getConversation({ + conversationId: args.conversationId, + state: args.state, + }); + return Boolean( + conversation?.execution.pendingMessages.some( + (message) => message.inboundMessageId === args.messageId, + ), + ); +} diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index 223340bae3..57975a8fc3 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -32,7 +32,12 @@ import { import type { ConversationTurnFailureCode } from "@/chat/conversations/history"; import { credentialContextForActor } from "@/chat/credentials/context"; import { getConversationEventStore, getConversationStore } from "@/chat/db"; -import { logException, setTags, withLogContext } from "@/chat/logging"; +import { + logException, + runBestEffort, + setTags, + withLogContext, +} from "@/chat/logging"; import type { AgentRunner } from "@/chat/runtime/agent-runner"; import { buildDeliveredTurnStatePatch } from "@/chat/runtime/delivered-turn-state"; import { @@ -102,6 +107,8 @@ export { resolveApiTurnWork } from "@/chat/api-turns/routing"; type EnqueueOptions = { conversationStore?: ConversationStore; + /** Revalidate an outer admission lock at the durable mailbox boundary. */ + fence?: () => Promise; nowMs?: number; queue: ConversationWorkQueue; state?: StateAdapter; @@ -158,7 +165,8 @@ export function createApiConversationId(args: { )}`; } -function apiMessageId(args: { +/** Build the retry-stable Message id used by one API mailbox request. */ +export function apiConversationMessageId(args: { conversationId: string; idempotencyKey: string; }): string { @@ -372,7 +380,7 @@ export async function appendAndEnqueueApiConversationMessage( throw new Error("API conversation actor requires a verified email"); } const nowMs = options.nowMs ?? Date.now(); - const messageId = apiMessageId({ + const messageId = apiConversationMessageId({ conversationId: input.conversationId, idempotencyKey: input.idempotencyKey, }); @@ -393,6 +401,7 @@ export async function appendAndEnqueueApiConversationMessage( nowMs, }), conversationStore: options.conversationStore, + fence: options.fence, nowMs, queue: options.queue, state: options.state, @@ -629,16 +638,34 @@ export function createApiTurnWorker(options: { let modelFailureEventId: string | undefined; let modelFailureCaptureAttempted = false; let reply: AgentRunResult | undefined; - const cancellationSignal = options.cancellation?.signal( + const cancellationSignal = await options.cancellation?.begin( context.conversationId, + turnId, ); - const finishCancellation = (): void => { - if (cancellationSignal) { - options.cancellation?.finish( - context.conversationId, - cancellationSignal, - ); - } + const finishCancellation = async (): Promise => { + if (!cancellationSignal) return; + await options.cancellation?.finish( + context.conversationId, + turnId, + cancellationSignal, + ); + }; + const finishCancellationAfterTerminal = async (): Promise => { + await runBestEffort( + finishCancellation, + "api.turn.cancellation_cleanup.exception", + { + conversationId: context.conversationId, + turnId, + }, + ); + }; + const releaseCancellation = (): void => { + if (!cancellationSignal) return; + options.cancellation?.release( + context.conversationId, + cancellationSignal, + ); }; const completeCancelledTurn = @@ -658,6 +685,7 @@ export function createApiTurnWorker(options: { }); } catch (error) { if (hasLostTurnInputCommit(error)) { + releaseCancellation(); return { status: "lost_lease" }; } throw error; @@ -770,6 +798,7 @@ export function createApiTurnWorker(options: { } if (outcome.status === "suspended") { + releaseCancellation(); return { status: "yielded" }; } if (outcome.status === "awaiting_auth") { @@ -784,16 +813,10 @@ export function createApiTurnWorker(options: { conversation, sandboxRef, }); - if (cancellationSignal) { - options.cancellation?.park( - context.conversationId, - cancellationSignal, - ); - } + releaseCancellation(); await acknowledge(); return { status: "completed" }; } - finishCancellation(); reply = outcome.result; modelFailureCaptureAttempted = reply.diagnostics.outcome !== "success"; @@ -886,15 +909,18 @@ export function createApiTurnWorker(options: { } await acknowledge(); + await finishCancellationAfterTerminal(); return { status: "completed" }; } catch (error) { if (hasLostTurnInputCommit(error)) { + releaseCancellation(); return { status: "lost_lease" }; } if (cancellationSignal?.aborted) { return await completeCancelledTurn(); } if (!context.attempt.isFinalAttempt) { + releaseCancellation(); throw error; } @@ -936,8 +962,8 @@ export function createApiTurnWorker(options: { failureCode, turnId, }); - finishCancellation(); await acknowledge(); + await finishCancellationAfterTerminal(); return { status: "completed" }; } }, diff --git a/packages/junior/src/chat/app/conversation-work.ts b/packages/junior/src/chat/app/conversation-work.ts index 09898f19de..8ade56c4fc 100644 --- a/packages/junior/src/chat/app/conversation-work.ts +++ b/packages/junior/src/chat/app/conversation-work.ts @@ -42,7 +42,7 @@ interface ConversationWorkOptions { export type ConversationWorkCallbackOptions = VercelConversationWorkCallbackOptions & { - /** App-scoped control required by the experimental ACP route. */ + /** API Turn cancellation required by the experimental ACP route. */ apiTurnCancellation?: ApiTurnCancellation; }; @@ -55,7 +55,9 @@ export function createConversationWork( ): ConversationWorkCallbackOptions & { runtime: ReturnType; } { - const apiTurnCancellation = createApiTurnCancellation(); + const apiTurnCancellation = createApiTurnCancellation({ + state: options.state, + }); const services: JuniorRuntimeServiceOverrides = { ...options.services, replyExecutor: { diff --git a/packages/junior/src/chat/task-execution/state.ts b/packages/junior/src/chat/task-execution/state.ts index f4264d830f..fdcaa544e4 100644 --- a/packages/junior/src/chat/task-execution/state.ts +++ b/packages/junior/src/chat/task-execution/state.ts @@ -1064,6 +1064,8 @@ export function hasRunnableConversationWork( /** Persist one inbound message idempotently in its conversation mailbox. */ export async function appendInboundMessage(args: { + /** Revalidate an outer admission lock before this mailbox mutation commits. */ + fence?: () => Promise; message: InboundMessage; nowMs?: number; state?: StateAdapter; @@ -1097,6 +1099,7 @@ export async function appendInboundMessage(args: { const existingMessage = current.execution.inboundMessageIds.includes( args.message.inboundMessageId, ); + await args.fence?.(); if (existingMessage) { if (!existingPending) { return { status: "duplicate" }; diff --git a/packages/junior/src/chat/task-execution/store.ts b/packages/junior/src/chat/task-execution/store.ts index 9a36e9cd78..d3b4a6ed05 100644 --- a/packages/junior/src/chat/task-execution/store.ts +++ b/packages/junior/src/chat/task-execution/store.ts @@ -220,6 +220,8 @@ export async function ensureConversationWake(args: { /** Persist one inbound message idempotently in its conversation mailbox. */ export async function appendInboundMessage(args: { + /** Revalidate an outer admission lock at the durable mailbox boundary. */ + fence?: () => Promise; message: InboundMessage; conversationStore?: ConversationStore; nowMs?: number; @@ -236,6 +238,8 @@ export async function appendInboundMessage(args: { /** Persist inbound work and ensure a worker wake-up. */ export async function appendAndEnqueueInboundMessage(args: { + /** Revalidate an outer admission lock at the durable mailbox boundary. */ + fence?: () => Promise; message: InboundMessage; conversationStore?: ConversationStore; nowMs?: number; @@ -244,6 +248,7 @@ export async function appendAndEnqueueInboundMessage(args: { }): Promise { const nowMs = args.nowMs ?? now(); const appendResult = await workState.appendInboundMessage({ + fence: args.fence, message: args.message, nowMs, state: args.state, diff --git a/packages/junior/tests/fixtures/acp-http.ts b/packages/junior/tests/fixtures/acp-http.ts new file mode 100644 index 0000000000..efe5626f39 --- /dev/null +++ b/packages/junior/tests/fixtures/acp-http.ts @@ -0,0 +1,129 @@ +import * as acp from "@agentclientprotocol/sdk"; +import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client"; +import type { StateAdapter } from "chat"; +import type { Hono } from "hono"; +import { createConversationWork } from "@/chat/app/conversation-work"; +import type { ConversationWorkWebHarness } from "./api-turn"; +import { createSlackAdapterFixture } from "./conversation-work"; + +export const ACP_TEST_URL = "http://junior.test/api/acp"; + +/** Route successive ACP transport requests across the supplied app instances. */ +export function appFetch(...apps: Hono[]): typeof globalThis.fetch { + let nextApp = 0; + return async (input, init) => { + const app = apps[nextApp % apps.length]; + nextApp += 1; + if (!app) throw new Error("ACP test fetch requires an app"); + return await app.fetch(new Request(input, init as RequestInit)); + }; +} + +/** Build another app-scoped Conversation worker over the shared test stores. */ +export function createIndependentConversationWork( + harness: ConversationWorkWebHarness, + state: StateAdapter = harness.state, +) { + return createConversationWork({ + agentRunner: harness.agentRunner, + conversationStore: harness.conversationStore, + getSlackAdapter: () => createSlackAdapterFixture(), + queue: harness.queue, + services: { replyExecutor: { agentRunner: harness.agentRunner } }, + state, + }); +} + +/** Build one authenticated or unauthenticated ACP initialize request. */ +export function initializeRequest(token?: string): Request { + return new Request(ACP_TEST_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + _meta: { source: "integration-test" }, + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }, + }), + }); +} + +/** Run one callback through the official ACP Streamable HTTP client. */ +export async function withAcpClient(args: { + app: Hono; + fetch?: typeof globalThis.fetch; + onUpdate?: (update: acp.SessionUpdate) => void; + run: (context: acp.ClientContext) => Promise; + token: string; +}): Promise { + const stream = createHttpStream(ACP_TEST_URL, { + fetch: args.fetch ?? appFetch(args.app), + headers: { Authorization: `Bearer ${args.token}` }, + }); + try { + return await acp + .client({ name: "junior-acp-test" }) + .onNotification(acp.methods.client.session.update, (context) => { + args.onUpdate?.(context.params.update); + }) + .connectWith(stream, args.run); + } finally { + await stream.writable.close().catch(() => undefined); + } +} + +/** Drive explicit JSON-RPC ids through the official HTTP transport. */ +export async function withRawAcpConnection(args: { + app: Hono; + run: ( + request: ( + id: acp.JsonRpcId, + method: string, + params: unknown, + ) => Promise, + ) => Promise; + token: string; +}): Promise { + const stream = createHttpStream(ACP_TEST_URL, { + fetch: appFetch(args.app), + headers: { Authorization: `Bearer ${args.token}` }, + }); + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + const request = async ( + id: acp.JsonRpcId, + method: string, + params: unknown, + ): Promise => { + await writer.write({ jsonrpc: "2.0", id, method, params }); + while (true) { + const next = await reader.read(); + if (next.done) { + throw new Error("ACP stream closed before the response arrived"); + } + if (!("id" in next.value) || next.value.id !== id) continue; + if ("method" in next.value) continue; + if ("error" in next.value) { + throw new Error( + `ACP request failed: ${next.value.error.code} ${next.value.error.message}`, + ); + } + return next.value.result; + } + }; + try { + return await args.run(request); + } finally { + await writer.close().catch(() => undefined); + writer.releaseLock(); + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts new file mode 100644 index 0000000000..453b024d73 --- /dev/null +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -0,0 +1,183 @@ +import * as acp from "@agentclientprotocol/sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "@/app"; +import { createPersonalToken } from "@/personal-tokens/store"; +import { + closeApiTurnWorkFixture, + createConversationWorkWebHarness, +} from "../fixtures/api-turn"; +import { + ACP_TEST_URL, + createIndependentConversationWork, + initializeRequest, + withAcpClient, +} from "../fixtures/acp-http"; +import { deferred, streamReplies } from "../fixtures/conversation-work"; +import { createModelStream } from "../fixtures/model-stream"; + +describe("remote ACP recovery", () => { + afterEach(async () => { + await closeApiTurnWorkFixture(); + }); + + it("rejects a second active prompt from another app instance", async () => { + const modelStarted = deferred(); + const releaseModel = deferred(); + const harness = await createConversationWorkWebHarness({ + modelStream: createModelStream([ + { + type: "text", + text: "First prompt complete.", + onRequest: () => modelStarted.resolve(), + waitFor: releaseModel.promise, + }, + ]), + }); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + const secondApp = await createApp({ + conversationWork: createIndependentConversationWork(harness), + experimental: { acp: true, subagents: true }, + }); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP active prompt exclusion", + }); + const sessionCreated = deferred(); + const firstPrompt = withAcpClient({ + app, + token: token.token, + run: async (context) => { + await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await context.request(acp.methods.agent.session.new, { + cwd: "/client/workspace", + mcpServers: [], + }); + sessionCreated.resolve(session.sessionId); + return await context.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Keep this prompt active." }], + }); + }, + }); + + const sessionId = await sessionCreated.promise; + await vi.waitFor(() => { + expect(harness.queue.hasQueuedMessages()).toBe(true); + }); + const draining = harness.drain(); + await modelStarted.promise; + + const secondPromptError = await withAcpClient({ + app: secondApp, + token: token.token, + run: async (context) => { + await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + await context.request(acp.methods.agent.session.load, { + sessionId, + cwd: "/client/workspace", + mcpServers: [], + }); + try { + await context.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "Do not overlap." }], + }); + return undefined; + } catch (error) { + return error; + } + }, + }); + + expect(secondPromptError).toMatchObject({ code: -32602 }); + expect(harness.agentRuns).toHaveLength(1); + releaseModel.resolve(); + await draining; + await expect(firstPrompt).resolves.toEqual({ stopReason: "end_turn" }); + await expect(harness.historyTexts(sessionId)).resolves.toEqual([ + "Keep this prompt active.", + "First prompt complete.", + ]); + }, 20_000); + + it("recovers prompt admission after the first queue send fails", async () => { + const harness = await createConversationWorkWebHarness({ + modelStream: streamReplies("Recovered queue reply."), + }); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + const secondApp = await createApp({ + conversationWork: createIndependentConversationWork(harness), + experimental: { acp: true, subagents: true }, + }); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP queue recovery", + }); + const sessionId = await withAcpClient({ + app, + token: token.token, + run: async (context) => { + await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await context.request(acp.methods.agent.session.new, { + cwd: "/client/workspace", + mcpServers: [], + }); + return session.sessionId; + }, + }); + const initialized = await app.fetch(initializeRequest(token.token)); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const promptRequest = () => + new Request(ACP_TEST_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + "Acp-Session-Id": sessionId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: acp.methods.agent.session.prompt, + params: { + sessionId, + prompt: [{ type: "text", text: "Recover this prompt." }], + }, + }), + }); + + harness.queue.rejectSends(); + const failed = await app.fetch(promptRequest()); + expect(failed.status).toBe(500); + expect(harness.queue.hasQueuedMessages()).toBe(false); + + harness.queue.allowSends(); + const retried = await secondApp.fetch(promptRequest()); + expect(retried.status).toBe(202); + await vi.waitFor(() => { + expect(harness.queue.hasQueuedMessages()).toBe(true); + }); + await harness.drain(); + await expect(harness.historyTexts(sessionId)).resolves.toEqual([ + "Recover this prompt.", + "Recovered queue reply.", + ]); + }, 20_000); +}); diff --git a/packages/junior/tests/integration/acp-http.test.ts b/packages/junior/tests/integration/acp-http.test.ts index adcea3e03e..e7edc9ea1f 100644 --- a/packages/junior/tests/integration/acp-http.test.ts +++ b/packages/junior/tests/integration/acp-http.test.ts @@ -1,6 +1,6 @@ import * as acp from "@agentclientprotocol/sdk"; import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client"; -import type { Hono } from "hono"; +import type { StateAdapter } from "chat"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; import { getConversationEventStore } from "@/chat/db"; @@ -9,106 +9,17 @@ import { closeApiTurnWorkFixture, createConversationWorkWebHarness, } from "../fixtures/api-turn"; +import { + ACP_TEST_URL as ACP_URL, + appFetch, + createIndependentConversationWork, + initializeRequest, + withAcpClient, + withRawAcpConnection, +} from "../fixtures/acp-http"; import { deferred, streamReplies } from "../fixtures/conversation-work"; import { createModelStream } from "../fixtures/model-stream"; -const ACP_URL = "http://junior.test/api/acp"; - -function appFetch(app: Hono): typeof globalThis.fetch { - return async (input, init) => - await app.fetch(new Request(input, init as RequestInit)); -} - -function initializeRequest(token?: string): Request { - return new Request(ACP_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }, - }), - }); -} - -async function withAcpClient(args: { - app: Hono; - onUpdate?: (update: acp.SessionUpdate) => void; - run: (context: acp.ClientContext) => Promise; - token: string; -}): Promise { - const stream = createHttpStream(ACP_URL, { - fetch: appFetch(args.app), - headers: { Authorization: `Bearer ${args.token}` }, - }); - try { - return await acp - .client({ name: "junior-acp-test" }) - .onNotification(acp.methods.client.session.update, (context) => { - args.onUpdate?.(context.params.update); - }) - .connectWith(stream, args.run); - } finally { - await stream.writable.close().catch(() => undefined); - } -} - -/** Drive explicit JSON-RPC ids through the official HTTP transport. */ -async function withRawAcpConnection(args: { - app: Hono; - run: ( - request: ( - id: acp.JsonRpcId, - method: string, - params: unknown, - ) => Promise, - ) => Promise; - token: string; -}): Promise { - const stream = createHttpStream(ACP_URL, { - fetch: appFetch(args.app), - headers: { Authorization: `Bearer ${args.token}` }, - }); - const reader = stream.readable.getReader(); - const writer = stream.writable.getWriter(); - const request = async ( - id: acp.JsonRpcId, - method: string, - params: unknown, - ): Promise => { - await writer.write({ jsonrpc: "2.0", id, method, params }); - while (true) { - const next = await reader.read(); - if (next.done) { - throw new Error("ACP stream closed before the response arrived"); - } - if (!("id" in next.value) || next.value.id !== id) continue; - if ("method" in next.value) continue; - if ("error" in next.value) { - throw new Error( - `ACP request failed: ${next.value.error.code} ${next.value.error.message}`, - ); - } - return next.value.result; - } - }; - try { - return await args.run(request); - } finally { - await writer.close().catch(() => undefined); - writer.releaseLock(); - await reader.cancel().catch(() => undefined); - reader.releaseLock(); - } -} - describe("remote ACP HTTP", () => { afterEach(async () => { await closeApiTurnWorkFixture(); @@ -192,6 +103,43 @@ describe("remote ACP HTTP", () => { params: { clientCapabilities: {} }, }), }); + const unknownInitializeField = await app.request(ACP_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + privatePrompt: "sentinel", + }, + }), + }); + const initialized = await app.fetch(initializeRequest(token.token)); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const invalidConnectionId = await app.request(ACP_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": "../../invalid", + }, + }); + const invalidSessionId = await app.request(ACP_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + "Acp-Session-Id": "local:acp:invalid", + }, + }); expect(malformed.status).toBe(400); expect(wrongVersion.status).toBe(400); @@ -201,9 +149,16 @@ describe("remote ACP HTTP", () => { await expect(failedInitialize.json()).resolves.toMatchObject({ error: { code: -32602 }, }); + expect(unknownInitializeField.status).toBe(200); + expect(unknownInitializeField.headers.get("Acp-Connection-Id")).toBeNull(); + await expect(unknownInitializeField.json()).resolves.toMatchObject({ + error: { code: -32602 }, + }); + expect(invalidConnectionId.status).toBe(400); + expect(invalidSessionId.status).toBe(400); }); - it("runs, reloads, and protects a private Conversation through the official client", async () => { + it("runs, reloads, and protects a private Conversation across app instances", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("First ACP reply."), }); @@ -211,6 +166,11 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); + const secondApp = await createApp({ + conversationWork: createIndependentConversationWork(harness), + experimental: { acp: true, subagents: true }, + }); + const fetch = appFetch(app, secondApp); const ownerToken = await createPersonalToken({ email: harness.actor.email, name: "ACP owner", @@ -227,6 +187,7 @@ describe("remote ACP HTTP", () => { const firstRun = withAcpClient({ app, + fetch, token: ownerToken.token, onUpdate: (update) => firstUpdates.push(update), run: async (context) => { @@ -309,13 +270,30 @@ describe("remote ACP HTTP", () => { }, }); expect(crossActorConnection.status).toBe(404); - await app.request(ACP_URL, { + const crossActorDelete = await app.request(ACP_URL, { + method: "DELETE", + headers: { + Authorization: `Bearer ${otherToken.token}`, + "Acp-Connection-Id": connectionId!, + }, + }); + expect(crossActorDelete.status).toBe(404); + const ownerDelete = await app.request(ACP_URL, { method: "DELETE", headers: { Authorization: `Bearer ${ownerToken.token}`, "Acp-Connection-Id": connectionId!, }, }); + expect(ownerDelete.status).toBe(202); + const deletedConnection = await app.request(ACP_URL, { + method: "DELETE", + headers: { + Authorization: `Bearer ${ownerToken.token}`, + "Acp-Connection-Id": connectionId!, + }, + }); + expect(deletedConnection.status).toBe(404); const crossActorSessionErrors = await withAcpClient({ app, @@ -365,6 +343,7 @@ describe("remote ACP HTTP", () => { }); const secondRun = withAcpClient({ app, + fetch, token: ownerToken.token, onUpdate: (update) => secondUpdates.push(update), run: async (context) => { @@ -413,7 +392,7 @@ describe("remote ACP HTTP", () => { ]); }, 20_000); - it("deduplicates repeated request ids without colliding id types", async () => { + it("deduplicates exact retries without colliding payloads or id types", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("Typed id reply."), }); @@ -466,6 +445,20 @@ describe("remote ACP HTTP", () => { expect(harness.queue.hasQueuedMessages()).toBe(false); expect(harness.agentRuns).toHaveLength(1); + harness.setModelStream(streamReplies("Changed payload reply.")); + const changedPayload = request(2, acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "Reused id, new payload." }], + }); + await vi.waitFor(() => { + expect(harness.queue.hasQueuedMessages()).toBe(true); + }); + await harness.drain(); + await expect(changedPayload).resolves.toEqual({ + stopReason: "end_turn", + }); + expect(harness.agentRuns).toHaveLength(2); + harness.setModelStream(streamReplies("Typed id reply.")); const typed = request("2", acp.methods.agent.session.prompt, { sessionId, @@ -479,10 +472,12 @@ describe("remote ACP HTTP", () => { }); await harness.drain(); await typedResult; - expect(harness.agentRuns).toHaveLength(2); + expect(harness.agentRuns).toHaveLength(3); await expect(harness.historyTexts(sessionId)).resolves.toEqual([ "Numeric request id.", "Typed id reply.", + "Reused id, new payload.", + "Changed payload reply.", "String request id.", "Typed id reply.", ]); @@ -490,6 +485,105 @@ describe("remote ACP HTTP", () => { }); }, 20_000); + it("holds one shared SSE lease and hands it to a later app instance", async () => { + const harness = await createConversationWorkWebHarness(); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + const secondApp = await createApp({ + conversationWork: createIndependentConversationWork(harness), + experimental: { acp: true, subagents: true }, + }); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP shared SSE lease", + }); + const initialized = await app.fetch(initializeRequest(token.token)); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const streamRequest = () => + new Request(ACP_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + }, + }); + + const first = await app.fetch(streamRequest()); + const overlapping = await secondApp.fetch(streamRequest()); + + expect(first.status).toBe(200); + expect(overlapping.status).toBe(409); + await first.body?.cancel(); + + const handedOff = await secondApp.fetch(streamRequest()); + expect(handedOff.status).toBe(200); + await handedOff.body?.cancel(); + }); + + it("terminates an SSE stream after it loses its shared lease", async () => { + const harness = await createConversationWorkWebHarness(); + const state = new Proxy(harness.state, { + get(target, property, receiver) { + if (property === "extendLock") { + return async ( + lock: Parameters[0], + ttlMs: number, + ) => + lock.threadId.endsWith(":subscriber") + ? false + : await target.extendLock(lock, ttlMs); + } + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const app = await createApp({ + conversationWork: createIndependentConversationWork(harness, state), + experimental: { acp: true, subagents: true }, + }); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP lost SSE lease", + }); + + const initialized = await app.fetch(initializeRequest(token.token)); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const stream = await app.request(ACP_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + }, + }); + const reader = stream.body?.getReader(); + if (!reader) throw new Error("ACP GET returned no stream body"); + + const accepted = await app.request(ACP_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "unsupported/test", + params: {}, + }), + }); + + expect(accepted.status).toBe(202); + await expect(reader.read()).rejects.toThrow("ACP SSE stream lease expired"); + reader.releaseLock(); + }); + it("finishes durable work after the ACP connection closes", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("Completed after disconnect."), @@ -502,8 +596,37 @@ describe("remote ACP HTTP", () => { email: harness.actor.email, name: "ACP disconnect", }); + const promptAccepted = deferred(); + const sessionStreamOpened = deferred(); + let connectionId: string | undefined; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init as RequestInit); + const isSessionStream = + request.method === "GET" && request.headers.has("Acp-Session-Id"); + let isPrompt = false; + if (request.method === "POST") { + const body: unknown = await request.clone().json(); + isPrompt = + typeof body === "object" && + body !== null && + "method" in body && + body.method === acp.methods.agent.session.prompt; + } + const response = await app.fetch( + isSessionStream + ? new Request(request, { signal: new AbortController().signal }) + : request, + ); + connectionId ??= response.headers.get("Acp-Connection-Id") ?? undefined; + if (isPrompt && response.status === 202) promptAccepted.resolve(); + if (isSessionStream && response.status === 200) { + sessionStreamOpened.resolve(response); + return response.clone(); + } + return response; + }; const stream = createHttpStream(ACP_URL, { - fetch: appFetch(app), + fetch, headers: { Authorization: `Bearer ${token.token}` }, }); let resolveSession!: (sessionId: string) => void; @@ -533,8 +656,28 @@ describe("remote ACP HTTP", () => { await vi.waitFor(() => { expect(harness.queue.hasQueuedMessages()).toBe(true); }); - await stream.writable.close(); + await promptAccepted.promise; + const sessionStream = await sessionStreamOpened.promise; + if (!sessionStream.body) + throw new Error("ACP session SSE returned no body"); + const sessionReader = sessionStream.body.getReader(); + const sessionClosed = (async () => { + while (!(await sessionReader.read()).done) { + // Drain all session output until the server ends the stream. + } + })(); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const deleted = await app.request(ACP_URL, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + }, + }); + expect(deleted.status).toBe(202); await connectionClosed; + await expect(sessionClosed).resolves.toBeUndefined(); + sessionReader.releaseLock(); await harness.drain(); const replayed: acp.SessionUpdate[] = []; @@ -588,6 +731,10 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); + const secondApp = await createApp({ + conversationWork: createIndependentConversationWork(harness), + experimental: { acp: true, subagents: true }, + }); const token = await createPersonalToken({ email: harness.actor.email, name: "ACP cancellation", @@ -597,6 +744,7 @@ describe("remote ACP HTTP", () => { const cancelledRun = withAcpClient({ app, + fetch: appFetch(app, secondApp), token: token.token, run: async (context) => { await context.request(acp.methods.agent.initialize, { @@ -810,6 +958,13 @@ describe("remote ACP HTTP", () => { ], [{ type: "text" as const, text: " " }], [{ type: "text" as const, text: "x".repeat(32_001) }], + [ + { + type: "text" as const, + text: "Unknown fields are not accepted.", + privatePrompt: "sentinel", + }, + ], ]) { try { await context.request(acp.methods.agent.session.prompt, { @@ -825,12 +980,13 @@ describe("remote ACP HTTP", () => { }); expect(errors.mcpError).toMatchObject({ code: -32602 }); - expect(errors.promptErrors).toHaveLength(4); + expect(errors.promptErrors).toHaveLength(5); expect(errors.promptErrors).toEqual([ expect.objectContaining({ code: -32602 }), expect.objectContaining({ code: -32602 }), expect.objectContaining({ code: -32602 }), expect.objectContaining({ code: -32602 }), + expect.objectContaining({ code: -32602 }), ]); expect(harness.queue.hasQueuedMessages()).toBe(false); }); diff --git a/packages/junior/tests/integration/api-turn-work.test.ts b/packages/junior/tests/integration/api-turn-work.test.ts index 315577ff58..4e3b7b00de 100644 --- a/packages/junior/tests/integration/api-turn-work.test.ts +++ b/packages/junior/tests/integration/api-turn-work.test.ts @@ -254,7 +254,7 @@ describe("api turn conversation work", () => { ); }); - it("reports a lost lease and releases cancellation when ack fails", async () => { + it("reports a lost lease and retains cancellation when ack fails", async () => { const { actor, conversationStore, queue, state } = await createApiTurnWorkFixture(); const accepted = await createAndEnqueueApiConversation( @@ -276,10 +276,20 @@ describe("api turn conversation work", () => { message: "Cancel before this Turn starts.", messageId: accepted.messageId, }); - const cancellation = createApiTurnCancellation(); - const signal = cancellation.begin(accepted.conversationId); - if (!signal) throw new Error("Expected an active Turn signal"); - cancellation.cancel(accepted.conversationId); + const cancellation = createApiTurnCancellation({ state }); + const turnId = apiTurnIdForMessage(accepted.messageId); + await cancellation.admit( + { + conversationId: accepted.conversationId, + messageId: accepted.messageId, + turnId, + }, + { + messageExists: async () => true, + run: async () => undefined, + }, + ); + await cancellation.cancel(accepted.conversationId); const agentRuns: AgentRun[] = []; const worker = createApiTurnWorker({ agentRunner: createModelAgentRunnerForRun((run) => { @@ -311,7 +321,10 @@ describe("api turn conversation work", () => { }), ).resolves.toEqual({ status: "lost_lease" }); expect(agentRuns).toHaveLength(0); - expect(cancellation.begin(accepted.conversationId)).toBeDefined(); + await expect(cancellation.cancel(accepted.conversationId)).resolves.toBe( + true, + ); + await cancellation.finish(accepted.conversationId, turnId); }); it("routes empty resume wakes to the active API turn", async () => { diff --git a/packages/junior/tests/unit/api/acp-transport.test.ts b/packages/junior/tests/unit/api/acp-transport.test.ts new file mode 100644 index 0000000000..2a38d1d969 --- /dev/null +++ b/packages/junior/tests/unit/api/acp-transport.test.ts @@ -0,0 +1,75 @@ +import { createHash } from "node:crypto"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { describe, expect, it, vi } from "vitest"; +import { acceptAcpRequest, type AcpRequestReceipt } from "@/api/acp/transport"; + +const CONNECTION_ID = "9dddb5f1-bd8f-42cc-a88e-c3cb909354dd"; +const SESSION_ID = "local:acp:11111111111111111111111111111111"; +const REQUEST_KEY = "overflow-request"; + +function stableHex(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +const STREAM_KEY = `junior:acp:v1:connection:${CONNECTION_ID}:stream:session:${stableHex(SESSION_ID)}`; +const STREAM_ITEMS_KEY = `${STREAM_KEY}:items`; +const STREAM_CURSOR_KEY = `${STREAM_KEY}:cursor`; +const MAX_STREAM_ITEMS = 1_024; +const REPLAY_ITEM_ID = `acp-item:${stableHex(`${REQUEST_KEY}:0`)}`; +const REPLAY_COMPLETION_KEY = `${STREAM_KEY}:complete:${stableHex(REPLAY_ITEM_ID)}`; + +function replayReceipt(): AcpRequestReceipt { + return { + outputs: [ + { + kind: "replay", + sessionId: SESSION_ID, + }, + ], + }; +} + +describe("ACP transport", () => { + it("rejects overflow without trimming pending output", async () => { + const state = createMemoryState(); + await state.connect(); + for (let index = 0; index < MAX_STREAM_ITEMS; index += 1) { + await state.appendToList(STREAM_ITEMS_KEY, { + id: `existing-${index}`, + output: { + kind: "message", + message: { jsonrpc: "2.0", id: index, result: {} }, + }, + }); + } + const createReceipt = vi.fn(async () => replayReceipt()); + const accept = () => + acceptAcpRequest({ + connectionId: CONNECTION_ID, + createReceipt, + requestKey: REQUEST_KEY, + state, + }); + + await expect(accept()).resolves.toBe("full"); + const pending = await state.getList(STREAM_ITEMS_KEY); + expect(pending).toHaveLength(MAX_STREAM_ITEMS); + expect(pending[0]).toMatchObject({ id: "existing-0" }); + + await state.set(STREAM_CURSOR_KEY, { + itemId: `existing-${MAX_STREAM_ITEMS - 1}`, + }); + await state.set(REPLAY_COMPLETION_KEY, true); + await expect(accept()).resolves.toBe("accepted"); + const reset = await state.getList(STREAM_ITEMS_KEY); + expect(reset).toEqual([ + expect.objectContaining({ + id: REPLAY_ITEM_ID, + output: { kind: "replay" }, + }), + ]); + await expect(state.get(REPLAY_COMPLETION_KEY)).resolves.toBeNull(); + expect(createReceipt).toHaveBeenCalledTimes(1); + await state.disconnect(); + }); +}); diff --git a/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts b/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts index 19771ef689..c0dae9e295 100644 --- a/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts +++ b/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts @@ -1,46 +1,145 @@ -import { describe, expect, it } from "vitest"; -import { createApiTurnCancellation } from "@/chat/api-turns/cancellation"; +import { createMemoryState } from "@chat-adapter/state-memory"; +import { describe, expect, it, vi } from "vitest"; +import { + createApiTurnCancellation, + type ApiTurnCancellation, +} from "@/chat/api-turns/cancellation"; + +async function admit( + cancellation: ApiTurnCancellation, + turnId: string, + value = turnId, +) { + return await cancellation.admit( + { + conversationId: "conversation-1", + messageId: `message:${turnId}`, + turnId, + }, + { + messageExists: async () => false, + run: async () => value, + }, + ); +} describe("API Turn cancellation", () => { - it("keeps disconnected running work active until it finishes", () => { - const cancellation = createApiTurnCancellation(); - const signal = cancellation.begin("conversation-1"); + it("cancels one active Turn and blocks a second Turn through shared state", async () => { + const state = createMemoryState(); + await state.connect(); + const ingress = createApiTurnCancellation({ state }); + const worker = createApiTurnCancellation({ pollIntervalMs: 5, state }); + + await expect(admit(ingress, "turn-1")).resolves.toEqual({ + status: "accepted", + value: "turn-1", + }); + await expect(admit(worker, "turn-2")).resolves.toEqual({ + messageId: "message:turn-1", + status: "active", + turnId: "turn-1", + }); + const signal = await worker.begin("conversation-1", "turn-1"); if (!signal) throw new Error("Expected an active Turn signal"); - cancellation.disconnect("conversation-1", signal); + await expect(ingress.cancel("conversation-1")).resolves.toBe(true); + await vi.waitFor(() => expect(signal.aborted).toBe(true)); - expect(cancellation.begin("conversation-1")).toBeUndefined(); - cancellation.finish("conversation-1", signal); - expect(cancellation.begin("conversation-1")).toBeDefined(); + await worker.finish("conversation-1", "turn-1", signal); + await expect(ingress.cancel("conversation-1")).resolves.toBe(false); + await state.disconnect(); }); - it.each(["disconnect-first", "park-first"] as const)( - "releases disconnected auth work when %s", - (order) => { - const cancellation = createApiTurnCancellation(); - const signal = cancellation.begin("conversation-1"); - if (!signal) throw new Error("Expected an active Turn signal"); - - if (order === "disconnect-first") { - cancellation.disconnect("conversation-1", signal); - cancellation.park("conversation-1", signal); - } else { - cancellation.park("conversation-1", signal); - cancellation.disconnect("conversation-1", signal); - } - - expect(cancellation.begin("conversation-1")).toBeDefined(); - }, - ); + it("fences a stale admission before its callback can append", async () => { + const state = createMemoryState(); + await state.connect(); + const cancellation = createApiTurnCancellation({ state }); + const appended: string[] = []; + let resumeStale: (() => void) | undefined; + let markStaleStarted: (() => void) | undefined; + const staleStarted = new Promise((resolve) => { + markStaleStarted = resolve; + }); + const stalePaused = new Promise((resolve) => { + resumeStale = resolve; + }); + + const staleAdmission = cancellation.admit( + { + conversationId: "conversation-1", + messageId: "message:turn-1", + turnId: "turn-1", + }, + { + messageExists: async () => false, + run: async (fence) => { + markStaleStarted?.(); + await stalePaused; + await fence(); + appended.push("turn-1"); + }, + }, + ); + await staleStarted; + await state.forceReleaseLock( + "junior:api-turn:v1:control:conversation-1:lock", + ); + + await expect( + cancellation.admit( + { + conversationId: "conversation-1", + messageId: "message:turn-2", + turnId: "turn-2", + }, + { + messageExists: async () => false, + run: async (fence) => { + await fence(); + appended.push("turn-2"); + }, + }, + ), + ).resolves.toMatchObject({ status: "accepted" }); + + resumeStale?.(); + await expect(staleAdmission).rejects.toThrow( + "Lock ownership was lost before write", + ); + expect(appended).toEqual(["turn-2"]); + await cancellation.finish("conversation-1", "turn-2"); + await state.disconnect(); + }); + + it("rebuilds a signal after an execution slice yields", async () => { + const state = createMemoryState(); + await state.connect(); + const cancellation = createApiTurnCancellation({ state }); + await admit(cancellation, "turn-1"); + const first = await cancellation.begin("conversation-1", "turn-1"); + if (!first) throw new Error("Expected an active Turn signal"); + + cancellation.release("conversation-1", first); + + const resumed = await cancellation.begin("conversation-1", "turn-1"); + expect(resumed).toBeDefined(); + expect(resumed).not.toBe(first); + await cancellation.finish("conversation-1", "turn-1", resumed); + await state.disconnect(); + }); - it("ignores cancellation after the Turn finishes", () => { - const cancellation = createApiTurnCancellation(); - const signal = cancellation.begin("conversation-1"); + it("ignores cancellation after the Turn finishes", async () => { + const state = createMemoryState(); + await state.connect(); + const cancellation = createApiTurnCancellation({ state }); + await admit(cancellation, "turn-1"); + const signal = await cancellation.begin("conversation-1", "turn-1"); if (!signal) throw new Error("Expected an active Turn signal"); - cancellation.finish("conversation-1", signal); + await cancellation.finish("conversation-1", "turn-1", signal); - expect(cancellation.cancel("conversation-1")).toBe(false); + await expect(cancellation.cancel("conversation-1")).resolves.toBe(false); expect(signal.aborted).toBe(false); + await state.disconnect(); }); }); From 96e7dc407983ca3a12aa58b4246a321084cae819 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:16:36 +0200 Subject: [PATCH 2/7] fix(acp): allow prompt after terminal turn --- packages/junior/src/api/acp/route.ts | 21 +--- packages/junior/src/chat/api-turns/mailbox.ts | 17 --- .../junior/src/chat/app/conversation-work.ts | 1 + packages/junior/tests/fixtures/api-turn.ts | 9 +- .../integration/acp-http-recovery.test.ts | 109 ++++++++++++++++++ 5 files changed, 121 insertions(+), 36 deletions(-) diff --git a/packages/junior/src/api/acp/route.ts b/packages/junior/src/api/acp/route.ts index 8d7b463629..243f7e2caa 100644 --- a/packages/junior/src/api/acp/route.ts +++ b/packages/junior/src/api/acp/route.ts @@ -13,10 +13,7 @@ import { z } from "zod"; import { readConversationAccessFromSql } from "@/api/conversations/access"; import type { WebActor } from "@/chat/actor"; import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; -import { - apiConversationMessageExists, - apiConversationMessageIsPending, -} from "@/chat/api-turns/mailbox"; +import { apiConversationMessageExists } from "@/chat/api-turns/mailbox"; import { apiConversationMessageId, apiTurnIdForMessage, @@ -544,12 +541,7 @@ async function acceptPrompt(args: { args.eventStore, params.sessionId, admission.turnId, - )) && - !(await apiConversationMessageIsPending({ - conversationId: params.sessionId, - messageId: admission.messageId, - state: args.state, - })) + )) ) { await args.cancellation.finish(params.sessionId, admission.turnId); admission = await admit(); @@ -561,14 +553,7 @@ async function acceptPrompt(args: { ); } - if ( - (await turnHasEnded(args.eventStore, params.sessionId, turnId)) && - !(await apiConversationMessageIsPending({ - conversationId: params.sessionId, - messageId, - state: args.state, - })) - ) { + if (await turnHasEnded(args.eventStore, params.sessionId, turnId)) { await args.cancellation.finish(params.sessionId, turnId); } return { diff --git a/packages/junior/src/chat/api-turns/mailbox.ts b/packages/junior/src/chat/api-turns/mailbox.ts index 6dcaa9e0e3..d08aa9adec 100644 --- a/packages/junior/src/chat/api-turns/mailbox.ts +++ b/packages/junior/src/chat/api-turns/mailbox.ts @@ -15,20 +15,3 @@ export async function apiConversationMessageExists(args: { conversation?.execution.inboundMessageIds.includes(args.messageId), ); } - -/** Return whether one API Message still waits for mailbox acknowledgement. */ -export async function apiConversationMessageIsPending(args: { - conversationId: string; - messageId: string; - state?: StateAdapter; -}): Promise { - const conversation = await getConversation({ - conversationId: args.conversationId, - state: args.state, - }); - return Boolean( - conversation?.execution.pendingMessages.some( - (message) => message.inboundMessageId === args.messageId, - ), - ); -} diff --git a/packages/junior/src/chat/app/conversation-work.ts b/packages/junior/src/chat/app/conversation-work.ts index 8ade56c4fc..be485fcea4 100644 --- a/packages/junior/src/chat/app/conversation-work.ts +++ b/packages/junior/src/chat/app/conversation-work.ts @@ -129,6 +129,7 @@ export function createConversationWork( apiTurnWorker: createApiTurnWorker({ agentRunner: options.agentRunner, cancellation: apiTurnCancellation, + turnLifecycle: services.replyExecutor?.turnLifecycle, }), fallbackWorker: routeAgentInvocationWork({ invocationWorker: createAgentInvocationWorker({ diff --git a/packages/junior/tests/fixtures/api-turn.ts b/packages/junior/tests/fixtures/api-turn.ts index ba7717ae7f..230ff3ea47 100644 --- a/packages/junior/tests/fixtures/api-turn.ts +++ b/packages/junior/tests/fixtures/api-turn.ts @@ -19,6 +19,7 @@ import { type ConversationWorkCallbackOptions, } from "@/chat/app/conversation-work"; import type { ConversationStore } from "@/chat/conversations/store"; +import type { ConversationTurnLifecycle } from "@/chat/conversations/turn-lifecycle"; import { closeDb, getConversationEventStore, @@ -131,6 +132,7 @@ export async function createConversationWorkWebHarness( options: { agentRunner?: AgentRunner; modelStream?: StreamFn; + turnLifecycle?: ConversationTurnLifecycle; } = {}, ): Promise { const conversationStore = getConversationStore(); @@ -151,7 +153,12 @@ export async function createConversationWorkWebHarness( getSlackAdapter: () => createSlackAdapterFixture(), queue, services: { - replyExecutor: { agentRunner }, + replyExecutor: { + agentRunner, + ...(options.turnLifecycle + ? { turnLifecycle: options.turnLifecycle } + : {}), + }, }, state, }); diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts index 453b024d73..63140780fa 100644 --- a/packages/junior/tests/integration/acp-http-recovery.test.ts +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -1,6 +1,11 @@ import * as acp from "@agentclientprotocol/sdk"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; +import { + ConversationTurnLifecycleService, + type ConversationTurnLifecycle, +} from "@/chat/conversations/turn-lifecycle"; +import { getConversationEventStore } from "@/chat/db"; import { createPersonalToken } from "@/personal-tokens/store"; import { closeApiTurnWorkFixture, @@ -109,6 +114,110 @@ describe("remote ACP recovery", () => { ]); }, 20_000); + it("accepts a follow-up as soon as the prior Turn becomes terminal", async () => { + const terminalPersisted = deferred(); + const releaseWorker = deferred(); + const persistedLifecycle = new ConversationTurnLifecycleService( + getConversationEventStore(), + ); + let blockFirstCompletion = true; + const turnLifecycle = { + start: async (input) => await persistedLifecycle.start(input), + complete: async (input) => { + await persistedLifecycle.complete(input); + if (!blockFirstCompletion) return; + blockFirstCompletion = false; + terminalPersisted.resolve(); + await releaseWorker.promise; + }, + fail: async (input) => await persistedLifecycle.fail(input), + } satisfies ConversationTurnLifecycle; + const harness = await createConversationWorkWebHarness({ + modelStream: streamReplies( + "First prompt complete.", + "Follow-up complete.", + ), + turnLifecycle, + }); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP terminal prompt handoff", + }); + const sessionCreated = deferred(); + const secondPromptPosted = deferred(); + let promptPosts = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init as RequestInit); + let isPrompt = false; + if (request.method === "POST") { + const body: unknown = await request.clone().json(); + isPrompt = + typeof body === "object" && + body !== null && + "method" in body && + body.method === acp.methods.agent.session.prompt; + } + const response = await app.fetch(request); + if (isPrompt) { + promptPosts += 1; + if (promptPosts === 2) { + secondPromptPosted.resolve(); + releaseWorker.resolve(); + } + } + return response; + }; + const prompts = withAcpClient({ + app, + fetch, + token: token.token, + run: async (context) => { + await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await context.request(acp.methods.agent.session.new, { + cwd: "/client/workspace", + mcpServers: [], + }); + sessionCreated.resolve(session.sessionId); + const first = await context.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "First prompt." }], + }); + const second = await context.request(acp.methods.agent.session.prompt, { + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Immediate follow-up." }], + }); + return { first, second }; + }, + }); + + const sessionId = await sessionCreated.promise; + await vi.waitFor(() => { + expect(harness.queue.hasQueuedMessages()).toBe(true); + }); + const draining = harness.drain(); + await terminalPersisted.promise; + await secondPromptPosted.promise; + await draining; + + await expect(prompts).resolves.toEqual({ + first: { stopReason: "end_turn" }, + second: { stopReason: "end_turn" }, + }); + await expect(harness.historyTexts(sessionId)).resolves.toEqual([ + "First prompt.", + "First prompt complete.", + "Immediate follow-up.", + "Follow-up complete.", + ]); + }, 20_000); + it("recovers prompt admission after the first queue send fails", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("Recovered queue reply."), From abd96ebe265259b8e444d0918efced826f9a6def Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:44:38 +0200 Subject: [PATCH 3/7] fix(acp): fence terminal prompt handoff --- packages/junior/src/api/acp/README.md | 4 + packages/junior/src/api/acp/route.ts | 22 ++- packages/junior/src/api/acp/transport.ts | 48 +++++++ packages/junior/src/chat/api-turns/mailbox.ts | 17 +++ .../integration/acp-http-recovery.test.ts | 136 +++++++++++++++--- 5 files changed, 203 insertions(+), 24 deletions(-) diff --git a/packages/junior/src/api/acp/README.md b/packages/junior/src/api/acp/README.md index 7904bc80c1..514332a944 100644 --- a/packages/junior/src/api/acp/README.md +++ b/packages/junior/src/api/acp/README.md @@ -26,6 +26,10 @@ The memory adapter remains process-local and is suitable only for local use. Each stream preserves up to 1,024 undelivered items. It returns `503` instead of dropping an item when that limit is full. +Junior sends a terminal prompt response only after it acknowledges the matching +mailbox message. Until then, durable Turn control blocks another prompt. This +prevents retryable work from overlapping a follow-up. + ## Live HTTP Limit ACP v1 delivers server messages over live SSE requests. The official client diff --git a/packages/junior/src/api/acp/route.ts b/packages/junior/src/api/acp/route.ts index 243f7e2caa..a7b1c15e45 100644 --- a/packages/junior/src/api/acp/route.ts +++ b/packages/junior/src/api/acp/route.ts @@ -13,7 +13,10 @@ import { z } from "zod"; import { readConversationAccessFromSql } from "@/api/conversations/access"; import type { WebActor } from "@/chat/actor"; import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; -import { apiConversationMessageExists } from "@/chat/api-turns/mailbox"; +import { + apiConversationMessageExists, + apiConversationMessageIsPending, +} from "@/chat/api-turns/mailbox"; import { apiConversationMessageId, apiTurnIdForMessage, @@ -541,7 +544,12 @@ async function acceptPrompt(args: { args.eventStore, params.sessionId, admission.turnId, - )) + )) && + !(await apiConversationMessageIsPending({ + conversationId: params.sessionId, + messageId: admission.messageId, + state: args.state, + })) ) { await args.cancellation.finish(params.sessionId, admission.turnId); admission = await admit(); @@ -553,13 +561,21 @@ async function acceptPrompt(args: { ); } - if (await turnHasEnded(args.eventStore, params.sessionId, turnId)) { + if ( + (await turnHasEnded(args.eventStore, params.sessionId, turnId)) && + !(await apiConversationMessageIsPending({ + conversationId: params.sessionId, + messageId, + state: args.state, + })) + ) { await args.cancellation.finish(params.sessionId, turnId); } return { output: { afterSeq: admission.value.status === "duplicate" ? 0 : currentSeq, kind: "prompt", + messageId, requestId: args.requestId, turnId, }, diff --git a/packages/junior/src/api/acp/transport.ts b/packages/junior/src/api/acp/transport.ts index 43534662eb..5fb968d8ef 100644 --- a/packages/junior/src/api/acp/transport.ts +++ b/packages/junior/src/api/acp/transport.ts @@ -10,6 +10,7 @@ import * as acp from "@agentclientprotocol/sdk"; import { z } from "zod"; import type { ConversationEventStore } from "@/chat/conversations/history"; import { projectConversationMessages } from "@/chat/conversations/message-projection"; +import { apiConversationMessageIsPending } from "@/chat/api-turns/mailbox"; import { logException } from "@/chat/logging"; import { sleep } from "@/chat/sleep"; import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "@/chat/state/locks"; @@ -85,6 +86,7 @@ const acpPromptStreamOutputSchema = z .object({ afterSeq: z.number().int().nonnegative(), kind: z.literal("prompt"), + messageId: z.string().min(1), requestId: jsonRpcIdSchema, turnId: z.string().min(1), }) @@ -493,6 +495,30 @@ function errorMessage( }; } +/** Delay a terminal response until its mailbox input cannot run again. */ +async function waitForPromptAcknowledgement(args: { + connectionId: string; + messageId: string; + sessionId: string; + signal: AbortSignal; + state: StateAdapter; +}): Promise { + while (!args.signal.aborted) { + if (!(await connectionIsLive(args.state, args.connectionId))) return false; + if ( + !(await apiConversationMessageIsPending({ + conversationId: args.sessionId, + messageId: args.messageId, + state: args.state, + })) + ) { + return true; + } + await sleep(EVENT_POLL_INTERVAL_MS, args.signal); + } + return false; +} + /** Emit assistant Messages and the final response for one durable Turn. */ async function streamPrompt(args: { connectionId: string; @@ -551,6 +577,17 @@ async function streamPrompt(args: { data.type === "turn_completed" && data.turnId === args.output.turnId ) { + if ( + !(await waitForPromptAcknowledgement({ + connectionId: args.connectionId, + messageId: args.output.messageId, + sessionId: args.sessionId, + signal: args.signal, + state: args.state, + })) + ) { + return false; + } return await args.emit( resultMessage(args.output.requestId, { stopReason: data.outcome === "cancelled" ? "cancelled" : "end_turn", @@ -558,6 +595,17 @@ async function streamPrompt(args: { ); } if (data.type === "turn_failed" && data.turnId === args.output.turnId) { + if ( + !(await waitForPromptAcknowledgement({ + connectionId: args.connectionId, + messageId: args.output.messageId, + sessionId: args.sessionId, + signal: args.signal, + state: args.state, + })) + ) { + return false; + } return await args.emit( errorMessage( args.output.requestId, diff --git a/packages/junior/src/chat/api-turns/mailbox.ts b/packages/junior/src/chat/api-turns/mailbox.ts index d08aa9adec..6dcaa9e0e3 100644 --- a/packages/junior/src/chat/api-turns/mailbox.ts +++ b/packages/junior/src/chat/api-turns/mailbox.ts @@ -15,3 +15,20 @@ export async function apiConversationMessageExists(args: { conversation?.execution.inboundMessageIds.includes(args.messageId), ); } + +/** Return whether one API Message still waits for mailbox acknowledgement. */ +export async function apiConversationMessageIsPending(args: { + conversationId: string; + messageId: string; + state?: StateAdapter; +}): Promise { + const conversation = await getConversation({ + conversationId: args.conversationId, + state: args.state, + }); + return Boolean( + conversation?.execution.pendingMessages.some( + (message) => message.inboundMessageId === args.messageId, + ), + ); +} diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts index 63140780fa..9234a56c8c 100644 --- a/packages/junior/tests/integration/acp-http-recovery.test.ts +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -1,4 +1,5 @@ import * as acp from "@agentclientprotocol/sdk"; +import type { StateAdapter } from "chat"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; import { @@ -114,7 +115,7 @@ describe("remote ACP recovery", () => { ]); }, 20_000); - it("accepts a follow-up as soon as the prior Turn becomes terminal", async () => { + it("holds terminal output until acknowledgement, then accepts a follow-up", async () => { const terminalPersisted = deferred(); const releaseWorker = deferred(); const persistedLifecycle = new ConversationTurnLifecycleService( @@ -133,14 +134,47 @@ describe("remote ACP recovery", () => { fail: async (input) => await persistedLifecycle.fail(input), } satisfies ConversationTurnLifecycle; const harness = await createConversationWorkWebHarness({ - modelStream: streamReplies( - "First prompt complete.", - "Follow-up complete.", - ), + modelStream: streamReplies("Follow-up complete."), turnLifecycle, }); + let observePendingRead = false; + let pendingReadObserved = false; + let sessionStateKey: string | undefined; + const observedState = new Proxy(harness.state, { + get(target, property, receiver) { + if (property === "get") { + return async (key: string) => { + const value = await target.get(key); + if ( + observePendingRead && + key === sessionStateKey && + typeof value === "object" && + value !== null && + "execution" in value && + typeof value.execution === "object" && + value.execution !== null && + "pendingMessages" in value.execution && + Array.isArray(value.execution.pendingMessages) && + value.execution.pendingMessages.length > 0 + ) { + pendingReadObserved = true; + } + return value; + }; + } + const value = Reflect.get(target, property, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }) as StateAdapter; const app = await createApp({ - conversationWork: harness.conversationWork, + conversationWork: { + ...harness.conversationWork, + state: observedState, + }, + experimental: { acp: true, subagents: true }, + }); + const secondApp = await createApp({ + conversationWork: createIndependentConversationWork(harness), experimental: { acp: true, subagents: true }, }); const token = await createPersonalToken({ @@ -149,26 +183,37 @@ describe("remote ACP recovery", () => { }); const sessionCreated = deferred(); const secondPromptPosted = deferred(); - let promptPosts = 0; + let workerReleased = false; + let followUpPostedBeforeAcknowledgement = false; + let cancelActiveTurn: (() => Promise) | undefined; const fetch: typeof globalThis.fetch = async (input, init) => { const request = new Request(input, init as RequestInit); - let isPrompt = false; + let isFollowUp = false; if (request.method === "POST") { const body: unknown = await request.clone().json(); - isPrompt = + isFollowUp = typeof body === "object" && body !== null && "method" in body && - body.method === acp.methods.agent.session.prompt; + body.method === acp.methods.agent.session.prompt && + "params" in body && + typeof body.params === "object" && + body.params !== null && + "prompt" in body.params && + Array.isArray(body.params.prompt) && + body.params.prompt.some( + (block) => + typeof block === "object" && + block !== null && + "text" in block && + block.text === "Immediate follow-up.", + ); } - const response = await app.fetch(request); - if (isPrompt) { - promptPosts += 1; - if (promptPosts === 2) { - secondPromptPosted.resolve(); - releaseWorker.resolve(); - } + if (isFollowUp && !workerReleased) { + followUpPostedBeforeAcknowledgement = true; } + const response = await app.fetch(request); + if (isFollowUp) secondPromptPosted.resolve(); return response; }; const prompts = withAcpClient({ @@ -184,10 +229,16 @@ describe("remote ACP recovery", () => { cwd: "/client/workspace", mcpServers: [], }); + sessionStateKey = `junior:conversation:v2:${session.sessionId}`; sessionCreated.resolve(session.sessionId); + cancelActiveTurn = async () => { + await context.notify(acp.methods.agent.session.cancel, { + sessionId: session.sessionId, + }); + }; const first = await context.request(acp.methods.agent.session.prompt, { sessionId: session.sessionId, - prompt: [{ type: "text", text: "First prompt." }], + prompt: [{ type: "text", text: "Cancel before input commit." }], }); const second = await context.request(acp.methods.agent.session.prompt, { sessionId: session.sessionId, @@ -201,18 +252,61 @@ describe("remote ACP recovery", () => { await vi.waitFor(() => { expect(harness.queue.hasQueuedMessages()).toBe(true); }); + if (!cancelActiveTurn) { + throw new Error("ACP cancellation handler was not ready"); + } + await cancelActiveTurn(); const draining = harness.drain(); await terminalPersisted.promise; + observePendingRead = true; + await vi.waitFor(() => { + expect(pendingReadObserved).toBe(true); + }); + expect(followUpPostedBeforeAcknowledgement).toBe(false); + + let prematurePromptError: unknown; + try { + prematurePromptError = await withAcpClient({ + app: secondApp, + token: token.token, + run: async (context) => { + await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + await context.request(acp.methods.agent.session.load, { + sessionId, + cwd: "/client/workspace", + mcpServers: [], + }); + try { + await context.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "Do not run before ack." }], + }); + return undefined; + } catch (error) { + return error; + } + }, + }); + } finally { + workerReleased = true; + releaseWorker.resolve(); + } + expect(prematurePromptError).toMatchObject({ code: -32602 }); + expect(harness.agentRuns).toHaveLength(0); + await secondPromptPosted.promise; await draining; + await harness.drain(); await expect(prompts).resolves.toEqual({ - first: { stopReason: "end_turn" }, + first: { stopReason: "cancelled" }, second: { stopReason: "end_turn" }, }); await expect(harness.historyTexts(sessionId)).resolves.toEqual([ - "First prompt.", - "First prompt complete.", + "Cancel before input commit.", "Immediate follow-up.", "Follow-up complete.", ]); From 131599b8927663f67068381b5d7776f55315341c Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:52:09 +0200 Subject: [PATCH 4/7] fix(acp): accept null initialize request IDs --- packages/junior/src/api/acp/route.ts | 2 +- packages/junior/tests/fixtures/acp-http.ts | 7 +++++-- packages/junior/tests/integration/acp-http.test.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/junior/src/api/acp/route.ts b/packages/junior/src/api/acp/route.ts index a7b1c15e45..55b21fdecb 100644 --- a/packages/junior/src/api/acp/route.ts +++ b/packages/junior/src/api/acp/route.ts @@ -710,7 +710,7 @@ async function handleInitialize(args: { state: StateAdapter; }): Promise { const requestId = args.call.id; - if (requestId === undefined || requestId === null) { + if (requestId === undefined) { return textResponse("Initialize request must include an ID", 400); } try { diff --git a/packages/junior/tests/fixtures/acp-http.ts b/packages/junior/tests/fixtures/acp-http.ts index efe5626f39..770f62d591 100644 --- a/packages/junior/tests/fixtures/acp-http.ts +++ b/packages/junior/tests/fixtures/acp-http.ts @@ -35,7 +35,10 @@ export function createIndependentConversationWork( } /** Build one authenticated or unauthenticated ACP initialize request. */ -export function initializeRequest(token?: string): Request { +export function initializeRequest( + token?: string, + id: acp.JsonRpcId = 1, +): Request { return new Request(ACP_TEST_URL, { method: "POST", headers: { @@ -44,7 +47,7 @@ export function initializeRequest(token?: string): Request { }, body: JSON.stringify({ jsonrpc: "2.0", - id: 1, + id, method: "initialize", params: { _meta: { source: "integration-test" }, diff --git a/packages/junior/tests/integration/acp-http.test.ts b/packages/junior/tests/integration/acp-http.test.ts index e7edc9ea1f..628900a6c7 100644 --- a/packages/junior/tests/integration/acp-http.test.ts +++ b/packages/junior/tests/integration/acp-http.test.ts @@ -51,7 +51,7 @@ describe("remote ACP HTTP", () => { expect(invalid.status).toBe(401); }); - it("rejects unsafe envelopes and failed initialization", async () => { + it("validates JSON-RPC envelopes and initialization", async () => { const harness = await createConversationWorkWebHarness(); const app = await createApp({ conversationWork: harness.conversationWork, @@ -61,6 +61,7 @@ describe("remote ACP HTTP", () => { email: harness.actor.email, name: "ACP envelope validation", }); + const nullId = await app.fetch(initializeRequest(token.token, null)); const malformed = await app.request(ACP_URL, { method: "POST", headers: { @@ -141,6 +142,12 @@ describe("remote ACP HTTP", () => { }, }); + expect(nullId.status).toBe(200); + expect(nullId.headers.get("Acp-Connection-Id")).toBeTruthy(); + await expect(nullId.json()).resolves.toMatchObject({ + id: null, + result: { protocolVersion: acp.PROTOCOL_VERSION }, + }); expect(malformed.status).toBe(400); expect(wrongVersion.status).toBe(400); expect(nonFiniteId.status).toBe(400); From 4aca0b0959d5643c00c09066ef3e085779c4c1ed Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:39:28 +0200 Subject: [PATCH 5/7] fix(acp): recover stale terminal control --- packages/junior/src/api/acp/route.ts | 21 +++-- packages/junior/src/chat/api-turns/work.ts | 1 + .../integration/acp-http-recovery.test.ts | 91 +++++++++++++++++++ 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/packages/junior/src/api/acp/route.ts b/packages/junior/src/api/acp/route.ts index 55b21fdecb..1779b331ee 100644 --- a/packages/junior/src/api/acp/route.ts +++ b/packages/junior/src/api/acp/route.ts @@ -60,7 +60,6 @@ const ACP_SESSION_ID_HEADER = "Acp-Session-Id"; const ACP_CONVERSATION_PREFIX = "local:acp:"; const ACP_EVENT_STREAM_MIME_TYPE = "text/event-stream"; const ACP_JSON_MIME_TYPE = "application/json"; -const EVENT_PAGE_SIZE = 50; const MAX_PROMPT_TEXT_LENGTH = 32_000; const ACP_SESSION_ID_PATTERN = /^local:acp:[a-f0-9]{32}$/; @@ -400,21 +399,23 @@ async function latestEventSeq( return page.events.at(-1)?.seq ?? 0; } +/** Find the canonical terminal event across every history version. */ async function turnHasEnded( eventStore: ConversationEventStore, conversationId: string, turnId: string, ): Promise { - const page = await eventStore.query(conversationId, { - limit: EVENT_PAGE_SIZE, - types: ["turn_completed", "turn_failed"], - }); - return page.events.some( - (event) => - (event.data.type === "turn_completed" || - event.data.type === "turn_failed") && - event.data.turnId === turnId, + const terminal = await eventStore.loadByIdempotencyKey( + conversationId, + `turn:${turnId}:terminal`, ); + if ( + terminal?.data.type !== "turn_completed" && + terminal?.data.type !== "turn_failed" + ) { + return false; + } + return terminal.data.turnId === turnId; } /** Trace one protocol operation; the HTTP or SSE boundary owns error capture. */ diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index 57975a8fc3..d2390bd1ae 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -650,6 +650,7 @@ export function createApiTurnWorker(options: { cancellationSignal, ); }; + // Call only after mailbox acknowledgement; control blocks retryable work. const finishCancellationAfterTerminal = async (): Promise => { await runBestEffort( finishCancellation, diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts index 9234a56c8c..f35717c8d3 100644 --- a/packages/junior/tests/integration/acp-http-recovery.test.ts +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -312,6 +312,97 @@ describe("remote ACP recovery", () => { ]); }, 20_000); + it("recovers stale prompt control from an old terminal Turn", async () => { + const harness = await createConversationWorkWebHarness({ + modelStream: streamReplies("Recovered old terminal control."), + }); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP old terminal recovery", + }); + const sessionId = await withAcpClient({ + app, + token: token.token, + run: async (context) => { + await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {}, + }); + const session = await context.request(acp.methods.agent.session.new, { + cwd: "/client/workspace", + mcpServers: [], + }); + return session.sessionId; + }, + }); + const cancellation = harness.conversationWork.apiTurnCancellation; + if (!cancellation) throw new Error("ACP cancellation is not configured"); + const staleTurnId = "turn-stale-control"; + await cancellation.admit( + { + conversationId: sessionId, + messageId: "api-msg-stale-control", + turnId: staleTurnId, + }, + { + messageExists: async () => true, + run: async () => undefined, + }, + ); + const lifecycle = new ConversationTurnLifecycleService( + getConversationEventStore(), + ); + await lifecycle.complete({ + conversationId: sessionId, + createdAtMs: Date.now(), + outcome: "success", + turnId: staleTurnId, + }); + // Recovery must not depend on the newest 50 terminal events. + for (let index = 0; index < 50; index += 1) { + await lifecycle.complete({ + conversationId: sessionId, + createdAtMs: Date.now(), + outcome: "success", + turnId: `newer-terminal-${index}`, + }); + } + + const initialized = await app.fetch(initializeRequest(token.token)); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const prompt = await app.request(ACP_TEST_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${token.token}`, + "Acp-Connection-Id": connectionId, + "Acp-Session-Id": sessionId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: acp.methods.agent.session.prompt, + params: { + sessionId, + prompt: [{ type: "text", text: "Recover old control." }], + }, + }), + }); + + expect(prompt.status).toBe(202); + expect(harness.queue.hasQueuedMessages()).toBe(true); + await harness.drain(); + await expect(harness.historyTexts(sessionId)).resolves.toEqual([ + "Recover old control.", + "Recovered old terminal control.", + ]); + }, 20_000); + it("recovers prompt admission after the first queue send fails", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("Recovered queue reply."), From 854229d8e153819cf6a1fc30758b3199e450d425 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:02:50 +0200 Subject: [PATCH 6/7] feat(acp): use browser auth and extract adapter --- .craft.yml | 3 + .github/workflows/ci.yml | 4 + CONTRIBUTING.md | 1 + README.md | 1 + package.json | 4 +- .../src/content/docs/contribute/releasing.md | 1 + .../content/docs/reference/config-and-env.md | 17 +- .../content/docs/reference/handler-surface.md | 14 +- .../src/api/acp => junior-acp}/README.md | 37 +- packages/junior-acp/package.json | 48 + packages/junior-acp/src/auth.ts | 323 ++++++ packages/junior-acp/src/conversations.ts | 19 + packages/junior-acp/src/index.ts | 14 + packages/junior-acp/src/route.ts | 739 +++++++++++++ packages/junior-acp/src/schema.ts | 139 +++ packages/junior-acp/src/sleep.ts | 18 + packages/junior-acp/src/state.ts | 57 + packages/junior-acp/src/testing.ts | 1 + .../api/acp => junior-acp/src}/transport.ts | 279 ++--- packages/junior-acp/tsconfig.build.json | 10 + packages/junior-acp/tsconfig.json | 14 + packages/junior-acp/tsup.config.ts | 20 + .../junior-dashboard/src/acp-authorization.ts | 181 ++++ packages/junior-dashboard/src/app.ts | 109 +- packages/junior-dashboard/src/auth.ts | 39 + .../tests/dashboard-routes.test.ts | 80 +- .../junior-plugin-api/src/conversations.ts | 60 ++ packages/junior-plugin-api/src/index.ts | 1 + packages/junior/package.json | 5 +- packages/junior/scripts/acp-local-server.ts | 38 +- packages/junior/scripts/acp-smoke.ts | 38 +- packages/junior/src/api/acp-conversations.ts | 272 +++++ packages/junior/src/api/acp-package.ts | 84 ++ packages/junior/src/api/acp/route.ts | 975 ------------------ packages/junior/src/app.ts | 113 +- packages/junior/src/build/virtual-config.ts | 5 + packages/junior/src/nitro.ts | 29 +- packages/junior/src/virtual-modules.d.ts | 1 + packages/junior/tests/fixtures/acp-http.ts | 206 +++- .../integration/acp-http-recovery.test.ts | 116 +-- .../junior/tests/integration/acp-http.test.ts | 251 +++-- .../tests/unit/api/acp-transport.test.ts | 5 +- .../tests/unit/build/virtual-config.test.ts | 1 + pnpm-lock.yaml | 114 +- scripts/bump-release-versions.mjs | 1 + 45 files changed, 2993 insertions(+), 1494 deletions(-) rename packages/{junior/src/api/acp => junior-acp}/README.md (65%) create mode 100644 packages/junior-acp/package.json create mode 100644 packages/junior-acp/src/auth.ts create mode 100644 packages/junior-acp/src/conversations.ts create mode 100644 packages/junior-acp/src/index.ts create mode 100644 packages/junior-acp/src/route.ts create mode 100644 packages/junior-acp/src/schema.ts create mode 100644 packages/junior-acp/src/sleep.ts create mode 100644 packages/junior-acp/src/state.ts create mode 100644 packages/junior-acp/src/testing.ts rename packages/{junior/src/api/acp => junior-acp/src}/transport.ts (81%) create mode 100644 packages/junior-acp/tsconfig.build.json create mode 100644 packages/junior-acp/tsconfig.json create mode 100644 packages/junior-acp/tsup.config.ts create mode 100644 packages/junior-dashboard/src/acp-authorization.ts create mode 100644 packages/junior-plugin-api/src/conversations.ts create mode 100644 packages/junior/src/api/acp-conversations.ts create mode 100644 packages/junior/src/api/acp-package.ts delete mode 100644 packages/junior/src/api/acp/route.ts diff --git a/.craft.yml b/.craft.yml index bea8700ed4..42f20abf2a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -7,6 +7,9 @@ targets: - name: npm id: "@sentry/junior" includeNames: /^sentry-junior-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-acp" + includeNames: /^sentry-junior-acp-\d.*\.tgz$/ - name: npm id: "@sentry/junior-plugin-api" includeNames: /^sentry-junior-plugin-api-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c6f017d5f..d062038e92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,12 +49,14 @@ jobs: - .github/actions/** core: - packages/junior/** + - packages/junior-acp/** - packages/junior-plugin-api/** - packages/junior-testing/** - policies/** dashboard: - packages/junior-dashboard/** - packages/junior/** + - packages/junior-acp/** - packages/junior-plugin-api/** docs: - packages/docs/** @@ -65,6 +67,7 @@ jobs: example: - apps/example/** - packages/junior/** + - packages/junior-acp/** - packages/junior-plugin-api/** lint_config: - ast-grep/** @@ -347,6 +350,7 @@ jobs: run: | mkdir -p artifacts pnpm --filter @sentry/junior pack --pack-destination artifacts + pnpm --filter @sentry/junior-acp pack --pack-destination artifacts pnpm --filter @sentry/junior-plugin-api pack --pack-destination artifacts pnpm --filter @sentry/junior-agent-browser pack --pack-destination artifacts pnpm --filter @sentry/junior-amplitude pack --pack-destination artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95fef036cc..272a5e46da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,6 +123,7 @@ pnpm build:pkg This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior` +- `@sentry/junior-acp` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-amplitude` diff --git a/README.md b/README.md index 595a6035bb..9a629df58c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Start here: | Package | Purpose | | ------------------------------ | ---------------------------------------------------------------------------- | | `@sentry/junior` | Core Slack bot runtime | +| `@sentry/junior-acp` | Remote ACP transport over Junior Conversations | | `@sentry/junior-plugin-api` | Lightweight plugin API types and helpers | | `@sentry/junior-agent-browser` | Agent Browser plugin package for browser automation | | `@sentry/junior-amplitude` | Read-only Amplitude product analytics through Amplitude's hosted MCP server | diff --git a/package.json b/package.json index b08a4ca6b8..bc4df2a094 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm file-length:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior tool-error-classification:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm file-length:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior tool-error-classification:check && pnpm --filter @sentry/junior-acp lint && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs", "migration-metadata:check": "node --test scripts/check-drizzle-migrations.test.mjs && node scripts/check-drizzle-migrations.mjs", @@ -26,7 +26,7 @@ "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", - "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-octolens packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", + "package:lint": "for pkg in packages/junior packages/junior-acp packages/junior-plugin-api packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-octolens packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-linear build && pnpm --filter @sentry/junior-sentry build && pnpm --filter @sentry/junior-vercel build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-sentry test && pnpm --filter @sentry/junior-vercel test && pnpm --filter @sentry/junior-dashboard test", diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index ae6875f431..b79855c665 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -12,6 +12,7 @@ related: Junior uses lockstep package releases for: - `@sentry/junior` +- `@sentry/junior-acp` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-amplitude` diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index de121b9913..e83230a51b 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -151,14 +151,15 @@ const app = await createApp({ `junior chat` enables experimental `subagents` automatically because it is the local createApp-equivalent entrypoint and already wires the child-worker path. -`acp` mounts `GET`, `POST`, and `DELETE /api/acp`. Every request needs a Junior -personal token in the bearer authorization header. The route stores transport -records in the configured `StateAdapter`. The production Redis adapter lets -requests reach different app instances. It does not need process affinity. The -memory adapter remains local to one process. A client must reconnect and call -`session/load` when its live SSE request reaches the deployment request limit. -Run `pnpm acp:local` in this repository for a loopback test with the official -ACP SDK client. +`acp` mounts `GET`, `POST`, and `DELETE /api/acp`. The client must support ACP +URL elicitation. Junior uses the dashboard Google sign-in flow and rejects +personal tokens on this route. The route stores connection, authorization, and +stream records in the configured `StateAdapter`. The production Redis adapter +lets requests reach different app instances. It does not need process affinity. +The memory adapter remains local to one process. A client must reconnect and +call `session/load` when its live SSE request reaches the deployment request +limit. Run `pnpm acp:local` in this repository for a loopback test with the +official ACP SDK client. ## Install-wide config defaults diff --git a/packages/docs/src/content/docs/reference/handler-surface.md b/packages/docs/src/content/docs/reference/handler-surface.md index 0830e68e3b..1445b126a1 100644 --- a/packages/docs/src/content/docs/reference/handler-surface.md +++ b/packages/docs/src/content/docs/reference/handler-surface.md @@ -20,7 +20,7 @@ Handled `GET` routes: - `/api/oauth/callback/:provider` - `/api/oauth/callback/mcp/:provider` -When `createApp({ dashboard })` mounts `@sentry/junior-dashboard`, the dashboard package owns `/`, `/conversations`, `/conversations/*`, `/locations`, `/locations/*`, `/people`, `/people/*`, `/system`, `/system/*`, `/_junior/dashboard/client.js`, `/auth/login`, `/api/auth/*`, and the authenticated product API routes `/api/health`, `/api/runtime`, `/api/plugins`, `/api/plugins/*`, `/api/plugin-reports`, `/api/skills`, `/api/conversations`, `/api/conversations/*`, `/api/locations`, `/api/locations/*`, `/api/people`, `/api/people/*`, `/api/config`, and `/api/me`; use `/health` for unauthenticated health checks. Plugin API routes are mounted under `/api/plugins/:plugin/*` and inherit auth. +When `createApp({ dashboard })` mounts `@sentry/junior-dashboard`, the dashboard package owns `/`, `/conversations`, `/conversations/*`, `/locations`, `/locations/*`, `/people`, `/people/*`, `/system`, `/system/*`, `/_junior/dashboard/client.js`, `/auth/login`, `/api/auth/*`, `/api/acp/auth/*`, and the authenticated product API routes `/api/health`, `/api/runtime`, `/api/plugins`, `/api/plugins/*`, `/api/plugin-reports`, `/api/skills`, `/api/conversations`, `/api/conversations/*`, `/api/locations`, `/api/locations/*`, `/api/people`, `/api/people/*`, `/api/config`, and `/api/me`; use `/health` for unauthenticated health checks. Plugin API routes are mounted under `/api/plugins/:plugin/*` and inherit auth. Handled `POST` routes: @@ -30,12 +30,12 @@ Handled `POST` routes: - `/api/webhooks/:platform` (Slack path is `/api/webhooks/slack`) When `createApp({ experimental: { acp: true } })` is set, `GET`, `POST`, and -`DELETE /api/acp` expose ACP v1 Streamable HTTP. Every request requires a Junior -personal token in the bearer authorization header. This experimental route -stores transport records in the configured `StateAdapter`. The production -Redis adapter supports requests from different app instances. Live SSE -requests still end at the deployment request limit. Clients must reconnect and -call `session/load` after that limit. +`DELETE /api/acp` expose ACP v1 Streamable HTTP. Clients authenticate through +ACP URL elicitation and the dashboard Google sign-in flow. Personal tokens do +not grant access. This experimental route stores transport records in the +configured `StateAdapter`. The production Redis adapter supports requests from +different app instances. Live SSE requests still end at the deployment request +limit. Clients must reconnect and call `session/load` after that limit. ## Expected behavior diff --git a/packages/junior/src/api/acp/README.md b/packages/junior-acp/README.md similarity index 65% rename from packages/junior/src/api/acp/README.md rename to packages/junior-acp/README.md index 514332a944..fdd361e134 100644 --- a/packages/junior/src/api/acp/README.md +++ b/packages/junior-acp/README.md @@ -2,8 +2,10 @@ Junior exposes ACP v1 Streamable HTTP at `/api/acp` when the app sets `experimental: { acp: true }`. The route accepts `GET`, `POST`, and `DELETE`. -Every request needs a Junior personal token in an `Authorization: Bearer` -header. +The client must support ACP URL elicitation. Junior advertises browser sign-in +as its ACP authentication method. The dashboard completes Google OAuth and +binds the verified Junior user to the ACP connection. Personal tokens do not +grant ACP access. The adapter maps an ACP session to a private Conversation. It uses the existing web Actor, API Turn mailbox, worker, event store, and Conversation access rules. @@ -12,14 +14,25 @@ links, media, filesystem callbacks, and terminal callbacks are not supported. `session/cancel` stops the active Turn and returns the ACP `cancelled` stop reason. +## Package Boundary + +`@sentry/junior-acp` owns ACP JSON-RPC, SSE, connection state, and browser +authorization transactions. It does not import Junior core. The `ConversationPort` +contract in `@sentry/junior-plugin-api` gives it six Conversation operations. +Junior implements that contract in one adapter module. The adapter owns user +access, mailbox admission, cancellation, and event projection. Core loads the +ACP package through app setup and does not statically import it. No ACP type +enters the agent loop. + ## Runtime Design The ACP `sessionId` is the Junior `conversationId`. The Conversation remains the source for history, Turn state, and replay. The normal mailbox, queue, lease, checkpoint, and event paths run the work. -The ACP transport stores connection ownership, retry receipts, pending stream -items, stream cursors, and stream leases in the Junior `StateAdapter`. +The ACP transport stores hashed connection credentials, browser authorization +transactions, retry receipts, pending stream items, stream cursors, and stream +leases in the Junior `StateAdapter`. Production uses the existing Redis adapter. Any ACP request can reach any app instance. The route does not need process affinity or a separate ACP service. The memory adapter remains process-local and is suitable only for local use. @@ -52,11 +65,14 @@ existing tunnel: ```sh JUNIOR_ACP_URL=https://example.trycloudflare.com/api/acp \ -JUNIOR_ACP_TOKEN=jr_pat_example \ JUNIOR_ACP_FOLLOW_UP="Send one follow-up reply." \ pnpm --filter @sentry/junior acp:smoke ``` +The client prints a one-time sign-in URL. Open it in a browser and finish +Google sign-in. The app must mount the authenticated dashboard, and its Google +OAuth client must allow the app's `/api/auth/callback/google` URL. + Set `JUNIOR_ACP_SESSION_ID` to load an earlier Conversation before the first prompt. The client always reconnects once and loads the active session. It prints the session id so it can be reused. @@ -65,11 +81,12 @@ prints the session id so it can be reused. Run `pnpm acp:local` from the repository root. The command starts the local Postgres and Redis services, applies core migrations, and opens the real -`/api/acp` route on loopback. It creates a short-lived test token, runs the -official SDK smoke client with two Turns and one reconnect, revokes the token, -and exits. The token does not enter terminal output. The test server uses the -normal auth, Conversation, mailbox, worker, event, and replay paths. It replaces -only Vercel Queue transport and model generation with in-process test adapters. +`/api/acp` route on loopback. It runs the official SDK smoke client with two +Turns and one reconnect, and then exits. A local callback route completes +the same ACP authorization transaction without Google. The test server uses +the normal Conversation, mailbox, worker, event, and replay paths. It replaces +only Google sign-in, Vercel Queue transport, and model generation with local +test adapters. This command is test equipment. It does not add a local ACP transport to the product. The Compose services stay available for later local tests. diff --git a/packages/junior-acp/package.json b/packages/junior-acp/package.json new file mode 100644 index 0000000000..2c9fc5c7d6 --- /dev/null +++ b/packages/junior-acp/package.json @@ -0,0 +1,48 @@ +{ + "name": "@sentry/junior-acp", + "version": "0.172.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-acp" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + }, + "./testing": { + "types": "./src/testing.ts", + "default": "./dist/testing.js" + } + }, + "files": [ + "dist", + "README.md", + "src" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tsup.config.ts", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", + "@sentry/junior-plugin-api": "workspace:*", + "chat": "4.29.0", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "oxlint": "^1.66.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3" + } +} diff --git a/packages/junior-acp/src/auth.ts b/packages/junior-acp/src/auth.ts new file mode 100644 index 0000000000..8cf643701e --- /dev/null +++ b/packages/junior-acp/src/auth.ts @@ -0,0 +1,323 @@ +import { + createHash, + randomBytes, + randomUUID, + timingSafeEqual, +} from "node:crypto"; +import type { StateAdapter } from "chat"; +import * as acp from "@agentclientprotocol/sdk"; +import { userSchema, type User } from "@sentry/junior-plugin-api"; +import { z } from "zod"; +import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "./state"; +import { + bindAcpConnectionUser, + completeAcpRequest, + type AcpRequestReceipt, +} from "./transport"; + +const ACP_AUTHORIZATION_TTL_MS = 10 * 60 * 1000; +const ACP_CONNECTION_COOKIE = "junior_acp_connection"; +const LOCK_WAIT_MS = 5_000; +const jsonRpcIdSchema = z.union([z.string(), z.number().finite(), z.null()]); + +const acpAuthorizationSchema = z + .object({ + connectionId: z.string().uuid(), + credentialHash: z.string().length(64), + elicitationId: z.string().uuid(), + expiresAtMs: z.number().int().positive(), + requestId: jsonRpcIdSchema, + requestKey: z.string().min(1), + }) + .strict(); + +type AcpAuthorization = z.output; + +export type AcpAuthorizationCompletion = "completed" | "conflict" | "expired"; + +export const ACP_AUTH_METHOD_ID = "junior"; + +export const ACP_AUTH_METHOD = { + id: ACP_AUTH_METHOD_ID, + name: "Sign in to Junior", + description: "Sign in with the Google account allowed by this Junior app.", +} satisfies acp.AuthMethod; + +function authorizationKey(transactionId: string): string { + return `junior:acp:v1:authorization:${transactionId}`; +} + +function authorizationLockKey(transactionId: string): string { + return `${authorizationKey(transactionId)}:lock`; +} + +function credentialHash(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function cookieValue(request: Request): string | undefined { + const header = request.headers.get("cookie"); + if (!header) return undefined; + for (const part of header.split(";")) { + const separator = part.indexOf("="); + if (separator <= 0) continue; + if (part.slice(0, separator).trim() !== ACP_CONNECTION_COOKIE) continue; + const value = part.slice(separator + 1).trim(); + return value || undefined; + } + return undefined; +} + +function cookieAttributes(request: Request): string { + return [ + "Path=/api/acp", + "HttpOnly", + "SameSite=Strict", + ...(new URL(request.url).protocol === "https:" ? ["Secure"] : []), + ].join("; "); +} + +/** Create the secret cookie that proves control of one ACP connection. */ +export function createAcpConnectionCredential(request: Request): { + cookie: string; + credentialHash: string; +} { + const credential = randomBytes(32).toString("base64url"); + return { + cookie: `${ACP_CONNECTION_COOKIE}=${credential}; ${cookieAttributes(request)}; Max-Age=86400`, + credentialHash: credentialHash(credential), + }; +} + +/** Clear the ACP connection credential in the client cookie store. */ +export function clearAcpConnectionCredential(request: Request): string { + return `${ACP_CONNECTION_COOKIE}=; ${cookieAttributes(request)}; Max-Age=0`; +} + +/** Verify that this request controls the supplied ACP connection hash. */ +export function hasAcpConnectionCredential( + request: Request, + expectedHash: string, +): boolean { + const credential = cookieValue(request); + if (!credential) return false; + const actual = Buffer.from(credentialHash(credential), "hex"); + const expected = Buffer.from(expectedHash, "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function authorizationBaseURL(request: Request, configured?: string): URL { + const value = configured?.trim(); + const url = new URL(value || new URL(request.url).origin); + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url; +} + +async function readAuthorization( + state: StateAdapter, + transactionId: string, +): Promise { + const value = await state.get(authorizationKey(transactionId)); + return value === null || value === undefined + ? undefined + : acpAuthorizationSchema.parse(value); +} + +function completionReceipt( + authorization: AcpAuthorization, + response: acp.AnyResponse, +): AcpRequestReceipt { + return { + deliveryKey: `${authorization.requestKey}:authorization-complete`, + outputs: [ + { + kind: "message", + message: { + jsonrpc: "2.0", + method: acp.methods.client.elicitation.complete, + params: { elicitationId: authorization.elicitationId }, + }, + }, + { kind: "message", message: response }, + ], + }; +} + +function resultMessage(requestId: acp.JsonRpcId): acp.AnyResponse { + return { jsonrpc: "2.0", id: requestId, result: {} }; +} + +function cancelledMessage(requestId: acp.JsonRpcId): acp.AnyResponse { + return { + jsonrpc: "2.0", + id: requestId, + error: acp.RequestError.requestCancelled( + undefined, + "Junior sign-in was cancelled", + ).toErrorResponse(), + }; +} + +/** Start browser authorization for one ACP authenticate request. */ +export async function beginAcpAuthorization(args: { + baseURL?: string; + connectionId: string; + credentialHash: string; + request: Request; + requestId: acp.JsonRpcId; + requestKey: string; + state: StateAdapter; +}): Promise { + const transactionId = randomUUID(); + const authorization = acpAuthorizationSchema.parse({ + connectionId: args.connectionId, + credentialHash: args.credentialHash, + elicitationId: transactionId, + expiresAtMs: Date.now() + ACP_AUTHORIZATION_TTL_MS, + requestId: args.requestId, + requestKey: args.requestKey, + }); + await args.state.set( + authorizationKey(transactionId), + authorization, + ACP_AUTHORIZATION_TTL_MS, + ); + const url = authorizationBaseURL(args.request, args.baseURL); + url.pathname = `/api/acp/auth/${transactionId}`; + const params = { + mode: "url", + message: "Sign in to Junior in your browser.", + elicitationId: transactionId, + requestId: args.requestId, + url: url.toString(), + } satisfies acp.CreateElicitationRequest; + return { + deliveryKey: `${args.requestKey}:authorization-start`, + outputs: [ + { + kind: "message", + message: { + jsonrpc: "2.0", + id: transactionId, + method: acp.methods.client.elicitation.create, + params, + }, + }, + ], + }; +} + +async function finishAuthorization(args: { + authorization: AcpAuthorization; + response: acp.AnyResponse; + state: StateAdapter; +}): Promise<"completed" | "expired"> { + return await completeAcpRequest({ + connectionId: args.authorization.connectionId, + receipt: completionReceipt(args.authorization, args.response), + requestKey: args.authorization.requestKey, + state: args.state, + }); +} + +/** Bind a Google-authenticated user and resume the pending ACP request. */ +export async function completeAcpAuthorization(args: { + state: StateAdapter; + transactionId: string; + user: User; +}): Promise { + const result = await withLock( + args.state, + authorizationLockKey(args.transactionId), + async (lock) => { + const authorization = await readAuthorization( + args.state, + args.transactionId, + ); + if (!authorization || authorization.expiresAtMs <= Date.now()) { + if (authorization) { + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.delete(authorizationKey(args.transactionId)); + } + return "expired" as const; + } + const binding = await bindAcpConnectionUser({ + connectionId: authorization.connectionId, + credentialHash: authorization.credentialHash, + state: args.state, + user: userSchema.parse(args.user), + }); + if (binding !== "completed") return binding; + const completion = await finishAuthorization({ + authorization, + response: resultMessage(authorization.requestId), + state: args.state, + }); + if (completion === "expired") return completion; + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.delete(authorizationKey(args.transactionId)); + return "completed" as const; + }, + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: LOCK_WAIT_MS, + }, + ); + if (!result.acquired) { + throw new Error("Timed out acquiring ACP authorization control"); + } + return result.value; +} + +/** Consume a client response to the browser URL elicitation. */ +export async function handleAcpAuthorizationResponse(args: { + connectionId: string; + response: acp.AnyResponse; + state: StateAdapter; +}): Promise<"accepted" | "cancelled" | "ignored"> { + if (typeof args.response.id !== "string") return "ignored"; + const parsedId = z.string().uuid().safeParse(args.response.id); + if (!parsedId.success) return "ignored"; + const authorization = await readAuthorization(args.state, parsedId.data); + if (!authorization || authorization.connectionId !== args.connectionId) { + return "ignored"; + } + const action = + "result" in args.response && + typeof args.response.result === "object" && + args.response.result !== null && + "action" in args.response.result && + typeof args.response.result.action === "string" + ? args.response.result.action + : undefined; + if (action === "accept") return "accepted"; + + const result = await withLock( + args.state, + authorizationLockKey(parsedId.data), + async (lock) => { + const current = await readAuthorization(args.state, parsedId.data); + if (!current || current.connectionId !== args.connectionId) return false; + await finishAuthorization({ + authorization: current, + response: cancelledMessage(current.requestId), + state: args.state, + }); + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.delete(authorizationKey(parsedId.data)); + return true; + }, + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: LOCK_WAIT_MS, + }, + ); + if (!result.acquired) { + throw new Error("Timed out acquiring ACP authorization control"); + } + return result.value ? "cancelled" : "ignored"; +} diff --git a/packages/junior-acp/src/conversations.ts b/packages/junior-acp/src/conversations.ts new file mode 100644 index 0000000000..d56a654589 --- /dev/null +++ b/packages/junior-acp/src/conversations.ts @@ -0,0 +1,19 @@ +export type { + ConversationPort, + ConversationPromptAdmission, + ConversationTextMessage, + ConversationTurnPage, + ConversationTurnTerminal, +} from "@sentry/junior-plugin-api"; + +export interface AcpErrorContext { + connectionId?: string; + conversationId?: string; + userId?: string; +} + +export type ReportAcpError = ( + error: unknown, + event: string, + context: AcpErrorContext, +) => void; diff --git a/packages/junior-acp/src/index.ts b/packages/junior-acp/src/index.ts new file mode 100644 index 0000000000..424491dd6f --- /dev/null +++ b/packages/junior-acp/src/index.ts @@ -0,0 +1,14 @@ +export { + completeAcpAuthorization, + type AcpAuthorizationCompletion, +} from "./auth"; +export { createAcpHttpHandler, type AcpHttpHandlerOptions } from "./route"; +export type { + AcpErrorContext, + ConversationPort, + ConversationPromptAdmission, + ConversationTextMessage, + ConversationTurnPage, + ConversationTurnTerminal, + ReportAcpError, +} from "./conversations"; diff --git a/packages/junior-acp/src/route.ts b/packages/junior-acp/src/route.ts new file mode 100644 index 0000000000..8af894157d --- /dev/null +++ b/packages/junior-acp/src/route.ts @@ -0,0 +1,739 @@ +/** + * Own the remote ACP v1 Streamable HTTP edge. + * + * StateAdapter transport records route short-lived JSON-RPC messages. Junior's + * Conversation log remains the source for session replay and Turn output. No + * request relies on another request reaching the same app process. + */ +import { createHash } from "node:crypto"; +import type { StateAdapter } from "chat"; +import type { User } from "@sentry/junior-plugin-api"; +import * as acp from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { ConversationPort, ReportAcpError } from "./conversations"; +import { + ACP_AUTH_METHOD, + ACP_AUTH_METHOD_ID, + beginAcpAuthorization, + clearAcpConnectionCredential, + createAcpConnectionCredential, + handleAcpAuthorizationResponse, + hasAcpConnectionCredential, +} from "./auth"; +import { + acceptAcpRequest, + createAcpConnection, + deleteAcpConnection, + openAcpSse, + readAcpConnection, + type AcpConnection, + type AcpPromptStreamOutput, + type AcpRequestReceipt, + type AcpStreamRoute, +} from "./transport"; +import { + SESSION_HEADER_METHODS, + acpConnectionIdSchema, + acpInboundMessageSchema, + acpSessionIdSchema, + authenticateParamsSchema, + cancelParamsSchema, + initializeParamsSchema, + loadSessionParamsSchema, + newSessionParamsSchema, + promptParamsSchema, + type AcpCall, + type AcpResponse, + type CancelParams, + type LoadSessionParams, + type PromptParams, + type SessionParams, +} from "./schema"; + +const ACP_CONNECTION_ID_HEADER = "Acp-Connection-Id"; +const ACP_SESSION_ID_HEADER = "Acp-Session-Id"; +const ACP_CONVERSATION_PREFIX = "local:acp:"; +const ACP_EVENT_STREAM_MIME_TYPE = "text/event-stream"; +const ACP_JSON_MIME_TYPE = "application/json"; +const MAX_PROMPT_TEXT_LENGTH = 32_000; + +/** Dependencies for one serverless-safe remote ACP HTTP handler. */ +export interface AcpHttpHandlerOptions { + baseURL?: string; + conversations: ConversationPort; + onError?: ReportAcpError; + state: StateAdapter; + version: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function authenticatedAcpUser(connection: AcpConnection): User | undefined { + return connection.user; +} + +function supportsUrlElicitation(params: unknown): boolean { + const parsed = initializeParamsSchema.safeParse(params); + if (!parsed.success) return false; + const elicitation = parsed.data.clientCapabilities?.elicitation; + return isRecord(elicitation) && isRecord(elicitation.url); +} + +function parseParams(schema: z.ZodType, params: unknown): T { + const parsed = schema.safeParse(params); + if (parsed.success) return parsed.data; + const firstPath = parsed.error.issues[0]?.path[0]; + throw acp.RequestError.invalidParams({ + field: + typeof firstPath === "string" || typeof firstPath === "number" + ? String(firstPath) + : "params", + }); +} + +/** Reject client MCP servers because Junior does not use the client workspace. */ +function rejectUnsupportedMcpServers(mcpServers: readonly unknown[]): void { + if (mcpServers.length > 0) { + throw acp.RequestError.invalidParams( + { field: "mcpServers" }, + "Junior does not accept client MCP servers", + ); + } +} + +/** Convert supported ACP text blocks to one bounded API Turn message. */ +function promptText(prompt: PromptParams["prompt"]): string { + if (prompt.length === 0) { + throw acp.RequestError.invalidParams( + { field: "prompt" }, + "Junior accepts one or more text blocks only", + ); + } + const blocks: string[] = []; + for (const block of prompt) { + if (!block.text.trim()) { + throw acp.RequestError.invalidParams( + { field: "prompt" }, + "Junior accepts non-empty text blocks only", + ); + } + blocks.push(block.text); + } + const text = blocks.join("\n"); + if (text.length > MAX_PROMPT_TEXT_LENGTH) { + throw acp.RequestError.invalidParams( + { field: "prompt" }, + `Junior accepts at most ${MAX_PROMPT_TEXT_LENGTH} prompt characters`, + ); + } + return text; +} + +function stableHex(value: string, length = 32): string { + return createHash("sha256").update(value).digest("hex").slice(0, length); +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson); + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalJson(child)]), + ); +} + +/** Preserve the JSON-RPC id type and request payload in durable retry keys. */ +function requestKey(call: AcpCall & { id: acp.JsonRpcId }): string { + const id = + call.id === null + ? "null" + : `${typeof call.id}:${stableHex(String(call.id))}`; + const payload = JSON.stringify( + canonicalJson({ method: call.method, params: call.params ?? null }), + ); + return `${id}:${stableHex(payload)}`; +} + +function sessionIdFromCall(call: AcpCall | undefined): string | undefined { + if (!call) return undefined; + const schema = + call.method === acp.methods.agent.session.load + ? loadSessionParamsSchema + : call.method === acp.methods.agent.session.prompt + ? promptParamsSchema + : call.method === acp.methods.agent.session.cancel + ? cancelParamsSchema + : undefined; + if (!schema) return undefined; + const parsed = schema.safeParse(call.params); + return parsed.success ? parsed.data.sessionId : undefined; +} + +/** Route session methods only from a canonical header and matching strict params. */ +function determineRoute( + connectionId: string, + call: AcpCall, + headerSessionId: string | undefined, +): AcpStreamRoute | Response { + const paramsSessionId = sessionIdFromCall(call); + if (SESSION_HEADER_METHODS.has(call.method) && !headerSessionId) { + return textResponse("Missing Acp-Session-Id", 400); + } + if ( + headerSessionId !== undefined && + paramsSessionId !== undefined && + headerSessionId !== paramsSessionId + ) { + return textResponse("Mismatched Acp-Session-Id", 400); + } + return { + connectionId, + ...(headerSessionId + ? { sessionId: headerSessionId } + : paramsSessionId + ? { sessionId: paramsSessionId } + : {}), + }; +} + +/** Keep load failures on the connection stream until session ownership succeeds. */ +function responseSessionId( + call: AcpCall, + route: AcpStreamRoute, +): string | undefined { + return call.method === acp.methods.agent.session.load + ? undefined + : route.sessionId; +} + +function resultMessage( + requestId: acp.JsonRpcId, + result: unknown, +): acp.AnyResponse { + return { jsonrpc: "2.0", id: requestId, result }; +} + +function errorMessage( + requestId: acp.JsonRpcId, + error: acp.RequestError, +): acp.AnyResponse { + return { + jsonrpc: "2.0", + id: requestId, + error: error.toErrorResponse(), + }; +} + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "Content-Type": "text/plain" }, + }); +} + +function jsonResponse( + value: acp.AnyResponse, + status: number, + headers?: HeadersInit, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "Content-Type": ACP_JSON_MIME_TYPE, ...headers }, + }); +} + +function emptyResponse(status: number): Response { + return new Response(null, { status }); +} + +/** Create a deterministic private Conversation for one session/new request. */ +async function createSession(args: { + call: AcpCall; + connection: AcpConnection; + conversations: ConversationPort; + requestKey: string; + user: User; +}): Promise { + const params = parseParams( + newSessionParamsSchema, + args.call.params, + ); + rejectUnsupportedMcpServers(params.mcpServers); + const sessionId = `${ACP_CONVERSATION_PREFIX}${stableHex( + `${args.connection.nonce}:${args.requestKey}`, + )}`; + await args.conversations.createConversation({ + conversationId: sessionId, + user: args.user, + }); + return sessionId; +} + +/** Accept one prompt into the existing durable Conversation mailbox. */ +async function acceptPrompt(args: { + call: AcpCall; + connection: AcpConnection; + conversations: ConversationPort; + requestId: acp.JsonRpcId; + requestKey: string; + user: User; +}): Promise<{ output: AcpPromptStreamOutput; sessionId: string }> { + const params = parseParams( + promptParamsSchema, + args.call.params, + ); + const admission = await args.conversations.prompt({ + conversationId: params.sessionId, + idempotencyKey: `${args.connection.nonce}:${args.requestKey}`, + text: promptText(params.prompt), + user: args.user, + }); + if (admission.status === "not_found") { + throw acp.RequestError.resourceNotFound(params.sessionId); + } + if (admission.status === "active") { + throw acp.RequestError.invalidParams( + { field: "sessionId" }, + "This ACP session already has an active prompt", + ); + } + return { + output: { + afterSeq: admission.afterCursor, + kind: "prompt", + messageId: admission.messageId, + requestId: args.requestId, + turnId: admission.turnId, + }, + sessionId: params.sessionId, + }; +} + +/** Dispatch one connected JSON-RPC request into a durable outbound receipt. */ +async function createRequestReceipt(args: { + call: AcpCall & { id: acp.JsonRpcId }; + connection: AcpConnection; + conversations: ConversationPort; + requestKey: string; + route: AcpStreamRoute; + user: User; +}): Promise { + if (args.call.method === acp.methods.agent.session.new) { + const sessionId = await createSession(args); + return { + outputs: [ + { + kind: "message", + message: resultMessage(args.call.id, { sessionId }), + }, + ], + }; + } + + if (args.call.method === acp.methods.agent.session.load) { + const params = parseParams( + loadSessionParamsSchema, + args.call.params, + ); + rejectUnsupportedMcpServers(params.mcpServers); + if ( + !(await args.conversations.hasConversationAccess({ + conversationId: params.sessionId, + user: args.user, + })) + ) { + throw acp.RequestError.resourceNotFound(params.sessionId); + } + return { + outputs: [ + { kind: "replay", sessionId: params.sessionId }, + { kind: "message", message: resultMessage(args.call.id, {}) }, + ], + }; + } + + if (args.call.method === acp.methods.agent.session.prompt) { + const prompt = await acceptPrompt({ + ...args, + requestId: args.call.id, + }); + return { + outputs: [ + { + kind: "prompt", + output: prompt.output, + sessionId: prompt.sessionId, + }, + ], + }; + } + + throw acp.RequestError.methodNotFound(args.call.method); +} + +/** Handle a session/cancel notification through durable Turn control. */ +async function handleCancelNotification(args: { + call: AcpCall; + conversations: ConversationPort; + user: User; +}): Promise { + const params = parseParams( + cancelParamsSchema, + args.call.params, + ); + const result = await args.conversations.cancel({ + conversationId: params.sessionId, + user: args.user, + }); + if (result === "not_found") { + throw acp.RequestError.resourceNotFound(params.sessionId); + } +} + +/** Require proof that this request controls the named ACP connection. */ +async function requireConnection(args: { + connectionId: string; + request: Request; + state: StateAdapter; +}): Promise { + const value = await readAcpConnection(args.state, args.connectionId); + if ( + !value || + !hasAcpConnectionCredential(args.request, value.credentialHash) + ) { + return undefined; + } + return value; +} + +function isJsonContentType(contentType: string | null): boolean { + return ( + contentType?.split(";", 1)[0]?.trim().toLowerCase() === ACP_JSON_MIME_TYPE + ); +} + +async function handleInitialize(args: { + call: AcpCall; + request: Request; + state: StateAdapter; + version: string; +}): Promise { + const requestId = args.call.id; + if (requestId === undefined) { + return textResponse("Initialize request must include an ID", 400); + } + try { + parseParams(initializeParamsSchema, args.call.params); + if (!supportsUrlElicitation(args.call.params)) { + throw acp.RequestError.invalidParams( + { field: "clientCapabilities.elicitation.url" }, + "Remote Junior authentication requires URL elicitation support", + ); + } + const credential = createAcpConnectionCredential(args.request); + const result = { + protocolVersion: acp.PROTOCOL_VERSION, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: false, + audio: false, + embeddedContext: false, + }, + }, + authMethods: [ACP_AUTH_METHOD], + agentInfo: { name: "junior", version: args.version }, + } satisfies acp.InitializeResponse; + const connection = await createAcpConnection( + args.state, + credential.credentialHash, + ); + return jsonResponse(resultMessage(requestId, result), 200, { + [ACP_CONNECTION_ID_HEADER]: connection.connectionId, + "Set-Cookie": credential.cookie, + }); + } catch (error) { + if (!(error instanceof acp.RequestError)) throw error; + return jsonResponse(errorMessage(requestId, error), 200); + } +} + +async function handleConnectedPost(args: { + authenticated?: User; + call: AcpCall; + connection: AcpConnection; + connectionId: string; + options: AcpHttpHandlerOptions; + request: Request; + route: AcpStreamRoute; + state: StateAdapter; +}): Promise { + const requestId = args.call.id; + if (requestId === undefined) { + if ( + args.authenticated && + args.call.method === acp.methods.agent.session.cancel + ) { + try { + await handleCancelNotification({ + call: args.call, + conversations: args.options.conversations, + user: args.authenticated, + }); + } catch (error) { + if (!(error instanceof acp.RequestError)) throw error; + } + } + return emptyResponse(202); + } + + const identifiedCall = { ...args.call, id: requestId }; + const identifiedRequestKey = requestKey(identifiedCall); + const status = await acceptAcpRequest({ + connectionId: args.connectionId, + requestKey: identifiedRequestKey, + state: args.state, + createReceipt: async (): Promise => { + try { + if (identifiedCall.method === acp.methods.agent.authenticate) { + const params = parseParams( + authenticateParamsSchema, + identifiedCall.params, + ); + if (params.methodId !== ACP_AUTH_METHOD_ID) { + throw acp.RequestError.invalidParams( + { field: "methodId" }, + "Junior did not advertise this authentication method", + ); + } + if (args.authenticated) { + return { + outputs: [ + { + kind: "message", + message: resultMessage(requestId, {}), + }, + ], + }; + } + return await beginAcpAuthorization({ + baseURL: args.options.baseURL, + connectionId: args.connectionId, + credentialHash: args.connection.credentialHash, + request: args.request, + requestId, + requestKey: identifiedRequestKey, + state: args.state, + }); + } + if (!args.authenticated) { + throw acp.RequestError.authRequired(); + } + return await createRequestReceipt({ + call: identifiedCall, + connection: args.connection, + conversations: args.options.conversations, + requestKey: identifiedRequestKey, + route: args.route, + user: args.authenticated, + }); + } catch (error) { + if (!(error instanceof acp.RequestError)) throw error; + const sessionId = responseSessionId(args.call, args.route); + return { + outputs: [ + { + kind: "message", + message: errorMessage(requestId, error), + ...(sessionId ? { sessionId } : {}), + }, + ], + }; + } + }, + }); + if (status === "busy") { + return textResponse("ACP request is already being accepted", 503); + } + if (status === "full") { + return textResponse("ACP stream has too much undelivered output", 503); + } + return emptyResponse(202); +} + +async function handleConnectedResponse(args: { + connectionId: string; + response: AcpResponse; + state: StateAdapter; +}): Promise { + await handleAcpAuthorizationResponse({ + connectionId: args.connectionId, + response: args.response as acp.AnyResponse, + state: args.state, + }); + return emptyResponse(202); +} + +/** Create a serverless-safe remote ACP v1 HTTP handler. */ +export function createAcpHttpHandler( + options: AcpHttpHandlerOptions, +): (request: Request) => Promise { + const state = options.state; + + return async (request) => { + let authenticated: User | undefined; + let call: AcpCall | undefined; + let connectionId: string | undefined; + let sessionId: string | undefined; + try { + if (request.headers.has("Authorization")) { + return new Response("Unauthorized", { status: 401 }); + } + + const connectionHeader = request.headers.get(ACP_CONNECTION_ID_HEADER); + if (connectionHeader !== null) { + const parsed = acpConnectionIdSchema.safeParse(connectionHeader); + if (!parsed.success) { + return textResponse("Invalid Acp-Connection-Id", 400); + } + connectionId = parsed.data; + } + const sessionHeader = request.headers.get(ACP_SESSION_ID_HEADER); + if (sessionHeader !== null) { + const parsed = acpSessionIdSchema.safeParse(sessionHeader); + if (!parsed.success) { + return textResponse("Invalid Acp-Session-Id", 400); + } + sessionId = parsed.data; + } + + if (request.method === "POST") { + if (!isJsonContentType(request.headers.get("Content-Type"))) { + return textResponse("Unsupported Media Type", 415); + } + let value: unknown; + try { + value = await request.json(); + } catch { + return textResponse("Invalid JSON", 400); + } + if (Array.isArray(value)) { + return textResponse( + "Batch JSON-RPC requests are not implemented", + 501, + ); + } + const parsedMessage = acpInboundMessageSchema.safeParse(value); + if (!parsedMessage.success) { + return textResponse("Invalid JSON-RPC message", 400); + } + const message = parsedMessage.data; + + if ( + "method" in message && + message.method === acp.methods.agent.initialize + ) { + call = message; + if (connectionId) { + return textResponse( + "Initialize not allowed on existing connection", + 400, + ); + } + return await handleInitialize({ + call, + request, + state, + version: options.version, + }); + } + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + const connection = await requireConnection({ + connectionId, + request, + state, + }); + if (!connection) { + return textResponse("Unauthorized", 401); + } + authenticated = authenticatedAcpUser(connection); + if (!("method" in message)) { + return await handleConnectedResponse({ + connectionId, + response: message, + state, + }); + } + call = message; + const route = determineRoute(connectionId, call, sessionId); + if (route instanceof Response) return route; + return await handleConnectedPost({ + authenticated, + call, + connection, + connectionId, + options, + request, + route, + state, + }); + } + + if (!connectionId) { + return textResponse("Missing Acp-Connection-Id", 400); + } + const connection = await requireConnection({ + connectionId, + request, + state, + }); + if (!connection) { + return textResponse("Unauthorized", 401); + } + authenticated = authenticatedAcpUser(connection); + + if (request.method === "GET") { + if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { + return textResponse("WebSocket upgrade is not implemented", 426); + } + const accept = request.headers.get("Accept")?.toLowerCase(); + if (!accept?.includes(ACP_EVENT_STREAM_MIME_TYPE)) { + return textResponse("Not Acceptable", 406); + } + return await openAcpSse({ + conversations: options.conversations, + onError: options.onError, + requestSignal: request.signal, + route: { connectionId, ...(sessionId ? { sessionId } : {}) }, + state, + ...(authenticated ? { userId: authenticated.id } : {}), + }); + } + + if (request.method === "DELETE") { + await deleteAcpConnection(state, connectionId); + return new Response(null, { + status: 202, + headers: { + "Set-Cookie": clearAcpConnectionCredential(request), + }, + }); + } + + return textResponse("Method Not Allowed", 405); + } catch (error) { + const conversationId = sessionIdFromCall(call) ?? sessionId; + options.onError?.(error, "acp.transport.exception", { + ...(authenticated ? { userId: authenticated.id } : {}), + ...(connectionId ? { connectionId } : {}), + ...(conversationId ? { conversationId } : {}), + }); + return textResponse("ACP transport failed", 500); + } + }; +} diff --git a/packages/junior-acp/src/schema.ts b/packages/junior-acp/src/schema.ts new file mode 100644 index 0000000000..def71352bb --- /dev/null +++ b/packages/junior-acp/src/schema.ts @@ -0,0 +1,139 @@ +import * as acp from "@agentclientprotocol/sdk"; +import { z } from "zod"; + +const ACP_SESSION_ID_PATTERN = /^local:acp:[a-f0-9]{32}$/; +const metaSchema = z.record(z.string(), z.unknown()).nullable(); +const jsonRpcIdSchema = z.union([z.string(), z.number().finite(), z.null()]); + +export const SESSION_HEADER_METHODS = new Set([ + acp.methods.agent.session.cancel, + acp.methods.agent.session.close, + acp.methods.agent.session.load, + acp.methods.agent.session.prompt, + acp.methods.agent.session.resume, + acp.methods.agent.session.setConfigOption, + acp.methods.agent.session.setMode, +]); + +export const acpConnectionIdSchema = z.string().uuid(); +export const acpSessionIdSchema = z.string().regex(ACP_SESSION_ID_PATTERN); + +export const acpCallSchema = z + .object({ + id: jsonRpcIdSchema.optional(), + jsonrpc: z.literal("2.0"), + method: z.string().min(1), + params: z.unknown().optional(), + }) + .strict(); + +const jsonRpcErrorSchema = z + .object({ + code: z.number().int(), + data: z.unknown().optional(), + message: z.string(), + }) + .strict(); + +export const acpResponseSchema = z.union([ + z + .object({ + id: jsonRpcIdSchema, + jsonrpc: z.literal("2.0"), + result: z.unknown(), + }) + .strict(), + z + .object({ + error: jsonRpcErrorSchema, + id: jsonRpcIdSchema, + jsonrpc: z.literal("2.0"), + }) + .strict(), +]); + +export const acpInboundMessageSchema = z.union([ + acpCallSchema, + acpResponseSchema, +]); + +const implementationSchema = z + .object({ + _meta: metaSchema.optional(), + name: z.string().min(1), + title: z.string().nullable().optional(), + version: z.string(), + }) + .strict(); + +export const initializeParamsSchema = z + .object({ + _meta: metaSchema.optional(), + clientCapabilities: z.record(z.string(), z.unknown()).optional(), + clientInfo: implementationSchema.nullable().optional(), + protocolVersion: z.number().int().min(0).max(65_535), + }) + .strict(); + +export const authenticateParamsSchema = z + .object({ + _meta: metaSchema.optional(), + methodId: z.string().min(1), + }) + .strict(); + +export const newSessionParamsSchema = z + .object({ + _meta: metaSchema.optional(), + additionalDirectories: z.array(z.string()).optional(), + cwd: z.string().min(1), + mcpServers: z.array(z.unknown()), + }) + .strict(); + +export const loadSessionParamsSchema = newSessionParamsSchema.extend({ + sessionId: acpSessionIdSchema, +}); + +const annotationsSchema = z + .object({ + _meta: metaSchema.optional(), + audience: z + .array(z.enum(["assistant", "user"])) + .nullable() + .optional(), + lastModified: z.string().nullable().optional(), + priority: z.number().finite().nullable().optional(), + }) + .strict(); + +const textContentBlockSchema = z + .object({ + _meta: metaSchema.optional(), + annotations: annotationsSchema.nullable().optional(), + text: z.string(), + type: z.literal("text"), + }) + .strict(); + +export const promptParamsSchema = z + .object({ + _meta: metaSchema.optional(), + prompt: z.array(textContentBlockSchema), + sessionId: acpSessionIdSchema, + }) + .strict(); + +export const cancelParamsSchema = z + .object({ + _meta: metaSchema.optional(), + sessionId: acpSessionIdSchema, + }) + .strict(); + +export type AcpCall = z.output; +export type AcpResponse = z.output; +export type SessionParams = z.output; +export type LoadSessionParams = z.output; +export type PromptParams = z.output; +export type CancelParams = z.output; diff --git a/packages/junior-acp/src/sleep.ts b/packages/junior-acp/src/sleep.ts new file mode 100644 index 0000000000..488befb724 --- /dev/null +++ b/packages/junior-acp/src/sleep.ts @@ -0,0 +1,18 @@ +/** Wait for a duration and reject when the owning request stops. */ +export function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const timeoutId = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timeoutId); + reject(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/junior-acp/src/state.ts b/packages/junior-acp/src/state.ts new file mode 100644 index 0000000000..4637e62b90 --- /dev/null +++ b/packages/junior-acp/src/state.ts @@ -0,0 +1,57 @@ +import { setTimeout as wait } from "node:timers/promises"; +import type { Lock, StateAdapter } from "chat"; + +export const MUTATION_LOCK_TTL_MS = 10_000; + +type LockAttempt = { acquired: false } | { acquired: true; value: T }; + +/** Run one ACP state mutation while its shared lock remains owned. */ +export async function withLock( + state: StateAdapter, + key: string, + run: (lock: Lock) => Promise, + options: { + keepAlive?: boolean; + retryMs?: number; + ttlMs?: number; + waitMs?: number; + } = {}, +): Promise> { + const ttlMs = options.ttlMs ?? MUTATION_LOCK_TTL_MS; + const startedAtMs = Date.now(); + let lock: Lock | null; + while (true) { + lock = await state.acquireLock(key, ttlMs); + if (lock) break; + if (Date.now() - startedAtMs >= (options.waitMs ?? 0)) { + return { acquired: false }; + } + await wait(options.retryMs ?? 25, undefined, { ref: false }); + } + const heartbeat = options.keepAlive + ? setInterval( + () => void state.extendLock(lock, ttlMs), + Math.floor(ttlMs / 3), + ) + : undefined; + heartbeat?.unref(); + try { + return { acquired: true, value: await run(lock) }; + } finally { + if (heartbeat) clearInterval(heartbeat); + await state.releaseLock(lock); + } +} + +/** Prove ACP lock ownership immediately before a blind state write. */ +export async function fenceLock( + state: StateAdapter, + lock: Lock, + ttlMs = MUTATION_LOCK_TTL_MS, +): Promise { + if (!(await state.extendLock(lock, ttlMs))) { + throw new Error( + `ACP lock ownership was lost before write for ${lock.threadId}`, + ); + } +} diff --git a/packages/junior-acp/src/testing.ts b/packages/junior-acp/src/testing.ts new file mode 100644 index 0000000000..2712e82b52 --- /dev/null +++ b/packages/junior-acp/src/testing.ts @@ -0,0 +1 @@ +export { acceptAcpRequest, type AcpRequestReceipt } from "./transport"; diff --git a/packages/junior/src/api/acp/transport.ts b/packages/junior-acp/src/transport.ts similarity index 81% rename from packages/junior/src/api/acp/transport.ts rename to packages/junior-acp/src/transport.ts index 5fb968d8ef..c868570b96 100644 --- a/packages/junior/src/api/acp/transport.ts +++ b/packages/junior-acp/src/transport.ts @@ -7,19 +7,16 @@ import { createHash, randomUUID } from "node:crypto"; import type { Lock, StateAdapter } from "chat"; import * as acp from "@agentclientprotocol/sdk"; +import { userSchema, type User } from "@sentry/junior-plugin-api"; import { z } from "zod"; -import type { ConversationEventStore } from "@/chat/conversations/history"; -import { projectConversationMessages } from "@/chat/conversations/message-projection"; -import { apiConversationMessageIsPending } from "@/chat/api-turns/mailbox"; -import { logException } from "@/chat/logging"; -import { sleep } from "@/chat/sleep"; -import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "@/chat/state/locks"; +import type { ConversationPort, ReportAcpError } from "./conversations"; +import { sleep } from "./sleep"; +import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "./state"; // Transport records can expire because the Conversation log owns durable history. const ACP_STATE_TTL_MS = 24 * 60 * 60 * 1000; const ACP_SSE_LEASE_TTL_MS = 60_000; const ACP_SSE_LEASE_RENEW_INTERVAL_MS = 20_000; -const EVENT_PAGE_SIZE = 50; const EVENT_POLL_INTERVAL_MS = 100; const LOCK_WAIT_MS = 5_000; // Bound undelivered transport output without truncating Conversation history. @@ -63,8 +60,9 @@ const acpMessageSchema = z.union([ const acpConnectionSchema = z .object({ - actorId: z.string().min(1), + credentialHash: z.string().length(64), nonce: z.string().min(1), + user: userSchema.optional(), }) .strict(); @@ -135,7 +133,10 @@ const acpReceiptOutputSchema = z.discriminatedUnion("kind", [ ]); const acpRequestReceiptSchema = z - .object({ outputs: z.array(acpReceiptOutputSchema) }) + .object({ + deliveryKey: z.string().min(1).optional(), + outputs: z.array(acpReceiptOutputSchema), + }) .strict(); const acpStreamCursorSchema = z.object({ itemId: z.string().min(1) }).strict(); @@ -168,6 +169,10 @@ function receiptLockKey(connectionId: string, requestKey: string): string { return `${receiptKey(connectionId, requestKey)}:lock`; } +function connectionLockKey(connectionId: string): string { + return `${connectionKey(connectionId)}:lock`; +} + function streamKey(route: AcpStreamRoute): string { const suffix = route.sessionId ? `session:${stableHex(route.sessionId)}` @@ -235,14 +240,14 @@ async function connectionIsLive( return Boolean(await readAcpConnection(state, connectionId)); } -/** Create one actor-bound ACP transport connection. */ +/** Create one unauthenticated ACP transport connection. */ export async function createAcpConnection( state: StateAdapter, - actorId: string, + credentialHash: string, ): Promise<{ connection: AcpConnection; connectionId: string }> { const connectionId = randomUUID(); const connection = acpConnectionSchema.parse({ - actorId, + credentialHash, nonce: randomUUID(), }); await state.set(connectionKey(connectionId), connection, ACP_STATE_TTL_MS); @@ -269,6 +274,44 @@ export async function deleteAcpConnection( await state.delete(streamCursorKey({ connectionId })); } +/** Bind one live ACP connection to the canonical authorized user. */ +export async function bindAcpConnectionUser(args: { + connectionId: string; + credentialHash: string; + state: StateAdapter; + user: User; +}): Promise<"completed" | "conflict" | "expired"> { + const result = await withLock( + args.state, + connectionLockKey(args.connectionId), + async (lock) => { + const connection = await readAcpConnection(args.state, args.connectionId); + if (!connection || connection.credentialHash !== args.credentialHash) { + return "expired" as const; + } + if (connection.user && connection.user.id !== args.user.id) { + return "conflict" as const; + } + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.set( + connectionKey(args.connectionId), + acpConnectionSchema.parse({ ...connection, user: args.user }), + ACP_STATE_TTL_MS, + ); + return "completed" as const; + }, + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: LOCK_WAIT_MS, + }, + ); + if (!result.acquired) { + throw new Error("Timed out acquiring ACP connection control"); + } + return result.value; +} + /** Reuse pending output IDs and fence stream capacity before each new delivery. */ async function appendStreamOutput(args: { id: string; @@ -352,15 +395,14 @@ type EmitAcpMessage = (message: acp.AnyMessage) => Promise; /** Replay durable Conversation Messages as ACP session updates. */ async function streamSessionReplay(args: { + conversations: ConversationPort; emit: EmitAcpMessage; - eventStore: ConversationEventStore; sessionId: string; signal: AbortSignal; }): Promise { - const history = await args.eventStore.loadMessageHistory(args.sessionId); - for (const message of projectConversationMessages(history)) { + const messages = await args.conversations.readMessages(args.sessionId); + for (const message of messages) { if (args.signal.aborted) return false; - if (message.role === "system") continue; const emitted = await args.emit({ jsonrpc: "2.0", method: acp.methods.client.session.update, @@ -389,9 +431,10 @@ async function queueReceipt(args: { state: StateAdapter; }): Promise { const receipt = parseReceipt(args.receipt); + const deliveryKey = receipt.deliveryKey ?? args.requestKey; let dependency: AcpStreamDependency | undefined; for (const [index, output] of receipt.outputs.entries()) { - const id = requestStreamItemId(args.requestKey, index); + const id = requestStreamItemId(deliveryKey, index); if (output.kind === "replay") { const itemId = await appendStreamOutput({ id, @@ -428,6 +471,48 @@ async function queueReceipt(args: { } } +/** Replace one pending request receipt and queue its final output once. */ +export async function completeAcpRequest(args: { + connectionId: string; + receipt: AcpRequestReceipt; + requestKey: string; + state: StateAdapter; +}): Promise<"completed" | "expired"> { + const result = await withLock( + args.state, + receiptLockKey(args.connectionId, args.requestKey), + async (lock) => { + if (!(await readAcpConnection(args.state, args.connectionId))) { + return "expired" as const; + } + const receipt = parseReceipt(args.receipt); + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.set( + receiptKey(args.connectionId, args.requestKey), + receipt, + ACP_STATE_TTL_MS, + ); + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await queueReceipt({ + connectionId: args.connectionId, + receipt, + requestKey: args.requestKey, + state: args.state, + }); + return "completed" as const; + }, + { + keepAlive: true, + ttlMs: MUTATION_LOCK_TTL_MS, + waitMs: LOCK_WAIT_MS, + }, + ); + if (!result.acquired) { + throw new Error("Timed out acquiring ACP request completion control"); + } + return result.value; +} + /** Store a receipt once and redeliver it when a client retries after delivery. */ export async function acceptAcpRequest(args: { connectionId: string; @@ -495,127 +580,71 @@ function errorMessage( }; } -/** Delay a terminal response until its mailbox input cannot run again. */ -async function waitForPromptAcknowledgement(args: { - connectionId: string; - messageId: string; - sessionId: string; - signal: AbortSignal; - state: StateAdapter; -}): Promise { - while (!args.signal.aborted) { - if (!(await connectionIsLive(args.state, args.connectionId))) return false; - if ( - !(await apiConversationMessageIsPending({ - conversationId: args.sessionId, - messageId: args.messageId, - state: args.state, - })) - ) { - return true; - } - await sleep(EVENT_POLL_INTERVAL_MS, args.signal); - } - return false; -} - /** Emit assistant Messages and the final response for one durable Turn. */ async function streamPrompt(args: { + conversations: ConversationPort; connectionId: string; emit: EmitAcpMessage; - eventStore: ConversationEventStore; output: AcpPromptStreamOutput; sessionId: string; signal: AbortSignal; state: StateAdapter; }): Promise { let cursor = args.output.afterSeq; - const assistantPrefix = `${args.output.turnId}:assistant:`; const sentMessageIds = new Set(); while (!args.signal.aborted) { if (!(await connectionIsLive(args.state, args.connectionId))) return false; - const page = await args.eventStore.query(args.sessionId, { - afterSeq: cursor, - limit: EVENT_PAGE_SIZE, - types: ["message", "turn_completed", "turn_failed"], + const page = await args.conversations.readTurn({ + afterCursor: cursor, + conversationId: args.sessionId, + messageId: args.output.messageId, + turnId: args.output.turnId, }); - if (page.events.length === 0) { + cursor = page.cursor; + if (page.messages.length === 0 && !page.terminal) { await sleep(EVENT_POLL_INTERVAL_MS, args.signal); continue; } - for (const event of page.events) { - cursor = event.seq; - const data = event.data; + for (const message of page.messages) { + if (sentMessageIds.has(message.id)) continue; + sentMessageIds.add(message.id); if ( - data.type === "message" && - data.role === "assistant" && - data.messageId.startsWith(assistantPrefix) && - !sentMessageIds.has(data.messageId) - ) { - sentMessageIds.add(data.messageId); - if ( - !(await args.emit({ - jsonrpc: "2.0", - method: acp.methods.client.session.update, - params: { - sessionId: args.sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: data.text }, - messageId: data.messageId, - }, + !(await args.emit({ + jsonrpc: "2.0", + method: acp.methods.client.session.update, + params: { + sessionId: args.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: message.text }, + messageId: message.id, }, - })) - ) { - return false; - } - continue; - } - if ( - data.type === "turn_completed" && - data.turnId === args.output.turnId + }, + })) ) { - if ( - !(await waitForPromptAcknowledgement({ - connectionId: args.connectionId, - messageId: args.output.messageId, - sessionId: args.sessionId, - signal: args.signal, - state: args.state, - })) - ) { - return false; - } - return await args.emit( - resultMessage(args.output.requestId, { - stopReason: data.outcome === "cancelled" ? "cancelled" : "end_turn", - } satisfies acp.PromptResponse), - ); + return false; } - if (data.type === "turn_failed" && data.turnId === args.output.turnId) { - if ( - !(await waitForPromptAcknowledgement({ - connectionId: args.connectionId, - messageId: args.output.messageId, - sessionId: args.sessionId, - signal: args.signal, - state: args.state, - })) - ) { - return false; - } - return await args.emit( - errorMessage( - args.output.requestId, - acp.RequestError.internalError( - { failureCode: data.failureCode }, - "Junior Turn failed", - ), + } + if (page.terminal?.status === "completed") { + return await args.emit( + resultMessage(args.output.requestId, { + stopReason: + page.terminal.outcome === "cancelled" ? "cancelled" : "end_turn", + } satisfies acp.PromptResponse), + ); + } + if (page.terminal?.status === "failed") { + return await args.emit( + errorMessage( + args.output.requestId, + acp.RequestError.internalError( + { failureCode: page.terminal.failureCode }, + "Junior Turn failed", ), - ); - } + ), + ); } } return false; @@ -651,11 +680,12 @@ async function waitForStreamDependency(args: { /** Open one connection or session SSE reader backed by shared state. */ export async function openAcpSse(args: { - actorId: string; - eventStore: ConversationEventStore; + conversations: ConversationPort; + onError?: ReportAcpError; requestSignal: AbortSignal; route: AcpStreamRoute; state: StateAdapter; + userId?: string; }): Promise { const route = acpStreamRouteSchema.parse(args.route); const lock = await args.state.acquireLock( @@ -677,12 +707,13 @@ export async function openAcpSse(args: { /** Keep one SSE body alive while it consumes durable stream items in order. */ function createSseBody(args: { - actorId: string; - eventStore: ConversationEventStore; + conversations: ConversationPort; lock: Lock; + onError?: ReportAcpError; requestSignal: AbortSignal; route: AcpStreamRoute; state: StateAdapter; + userId?: string; }): ReadableStream { const abort = new AbortController(); let cleaned = false; @@ -692,11 +723,9 @@ function createSseBody(args: { let retainingLease: Promise | undefined; const correlation = { - actorId: args.actorId, connectionId: args.route.connectionId, ...(args.route.sessionId ? { conversationId: args.route.sessionId } : {}), - platform: "acp", - userId: args.actorId, + ...(args.userId ? { userId: args.userId } : {}), }; const cleanup = async (): Promise => { if (cleaned) return; @@ -708,7 +737,7 @@ function createSseBody(args: { try { await args.state.releaseLock(args.lock); } catch (error) { - logException(error, "acp.sse.lock_cleanup.exception", correlation); + args.onError?.(error, "acp.sse.lock_cleanup.exception", correlation); } }; const onRequestAbort = (): void => void cleanup(); @@ -747,7 +776,7 @@ function createSseBody(args: { : new Error("ACP SSE stream failed", { cause: error }); if (!failureCaptured) { failureCaptured = true; - logException(failure, "acp.sse.exception", correlation); + args.onError?.(failure, "acp.sse.exception", correlation); } abort.abort(failure); try { @@ -840,15 +869,15 @@ function createSseBody(args: { delivered = item.output.kind === "replay" ? await streamSessionReplay({ + conversations: args.conversations, emit, - eventStore: args.eventStore, sessionId, signal: abort.signal, }) : await streamPrompt({ + conversations: args.conversations, connectionId: args.route.connectionId, emit, - eventStore: args.eventStore, output: item.output, sessionId, signal: abort.signal, diff --git a/packages/junior-acp/tsconfig.build.json b/packages/junior-acp/tsconfig.build.json new file mode 100644 index 0000000000..b398e67a86 --- /dev/null +++ b/packages/junior-acp/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "incremental": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/junior-acp/tsconfig.json b/packages/junior-acp/tsconfig.json new file mode 100644 index 0000000000..4c12d9d7dc --- /dev/null +++ b/packages/junior-acp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-acp/tsup.config.ts b/packages/junior-acp/tsup.config.ts new file mode 100644 index 0000000000..10c5e93820 --- /dev/null +++ b/packages/junior-acp/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + testing: "src/testing.ts", + }, + format: "esm", + tsconfig: "tsconfig.build.json", + dts: false, + outDir: "dist", + clean: true, + splitting: true, + external: [ + "@agentclientprotocol/sdk", + "@sentry/junior-plugin-api", + "chat", + "zod", + ], +}); diff --git a/packages/junior-dashboard/src/acp-authorization.ts b/packages/junior-dashboard/src/acp-authorization.ts new file mode 100644 index 0000000000..f9f35e57bd --- /dev/null +++ b/packages/junior-dashboard/src/acp-authorization.ts @@ -0,0 +1,181 @@ +import { resolveViewerUser } from "@sentry/junior/api"; +import type { User } from "@sentry/junior-plugin-api"; +import { + dashboardSessionIsAuthorized, + sanitizeDashboardSession, + verifiedDashboardSessionEmail, + type DashboardAuth, + type DashboardSession, +} from "./auth"; + +export const ACP_AUTHORIZATION_PATH_PREFIX = "/api/acp/auth/"; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface DashboardAcpAuthorization { + complete( + transactionId: string, + user: User, + ): Promise<"completed" | "conflict" | "expired">; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function loginPath(basePath: string): string { + return basePath === "/" ? "/auth/login" : `${basePath}/auth/login`; +} + +function loginUrl( + request: Request, + basePath: string, + canonicalBaseURL?: string, +): string { + const requestUrl = new URL(request.url); + const url = canonicalBaseURL + ? new URL(canonicalBaseURL) + : new URL(request.url); + url.pathname = loginPath(basePath); + url.search = ""; + url.searchParams.set("next", `${requestUrl.pathname}${requestUrl.search}`); + return url.toString(); +} + +function canonicalAuthorizationUrl( + request: Request, + canonicalBaseURL?: string, +): string | undefined { + if (!canonicalBaseURL) return undefined; + const requestUrl = new URL(request.url); + const canonicalUrl = new URL(canonicalBaseURL); + if (requestUrl.origin === canonicalUrl.origin) return undefined; + canonicalUrl.pathname = requestUrl.pathname; + canonicalUrl.search = requestUrl.search; + return canonicalUrl.toString(); +} + +function renderResult( + agentName: string, + result: "completed" | "conflict" | "expired" | "forbidden", +): Response { + const content = { + completed: { + status: 200, + title: `${agentName} is connected`, + message: "You can close this window and return to your ACP client.", + }, + conflict: { + status: 409, + title: "This connection uses another account", + message: "Start a new connection in your ACP client and sign in again.", + }, + expired: { + status: 410, + title: "This sign-in request expired", + message: "Return to your ACP client and start sign-in again.", + }, + forbidden: { + status: 403, + title: "Access denied", + message: `This Google account is not allowed to use ${agentName}.`, + }, + }[result]; + return new Response( + ` + + + + + ${escapeHtml(content.title)} + + +
+
+

${escapeHtml(content.title)}

+

${escapeHtml(content.message)}

+
+
+ +`, + { + status: content.status, + headers: { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }, + }, + ); +} + +/** Return whether this path names one valid ACP authorization transaction. */ +export function isAcpAuthorizationPath(pathname: string): boolean { + if (!pathname.startsWith(ACP_AUTHORIZATION_PATH_PREFIX)) return false; + return UUID_PATTERN.test( + pathname.slice(ACP_AUTHORIZATION_PATH_PREFIX.length), + ); +} + +/** Complete one ACP browser authorization through dashboard identity policy. */ +export async function handleDashboardAcpAuthorization(args: { + agentName: string; + allowedDomains: string[]; + allowedEmails: string[]; + auth?: DashboardAuth; + authRequired: boolean; + authorization: DashboardAcpAuthorization; + basePath: string; + canonicalBaseURL?: string; + localViewerEmail: string; + request: Request; + transactionId: string; +}): Promise { + if (!UUID_PATTERN.test(args.transactionId)) { + return renderResult(args.agentName, "expired"); + } + const canonicalUrl = canonicalAuthorizationUrl( + args.request, + args.canonicalBaseURL, + ); + if (canonicalUrl) return Response.redirect(canonicalUrl, 302); + + let session: DashboardSession; + if (!args.authRequired) { + session = { + user: { email: args.localViewerEmail, emailVerified: true }, + }; + } else { + if (!args.auth) { + throw new Error("ACP authorization requires dashboard auth"); + } + const browserSession = await args.auth.getSession(args.request); + if (!browserSession) { + return Response.redirect( + loginUrl(args.request, args.basePath, args.canonicalBaseURL), + 302, + ); + } + if ( + !dashboardSessionIsAuthorized( + browserSession, + args.allowedDomains, + args.allowedEmails, + ) + ) { + return renderResult(args.agentName, "forbidden"); + } + session = sanitizeDashboardSession(browserSession); + } + + const email = verifiedDashboardSessionEmail(session); + if (!email) return renderResult(args.agentName, "forbidden"); + const user = await resolveViewerUser(email); + if (!user) throw new Error("Authenticated ACP user could not be resolved"); + const result = await args.authorization.complete(args.transactionId, user); + return renderResult(args.agentName, result); +} diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index ad44e9daab..8dea85b4d1 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -30,12 +30,22 @@ import { } from "./assets"; import { createDashboardAuth, + dashboardBearerSession, + dashboardPersonalBearerToken, + dashboardSessionIsAuthorized, resolveGoogleHostedDomainHint, sanitizeDashboardSession, + verifiedDashboardSessionEmail, type DashboardAuth, type DashboardSession, } from "./auth"; import { dashboardRainbowProgressClass } from "./dashboardLoader"; +import { + ACP_AUTHORIZATION_PATH_PREFIX, + handleDashboardAcpAuthorization, + isAcpAuthorizationPath, + type DashboardAcpAuthorization, +} from "./acp-authorization"; import { createMockReportingApi } from "./mock-reporting/routes"; import { resolveDashboardBaseURL } from "./url"; @@ -69,6 +79,7 @@ export interface JuniorDashboardOptions { } interface DashboardRuntimeOptions extends JuniorDashboardOptions { + acpAuthorization?: DashboardAcpAuthorization; pluginRoutes?: DashboardPluginRoute[]; } @@ -202,9 +213,12 @@ function isDashboardPagePath( function dashboardReturnPath( url: URL, basePath: string, - options: { componentGallery?: boolean } = {}, + options: { acpAuthorization?: boolean; componentGallery?: boolean } = {}, ): string | undefined { - if (!isDashboardPagePath(url.pathname, basePath, options)) { + if ( + !isDashboardPagePath(url.pathname, basePath, options) && + !(options.acpAuthorization && isAcpAuthorizationPath(url.pathname)) + ) { return undefined; } @@ -215,7 +229,7 @@ function dashboardReturnPath( function requestedReturnPath( url: URL, basePath: string, - options: { componentGallery?: boolean } = {}, + options: { acpAuthorization?: boolean; componentGallery?: boolean } = {}, ): string | undefined { const next = url.searchParams.get(LOGIN_NEXT_PARAM); if (!next?.startsWith("/") || next.startsWith("//")) { @@ -225,7 +239,8 @@ function requestedReturnPath( const returnUrl = new URL(next, url.origin); if ( returnUrl.origin !== url.origin || - !isDashboardPagePath(returnUrl.pathname, basePath, options) + (!isDashboardPagePath(returnUrl.pathname, basePath, options) && + !(options.acpAuthorization && isAcpAuthorizationPath(returnUrl.pathname))) ) { return undefined; } @@ -237,7 +252,7 @@ function dashboardLoginUrl( request: Request, basePath: string, canonicalBaseURL?: string, - options: { componentGallery?: boolean } = {}, + options: { acpAuthorization?: boolean; componentGallery?: boolean } = {}, ): string { const requestUrl = new URL(request.url); const url = canonicalBaseURL @@ -278,7 +293,7 @@ function dashboardLoginPath(basePath: string): string { function callbackUrl( request: Request, basePath: string, - options: { componentGallery?: boolean } = {}, + options: { acpAuthorization?: boolean; componentGallery?: boolean } = {}, ): string { const requestUrl = new URL(request.url); const returnPath = requestedReturnPath(requestUrl, basePath, options); @@ -294,27 +309,6 @@ function callbackUrl( return url.toString(); } -function isAuthorized( - session: DashboardSession, - allowedDomains: string[], - allowedEmails: string[], -): boolean { - const email = session.user.email.toLowerCase(); - const emailSeparator = email.lastIndexOf("@"); - const emailDomain = - emailSeparator > 0 ? email.slice(emailSeparator + 1) : undefined; - - if (session.user.emailVerified && email && allowedEmails.includes(email)) { - return true; - } - - return Boolean( - session.user.emailVerified && - emailDomain && - allowedDomains.includes(emailDomain), - ); -} - function unauthorized( request: Request, basePath: string, @@ -377,31 +371,9 @@ function localAuthBypassSession(email = LOCAL_VIEWER_EMAIL): DashboardSession { }; } -function personalBearerToken(request: Request): string | undefined { - const authorization = request.headers.get("authorization"); - if (!authorization) return undefined; - const match = /^Bearer ([^\s]+)$/.exec(authorization); - return match?.[1]; -} - -function bearerSession(email: string): DashboardSession { - return { - user: { - email, - emailVerified: true, - }, - }; -} - -function verifiedSessionEmail(session: DashboardSession): string | undefined { - if (session.user.emailVerified !== true) return undefined; - const email = session.user.email.trim().toLowerCase(); - return email || undefined; -} - /** Build a local mock viewer without creating a durable Junior user. */ function mockViewerFromSession(session: DashboardSession) { - const email = verifiedSessionEmail(session); + const email = verifiedDashboardSessionEmail(session); if (!email) return undefined; const displayName = mockDisplayNamesByEmail.get(email) ?? @@ -463,8 +435,7 @@ function readDashboardTailwind(): string { } function readDashboardColorIcon(): ArrayBuffer { - const embeddedAsset = - dashboardInstallIconAsset || dashboardAvatarHeaderAsset; + const embeddedAsset = dashboardInstallIconAsset || dashboardAvatarHeaderAsset; if (embeddedAsset) { return Uint8Array.from(Buffer.from(embeddedAsset, "base64")).buffer; } @@ -761,13 +732,17 @@ export function createDashboardApp( return Response.redirect(canonicalUrl, 302); } const returnUrl = callbackUrl(c.req.raw, basePath, { + acpAuthorization: Boolean(options.acpAuthorization), componentGallery: options.componentGallery, }); if (!auth) { return Response.redirect(returnUrl, 302); } const session = await auth.getSession(c.req.raw); - if (session && isAuthorized(session, allowedDomains, allowedEmails)) { + if ( + session && + dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails) + ) { return Response.redirect(returnUrl, 302); } return auth.signInWithGoogle(c.req.raw, returnUrl); @@ -777,6 +752,25 @@ export function createDashboardApp( app.on(["GET", "POST"], `${authPath}/*`, (c) => auth.handler(c.req.raw)); } + const acpAuthorization = options.acpAuthorization; + if (acpAuthorization) { + app.get(`${ACP_AUTHORIZATION_PATH_PREFIX}:transactionId`, (c) => + handleDashboardAcpAuthorization({ + agentName, + allowedDomains, + allowedEmails, + auth, + authRequired, + authorization: acpAuthorization, + basePath, + canonicalBaseURL, + localViewerEmail: LOCAL_VIEWER_EMAIL, + request: c.req.raw, + transactionId: c.req.param("transactionId"), + }), + ); + } + app.get("/favicon.ico", () => renderFavicon()); app.get(DASHBOARD_MANIFEST_PATH, () => renderManifest(basePath, agentName)); app.get(DASHBOARD_INSTALL_ICON_PATH, () => renderInstallIcon()); @@ -813,7 +807,7 @@ export function createDashboardApp( }); } const browserSession = await auth.getSession(c.req.raw); - const token = personalBearerToken(c.req.raw); + const token = dashboardPersonalBearerToken(c.req.raw); const tokenEmail = !browserSession && token && @@ -823,20 +817,21 @@ export function createDashboardApp( ? await authenticatePersonalToken(token) : undefined; const session = - browserSession ?? (tokenEmail ? bearerSession(tokenEmail) : null); + browserSession ?? + (tokenEmail ? dashboardBearerSession(tokenEmail) : null); if (!session) { return unauthorized(c.req.raw, basePath, canonicalBaseURL, { componentGallery: options.componentGallery, }); } - if (!isAuthorized(session, allowedDomains, allowedEmails)) { + if (!dashboardSessionIsAuthorized(session, allowedDomains, allowedEmails)) { return forbidden(c.req.raw, agentName); } const sanitizedSession = sanitizeDashboardSession(session); c.set("authSession", sanitizedSession); // Resolve the canonical user only for authenticated API requests. if (pathname.startsWith("/api/")) { - const email = verifiedSessionEmail(sanitizedSession); + const email = verifiedDashboardSessionEmail(sanitizedSession); if (!email) { throw new Error( "Authenticated dashboard session has no verified email", diff --git a/packages/junior-dashboard/src/auth.ts b/packages/junior-dashboard/src/auth.ts index 0894d8f87d..71db8859c8 100644 --- a/packages/junior-dashboard/src/auth.ts +++ b/packages/junior-dashboard/src/auth.ts @@ -26,6 +26,45 @@ export interface DashboardAuth { signInWithGoogle(request: Request, callbackURL: string): Promise; } +/** Read a personal bearer token from one dashboard API request. */ +export function dashboardPersonalBearerToken( + request: Request, +): string | undefined { + const authorization = request.headers.get("authorization"); + if (!authorization) return undefined; + return /^Bearer ([^\s]+)$/.exec(authorization)?.[1]; +} + +/** Adapt a verified personal-token email to dashboard session policy. */ +export function dashboardBearerSession(email: string): DashboardSession { + return { user: { email, emailVerified: true } }; +} + +/** Check the dashboard allowlist against one verified session. */ +export function dashboardSessionIsAuthorized( + session: DashboardSession, + allowedDomains: string[], + allowedEmails: string[], +): boolean { + const email = session.user.email.toLowerCase(); + const separator = email.lastIndexOf("@"); + const domain = separator > 0 ? email.slice(separator + 1) : undefined; + return Boolean( + session.user.emailVerified && + (allowedEmails.includes(email) || + (domain !== undefined && allowedDomains.includes(domain))), + ); +} + +/** Read a normalized email only from a verified dashboard session. */ +export function verifiedDashboardSessionEmail( + session: DashboardSession, +): string | undefined { + if (session.user.emailVerified !== true) return undefined; + const email = session.user.email.trim().toLowerCase(); + return email || undefined; +} + /** Keep dashboard identity responses limited to user display fields. */ export function sanitizeDashboardSession( session: DashboardSession, diff --git a/packages/junior-dashboard/tests/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts index c9d26f8b33..3188842f2f 100644 --- a/packages/junior-dashboard/tests/dashboard-routes.test.ts +++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts @@ -107,6 +107,78 @@ describe("dashboard routes", () => { ); }); + it("returns ACP browser authorization through Google sign-in", async () => { + const transactionId = "11111111-1111-4111-8111-111111111111"; + const path = `/api/acp/auth/${transactionId}`; + let callbackURL: string | undefined; + const complete = vi.fn(async () => "completed" as const); + const unauthenticatedApp = createDashboardApp({ + acpAuthorization: { complete }, + allowedGoogleDomains: ["sentry.io"], + auth: auth(null, (value) => { + callbackURL = value; + }), + }); + + const unauthenticated = await unauthenticatedApp.fetch( + new Request(`http://localhost${path}`), + ); + expect(unauthenticated.status).toBe(302); + const loginURL = new URL(unauthenticated.headers.get("location")!); + expect(loginURL.pathname).toBe("/auth/login"); + expect(loginURL.searchParams.get("next")).toBe(path); + + await unauthenticatedApp.fetch(new Request(loginURL)); + expect(callbackURL).toBe(`http://localhost${path}`); + expect(complete).not.toHaveBeenCalled(); + + const authenticatedApp = createDashboardApp({ + acpAuthorization: { complete }, + allowedGoogleDomains: ["sentry.io"], + auth: auth({ + user: { + email: "person@sentry.io", + emailVerified: true, + name: "ACP User", + }, + }), + }); + const completed = await authenticatedApp.fetch( + new Request(`http://localhost${path}`), + ); + + expect(completed.status).toBe(200); + expect(await completed.text()).toContain("Junior is connected"); + expect(resolveViewerUser).toHaveBeenCalledWith("person@sentry.io"); + expect(complete).toHaveBeenCalledWith( + transactionId, + expect.objectContaining({ id: "user:person@sentry.io" }), + ); + }); + + it("does not complete ACP authorization for a disallowed Google account", async () => { + const complete = vi.fn(async () => "completed" as const); + const app = createDashboardApp({ + acpAuthorization: { complete }, + allowedGoogleDomains: ["sentry.io"], + auth: auth({ + user: { + email: "person@example.com", + emailVerified: true, + }, + }), + }); + + const response = await app.fetch( + new Request( + "http://localhost/api/acp/auth/11111111-1111-4111-8111-111111111111", + ), + ); + + expect(response.status).toBe(403); + expect(complete).not.toHaveBeenCalled(); + }); + it("starts OAuth on the JUNIOR_BASE_URL origin", async () => { process.env.BETTER_AUTH_URL = "https://legacy-auth.example.com"; process.env.JUNIOR_BASE_URL = "https://junior.example.com"; @@ -381,12 +453,8 @@ describe("dashboard routes", () => { 'content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"', ); expect(html).toContain('name="theme-color" content="#000000"'); - expect(html).toContain( - 'href="/_junior/dashboard/manifest.webmanifest"', - ); - expect(html).toContain( - 'href="/_junior/dashboard/icon-512.png"', - ); + expect(html).toContain('href="/_junior/dashboard/manifest.webmanifest"'); + expect(html).toContain('href="/_junior/dashboard/icon-512.png"'); }); it("renders the configured agent name from the dashboard shell", async () => { diff --git a/packages/junior-plugin-api/src/conversations.ts b/packages/junior-plugin-api/src/conversations.ts new file mode 100644 index 0000000000..0f260f770a --- /dev/null +++ b/packages/junior-plugin-api/src/conversations.ts @@ -0,0 +1,60 @@ +import type { User } from "./context"; + +/** One text Message exposed through a bounded Conversation runtime port. */ +export interface ConversationTextMessage { + id: string; + role: "assistant" | "user"; + text: string; +} + +/** Result of admitting one retry-stable prompt to a Conversation. */ +export type ConversationPromptAdmission = + | { + afterCursor: number; + messageId: string; + status: "accepted"; + turnId: string; + } + | { status: "active" } + | { status: "not_found" }; + +/** Terminal state for one correlated Conversation Turn. */ +export type ConversationTurnTerminal = + | { outcome: "cancelled" | "completed"; status: "completed" } + | { failureCode: string; status: "failed" }; + +/** One ordered page of Messages and optional terminal Turn state. */ +export interface ConversationTurnPage { + cursor: number; + messages: ConversationTextMessage[]; + terminal?: ConversationTurnTerminal; +} + +/** Bounded Conversation operations available to a protocol adapter. */ +export interface ConversationPort { + cancel(args: { + conversationId: string; + user: User; + }): Promise<"cancelled" | "not_found">; + createConversation(args: { + conversationId: string; + user: User; + }): Promise; + hasConversationAccess(args: { + conversationId: string; + user: User; + }): Promise; + prompt(args: { + conversationId: string; + idempotencyKey: string; + text: string; + user: User; + }): Promise; + readMessages(conversationId: string): Promise; + readTurn(args: { + afterCursor: number; + conversationId: string; + messageId: string; + turnId: string; + }): Promise; +} diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index 015c8c70c6..0af569aa9b 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -1,5 +1,6 @@ export * from "./annotations"; export * from "./conversation-events"; +export * from "./conversations"; export * from "./schemas"; export * from "./context"; export * from "./state"; diff --git a/packages/junior/package.json b/packages/junior/package.json index 9079cd2935..65baf1dc4d 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -54,7 +54,7 @@ "scripts": { "prepare": "pnpm run build", "prepack": "pnpm run build", - "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "build": "pnpm --filter @sentry/junior-acp build && tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "acp:smoke": "pnpm exec tsx scripts/acp-smoke.ts", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", "lint": "oxlint --config .oxlintrc.json --deny-warnings src tests scripts bin tsup.config.ts && depcruise --config .dependency-cruiser.mjs src/chat", @@ -68,7 +68,6 @@ "test:coverage": "vitest run --maxWorkers=4 --coverage --reporter=default --reporter=junit --outputFile.junit=coverage/results.junit.xml" }, "dependencies": { - "@agentclientprotocol/sdk": "1.3.0", "@ai-sdk/gateway": "^3.0.119", "@chat-adapter/slack": "4.29.0", "@chat-adapter/state-memory": "4.29.0", @@ -81,6 +80,7 @@ "@neondatabase/serverless": "^1.1.0", "@opentelemetry/api": "catalog:", "@opentelemetry/core": "catalog:", + "@sentry/junior-acp": "workspace:*", "@sentry/junior-plugin-api": "workspace:*", "@sentry/node": "catalog:", "@sinclair/typebox": "^0.34.49", @@ -104,6 +104,7 @@ "zod": "catalog:" }, "devDependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@hono/node-server": "1.19.14", "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", diff --git a/packages/junior/scripts/acp-local-server.ts b/packages/junior/scripts/acp-local-server.ts index aeb7042436..ed43dfad5d 100644 --- a/packages/junior/scripts/acp-local-server.ts +++ b/packages/junior/scripts/acp-local-server.ts @@ -1,18 +1,16 @@ /** - * Serve one loopback ACP process, run the official client, and clean up its - * short-lived personal token. This is test equipment, not a product transport. + * Serve one loopback ACP process and run the official client through the + * local dashboard authorization route. This is test equipment. */ import { spawn } from "node:child_process"; import { once } from "node:events"; import type { AddressInfo } from "node:net"; import { serve } from "@hono/node-server"; import { createApp } from "@/app"; +import { completeAcpAuthorization } from "@sentry/junior-acp"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { getSqlExecutor } from "@/chat/db"; -import { - createPersonalToken, - revokePersonalToken, -} from "@/personal-tokens/store"; +import { resolveViewerUser } from "@/chat/plugins/viewer"; import { closeApiTurnWorkFixture, createConversationWorkWebHarness, @@ -60,7 +58,24 @@ async function drainQueuedWork(): Promise { const drainTimer = setInterval(() => void drainQueuedWork(), 10); const server = serve({ - fetch: app.fetch, + async fetch(request) { + const match = /^\/api\/acp\/auth\/([0-9a-f-]+)$/i.exec( + new URL(request.url).pathname, + ); + if (request.method === "GET" && match?.[1]) { + const user = await resolveViewerUser(harness.actor.email); + if (!user) throw new Error("Local ACP user could not be resolved"); + const result = await completeAcpAuthorization({ + state: harness.state, + transactionId: match[1], + user, + }); + return new Response(`ACP authorization ${result}`, { + status: result === "completed" ? 200 : 410, + }); + } + return app.fetch(request); + }, hostname: "127.0.0.1", port: localPort(), }); @@ -68,10 +83,6 @@ if (!server.listening) { await once(server, "listening"); } -const token = await createPersonalToken({ - email: harness.actor.email, - name: "Local ACP test", -}); const address = server.address() as AddressInfo; const url = `http://127.0.0.1:${address.port}/api/acp`; console.log(`Local ACP URL: ${url}`); @@ -79,7 +90,7 @@ console.log(`Local ACP URL: ${url}`); let smoke: ReturnType | undefined; let shutdownPromise: Promise | undefined; -/** Stop the HTTP client and server, revoke the token, and close test adapters. */ +/** Stop the HTTP client and server, then close test adapters. */ function shutdown(): Promise { shutdownPromise ??= (async () => { clearInterval(drainTimer); @@ -88,7 +99,6 @@ function shutdown(): Promise { } server.close(); await once(server, "close"); - await revokePersonalToken({ email: harness.actor.email, id: token.id }); await closeApiTurnWorkFixture(); })(); return shutdownPromise; @@ -120,7 +130,7 @@ try { ...process.env, JUNIOR_ACP_FOLLOW_UP: process.env.JUNIOR_ACP_FOLLOW_UP?.trim() || "Send a follow-up.", - JUNIOR_ACP_TOKEN: token.token, + JUNIOR_ACP_AUTO_AUTHORIZE: "true", JUNIOR_ACP_URL: url, }, stdio: "inherit", diff --git a/packages/junior/scripts/acp-smoke.ts b/packages/junior/scripts/acp-smoke.ts index 423d52c554..e9fdcedd01 100644 --- a/packages/junior/scripts/acp-smoke.ts +++ b/packages/junior/scripts/acp-smoke.ts @@ -1,5 +1,8 @@ import * as acp from "@agentclientprotocol/sdk"; -import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client"; +import { + MemoryAcpCookieStore, + createHttpStream, +} from "@agentclientprotocol/sdk/experimental/http-client"; function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); @@ -10,22 +13,42 @@ function requiredEnvironment(name: string): string { } const url = requiredEnvironment("JUNIOR_ACP_URL"); -const token = requiredEnvironment("JUNIOR_ACP_TOKEN"); const prompt = process.env.JUNIOR_ACP_PROMPT?.trim() || "Reply with a short confirmation that remote ACP works."; const savedSessionId = process.env.JUNIOR_ACP_SESSION_ID?.trim(); const followUp = process.env.JUNIOR_ACP_FOLLOW_UP?.trim(); +const autoAuthorize = process.env.JUNIOR_ACP_AUTO_AUTHORIZE === "true"; +const cookieStore = new MemoryAcpCookieStore(); + +async function authorize( + params: acp.CreateElicitationRequest, +): Promise { + if (params.mode !== "url" || typeof params.url !== "string") { + return { action: "decline" }; + } + process.stdout.write(`[sign-in] ${params.url}\n`); + if (autoAuthorize) { + const response = await fetch(params.url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`ACP sign-in returned HTTP ${response.status}`); + } + } + return { action: "accept" }; +} async function withConnection( run: (context: acp.ClientContext) => Promise, ): Promise { const stream = createHttpStream(url, { - headers: { Authorization: `Bearer ${token}` }, + cookieStore, }); try { return await acp .client({ name: "junior-acp-smoke" }) + .onRequest(acp.methods.client.elicitation.create, (context) => + authorize(context.params), + ) .onNotification(acp.methods.client.session.update, (context) => { const update = context.params.update; if ( @@ -47,12 +70,19 @@ async function withConnection( async function initialize(context: acp.ClientContext): Promise { const result = await context.request(acp.methods.agent.initialize, { protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, + clientCapabilities: { elicitation: { url: {} } }, clientInfo: { name: "junior-acp-smoke", version: "1" }, }); if (result.agentCapabilities?.loadSession !== true) { throw new Error("Junior did not advertise session/load support"); } + const method = result.authMethods?.find( + (candidate) => candidate.id === "junior", + ); + if (!method) throw new Error("Junior did not advertise browser sign-in"); + await context.request(acp.methods.agent.authenticate, { + methodId: method.id, + }); } const sessionId = await withConnection(async (context) => { diff --git a/packages/junior/src/api/acp-conversations.ts b/packages/junior/src/api/acp-conversations.ts new file mode 100644 index 0000000000..2c3d6f88d1 --- /dev/null +++ b/packages/junior/src/api/acp-conversations.ts @@ -0,0 +1,272 @@ +/** + * Bind the protocol adapter port to Junior's Conversation runtime. + * + * The ACP package cannot reach the mailbox, worker, database, or Conversation + * event store directly. Core imports only the shared ConversationPort type. + */ +import type { StateAdapter } from "chat"; +import type { + ConversationPort, + ConversationTurnTerminal, + User, +} from "@sentry/junior-plugin-api"; +import { readConversationAccessFromSql } from "@/api/conversations/access"; +import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; +import { + apiConversationMessageExists, + apiConversationMessageIsPending, +} from "@/chat/api-turns/mailbox"; +import { + apiConversationMessageId, + apiTurnIdForMessage, + appendAndEnqueueApiConversationMessage, + recordApiConversationActivity, + webActorFromEmail, +} from "@/chat/api-turns/work"; +import type { ConversationEventStore } from "@/chat/conversations/history"; +import { projectConversationMessages } from "@/chat/conversations/message-projection"; +import type { ConversationStore } from "@/chat/conversations/store"; +import { getDb } from "@/chat/db"; +import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; +import { ensureConversationWake } from "@/chat/task-execution/store"; + +const EVENT_PAGE_SIZE = 50; + +interface AcpConversationOptions { + cancellation: ApiTurnCancellation; + conversationStore?: ConversationStore; + eventStore: ConversationEventStore; + queue: ConversationWorkQueue; + state: StateAdapter; +} + +function actorFromUser(user: User) { + return webActorFromEmail( + user.email, + user.displayName ? { fullName: user.displayName } : undefined, + ); +} + +async function hasConversationAccess( + conversationId: string, + user: User, +): Promise { + const access = ( + await readConversationAccessFromSql(getDb(), [conversationId], user) + ).get(conversationId); + return Boolean(access?.isParticipant); +} + +async function turnHasEnded( + eventStore: ConversationEventStore, + conversationId: string, + turnId: string, +): Promise { + const terminal = await eventStore.loadByIdempotencyKey( + conversationId, + `turn:${turnId}:terminal`, + ); + return Boolean( + (terminal?.data.type === "turn_completed" || + terminal?.data.type === "turn_failed") && + terminal.data.turnId === turnId, + ); +} + +async function messageIsPending(args: { + conversationId: string; + messageId: string; + state: StateAdapter; +}): Promise { + return await apiConversationMessageIsPending(args); +} + +async function latestEventCursor( + eventStore: ConversationEventStore, + conversationId: string, +): Promise { + const page = await eventStore.query(conversationId, { limit: 1 }); + return page.events.at(-1)?.seq ?? 0; +} + +/** Create the narrow Conversation capability consumed by remote ACP. */ +export function createAcpConversationPort( + options: AcpConversationOptions, +): ConversationPort { + return { + async cancel({ conversationId, user }) { + if (!(await hasConversationAccess(conversationId, user))) { + return "not_found"; + } + if (await options.cancellation.cancel(conversationId)) { + const nowMs = Date.now(); + await ensureConversationWake({ + conversationId, + conversationStore: options.conversationStore, + idempotencyKey: `acp-cancel:${conversationId}:${nowMs}`, + nowMs, + queue: options.queue, + replaceExistingWake: true, + state: options.state, + }); + } + return "cancelled"; + }, + + async createConversation({ conversationId, user }) { + await recordApiConversationActivity({ + actor: actorFromUser(user), + conversationId, + conversationStore: options.conversationStore, + nowMs: Date.now(), + rootVisibility: "private", + }); + }, + + async hasConversationAccess({ conversationId, user }) { + return await hasConversationAccess(conversationId, user); + }, + + async prompt({ conversationId, idempotencyKey, text, user }) { + if (!(await hasConversationAccess(conversationId, user))) { + return { status: "not_found" }; + } + const currentCursor = await latestEventCursor( + options.eventStore, + conversationId, + ); + const messageId = apiConversationMessageId({ + conversationId, + idempotencyKey, + }); + const turnId = apiTurnIdForMessage(messageId); + const admit = async () => + await options.cancellation.admit( + { conversationId, messageId, turnId }, + { + messageExists: async (candidateMessageId) => + await apiConversationMessageExists({ + conversationId, + messageId: candidateMessageId, + state: options.state, + }), + run: async (fence) => + await appendAndEnqueueApiConversationMessage( + { + actor: actorFromUser(user), + conversationId, + idempotencyKey, + message: text, + }, + { + conversationStore: options.conversationStore, + fence, + queue: options.queue, + state: options.state, + }, + ), + }, + ); + + let admission = await admit(); + if ( + admission.status === "active" && + (await turnHasEnded( + options.eventStore, + conversationId, + admission.turnId, + )) && + !(await messageIsPending({ + conversationId, + messageId: admission.messageId, + state: options.state, + })) + ) { + await options.cancellation.finish(conversationId, admission.turnId); + admission = await admit(); + } + if (admission.status === "active") return { status: "active" }; + + if ( + (await turnHasEnded(options.eventStore, conversationId, turnId)) && + !(await messageIsPending({ + conversationId, + messageId, + state: options.state, + })) + ) { + await options.cancellation.finish(conversationId, turnId); + } + return { + afterCursor: admission.value.status === "duplicate" ? 0 : currentCursor, + messageId, + status: "accepted", + turnId, + }; + }, + + async readMessages(conversationId) { + const history = + await options.eventStore.loadMessageHistory(conversationId); + return projectConversationMessages(history) + .filter( + ( + message, + ): message is typeof message & { + role: "assistant" | "user"; + } => message.role !== "system", + ) + .map(({ id, role, text }) => ({ id, role, text })); + }, + + async readTurn({ afterCursor, conversationId, messageId, turnId }) { + const page = await options.eventStore.query(conversationId, { + afterSeq: afterCursor, + limit: EVENT_PAGE_SIZE, + types: ["message", "turn_completed", "turn_failed"], + }); + const cursor = page.events.at(-1)?.seq ?? afterCursor; + const assistantPrefix = `${turnId}:assistant:`; + const messages = page.events.flatMap((event) => { + const data = event.data; + return data.type === "message" && + data.role === "assistant" && + data.messageId.startsWith(assistantPrefix) + ? [ + { + id: data.messageId, + role: "assistant" as const, + text: data.text, + }, + ] + : []; + }); + + let terminal: ConversationTurnTerminal | undefined; + const terminalEvent = await options.eventStore.loadByIdempotencyKey( + conversationId, + `turn:${turnId}:terminal`, + ); + if ( + terminalEvent && + terminalEvent.seq <= cursor && + !(await messageIsPending({ + conversationId, + messageId, + state: options.state, + })) + ) { + const data = terminalEvent.data; + if (data.type === "turn_completed" && data.turnId === turnId) { + terminal = { + outcome: data.outcome === "cancelled" ? "cancelled" : "completed", + status: "completed", + }; + } else if (data.type === "turn_failed" && data.turnId === turnId) { + terminal = { failureCode: data.failureCode, status: "failed" }; + } + } + return { cursor, messages, ...(terminal ? { terminal } : {}) }; + }, + }; +} diff --git a/packages/junior/src/api/acp-package.ts b/packages/junior/src/api/acp-package.ts new file mode 100644 index 0000000000..a98b517f8c --- /dev/null +++ b/packages/junior/src/api/acp-package.ts @@ -0,0 +1,84 @@ +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import type { StateAdapter } from "chat"; +import type { ConversationPort, User } from "@sentry/junior-plugin-api"; + +const ACP_PACKAGE_NAME = "@sentry/junior-acp"; + +export type AcpAuthorizationCompletion = "completed" | "conflict" | "expired"; + +interface AcpErrorContext { + connectionId?: string; + conversationId?: string; + userId?: string; +} + +export interface AcpPackage { + completeAcpAuthorization(args: { + state: StateAdapter; + transactionId: string; + user: User; + }): Promise; + createAcpHttpHandler(options: { + baseURL?: string; + conversations: ConversationPort; + onError?: (error: unknown, event: string, context: AcpErrorContext) => void; + state: StateAdapter; + version: string; + }): (request: Request) => Promise; +} + +/** Validate the ACP exports supplied by build-time app setup. */ +export function acpPackageFromValue(value: unknown): AcpPackage | undefined { + if (value === undefined) return undefined; + if ( + !value || + typeof value !== "object" || + typeof (value as { completeAcpAuthorization?: unknown }) + .completeAcpAuthorization !== "function" || + typeof (value as { createAcpHttpHandler?: unknown }) + .createAcpHttpHandler !== "function" + ) { + throw new Error( + '@sentry/junior-acp must export "completeAcpAuthorization" and "createAcpHttpHandler" functions', + ); + } + return value as AcpPackage; +} + +/** Load the ACP package from app setup or local package resolution. */ +export async function loadAcpPackage( + load?: () => Promise, +): Promise { + try { + const mod = load + ? await load() + : await import( + pathToFileURL( + createRequire(`${process.cwd()}/package.json`).resolve( + ACP_PACKAGE_NAME, + ), + ).href + ); + const acpPackage = acpPackageFromValue(mod); + if (!acpPackage) throw new Error("ACP package resolved with no exports"); + return acpPackage; + } catch (error) { + if (isMissingAcpPackage(error)) { + throw new Error( + 'createApp({ experimental: { acp: true } }) requires installing "@sentry/junior-acp"', + { cause: error }, + ); + } + throw error; + } +} + +function isMissingAcpPackage(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = (error as { code?: string }).code; + return ( + (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") && + error.message.includes(ACP_PACKAGE_NAME) + ); +} diff --git a/packages/junior/src/api/acp/route.ts b/packages/junior/src/api/acp/route.ts deleted file mode 100644 index 1779b331ee..0000000000 --- a/packages/junior/src/api/acp/route.ts +++ /dev/null @@ -1,975 +0,0 @@ -/** - * Own the remote ACP v1 Streamable HTTP edge. - * - * StateAdapter transport records route short-lived JSON-RPC messages. Junior's - * Conversation log remains the source for session replay and Turn output. No - * request relies on another request reaching the same app process. - */ -import { createHash } from "node:crypto"; -import type { StateAdapter } from "chat"; -import type { User } from "@sentry/junior-plugin-api"; -import * as acp from "@agentclientprotocol/sdk"; -import { z } from "zod"; -import { readConversationAccessFromSql } from "@/api/conversations/access"; -import type { WebActor } from "@/chat/actor"; -import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; -import { - apiConversationMessageExists, - apiConversationMessageIsPending, -} from "@/chat/api-turns/mailbox"; -import { - apiConversationMessageId, - apiTurnIdForMessage, - appendAndEnqueueApiConversationMessage, - recordApiConversationActivity, - webActorFromEmail, -} from "@/chat/api-turns/work"; -import type { ConversationEventStore } from "@/chat/conversations/history"; -import type { ConversationStore } from "@/chat/conversations/store"; -import { - getConversationEventStore, - getConversationStore, - getDb, -} from "@/chat/db"; -import { - createRequestContext, - logException, - withContext, - withSpan, -} from "@/chat/logging"; -import { resolveViewerUser } from "@/chat/plugins/viewer"; -import { getStateAdapter } from "@/chat/state/adapter"; -import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; -import { ensureConversationWake } from "@/chat/task-execution/store"; -import { authenticatePersonalToken } from "@/personal-tokens/store"; -import { JUNIOR_VERSION } from "@/version"; -import { - acceptAcpRequest, - createAcpConnection, - deleteAcpConnection, - openAcpSse, - readAcpConnection, - type AcpConnection, - type AcpPromptStreamOutput, - type AcpRequestReceipt, - type AcpStreamRoute, -} from "./transport"; - -const ACP_CONNECTION_ID_HEADER = "Acp-Connection-Id"; -const ACP_SESSION_ID_HEADER = "Acp-Session-Id"; -const ACP_CONVERSATION_PREFIX = "local:acp:"; -const ACP_EVENT_STREAM_MIME_TYPE = "text/event-stream"; -const ACP_JSON_MIME_TYPE = "application/json"; -const MAX_PROMPT_TEXT_LENGTH = 32_000; -const ACP_SESSION_ID_PATTERN = /^local:acp:[a-f0-9]{32}$/; - -const SESSION_HEADER_METHODS = new Set([ - acp.methods.agent.session.cancel, - acp.methods.agent.session.close, - acp.methods.agent.session.load, - acp.methods.agent.session.prompt, - acp.methods.agent.session.resume, - acp.methods.agent.session.setConfigOption, - acp.methods.agent.session.setMode, -]); - -const metaSchema = z.record(z.string(), z.unknown()).nullable(); -const jsonRpcIdSchema = z.union([z.string(), z.number().finite(), z.null()]); -const acpConnectionIdSchema = z.string().uuid(); -const acpSessionIdSchema = z.string().regex(ACP_SESSION_ID_PATTERN); -const acpCallSchema = z - .object({ - id: jsonRpcIdSchema.optional(), - jsonrpc: z.literal("2.0"), - method: z.string().min(1), - params: z.unknown().optional(), - }) - .strict(); - -const implementationSchema = z - .object({ - _meta: metaSchema.optional(), - name: z.string().min(1), - title: z.string().nullable().optional(), - version: z.string(), - }) - .strict(); - -const initializeParamsSchema = z - .object({ - _meta: metaSchema.optional(), - clientCapabilities: z.record(z.string(), z.unknown()).optional(), - clientInfo: implementationSchema.nullable().optional(), - protocolVersion: z.number().int().min(0).max(65_535), - }) - .strict(); - -const newSessionParamsSchema = z - .object({ - _meta: metaSchema.optional(), - additionalDirectories: z.array(z.string()).optional(), - cwd: z.string().min(1), - mcpServers: z.array(z.unknown()), - }) - .strict(); - -const loadSessionParamsSchema = newSessionParamsSchema.extend({ - sessionId: acpSessionIdSchema, -}); - -const annotationsSchema = z - .object({ - _meta: metaSchema.optional(), - audience: z - .array(z.enum(["assistant", "user"])) - .nullable() - .optional(), - lastModified: z.string().nullable().optional(), - priority: z.number().finite().nullable().optional(), - }) - .strict(); - -const textContentBlockSchema = z - .object({ - _meta: metaSchema.optional(), - annotations: annotationsSchema.nullable().optional(), - text: z.string(), - type: z.literal("text"), - }) - .strict(); - -const promptParamsSchema = z - .object({ - _meta: metaSchema.optional(), - prompt: z.array(textContentBlockSchema), - sessionId: acpSessionIdSchema, - }) - .strict(); - -const cancelParamsSchema = z - .object({ - _meta: metaSchema.optional(), - sessionId: acpSessionIdSchema, - }) - .strict(); - -interface AcpRouteOptions { - cancellation: ApiTurnCancellation; - conversationStore?: ConversationStore; - queue: ConversationWorkQueue; - state?: StateAdapter; -} - -interface AuthenticatedAcpActor { - actor: WebActor; - user: User; -} - -type AcpCall = z.output; -type SessionParams = z.output; -type LoadSessionParams = z.output; -type PromptParams = z.output; -type CancelParams = z.output; - -type AcpOperation = - | "initialize" - | "session_cancel" - | "session_load" - | "session_new" - | "session_prompt"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function bearerToken(request: Request): string | undefined { - const authorization = request.headers.get("Authorization"); - const match = authorization?.match(/^Bearer ([^\s]+)$/); - return match?.[1]; -} - -/** Resolve one personal token to the Actor and user that own the request. */ -async function authenticateRequest( - request: Request, -): Promise { - const token = bearerToken(request); - if (!token) return undefined; - const email = await authenticatePersonalToken(token); - if (!email) return undefined; - const user = await resolveViewerUser(email); - if (!user) return undefined; - return { - actor: webActorFromEmail( - user.email ?? email, - user.displayName ? { fullName: user.displayName } : undefined, - ), - user, - }; -} - -function parseParams(schema: z.ZodType, params: unknown): T { - const parsed = schema.safeParse(params); - if (parsed.success) return parsed.data; - const firstPath = parsed.error.issues[0]?.path[0]; - throw acp.RequestError.invalidParams({ - field: - typeof firstPath === "string" || typeof firstPath === "number" - ? String(firstPath) - : "params", - }); -} - -/** Reject client MCP servers because Junior does not use the client workspace. */ -function rejectUnsupportedMcpServers(mcpServers: readonly unknown[]): void { - if (mcpServers.length > 0) { - throw acp.RequestError.invalidParams( - { field: "mcpServers" }, - "Junior does not accept client MCP servers", - ); - } -} - -/** Require an ACP session that belongs to the authenticated participant. */ -async function requireOwnedSession( - sessionId: string, - user: User, -): Promise { - if (!acpSessionIdSchema.safeParse(sessionId).success) { - throw acp.RequestError.resourceNotFound(sessionId); - } - const access = ( - await readConversationAccessFromSql(getDb(), [sessionId], user) - ).get(sessionId); - if (!access?.isParticipant) { - throw acp.RequestError.resourceNotFound(sessionId); - } -} - -/** Convert supported ACP text blocks to one bounded API Turn message. */ -function promptText(prompt: PromptParams["prompt"]): string { - if (prompt.length === 0) { - throw acp.RequestError.invalidParams( - { field: "prompt" }, - "Junior accepts one or more text blocks only", - ); - } - const blocks: string[] = []; - for (const block of prompt) { - if (!block.text.trim()) { - throw acp.RequestError.invalidParams( - { field: "prompt" }, - "Junior accepts non-empty text blocks only", - ); - } - blocks.push(block.text); - } - const text = blocks.join("\n"); - if (text.length > MAX_PROMPT_TEXT_LENGTH) { - throw acp.RequestError.invalidParams( - { field: "prompt" }, - `Junior accepts at most ${MAX_PROMPT_TEXT_LENGTH} prompt characters`, - ); - } - return text; -} - -function stableHex(value: string, length = 32): string { - return createHash("sha256").update(value).digest("hex").slice(0, length); -} - -function canonicalJson(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonicalJson); - if (!isRecord(value)) return value; - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, child]) => [key, canonicalJson(child)]), - ); -} - -/** Preserve the JSON-RPC id type and request payload in durable retry keys. */ -function requestKey(call: AcpCall & { id: acp.JsonRpcId }): string { - const id = - call.id === null - ? "null" - : `${typeof call.id}:${stableHex(String(call.id))}`; - const payload = JSON.stringify( - canonicalJson({ method: call.method, params: call.params ?? null }), - ); - return `${id}:${stableHex(payload)}`; -} - -function sessionIdFromCall(call: AcpCall | undefined): string | undefined { - if (!call) return undefined; - const schema = - call.method === acp.methods.agent.session.load - ? loadSessionParamsSchema - : call.method === acp.methods.agent.session.prompt - ? promptParamsSchema - : call.method === acp.methods.agent.session.cancel - ? cancelParamsSchema - : undefined; - if (!schema) return undefined; - const parsed = schema.safeParse(call.params); - return parsed.success ? parsed.data.sessionId : undefined; -} - -/** Route session methods only from a canonical header and matching strict params. */ -function determineRoute( - connectionId: string, - call: AcpCall, - headerSessionId: string | undefined, -): AcpStreamRoute | Response { - const paramsSessionId = sessionIdFromCall(call); - if (SESSION_HEADER_METHODS.has(call.method) && !headerSessionId) { - return textResponse("Missing Acp-Session-Id", 400); - } - if ( - headerSessionId !== undefined && - paramsSessionId !== undefined && - headerSessionId !== paramsSessionId - ) { - return textResponse("Mismatched Acp-Session-Id", 400); - } - return { - connectionId, - ...(headerSessionId - ? { sessionId: headerSessionId } - : paramsSessionId - ? { sessionId: paramsSessionId } - : {}), - }; -} - -/** Keep load failures on the connection stream until session ownership succeeds. */ -function responseSessionId( - call: AcpCall, - route: AcpStreamRoute, -): string | undefined { - return call.method === acp.methods.agent.session.load - ? undefined - : route.sessionId; -} - -function resultMessage( - requestId: acp.JsonRpcId, - result: unknown, -): acp.AnyResponse { - return { jsonrpc: "2.0", id: requestId, result }; -} - -function errorMessage( - requestId: acp.JsonRpcId, - error: acp.RequestError, -): acp.AnyResponse { - return { - jsonrpc: "2.0", - id: requestId, - error: error.toErrorResponse(), - }; -} - -function textResponse(body: string, status: number): Response { - return new Response(body, { - status, - headers: { "Content-Type": "text/plain" }, - }); -} - -function jsonResponse( - value: acp.AnyResponse, - status: number, - headers?: HeadersInit, -): Response { - return new Response(JSON.stringify(value), { - status, - headers: { "Content-Type": ACP_JSON_MIME_TYPE, ...headers }, - }); -} - -function emptyResponse(status: number): Response { - return new Response(null, { status }); -} - -async function latestEventSeq( - eventStore: ConversationEventStore, - conversationId: string, -): Promise { - const page = await eventStore.query(conversationId, { limit: 1 }); - return page.events.at(-1)?.seq ?? 0; -} - -/** Find the canonical terminal event across every history version. */ -async function turnHasEnded( - eventStore: ConversationEventStore, - conversationId: string, - turnId: string, -): Promise { - const terminal = await eventStore.loadByIdempotencyKey( - conversationId, - `turn:${turnId}:terminal`, - ); - if ( - terminal?.data.type !== "turn_completed" && - terminal?.data.type !== "turn_failed" - ) { - return false; - } - return terminal.data.turnId === turnId; -} - -/** Trace one protocol operation; the HTTP or SSE boundary owns error capture. */ -async function runAcpOperation( - authenticated: AuthenticatedAcpActor, - operation: AcpOperation, - conversationId: string | undefined, - callback: () => Promise | T, -): Promise { - const name = `acp.${operation}`; - return await withSpan( - name, - name, - { - actorId: authenticated.actor.userId, - conversationId, - platform: "acp", - userId: authenticated.actor.userId, - }, - async () => await callback(), - ); -} - -/** Create a deterministic private Conversation for one session/new request. */ -async function createSession(args: { - authenticated: AuthenticatedAcpActor; - call: AcpCall; - connection: AcpConnection; - conversationStore: ConversationStore; - requestKey: string; -}): Promise { - return await runAcpOperation( - args.authenticated, - "session_new", - undefined, - async () => { - const params = parseParams( - newSessionParamsSchema, - args.call.params, - ); - rejectUnsupportedMcpServers(params.mcpServers); - const sessionId = `${ACP_CONVERSATION_PREFIX}${stableHex( - `${args.connection.nonce}:${args.requestKey}`, - )}`; - await recordApiConversationActivity({ - actor: args.authenticated.actor, - conversationId: sessionId, - conversationStore: args.conversationStore, - nowMs: Date.now(), - rootVisibility: "private", - }); - return sessionId; - }, - ); -} - -/** Accept one prompt into the existing durable Conversation mailbox. */ -async function acceptPrompt(args: { - authenticated: AuthenticatedAcpActor; - call: AcpCall; - cancellation: ApiTurnCancellation; - connection: AcpConnection; - conversationStore: ConversationStore; - eventStore: ConversationEventStore; - queue: ConversationWorkQueue; - requestId: acp.JsonRpcId; - requestKey: string; - state: StateAdapter; -}): Promise<{ output: AcpPromptStreamOutput; sessionId: string }> { - const params = parseParams( - promptParamsSchema, - args.call.params, - ); - return await runAcpOperation( - args.authenticated, - "session_prompt", - params.sessionId, - async () => { - await requireOwnedSession(params.sessionId, args.authenticated.user); - const text = promptText(params.prompt); - const currentSeq = await latestEventSeq( - args.eventStore, - params.sessionId, - ); - const idempotencyKey = `${args.connection.nonce}:${args.requestKey}`; - const messageId = apiConversationMessageId({ - conversationId: params.sessionId, - idempotencyKey, - }); - const turnId = apiTurnIdForMessage(messageId); - const admit = async () => - await args.cancellation.admit( - { - conversationId: params.sessionId, - messageId, - turnId, - }, - { - messageExists: async (candidateMessageId) => - await apiConversationMessageExists({ - conversationId: params.sessionId, - messageId: candidateMessageId, - state: args.state, - }), - run: async (fence) => - await appendAndEnqueueApiConversationMessage( - { - actor: args.authenticated.actor, - conversationId: params.sessionId, - idempotencyKey, - message: text, - }, - { - conversationStore: args.conversationStore, - fence, - queue: args.queue, - state: args.state, - }, - ), - }, - ); - - let admission = await admit(); - if ( - admission.status === "active" && - (await turnHasEnded( - args.eventStore, - params.sessionId, - admission.turnId, - )) && - !(await apiConversationMessageIsPending({ - conversationId: params.sessionId, - messageId: admission.messageId, - state: args.state, - })) - ) { - await args.cancellation.finish(params.sessionId, admission.turnId); - admission = await admit(); - } - if (admission.status === "active") { - throw acp.RequestError.invalidParams( - { field: "sessionId" }, - "This ACP session already has an active prompt", - ); - } - - if ( - (await turnHasEnded(args.eventStore, params.sessionId, turnId)) && - !(await apiConversationMessageIsPending({ - conversationId: params.sessionId, - messageId, - state: args.state, - })) - ) { - await args.cancellation.finish(params.sessionId, turnId); - } - return { - output: { - afterSeq: admission.value.status === "duplicate" ? 0 : currentSeq, - kind: "prompt", - messageId, - requestId: args.requestId, - turnId, - }, - sessionId: params.sessionId, - }; - }, - ); -} - -/** Dispatch one connected JSON-RPC request into a durable outbound receipt. */ -async function createRequestReceipt(args: { - authenticated: AuthenticatedAcpActor; - call: AcpCall & { id: acp.JsonRpcId }; - cancellation: ApiTurnCancellation; - connection: AcpConnection; - conversationStore: ConversationStore; - eventStore: ConversationEventStore; - queue: ConversationWorkQueue; - requestKey: string; - route: AcpStreamRoute; - state: StateAdapter; -}): Promise { - if (args.call.method === acp.methods.agent.session.new) { - const sessionId = await createSession(args); - return { - outputs: [ - { - kind: "message", - message: resultMessage(args.call.id, { sessionId }), - }, - ], - }; - } - - if (args.call.method === acp.methods.agent.session.load) { - const params = parseParams( - loadSessionParamsSchema, - args.call.params, - ); - await runAcpOperation( - args.authenticated, - "session_load", - params.sessionId, - async () => { - rejectUnsupportedMcpServers(params.mcpServers); - await requireOwnedSession(params.sessionId, args.authenticated.user); - }, - ); - return { - outputs: [ - { kind: "replay", sessionId: params.sessionId }, - { kind: "message", message: resultMessage(args.call.id, {}) }, - ], - }; - } - - if (args.call.method === acp.methods.agent.session.prompt) { - const prompt = await acceptPrompt({ - ...args, - requestId: args.call.id, - }); - return { - outputs: [ - { - kind: "prompt", - output: prompt.output, - sessionId: prompt.sessionId, - }, - ], - }; - } - - throw acp.RequestError.methodNotFound(args.call.method); -} - -/** Handle a session/cancel notification through durable Turn control. */ -async function handleCancelNotification(args: { - authenticated: AuthenticatedAcpActor; - call: AcpCall; - cancellation: ApiTurnCancellation; - conversationStore: ConversationStore; - queue: ConversationWorkQueue; - state: StateAdapter; -}): Promise { - const params = parseParams( - cancelParamsSchema, - args.call.params, - ); - await runAcpOperation( - args.authenticated, - "session_cancel", - params.sessionId, - async () => { - await requireOwnedSession(params.sessionId, args.authenticated.user); - if (!(await args.cancellation.cancel(params.sessionId))) return; - const nowMs = Date.now(); - await ensureConversationWake({ - conversationId: params.sessionId, - conversationStore: args.conversationStore, - idempotencyKey: `acp-cancel:${params.sessionId}:${nowMs}`, - nowMs, - queue: args.queue, - replaceExistingWake: true, - state: args.state, - }); - }, - ); -} - -/** Require an actor-bound connection without revealing another actor's ID. */ -async function requireConnection(args: { - authenticated: AuthenticatedAcpActor; - connectionId: string; - state: StateAdapter; -}): Promise { - const value = await readAcpConnection(args.state, args.connectionId); - if (!value || value.actorId !== args.authenticated.actor.userId) { - return undefined; - } - return value; -} - -function isJsonContentType(contentType: string | null): boolean { - return ( - contentType?.split(";", 1)[0]?.trim().toLowerCase() === ACP_JSON_MIME_TYPE - ); -} - -async function handleInitialize(args: { - authenticated: AuthenticatedAcpActor; - call: AcpCall; - state: StateAdapter; -}): Promise { - const requestId = args.call.id; - if (requestId === undefined) { - return textResponse("Initialize request must include an ID", 400); - } - try { - const { connectionId, result } = await runAcpOperation( - args.authenticated, - "initialize", - undefined, - async () => { - parseParams(initializeParamsSchema, args.call.params); - const result = { - protocolVersion: acp.PROTOCOL_VERSION, - agentCapabilities: { - loadSession: true, - promptCapabilities: { - image: false, - audio: false, - embeddedContext: false, - }, - }, - authMethods: [], - agentInfo: { name: "junior", version: JUNIOR_VERSION }, - } satisfies acp.InitializeResponse; - const connection = await createAcpConnection( - args.state, - args.authenticated.actor.userId, - ); - return { connectionId: connection.connectionId, result }; - }, - ); - return jsonResponse(resultMessage(requestId, result), 200, { - [ACP_CONNECTION_ID_HEADER]: connectionId, - }); - } catch (error) { - if (!(error instanceof acp.RequestError)) throw error; - return jsonResponse(errorMessage(requestId, error), 200); - } -} - -async function handleConnectedPost(args: { - authenticated: AuthenticatedAcpActor; - call: AcpCall; - connection: AcpConnection; - connectionId: string; - options: AcpRouteOptions; - route: AcpStreamRoute; - state: StateAdapter; -}): Promise { - const conversationStore = - args.options.conversationStore ?? getConversationStore(); - const eventStore = getConversationEventStore(); - - const requestId = args.call.id; - if (requestId === undefined) { - if (args.call.method === acp.methods.agent.session.cancel) { - try { - await handleCancelNotification({ - authenticated: args.authenticated, - call: args.call, - cancellation: args.options.cancellation, - conversationStore, - queue: args.options.queue, - state: args.state, - }); - } catch (error) { - if (!(error instanceof acp.RequestError)) throw error; - } - } - return emptyResponse(202); - } - - const identifiedCall = { ...args.call, id: requestId }; - const identifiedRequestKey = requestKey(identifiedCall); - const status = await acceptAcpRequest({ - connectionId: args.connectionId, - requestKey: identifiedRequestKey, - state: args.state, - createReceipt: async (): Promise => { - try { - return await createRequestReceipt({ - authenticated: args.authenticated, - call: identifiedCall, - cancellation: args.options.cancellation, - connection: args.connection, - conversationStore, - eventStore, - queue: args.options.queue, - requestKey: identifiedRequestKey, - route: args.route, - state: args.state, - }); - } catch (error) { - if (!(error instanceof acp.RequestError)) throw error; - const sessionId = responseSessionId(args.call, args.route); - return { - outputs: [ - { - kind: "message", - message: errorMessage(requestId, error), - ...(sessionId ? { sessionId } : {}), - }, - ], - }; - } - }, - }); - if (status === "busy") { - return textResponse("ACP request is already being accepted", 503); - } - if (status === "full") { - return textResponse("ACP stream has too much undelivered output", 503); - } - return emptyResponse(202); -} - -/** Create a serverless-safe remote ACP v1 HTTP handler. */ -export function createAcpHttpHandler( - options: AcpRouteOptions, -): (request: Request) => Promise { - const state = options.state ?? getStateAdapter(); - - return async (request) => - await withContext( - createRequestContext(request, { platform: "acp" }), - async () => { - let authenticated: AuthenticatedAcpActor | undefined; - let call: AcpCall | undefined; - let connectionId: string | undefined; - let sessionId: string | undefined; - try { - authenticated = await authenticateRequest(request); - if (!authenticated) { - return new Response("Unauthorized", { status: 401 }); - } - - const connectionHeader = request.headers.get( - ACP_CONNECTION_ID_HEADER, - ); - if (connectionHeader !== null) { - const parsed = acpConnectionIdSchema.safeParse(connectionHeader); - if (!parsed.success) { - return textResponse("Invalid Acp-Connection-Id", 400); - } - connectionId = parsed.data; - } - const sessionHeader = request.headers.get(ACP_SESSION_ID_HEADER); - if (sessionHeader !== null) { - const parsed = acpSessionIdSchema.safeParse(sessionHeader); - if (!parsed.success) { - return textResponse("Invalid Acp-Session-Id", 400); - } - sessionId = parsed.data; - } - - if (request.method === "POST") { - if (!isJsonContentType(request.headers.get("Content-Type"))) { - return textResponse("Unsupported Media Type", 415); - } - let value: unknown; - try { - value = await request.json(); - } catch { - return textResponse("Invalid JSON", 400); - } - if (Array.isArray(value)) { - return textResponse( - "Batch JSON-RPC requests are not implemented", - 501, - ); - } - const parsedCall = acpCallSchema.safeParse(value); - if (!parsedCall.success) { - return textResponse("Invalid JSON-RPC message", 400); - } - call = parsedCall.data; - - if (call.method === acp.methods.agent.initialize) { - if (connectionId) { - return textResponse( - "Initialize not allowed on existing connection", - 400, - ); - } - return await handleInitialize({ authenticated, call, state }); - } - if (!connectionId) { - return textResponse("Missing Acp-Connection-Id", 400); - } - const connection = await requireConnection({ - authenticated, - connectionId, - state, - }); - if (!connection) { - return textResponse("Unknown Acp-Connection-Id", 404); - } - const route = determineRoute(connectionId, call, sessionId); - if (route instanceof Response) return route; - return await handleConnectedPost({ - authenticated, - call, - connection, - connectionId, - options, - route, - state, - }); - } - - if (!connectionId) { - return textResponse("Missing Acp-Connection-Id", 400); - } - const connection = await requireConnection({ - authenticated, - connectionId, - state, - }); - if (!connection) { - return textResponse("Unknown Acp-Connection-Id", 404); - } - - if (request.method === "GET") { - if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { - return textResponse("WebSocket upgrade is not implemented", 426); - } - const accept = request.headers.get("Accept")?.toLowerCase(); - if (!accept?.includes(ACP_EVENT_STREAM_MIME_TYPE)) { - return textResponse("Not Acceptable", 406); - } - return await openAcpSse({ - actorId: authenticated.actor.userId, - eventStore: getConversationEventStore(), - requestSignal: request.signal, - route: { connectionId, ...(sessionId ? { sessionId } : {}) }, - state, - }); - } - - if (request.method === "DELETE") { - await deleteAcpConnection(state, connectionId); - return emptyResponse(202); - } - - return textResponse("Method Not Allowed", 405); - } catch (error) { - const conversationId = sessionIdFromCall(call) ?? sessionId; - logException(error, "acp.transport.exception", { - ...(authenticated - ? { - actorId: authenticated.actor.userId, - userId: authenticated.actor.userId, - } - : {}), - ...(connectionId ? { connectionId } : {}), - ...(conversationId ? { conversationId } : {}), - platform: "acp", - }); - return textResponse("ACP transport failed", 500); - } - }, - ); -} diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index a1568dd21a..f191b47627 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -14,7 +14,7 @@ import { getSlackReactionConfig, setSlackReactionConfig, } from "@/chat/config"; -import { getDb } from "@/chat/db"; +import { getConversationEventStore, getDb } from "@/chat/db"; import { logException, logWarn } from "@/chat/logging"; import { executeAgentRun } from "@/chat/agent"; import { normalizeSandboxEgressTracePropagationDomains } from "@/chat/sandbox/egress/tracing"; @@ -48,6 +48,7 @@ import type { PluginRegistration, ResourceEvent, PluginRouteMethod, + User, } from "@sentry/junior-plugin-api"; import { pluginCatalogConfigFromEnv, @@ -91,10 +92,16 @@ import { ingestResourceEvent } from "@/chat/resource-events/ingest"; import { createResourceEventTeamIdResolver } from "@/chat/resource-events/workspace"; import { ingestEventTasks } from "@/chat/event-tasks/ingest"; import { receiveLocalOAuthCredential } from "@/chat/local/credential-sync"; -import { createAcpHttpHandler } from "@/api/acp/route"; +import { createAcpConversationPort } from "@/api/acp-conversations"; +import { + loadAcpPackage, + type AcpAuthorizationCompletion, +} from "@/api/acp-package"; +import { getStateAdapter } from "@/chat/state/adapter"; +import { JUNIOR_VERSION } from "./version"; export { defineJuniorPlugins } from "./plugins"; -export { JUNIOR_VERSION } from "./version"; +export { JUNIOR_VERSION }; export type { JuniorPluginInput, JuniorPluginSet, @@ -161,10 +168,18 @@ export interface JuniorDashboardOptions { } interface JuniorDashboardRuntimeOptions extends JuniorDashboardOptions { + acpAuthorization?: AcpAuthorizationBridge; agentName?: string; pluginRoutes?: PluginApiRouteRegistration[]; } +interface AcpAuthorizationBridge { + complete( + transactionId: string, + user: User, + ): Promise; +} + type JuniorVirtualDashboardOptions = JuniorDashboardOptions; interface DashboardApp { @@ -179,6 +194,7 @@ interface JuniorVirtualConfig { functionMaxDurationSeconds?: number; createDashboardApp?: CreateDashboardApp; dashboard?: JuniorVirtualDashboardOptions; + loadAcp?: () => Promise; pluginSet?: JuniorPluginSet; plugins?: PluginCatalogConfig; pluginRuntimeRegistrations: string[]; @@ -210,22 +226,28 @@ async function defaultWaitUntil(): Promise { } /** Resolve build-time configuration from the virtual module injected by juniorNitro(). */ -async function resolveVirtualConfig(): Promise< - JuniorVirtualConfig | undefined -> { +async function resolveVirtualConfig(options: { + includeAcp: boolean; +}): Promise { try { const mod: { createDashboardApp?: CreateDashboardApp; functionMaxDurationSeconds?: number; dashboard?: JuniorVirtualDashboardOptions; + loadAcp?: () => Promise; pluginSet?: JuniorPluginSet; plugins?: PluginCatalogConfig; pluginRuntimeRegistrations?: string[]; } = await import("#junior/config"); + const loadAcp = + options.includeAcp && Object.prototype.hasOwnProperty.call(mod, "loadAcp") + ? mod.loadAcp + : undefined; return { createDashboardApp: mod.createDashboardApp, functionMaxDurationSeconds: mod.functionMaxDurationSeconds, dashboard: mod.dashboard, + loadAcp, pluginSet: mod.pluginSet, plugins: mod.plugins, pluginRuntimeRegistrations: mod.pluginRuntimeRegistrations ?? [], @@ -324,6 +346,7 @@ function validateBuildIncludesPluginRuntimeRegistrations( } async function createDashboardRouteRegistrations(args: { + acpAuthorization?: AcpAuthorizationBridge; dashboard: JuniorDashboardOptions | undefined; createDashboardApp: CreateDashboardApp | undefined; pluginRoutes: PluginApiRouteRegistration[]; @@ -335,6 +358,7 @@ async function createDashboardRouteRegistrations(args: { const createDashboardApp = args.createDashboardApp ?? (await loadDashboardAppFactory()); return dashboardRouteRegistrations({ + acpAuthorization: args.acpAuthorization, dashboard: args.dashboard, createDashboardApp, pluginRoutes: args.pluginRoutes, @@ -464,6 +488,7 @@ function dashboardHostRoutePaths(dashboard: JuniorDashboardOptions): string[] { "/api/workspaces/*", "/api/config", "/api/me", + "/api/acp/auth/*", authPath, `${authPath}/*`, ]; @@ -543,6 +568,7 @@ function dashboardOwnedRoutePath( } function dashboardRouteRegistrations(args: { + acpAuthorization?: AcpAuthorizationBridge; dashboard: JuniorDashboardOptions; createDashboardApp: CreateDashboardApp; pluginRoutes: PluginApiRouteRegistration[]; @@ -551,6 +577,9 @@ function dashboardRouteRegistrations(args: { const fetch = (request: Request) => { app ??= args.createDashboardApp({ ...args.dashboard, + ...(args.acpAuthorization + ? { acpAuthorization: args.acpAuthorization } + : {}), agentName: botConfig.userName, pluginRoutes: args.pluginRoutes, }); @@ -603,7 +632,9 @@ function mountRoutes(app: Hono, routes: HostRouteRegistration[]): void { /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { - const virtualConfig = await resolveVirtualConfig(); + const virtualConfig = await resolveVirtualConfig({ + includeAcp: options?.experimental?.acp === true, + }); if (virtualConfig?.functionMaxDurationSeconds !== undefined) { configureFunctionMaxDurationSeconds( virtualConfig.functionMaxDurationSeconds, @@ -712,6 +743,37 @@ export async function createApp(options?: JuniorAppOptions): Promise { replyExecutor: { agentRunner }, sandbox: { tracePropagation }, }; + let conversationWorkOptions: ConversationWorkCallbackOptions | undefined; + const getConversationWorkOptions = () => { + conversationWorkOptions ??= + options?.conversationWork ?? + createProductionConversationWorkOptions({ + agentRunner, + services: runtimeServiceOverrides, + }); + return conversationWorkOptions; + }; + const acpWork = isExperimentalFeatureEnabled("acp") + ? getConversationWorkOptions() + : undefined; + const acpState = acpWork ? (acpWork.state ?? getStateAdapter()) : undefined; + const acpPackage = acpWork + ? await loadAcpPackage(virtualConfig?.loadAcp) + : undefined; + if (acpWork && !acpWork.apiTurnCancellation) { + throw new Error("Experimental ACP requires API Turn cancellation wiring"); + } + const acpAuthorization: AcpAuthorizationBridge | undefined = + acpState && acpPackage + ? { + complete: async (transactionId, user) => + await acpPackage.completeAcpAuthorization({ + state: acpState, + transactionId, + user, + }), + } + : undefined; const slackWebhookServices = createProductionSlackWebhookServices({ services: runtimeServiceOverrides, }); @@ -735,6 +797,7 @@ export async function createApp(options?: JuniorAppOptions): Promise { mountRoutes( app, await createDashboardRouteRegistrations({ + acpAuthorization, dashboard, createDashboardApp: virtualConfig?.createDashboardApp, pluginRoutes: pluginApiRoutes, @@ -790,27 +853,21 @@ export async function createApp(options?: JuniorAppOptions): Promise { let pluginTaskPOST: | ReturnType | undefined; - let conversationWorkOptions: ConversationWorkCallbackOptions | undefined; - const getConversationWorkOptions = () => { - conversationWorkOptions ??= - options?.conversationWork ?? - createProductionConversationWorkOptions({ - agentRunner, - services: runtimeServiceOverrides, - }); - return conversationWorkOptions; - }; - if (isExperimentalFeatureEnabled("acp")) { - const work = getConversationWorkOptions(); - const cancellation = work.apiTurnCancellation; - if (!cancellation) { - throw new Error("Experimental ACP requires API Turn cancellation wiring"); - } - const handleAcpRequest = createAcpHttpHandler({ - cancellation, - conversationStore: work.conversationStore, - queue: work.queue ?? getVercelConversationWorkQueue(), - state: work.state, + if (acpWork && acpState && acpWork.apiTurnCancellation && acpPackage) { + const queue = acpWork.queue ?? getVercelConversationWorkQueue(); + const handleAcpRequest = acpPackage.createAcpHttpHandler({ + conversations: createAcpConversationPort({ + cancellation: acpWork.apiTurnCancellation, + conversationStore: acpWork.conversationStore, + eventStore: getConversationEventStore(), + queue, + state: acpState, + }), + baseURL: dashboard?.baseURL ?? process.env.JUNIOR_BASE_URL, + onError: (error, event, context) => + void logException(error, event, { ...context, platform: "acp" }), + state: acpState, + version: JUNIOR_VERSION, }); app.on(["GET", "POST", "DELETE"], "/api/acp", (c) => handleAcpRequest(c.req.raw), diff --git a/packages/junior/src/build/virtual-config.ts b/packages/junior/src/build/virtual-config.ts index 0901cd19fe..9541972eec 100644 --- a/packages/junior/src/build/virtual-config.ts +++ b/packages/junior/src/build/virtual-config.ts @@ -29,6 +29,10 @@ function renderDashboardImport(enabled: boolean): string[] { : ["export const createDashboardApp = undefined;"]; } +function renderAcpImport(): string[] { + return ['export const loadAcp = () => import("@sentry/junior-acp");']; +} + function dashboardEnabled( dashboard: JuniorDashboardOptions | undefined, ): boolean { @@ -44,6 +48,7 @@ export function renderVirtualConfig(options: { pluginRuntimeRegistrations?: string[]; }): string { const lines = [ + ...renderAcpImport(), ...renderDashboardImport(dashboardEnabled(options.dashboard)), ...(options.pluginModule ? [ diff --git a/packages/junior/src/nitro.ts b/packages/junior/src/nitro.ts index e32abfb3cd..6ac65043be 100644 --- a/packages/junior/src/nitro.ts +++ b/packages/junior/src/nitro.ts @@ -115,8 +115,8 @@ function runtimeModuleForResolvedPluginModule( } /** - * Prevent import-in-the-middle and require-in-the-middle from being - * externalized so Rolldown bundles them inline. + * Prevent required runtime packages from being externalized so Rolldown + * bundles them inline. * * @sentry/node (via @opentelemetry/instrumentation) statically imports these * packages. Nitro already lists them in nf3's NonBundleablePackages, which @@ -125,19 +125,24 @@ function runtimeModuleForResolvedPluginModule( * step fails to materialize the packages at runtime, causing an * ERR_MODULE_NOT_FOUND startup crash. * - * Adding them to noExternals overrides the NonBundleablePackages default and - * forces Rolldown to bundle their CJS code inline. The trade-off is that - * Node.js ESM loader hooks (hook.mjs) are not active, which limits OTEL - * auto-instrumentation of modules loaded after initialization. That is an - * acceptable cost compared to a fatal startup failure. + * The virtual app config also loads @sentry/junior-acp only when ACP is + * enabled. Bundling it keeps that optional runtime load inside the function + * output instead of relying on module tracing. + * + * Adding these packages to noExternals overrides the NonBundleablePackages + * default. The trade-off is that Node.js ESM loader hooks (hook.mjs) are not + * active, which limits OTEL auto-instrumentation of modules loaded after + * initialization. That is an acceptable cost compared to a startup failure. */ -function bundleOpenTelemetryLoaderHooks(nitro: Nitro): void { +function bundleRequiredRuntimePackages(nitro: Nitro): void { const existing = Array.isArray(nitro.options.noExternals) ? nitro.options.noExternals : []; - const additions = ["import-in-the-middle", "require-in-the-middle"].filter( - (pkg) => !existing.includes(pkg), - ); + const additions = [ + "@sentry/junior-acp", + "import-in-the-middle", + "require-in-the-middle", + ].filter((pkg) => !existing.includes(pkg)); if (additions.length > 0) { nitro.options.noExternals = [...existing, ...additions]; } @@ -246,7 +251,7 @@ export function juniorNitro(options: JuniorNitroOptions = {}): { nitro, options, ); - bundleOpenTelemetryLoaderHooks(nitro); + bundleRequiredRuntimePackages(nitro); applyRolldownTreeshakeWorkaround(nitro); const pluginSource = options.plugins; diff --git a/packages/junior/src/virtual-modules.d.ts b/packages/junior/src/virtual-modules.d.ts index 0dcfd4f6cb..bd67f96611 100644 --- a/packages/junior/src/virtual-modules.d.ts +++ b/packages/junior/src/virtual-modules.d.ts @@ -21,6 +21,7 @@ declare module "#junior/config" { | undefined; export const dashboard: VirtualDashboardConfig | undefined; export const functionMaxDurationSeconds: number; + export const loadAcp: () => Promise; export const pluginSet: JuniorPluginSet | undefined; export const plugins: PluginCatalogConfig; export const pluginRuntimeRegistrations: string[]; diff --git a/packages/junior/tests/fixtures/acp-http.ts b/packages/junior/tests/fixtures/acp-http.ts index 770f62d591..f9582c2796 100644 --- a/packages/junior/tests/fixtures/acp-http.ts +++ b/packages/junior/tests/fixtures/acp-http.ts @@ -2,12 +2,45 @@ import * as acp from "@agentclientprotocol/sdk"; import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client"; import type { StateAdapter } from "chat"; import type { Hono } from "hono"; +import { completeAcpAuthorization } from "@sentry/junior-acp"; import { createConversationWork } from "@/chat/app/conversation-work"; +import { resolveViewerUser } from "@/chat/plugins/viewer"; import type { ConversationWorkWebHarness } from "./api-turn"; import { createSlackAdapterFixture } from "./conversation-work"; export const ACP_TEST_URL = "http://junior.test/api/acp"; +/** Read the ACP connection cookie from an initialize response. */ +export function connectionCookie(response: Response): string { + const value = response.headers.get("set-cookie")?.split(";", 1)[0]; + if (!value) throw new Error("ACP initialize returned no connection cookie"); + return value; +} + +async function readSseMessage( + reader: ReadableStreamDefaultReader, + buffer: { value: string }, +): Promise { + const decoder = new TextDecoder(); + while (true) { + const boundary = buffer.value.indexOf("\n\n"); + if (boundary >= 0) { + const event = buffer.value.slice(0, boundary); + buffer.value = buffer.value.slice(boundary + 2); + const data = event + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .join("\n"); + if (data) return JSON.parse(data) as acp.AnyMessage; + continue; + } + const chunk = await reader.read(); + if (chunk.done) throw new Error("ACP SSE closed before authentication"); + buffer.value += decoder.decode(chunk.value, { stream: true }); + } +} + /** Route successive ACP transport requests across the supplied app instances. */ export function appFetch(...apps: Hono[]): typeof globalThis.fetch { let nextApp = 0; @@ -34,16 +67,12 @@ export function createIndependentConversationWork( }); } -/** Build one authenticated or unauthenticated ACP initialize request. */ -export function initializeRequest( - token?: string, - id: acp.JsonRpcId = 1, -): Request { +/** Build one ACP initialize request with browser authorization support. */ +export function initializeRequest(id: acp.JsonRpcId = 1): Request { return new Request(ACP_TEST_URL, { method: "POST", headers: { "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ jsonrpc: "2.0", @@ -52,27 +81,164 @@ export function initializeRequest( params: { _meta: { source: "integration-test" }, protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, + clientCapabilities: { elicitation: { url: {} } }, + }, + }), + }); +} + +async function authorizeFromElicitation(args: { + email: string; + params: acp.CreateElicitationRequest; + state: StateAdapter; +}): Promise { + if (args.params.mode !== "url" || typeof args.params.url !== "string") { + throw new Error("ACP test client requires URL elicitation"); + } + const transactionId = new URL(args.params.url).pathname + .split("/") + .filter(Boolean) + .at(-1); + if (!transactionId) { + throw new Error("ACP authorization URL has no transaction id"); + } + const user = await resolveViewerUser(args.email); + if (!user) throw new Error("ACP test user could not be resolved"); + const completed = await completeAcpAuthorization({ + state: args.state, + transactionId, + user, + }); + if (completed !== "completed") { + throw new Error(`ACP test authorization ${completed}`); + } + return { action: "accept" }; +} + +/** Open and authenticate a raw ACP connection for HTTP boundary assertions. */ +export async function openAuthenticatedAcpConnection(args: { + app: Hono; + email: string; + state: StateAdapter; +}): Promise<{ connectionId: string; cookie: string }> { + const initialized = await args.app.fetch(initializeRequest()); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const cookie = connectionCookie(initialized); + const stream = await args.app.fetch( + new Request(ACP_TEST_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + "Acp-Connection-Id": connectionId, + Cookie: cookie, + }, + }), + ); + const reader = stream.body?.getReader(); + if (!reader) throw new Error("ACP connection stream returned no body"); + const requestId = "fixture-authenticate"; + const accepted = await args.app.fetch( + new Request(ACP_TEST_URL, { + method: "POST", + headers: { + "Acp-Connection-Id": connectionId, + "Content-Type": "application/json", + Cookie: cookie, }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: requestId, + method: acp.methods.agent.authenticate, + params: { methodId: "junior" }, + }), }), + ); + if (accepted.status !== 202) { + throw new Error(`ACP authenticate returned HTTP ${accepted.status}`); + } + + const buffer = { value: "" }; + while (true) { + const message = await readSseMessage(reader, buffer); + if ( + "method" in message && + message.method === acp.methods.client.elicitation.create && + "id" in message && + message.id !== undefined + ) { + const result = await authorizeFromElicitation({ + email: args.email, + params: message.params as acp.CreateElicitationRequest, + state: args.state, + }); + await args.app.fetch( + new Request(ACP_TEST_URL, { + method: "POST", + headers: { + "Acp-Connection-Id": connectionId, + "Content-Type": "application/json", + Cookie: cookie, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + result, + }), + }), + ); + continue; + } + if ("id" in message && message.id === requestId && !("method" in message)) { + if ("error" in message) { + throw new Error(`ACP authentication failed: ${message.error.message}`); + } + break; + } + } + await reader.cancel(); + reader.releaseLock(); + return { connectionId, cookie }; +} + +/** Initialize and complete the ACP-advertised browser authentication method. */ +export async function initializeAndAuthenticate( + context: acp.ClientContext, +): Promise { + const initialized = await context.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { elicitation: { url: {} } }, + }); + await context.request(acp.methods.agent.authenticate, { + methodId: "junior", }); + return initialized; } /** Run one callback through the official ACP Streamable HTTP client. */ export async function withAcpClient(args: { app: Hono; + email: string; fetch?: typeof globalThis.fetch; onUpdate?: (update: acp.SessionUpdate) => void; run: (context: acp.ClientContext) => Promise; - token: string; + state: StateAdapter; }): Promise { const stream = createHttpStream(ACP_TEST_URL, { fetch: args.fetch ?? appFetch(args.app), - headers: { Authorization: `Bearer ${args.token}` }, }); try { return await acp .client({ name: "junior-acp-test" }) + .onRequest( + acp.methods.client.elicitation.create, + async (context) => + await authorizeFromElicitation({ + email: args.email, + params: context.params, + state: args.state, + }), + ) .onNotification(acp.methods.client.session.update, (context) => { args.onUpdate?.(context.params.update); }) @@ -85,6 +251,7 @@ export async function withAcpClient(args: { /** Drive explicit JSON-RPC ids through the official HTTP transport. */ export async function withRawAcpConnection(args: { app: Hono; + email: string; run: ( request: ( id: acp.JsonRpcId, @@ -92,11 +259,10 @@ export async function withRawAcpConnection(args: { params: unknown, ) => Promise, ) => Promise; - token: string; + state: StateAdapter; }): Promise { const stream = createHttpStream(ACP_TEST_URL, { fetch: appFetch(args.app), - headers: { Authorization: `Bearer ${args.token}` }, }); const reader = stream.readable.getReader(); const writer = stream.writable.getWriter(); @@ -111,6 +277,24 @@ export async function withRawAcpConnection(args: { if (next.done) { throw new Error("ACP stream closed before the response arrived"); } + if ( + "method" in next.value && + next.value.method === acp.methods.client.elicitation.create && + "id" in next.value && + next.value.id !== undefined + ) { + const result = await authorizeFromElicitation({ + email: args.email, + params: next.value.params as acp.CreateElicitationRequest, + state: args.state, + }); + await writer.write({ + jsonrpc: "2.0", + id: next.value.id, + result, + }); + continue; + } if (!("id" in next.value) || next.value.id !== id) continue; if ("method" in next.value) continue; if ("error" in next.value) { diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts index f35717c8d3..aeafd1f22d 100644 --- a/packages/junior/tests/integration/acp-http-recovery.test.ts +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -7,7 +7,6 @@ import { type ConversationTurnLifecycle, } from "@/chat/conversations/turn-lifecycle"; import { getConversationEventStore } from "@/chat/db"; -import { createPersonalToken } from "@/personal-tokens/store"; import { closeApiTurnWorkFixture, createConversationWorkWebHarness, @@ -15,7 +14,8 @@ import { import { ACP_TEST_URL, createIndependentConversationWork, - initializeRequest, + initializeAndAuthenticate, + openAuthenticatedAcpConnection, withAcpClient, } from "../fixtures/acp-http"; import { deferred, streamReplies } from "../fixtures/conversation-work"; @@ -47,19 +47,12 @@ describe("remote ACP recovery", () => { conversationWork: createIndependentConversationWork(harness), experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP active prompt exclusion", - }); const sessionCreated = deferred(); const firstPrompt = withAcpClient({ app, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); const session = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], @@ -70,6 +63,7 @@ describe("remote ACP recovery", () => { prompt: [{ type: "text", text: "Keep this prompt active." }], }); }, + state: harness.state, }); const sessionId = await sessionCreated.promise; @@ -81,12 +75,9 @@ describe("remote ACP recovery", () => { const secondPromptError = await withAcpClient({ app: secondApp, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); await context.request(acp.methods.agent.session.load, { sessionId, cwd: "/client/workspace", @@ -102,6 +93,7 @@ describe("remote ACP recovery", () => { return error; } }, + state: harness.state, }); expect(secondPromptError).toMatchObject({ code: -32602 }); @@ -177,10 +169,6 @@ describe("remote ACP recovery", () => { conversationWork: createIndependentConversationWork(harness), experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP terminal prompt handoff", - }); const sessionCreated = deferred(); const secondPromptPosted = deferred(); let workerReleased = false; @@ -218,13 +206,10 @@ describe("remote ACP recovery", () => { }; const prompts = withAcpClient({ app, + email: harness.actor.email, fetch, - token: token.token, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); const session = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], @@ -246,6 +231,7 @@ describe("remote ACP recovery", () => { }); return { first, second }; }, + state: harness.state, }); const sessionId = await sessionCreated.promise; @@ -268,12 +254,9 @@ describe("remote ACP recovery", () => { try { prematurePromptError = await withAcpClient({ app: secondApp, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); await context.request(acp.methods.agent.session.load, { sessionId, cwd: "/client/workspace", @@ -289,6 +272,7 @@ describe("remote ACP recovery", () => { return error; } }, + state: harness.state, }); } finally { workerReleased = true; @@ -320,24 +304,18 @@ describe("remote ACP recovery", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP old terminal recovery", - }); const sessionId = await withAcpClient({ app, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); const session = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], }); return session.sessionId; }, + state: harness.state, }); const cancellation = harness.conversationWork.apiTurnCancellation; if (!cancellation) throw new Error("ACP cancellation is not configured"); @@ -372,31 +350,29 @@ describe("remote ACP recovery", () => { }); } - const initialized = await app.fetch(initializeRequest(token.token)); - const connectionId = initialized.headers.get("Acp-Connection-Id"); - if (!connectionId) throw new Error("ACP initialize returned no connection"); - const prompt = await app.request(ACP_TEST_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${token.token}`, - "Acp-Connection-Id": connectionId, - "Acp-Session-Id": sessionId, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: acp.methods.agent.session.prompt, - params: { + const prompt = withAcpClient({ + app, + email: harness.actor.email, + run: async (context) => { + await initializeAndAuthenticate(context); + await context.request(acp.methods.agent.session.load, { + sessionId, + cwd: "/client/workspace", + mcpServers: [], + }); + return await context.request(acp.methods.agent.session.prompt, { sessionId, prompt: [{ type: "text", text: "Recover old control." }], - }, - }), + }); + }, + state: harness.state, }); - expect(prompt.status).toBe(202); - expect(harness.queue.hasQueuedMessages()).toBe(true); + await vi.waitFor(() => { + expect(harness.queue.hasQueuedMessages()).toBe(true); + }); await harness.drain(); + await expect(prompt).resolves.toEqual({ stopReason: "end_turn" }); await expect(harness.historyTexts(sessionId)).resolves.toEqual([ "Recover old control.", "Recovered old terminal control.", @@ -415,36 +391,32 @@ describe("remote ACP recovery", () => { conversationWork: createIndependentConversationWork(harness), experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP queue recovery", - }); const sessionId = await withAcpClient({ app, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); const session = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], }); return session.sessionId; }, + state: harness.state, + }); + const { connectionId, cookie } = await openAuthenticatedAcpConnection({ + app, + email: harness.actor.email, + state: harness.state, }); - const initialized = await app.fetch(initializeRequest(token.token)); - const connectionId = initialized.headers.get("Acp-Connection-Id"); - if (!connectionId) throw new Error("ACP initialize returned no connection"); const promptRequest = () => new Request(ACP_TEST_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": connectionId, "Acp-Session-Id": sessionId, "Content-Type": "application/json", + Cookie: cookie, }, body: JSON.stringify({ jsonrpc: "2.0", diff --git a/packages/junior/tests/integration/acp-http.test.ts b/packages/junior/tests/integration/acp-http.test.ts index 628900a6c7..80a7d8c04f 100644 --- a/packages/junior/tests/integration/acp-http.test.ts +++ b/packages/junior/tests/integration/acp-http.test.ts @@ -1,5 +1,4 @@ import * as acp from "@agentclientprotocol/sdk"; -import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client"; import type { StateAdapter } from "chat"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; @@ -12,7 +11,9 @@ import { import { ACP_TEST_URL as ACP_URL, appFetch, + connectionCookie, createIndependentConversationWork, + initializeAndAuthenticate, initializeRequest, withAcpClient, withRawAcpConnection, @@ -37,18 +38,24 @@ describe("remote ACP HTTP", () => { expect(response.status).toBe(404); }); - it("requires a valid personal bearer token before ACP dispatch", async () => { + it("initializes anonymously and rejects a valid personal token", async () => { const harness = await createConversationWorkWebHarness(); const app = await createApp({ conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const missing = await app.fetch(initializeRequest()); - const invalid = await app.fetch(initializeRequest("jr_pat_invalid")); + const anonymous = await app.fetch(initializeRequest()); + const token = await createPersonalToken({ + email: harness.actor.email, + name: "ACP rejected token", + }); + const bearerRequest = initializeRequest(); + bearerRequest.headers.set("Authorization", `Bearer ${token.token}`); + const bearer = await app.fetch(bearerRequest); - expect(missing.status).toBe(401); - expect(invalid.status).toBe(401); + expect(anonymous.status).toBe(200); + expect(bearer.status).toBe(401); }); it("validates JSON-RPC envelopes and initialization", async () => { @@ -57,15 +64,10 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP envelope validation", - }); - const nullId = await app.fetch(initializeRequest(token.token, null)); + const nullId = await app.fetch(initializeRequest(null)); const malformed = await app.request(ACP_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ jsonrpc: "2.0", privatePrompt: "sentinel" }), @@ -73,7 +75,6 @@ describe("remote ACP HTTP", () => { const wrongVersion = await app.request(ACP_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -86,7 +87,6 @@ describe("remote ACP HTTP", () => { const nonFiniteId = await app.request(ACP_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Content-Type": "application/json", }, body: '{"jsonrpc":"2.0","id":1e400,"method":"session/new"}', @@ -94,7 +94,6 @@ describe("remote ACP HTTP", () => { const failedInitialize = await app.request(ACP_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -107,7 +106,6 @@ describe("remote ACP HTTP", () => { const unknownInitializeField = await app.request(ACP_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -121,14 +119,14 @@ describe("remote ACP HTTP", () => { }, }), }); - const initialized = await app.fetch(initializeRequest(token.token)); + const initialized = await app.fetch(initializeRequest()); const connectionId = initialized.headers.get("Acp-Connection-Id"); if (!connectionId) throw new Error("ACP initialize returned no connection"); + const cookie = connectionCookie(initialized); const invalidConnectionId = await app.request(ACP_URL, { method: "GET", headers: { Accept: "text/event-stream", - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": "../../invalid", }, }); @@ -136,9 +134,9 @@ describe("remote ACP HTTP", () => { method: "GET", headers: { Accept: "text/event-stream", - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": connectionId, "Acp-Session-Id": "local:acp:invalid", + Cookie: cookie, }, }); @@ -165,6 +163,44 @@ describe("remote ACP HTTP", () => { expect(invalidSessionId.status).toBe(400); }); + it("requires ACP authentication before session methods", async () => { + const harness = await createConversationWorkWebHarness(); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + + const error = await withAcpClient({ + app, + email: harness.actor.email, + run: async (context) => { + const initialized = await context.request( + acp.methods.agent.initialize, + { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { elicitation: { url: {} } }, + }, + ); + expect(initialized.authMethods).toEqual([ + expect.objectContaining({ id: "junior" }), + ]); + try { + await context.request(acp.methods.agent.session.new, { + cwd: "/client/workspace", + mcpServers: [], + }); + return undefined; + } catch (cause) { + return cause; + } + }, + state: harness.state, + }); + + expect(error).toMatchObject({ code: -32000 }); + expect(harness.queue.hasQueuedMessages()).toBe(false); + }); + it("runs, reloads, and protects a private Conversation across app instances", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("First ACP reply."), @@ -178,14 +214,6 @@ describe("remote ACP HTTP", () => { experimental: { acp: true, subagents: true }, }); const fetch = appFetch(app, secondApp); - const ownerToken = await createPersonalToken({ - email: harness.actor.email, - name: "ACP owner", - }); - const otherToken = await createPersonalToken({ - email: "bob@example.com", - name: "ACP other actor", - }); const firstUpdates: acp.SessionUpdate[] = []; let resolveFirstSession!: (sessionId: string) => void; const firstSession = new Promise((resolve) => { @@ -194,20 +222,19 @@ describe("remote ACP HTTP", () => { const firstRun = withAcpClient({ app, + email: harness.actor.email, fetch, - token: ownerToken.token, onUpdate: (update) => firstUpdates.push(update), run: async (context) => { - const initialized = await context.request( - acp.methods.agent.initialize, - { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }, - ); + const initialized = await initializeAndAuthenticate(context); expect(initialized).toMatchObject({ protocolVersion: acp.PROTOCOL_VERSION, - authMethods: [], + authMethods: [ + expect.objectContaining({ + id: "junior", + name: "Sign in to Junior", + }), + ], }); expect(initialized.agentCapabilities).toEqual({ loadSession: true, @@ -231,6 +258,7 @@ describe("remote ACP HTTP", () => { }); return { result, sessionId: session.sessionId }; }, + state: harness.state, }); const sessionId = await firstSession; @@ -265,51 +293,47 @@ describe("remote ACP HTTP", () => { "First ACP reply.", ]); - const rawInitialize = await app.fetch(initializeRequest(ownerToken.token)); + const rawInitialize = await app.fetch(initializeRequest()); const connectionId = rawInitialize.headers.get("Acp-Connection-Id"); expect(connectionId).toBeTruthy(); - const crossActorConnection = await app.request(ACP_URL, { + const cookie = connectionCookie(rawInitialize); + const stolenConnection = await app.request(ACP_URL, { method: "GET", headers: { Accept: "text/event-stream", - Authorization: `Bearer ${otherToken.token}`, "Acp-Connection-Id": connectionId!, }, }); - expect(crossActorConnection.status).toBe(404); - const crossActorDelete = await app.request(ACP_URL, { + expect(stolenConnection.status).toBe(401); + const stolenDelete = await app.request(ACP_URL, { method: "DELETE", headers: { - Authorization: `Bearer ${otherToken.token}`, "Acp-Connection-Id": connectionId!, }, }); - expect(crossActorDelete.status).toBe(404); + expect(stolenDelete.status).toBe(401); const ownerDelete = await app.request(ACP_URL, { method: "DELETE", headers: { - Authorization: `Bearer ${ownerToken.token}`, "Acp-Connection-Id": connectionId!, + Cookie: cookie, }, }); expect(ownerDelete.status).toBe(202); const deletedConnection = await app.request(ACP_URL, { method: "DELETE", headers: { - Authorization: `Bearer ${ownerToken.token}`, "Acp-Connection-Id": connectionId!, + Cookie: cookie, }, }); - expect(deletedConnection.status).toBe(404); + expect(deletedConnection.status).toBe(401); const crossActorSessionErrors = await withAcpClient({ app, - token: otherToken.token, + email: "bob@example.com", run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); let loadError: unknown; let promptError: unknown; try { @@ -331,6 +355,7 @@ describe("remote ACP HTTP", () => { } return { loadError, promptError }; }, + state: harness.state, }); expect(crossActorSessionErrors).toEqual({ loadError: expect.objectContaining({ code: -32002 }), @@ -350,14 +375,11 @@ describe("remote ACP HTTP", () => { }); const secondRun = withAcpClient({ app, + email: harness.actor.email, fetch, - token: ownerToken.token, onUpdate: (update) => secondUpdates.push(update), run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); await context.request(acp.methods.agent.session.load, { sessionId, cwd: "/different/client/workspace", @@ -369,6 +391,7 @@ describe("remote ACP HTTP", () => { prompt: [{ type: "text", text: "Follow up." }], }); }, + state: harness.state, }); await secondPromptStarted; @@ -407,18 +430,17 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP idempotency", - }); await withRawAcpConnection({ app, - token: token.token, + email: harness.actor.email, run: async (request) => { await request(0, acp.methods.agent.initialize, { protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, + clientCapabilities: { elicitation: { url: {} } }, + }); + await request("authenticate", acp.methods.agent.authenticate, { + methodId: "junior", }); const created = await request(1, acp.methods.agent.session.new, { cwd: "/client/workspace", @@ -489,6 +511,7 @@ describe("remote ACP HTTP", () => { "Typed id reply.", ]); }, + state: harness.state, }); }, 20_000); @@ -502,20 +525,17 @@ describe("remote ACP HTTP", () => { conversationWork: createIndependentConversationWork(harness), experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP shared SSE lease", - }); - const initialized = await app.fetch(initializeRequest(token.token)); + const initialized = await app.fetch(initializeRequest()); const connectionId = initialized.headers.get("Acp-Connection-Id"); if (!connectionId) throw new Error("ACP initialize returned no connection"); + const cookie = connectionCookie(initialized); const streamRequest = () => new Request(ACP_URL, { method: "GET", headers: { Accept: "text/event-stream", - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": connectionId, + Cookie: cookie, }, }); @@ -552,20 +572,16 @@ describe("remote ACP HTTP", () => { conversationWork: createIndependentConversationWork(harness, state), experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP lost SSE lease", - }); - - const initialized = await app.fetch(initializeRequest(token.token)); + const initialized = await app.fetch(initializeRequest()); const connectionId = initialized.headers.get("Acp-Connection-Id"); if (!connectionId) throw new Error("ACP initialize returned no connection"); + const cookie = connectionCookie(initialized); const stream = await app.request(ACP_URL, { method: "GET", headers: { Accept: "text/event-stream", - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": connectionId, + Cookie: cookie, }, }); const reader = stream.body?.getReader(); @@ -574,9 +590,9 @@ describe("remote ACP HTTP", () => { const accepted = await app.request(ACP_URL, { method: "POST", headers: { - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": connectionId, "Content-Type": "application/json", + Cookie: cookie, }, body: JSON.stringify({ jsonrpc: "2.0", @@ -599,15 +615,13 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP disconnect", - }); const promptAccepted = deferred(); const sessionStreamOpened = deferred(); let connectionId: string | undefined; + let cookie: string | undefined; const fetch: typeof globalThis.fetch = async (input, init) => { const request = new Request(input, init as RequestInit); + cookie ??= request.headers.get("cookie") ?? undefined; const isSessionStream = request.method === "GET" && request.headers.has("Acp-Session-Id"); let isPrompt = false; @@ -632,21 +646,16 @@ describe("remote ACP HTTP", () => { } return response; }; - const stream = createHttpStream(ACP_URL, { - fetch, - headers: { Authorization: `Bearer ${token.token}` }, - }); let resolveSession!: (sessionId: string) => void; const session = new Promise((resolve) => { resolveSession = resolve; }); - const connected = acp - .client({ name: "junior-acp-disconnect-test" }) - .connectWith(stream, async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + const connected = withAcpClient({ + app, + email: harness.actor.email, + fetch, + run: async (context) => { + await initializeAndAuthenticate(context); const created = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], @@ -656,7 +665,9 @@ describe("remote ACP HTTP", () => { sessionId: created.sessionId, prompt: [{ type: "text", text: "Keep running." }], }); - }); + }, + state: harness.state, + }); const connectionClosed = connected.catch(() => undefined); const sessionId = await session; @@ -674,11 +685,12 @@ describe("remote ACP HTTP", () => { } })(); if (!connectionId) throw new Error("ACP initialize returned no connection"); + if (!cookie) throw new Error("ACP client sent no connection cookie"); const deleted = await app.request(ACP_URL, { method: "DELETE", headers: { - Authorization: `Bearer ${token.token}`, "Acp-Connection-Id": connectionId, + Cookie: cookie, }, }); expect(deleted.status).toBe(202); @@ -690,19 +702,17 @@ describe("remote ACP HTTP", () => { const replayed: acp.SessionUpdate[] = []; await withAcpClient({ app, - token: token.token, + email: harness.actor.email, onUpdate: (update) => replayed.push(update), run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); await context.request(acp.methods.agent.session.load, { sessionId, cwd: "/client/workspace", mcpServers: [], }); }, + state: harness.state, }); await expect(harness.historyTexts(sessionId)).resolves.toEqual([ @@ -742,22 +752,15 @@ describe("remote ACP HTTP", () => { conversationWork: createIndependentConversationWork(harness), experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP cancellation", - }); const sessionCreated = deferred(); let cancelActiveTurn: (() => Promise) | undefined; const cancelledRun = withAcpClient({ app, + email: harness.actor.email, fetch: appFetch(app, secondApp), - token: token.token, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); const session = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], @@ -773,6 +776,7 @@ describe("remote ACP HTTP", () => { prompt: [{ type: "text", text: "Cancel this Turn." }], }); }, + state: harness.state, }); const sessionId = await sessionCreated.promise; @@ -813,12 +817,9 @@ describe("remote ACP HTTP", () => { const followUpStarted = deferred(); const followUp = withAcpClient({ app, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); await context.request(acp.methods.agent.session.load, { sessionId, cwd: "/client/workspace", @@ -830,6 +831,7 @@ describe("remote ACP HTTP", () => { prompt: [{ type: "text", text: "Continue after cancellation." }], }); }, + state: harness.state, }); await followUpStarted.promise; @@ -856,19 +858,12 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP failed Turn", - }); let sessionId: string | undefined; const failed = withAcpClient({ app, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); const session = await context.request(acp.methods.agent.session.new, { cwd: "/client/workspace", mcpServers: [], @@ -879,6 +874,7 @@ describe("remote ACP HTTP", () => { prompt: [{ type: "text", text: "Fail this Turn." }], }); }, + state: harness.state, }); const failedResult = failed.then( () => { @@ -919,19 +915,11 @@ describe("remote ACP HTTP", () => { conversationWork: harness.conversationWork, experimental: { acp: true, subagents: true }, }); - const token = await createPersonalToken({ - email: harness.actor.email, - name: "ACP validation", - }); - const errors = await withAcpClient({ app, - token: token.token, + email: harness.actor.email, run: async (context) => { - await context.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: {}, - }); + await initializeAndAuthenticate(context); let mcpError: unknown; try { await context.request(acp.methods.agent.session.new, { @@ -984,6 +972,7 @@ describe("remote ACP HTTP", () => { } return { mcpError, promptErrors }; }, + state: harness.state, }); expect(errors.mcpError).toMatchObject({ code: -32602 }); diff --git a/packages/junior/tests/unit/api/acp-transport.test.ts b/packages/junior/tests/unit/api/acp-transport.test.ts index 2a38d1d969..60d1356fb4 100644 --- a/packages/junior/tests/unit/api/acp-transport.test.ts +++ b/packages/junior/tests/unit/api/acp-transport.test.ts @@ -1,7 +1,10 @@ import { createHash } from "node:crypto"; import { createMemoryState } from "@chat-adapter/state-memory"; import { describe, expect, it, vi } from "vitest"; -import { acceptAcpRequest, type AcpRequestReceipt } from "@/api/acp/transport"; +import { + acceptAcpRequest, + type AcpRequestReceipt, +} from "@sentry/junior-acp/testing"; const CONNECTION_ID = "9dddb5f1-bd8f-42cc-a88e-c3cb909354dd"; const SESSION_ID = "local:acp:11111111111111111111111111111111"; diff --git a/packages/junior/tests/unit/build/virtual-config.test.ts b/packages/junior/tests/unit/build/virtual-config.test.ts index 6679a8912c..ef532b14ce 100644 --- a/packages/junior/tests/unit/build/virtual-config.test.ts +++ b/packages/junior/tests/unit/build/virtual-config.test.ts @@ -24,6 +24,7 @@ describe("renderVirtualConfig", () => { expect(code).toContain( 'export const pluginRuntimeRegistrations = ["github"];', ); + expect(code).toContain('import("@sentry/junior-acp")'); }); it("supports default runtime plugin exports", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac7d48790..772a376150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,9 +166,6 @@ importers: packages/junior: dependencies: - '@agentclientprotocol/sdk': - specifier: 1.3.0 - version: 1.3.0(zod@4.4.3) '@ai-sdk/gateway': specifier: ^3.0.119 version: 3.0.119(zod@4.4.3) @@ -205,6 +202,9 @@ importers: '@opentelemetry/core': specifier: 'catalog:' version: 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-acp': + specifier: workspace:* + version: file:packages/junior-acp(ai@6.0.190(zod@4.4.3)) '@sentry/junior-plugin-api': specifier: workspace:* version: link:../junior-plugin-api @@ -269,6 +269,9 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: + '@agentclientprotocol/sdk': + specifier: 1.3.0 + version: 1.3.0(zod@4.4.3) '@emnapi/core': specifier: ^1.10.0 version: 1.10.0 @@ -324,6 +327,34 @@ importers: specifier: ^4.1.7 version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(tsx@4.22.3)(yaml@2.9.0) + packages/junior-acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: 1.3.0 + version: 1.3.0(zod@4.4.3) + '@sentry/junior-plugin-api': + specifier: workspace:* + version: link:../junior-plugin-api + chat: + specifier: 4.29.0 + version: 4.29.0(zod@4.4.3) + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(tsx@4.22.3)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/junior-agent-browser: {} packages/junior-amplitude: {} @@ -2998,8 +3029,8 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} - '@publint/pack@0.1.5': - resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} + '@publint/pack@0.1.6': + resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} engines: {node: '>=18'} '@radix-ui/primitive@1.1.7': @@ -3617,6 +3648,9 @@ packages: resolution: {integrity: sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==} engines: {node: '>=18'} + '@sentry/junior-acp@file:packages/junior-acp': + resolution: {directory: packages/junior-acp, type: directory} + '@sentry/junior-dashboard@file:packages/junior-dashboard': resolution: {directory: packages/junior-dashboard, type: directory} @@ -4319,8 +4353,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -5164,6 +5198,7 @@ packages: esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} + hasBin: true esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} @@ -6796,9 +6831,6 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} @@ -6942,10 +6974,12 @@ packages: playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} + hasBin: true playwright@1.60.0: resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} + hasBin: true postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} @@ -7556,8 +7590,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} string_decoder@1.3.0: @@ -7819,6 +7853,7 @@ packages: typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} + hasBin: true ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -8308,8 +8343,8 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + wrap-ansi@10.0.1: + resolution: {integrity: sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==} engines: {node: '>=20'} wrap-ansi@7.0.0: @@ -10533,7 +10568,7 @@ snapshots: '@protobufjs/utf8@1.1.1': {} - '@publint/pack@0.1.5': + '@publint/pack@0.1.6': dependencies: tinyexec: 1.1.2 @@ -11061,6 +11096,16 @@ snapshots: dependencies: '@sentry/conventions': 0.15.1 + '@sentry/junior-acp@file:packages/junior-acp(ai@6.0.190(zod@4.4.3))': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.4.3) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + chat: 4.29.0(ai@6.0.190(zod@4.4.3))(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - ai + - supports-color + '@sentry/junior-dashboard@file:packages/junior-dashboard(@opentelemetry/api@1.9.1)(jiti@2.7.0)': dependencies: '@radix-ui/react-hover-card': 1.1.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -11465,7 +11510,6 @@ snapshots: '@sentry/junior@file:packages/junior': dependencies: - '@agentclientprotocol/sdk': 1.3.0(zod@4.4.3) '@ai-sdk/gateway': 3.0.119(zod@4.4.3) '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.4.3))(zod@4.4.3) '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.4.3))(zod@4.4.3) @@ -11478,6 +11522,7 @@ snapshots: '@neondatabase/serverless': 1.1.0 '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-acp': file:packages/junior-acp(ai@6.0.190(zod@4.4.3)) '@sentry/junior-plugin-api': file:packages/junior-plugin-api '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) '@sinclair/typebox': 0.34.49 @@ -12567,7 +12612,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@4.3.0: dependencies: @@ -12935,6 +12980,20 @@ snapshots: transitivePeerDependencies: - supports-color + chat@4.29.0(zod@4.4.3): + dependencies: + '@workflow/serde': 4.1.0-beta.2 + mdast-util-to-string: 4.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + remend: 1.3.0 + unified: 11.0.5 + optionalDependencies: + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + chokidar@4.0.0: dependencies: readdirp: 4.1.2 @@ -12965,7 +13024,7 @@ snapshots: cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 - string-width: 8.2.1 + string-width: 8.2.2 cli-width@4.1.0: {} @@ -14588,7 +14647,7 @@ snapshots: lint-staged@17.0.5: dependencies: listr2: 10.2.2 - picomatch: 4.0.4 + picomatch: 4.0.5 string-argv: 0.3.2 tinyexec: 1.1.2 optionalDependencies: @@ -14600,7 +14659,7 @@ snapshots: eventemitter3: 5.0.4 log-update: 6.1.0 rfdc: 1.4.1 - wrap-ansi: 10.0.0 + wrap-ansi: 10.0.1 load-tsconfig@0.2.5: {} @@ -15646,8 +15705,6 @@ snapshots: degenerator: 5.0.1 netmask: 2.1.1 - package-manager-detector@1.6.0: {} - package-manager-detector@1.8.0: {} pagefind@1.5.2: @@ -15903,8 +15960,8 @@ snapshots: publint@0.3.21: dependencies: - '@publint/pack': 0.1.5 - package-manager-detector: 1.6.0 + '@publint/pack': 0.1.6 + package-manager-detector: 1.8.0 picocolors: 1.1.1 sade: 1.8.1 @@ -16610,7 +16667,7 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.1: + string-width@8.2.2: dependencies: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 @@ -16631,7 +16688,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-bom@3.0.0: {} @@ -17505,11 +17562,10 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@10.0.0: + wrap-ansi@10.0.1: dependencies: ansi-styles: 6.2.3 - string-width: 8.2.1 - strip-ansi: 7.2.0 + string-width: 8.2.2 wrap-ansi@7.0.0: dependencies: diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 4c0bfcf5b0..493c0376c2 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -10,6 +10,7 @@ if (!newVersion) { const files = [ "packages/junior/package.json", + "packages/junior-acp/package.json", "packages/junior-plugin-api/package.json", "packages/junior-agent-browser/package.json", "packages/junior-amplitude/package.json", From ec9139bf54a0313a91032d885126d30a446ac63e Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+gricha@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:21:25 +0200 Subject: [PATCH 7/7] fix(acp): simplify admission and harden browser auth --- .../rules/no-core-plugin-static-imports.yml | 9 +- packages/junior-acp/README.md | 15 +- packages/junior-acp/src/auth.ts | 29 ++- packages/junior-acp/src/conversations.ts | 67 ++++++- packages/junior-acp/src/transport.ts | 2 +- .../junior-dashboard/src/acp-authorization.ts | 58 +++++- packages/junior-dashboard/src/app.ts | 31 +-- .../tests/dashboard-routes.test.ts | 19 +- .../junior-plugin-api/src/conversations.ts | 60 ------ packages/junior-plugin-api/src/index.ts | 1 - packages/junior/src/api/acp-conversations.ts | 161 +++++++-------- packages/junior/src/api/acp-package.ts | 3 +- .../junior/src/chat/api-turns/cancellation.ts | 88 +-------- packages/junior/src/chat/api-turns/mailbox.ts | 34 ---- packages/junior/src/chat/api-turns/routing.ts | 1 + packages/junior/src/chat/api-turns/work.ts | 18 +- .../junior/src/chat/task-execution/state.ts | 54 ++++- .../junior/src/chat/task-execution/store.ts | 67 +++++-- packages/junior/tests/fixtures/acp-http.ts | 5 +- .../integration/acp-http-recovery.test.ts | 186 ++++++++++-------- .../tests/integration/api-turn-work.test.ts | 13 +- .../unit/chat/api-turn-cancellation.test.ts | 100 +--------- 22 files changed, 494 insertions(+), 527 deletions(-) delete mode 100644 packages/junior-plugin-api/src/conversations.ts delete mode 100644 packages/junior/src/chat/api-turns/mailbox.ts diff --git a/ast-grep/rules/no-core-plugin-static-imports.yml b/ast-grep/rules/no-core-plugin-static-imports.yml index c2af1d8e93..1a26f0c61b 100644 --- a/ast-grep/rules/no-core-plugin-static-imports.yml +++ b/ast-grep/rules/no-core-plugin-static-imports.yml @@ -12,6 +12,9 @@ rule: kind: string regex: '^["@'']@sentry/junior-.+["@'']$' - not: - has: - kind: string - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + any: + - has: + kind: string + regex: '^["@'']@sentry/junior-plugin-api["@'']$' + # ACP is an optional transport package. Type imports do not load it. + - pattern: import type $$$A from "@sentry/junior-acp" diff --git a/packages/junior-acp/README.md b/packages/junior-acp/README.md index fdd361e134..7a8df2ddd5 100644 --- a/packages/junior-acp/README.md +++ b/packages/junior-acp/README.md @@ -17,12 +17,11 @@ reason. ## Package Boundary `@sentry/junior-acp` owns ACP JSON-RPC, SSE, connection state, and browser -authorization transactions. It does not import Junior core. The `ConversationPort` -contract in `@sentry/junior-plugin-api` gives it six Conversation operations. -Junior implements that contract in one adapter module. The adapter owns user -access, mailbox admission, cancellation, and event projection. Core loads the -ACP package through app setup and does not statically import it. No ACP type -enters the agent loop. +authorization transactions. It does not import Junior core. The package owns a +`ConversationPort` with six Conversation operations. Junior implements that +contract in one adapter module. The adapter owns user access, mailbox admission, +cancellation, and event projection. Core loads the ACP runtime through app setup. +No ACP type enters the agent loop. ## Runtime Design @@ -39,8 +38,8 @@ The memory adapter remains process-local and is suitable only for local use. Each stream preserves up to 1,024 undelivered items. It returns `503` instead of dropping an item when that limit is full. -Junior sends a terminal prompt response only after it acknowledges the matching -mailbox message. Until then, durable Turn control blocks another prompt. This +Junior admits a prompt only while the Conversation has no runnable work. The +Conversation mutation lock protects this check and the mailbox append. This prevents retryable work from overlapping a follow-up. ## Live HTTP Limit diff --git a/packages/junior-acp/src/auth.ts b/packages/junior-acp/src/auth.ts index 8cf643701e..819c4e1984 100644 --- a/packages/junior-acp/src/auth.ts +++ b/packages/junior-acp/src/auth.ts @@ -10,6 +10,7 @@ import { userSchema, type User } from "@sentry/junior-plugin-api"; import { z } from "zod"; import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "./state"; import { + ACP_STATE_TTL_MS, bindAcpConnectionUser, completeAcpRequest, type AcpRequestReceipt, @@ -149,13 +150,16 @@ function resultMessage(requestId: acp.JsonRpcId): acp.AnyResponse { return { jsonrpc: "2.0", id: requestId, result: {} }; } -function cancelledMessage(requestId: acp.JsonRpcId): acp.AnyResponse { +function cancelledMessage( + requestId: acp.JsonRpcId, + message = "Junior sign-in was cancelled", +): acp.AnyResponse { return { jsonrpc: "2.0", id: requestId, error: acp.RequestError.requestCancelled( undefined, - "Junior sign-in was cancelled", + message, ).toErrorResponse(), }; } @@ -182,7 +186,7 @@ export async function beginAcpAuthorization(args: { await args.state.set( authorizationKey(transactionId), authorization, - ACP_AUTHORIZATION_TTL_MS, + ACP_STATE_TTL_MS, ); const url = authorizationBaseURL(args.request, args.baseURL); url.pathname = `/api/acp/auth/${transactionId}`; @@ -236,11 +240,20 @@ export async function completeAcpAuthorization(args: { args.state, args.transactionId, ); - if (!authorization || authorization.expiresAtMs <= Date.now()) { - if (authorization) { - await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); - await args.state.delete(authorizationKey(args.transactionId)); - } + if (!authorization) { + return "expired" as const; + } + if (authorization.expiresAtMs <= Date.now()) { + await finishAuthorization({ + authorization, + response: cancelledMessage( + authorization.requestId, + "Junior sign-in request expired", + ), + state: args.state, + }); + await fenceLock(args.state, lock, MUTATION_LOCK_TTL_MS); + await args.state.delete(authorizationKey(args.transactionId)); return "expired" as const; } const binding = await bindAcpConnectionUser({ diff --git a/packages/junior-acp/src/conversations.ts b/packages/junior-acp/src/conversations.ts index d56a654589..9d057e4c1c 100644 --- a/packages/junior-acp/src/conversations.ts +++ b/packages/junior-acp/src/conversations.ts @@ -1,10 +1,63 @@ -export type { - ConversationPort, - ConversationPromptAdmission, - ConversationTextMessage, - ConversationTurnPage, - ConversationTurnTerminal, -} from "@sentry/junior-plugin-api"; +import type { User } from "@sentry/junior-plugin-api"; + +/** One text Message exposed through the ACP Conversation port. */ +export interface ConversationTextMessage { + id: string; + role: "assistant" | "user"; + text: string; +} + +/** Result of admitting one retry-stable prompt to a Conversation. */ +export type ConversationPromptAdmission = + | { + afterCursor: number; + messageId: string; + status: "accepted"; + turnId: string; + } + | { status: "active" } + | { status: "not_found" }; + +/** Terminal state for one correlated Conversation Turn. */ +export type ConversationTurnTerminal = + | { outcome: "cancelled" | "completed"; status: "completed" } + | { failureCode: string; status: "failed" }; + +/** One ordered page of Messages and optional terminal Turn state. */ +export interface ConversationTurnPage { + cursor: number; + messages: ConversationTextMessage[]; + terminal?: ConversationTurnTerminal; +} + +/** Junior Conversation operations required by the ACP adapter. */ +export interface ConversationPort { + cancel(args: { + conversationId: string; + user: User; + }): Promise<"cancelled" | "not_found">; + createConversation(args: { + conversationId: string; + user: User; + }): Promise; + hasConversationAccess(args: { + conversationId: string; + user: User; + }): Promise; + prompt(args: { + conversationId: string; + idempotencyKey: string; + text: string; + user: User; + }): Promise; + readMessages(conversationId: string): Promise; + readTurn(args: { + afterCursor: number; + conversationId: string; + messageId: string; + turnId: string; + }): Promise; +} export interface AcpErrorContext { connectionId?: string; diff --git a/packages/junior-acp/src/transport.ts b/packages/junior-acp/src/transport.ts index c868570b96..8d24c35dca 100644 --- a/packages/junior-acp/src/transport.ts +++ b/packages/junior-acp/src/transport.ts @@ -14,7 +14,7 @@ import { sleep } from "./sleep"; import { fenceLock, MUTATION_LOCK_TTL_MS, withLock } from "./state"; // Transport records can expire because the Conversation log owns durable history. -const ACP_STATE_TTL_MS = 24 * 60 * 60 * 1000; +export const ACP_STATE_TTL_MS = 24 * 60 * 60 * 1000; const ACP_SSE_LEASE_TTL_MS = 60_000; const ACP_SSE_LEASE_RENEW_INTERVAL_MS = 20_000; const EVENT_POLL_INTERVAL_MS = 100; diff --git a/packages/junior-dashboard/src/acp-authorization.ts b/packages/junior-dashboard/src/acp-authorization.ts index f9f35e57bd..6271073a43 100644 --- a/packages/junior-dashboard/src/acp-authorization.ts +++ b/packages/junior-dashboard/src/acp-authorization.ts @@ -62,9 +62,14 @@ function canonicalAuthorizationUrl( function renderResult( agentName: string, - result: "completed" | "conflict" | "expired" | "forbidden", + result: "blocked" | "completed" | "conflict" | "expired" | "forbidden", ): Response { const content = { + blocked: { + status: 403, + title: "Request blocked", + message: "Open the sign-in link again and confirm the connection.", + }, completed: { status: 200, title: `${agentName} is connected`, @@ -107,12 +112,56 @@ function renderResult( status: content.status, headers: { "cache-control": "no-store", + "content-security-policy": "frame-ancestors 'none'; form-action 'self'", "content-type": "text/html; charset=utf-8", + "x-frame-options": "DENY", }, }, ); } +function renderConfirmation(agentName: string): Response { + return new Response( + ` + + + + + Connect ${escapeHtml(agentName)} + + +
+
+

Connect ${escapeHtml(agentName)}

+

Confirm that you want this ACP client to use ${escapeHtml(agentName)}.

+
+ +
+
+
+ +`, + { + headers: { + "cache-control": "no-store", + "content-security-policy": "frame-ancestors 'none'; form-action 'self'", + "content-type": "text/html; charset=utf-8", + "x-frame-options": "DENY", + }, + }, + ); +} + +function requestHasSameOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + if (!origin) return false; + try { + return new URL(origin).origin === new URL(request.url).origin; + } catch { + return false; + } +} + /** Return whether this path names one valid ACP authorization transaction. */ export function isAcpAuthorizationPath(pathname: string): boolean { if (!pathname.startsWith(ACP_AUTHORIZATION_PATH_PREFIX)) return false; @@ -172,6 +221,13 @@ export async function handleDashboardAcpAuthorization(args: { session = sanitizeDashboardSession(browserSession); } + if (args.request.method === "GET") { + return renderConfirmation(args.agentName); + } + if (args.request.method !== "POST" || !requestHasSameOrigin(args.request)) { + return renderResult(args.agentName, "blocked"); + } + const email = verifiedDashboardSessionEmail(session); if (!email) return renderResult(args.agentName, "forbidden"); const user = await resolveViewerUser(email); diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index 8dea85b4d1..c9db33c088 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -754,20 +754,23 @@ export function createDashboardApp( const acpAuthorization = options.acpAuthorization; if (acpAuthorization) { - app.get(`${ACP_AUTHORIZATION_PATH_PREFIX}:transactionId`, (c) => - handleDashboardAcpAuthorization({ - agentName, - allowedDomains, - allowedEmails, - auth, - authRequired, - authorization: acpAuthorization, - basePath, - canonicalBaseURL, - localViewerEmail: LOCAL_VIEWER_EMAIL, - request: c.req.raw, - transactionId: c.req.param("transactionId"), - }), + app.on( + ["GET", "POST"], + `${ACP_AUTHORIZATION_PATH_PREFIX}:transactionId`, + (c) => + handleDashboardAcpAuthorization({ + agentName, + allowedDomains, + allowedEmails, + auth, + authRequired, + authorization: acpAuthorization, + basePath, + canonicalBaseURL, + localViewerEmail: LOCAL_VIEWER_EMAIL, + request: c.req.raw, + transactionId: c.req.param("transactionId"), + }), ); } diff --git a/packages/junior-dashboard/tests/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts index 3188842f2f..072b34cede 100644 --- a/packages/junior-dashboard/tests/dashboard-routes.test.ts +++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts @@ -119,7 +119,6 @@ describe("dashboard routes", () => { callbackURL = value; }), }); - const unauthenticated = await unauthenticatedApp.fetch( new Request(`http://localhost${path}`), ); @@ -143,13 +142,19 @@ describe("dashboard routes", () => { }, }), }); - const completed = await authenticatedApp.fetch( - new Request(`http://localhost${path}`), - ); - + expect( + (await authenticatedApp.fetch(new Request(`http://localhost${path}`))) + .status, + ).toBe(200); + const post = (origin: string) => + authenticatedApp.request(`http://localhost${path}`, { + method: "POST", + headers: { Origin: origin }, + }); + expect((await post("https://attacker.example")).status).toBe(403); + expect(complete).not.toHaveBeenCalled(); + const completed = await post("http://localhost"); expect(completed.status).toBe(200); - expect(await completed.text()).toContain("Junior is connected"); - expect(resolveViewerUser).toHaveBeenCalledWith("person@sentry.io"); expect(complete).toHaveBeenCalledWith( transactionId, expect.objectContaining({ id: "user:person@sentry.io" }), diff --git a/packages/junior-plugin-api/src/conversations.ts b/packages/junior-plugin-api/src/conversations.ts deleted file mode 100644 index 0f260f770a..0000000000 --- a/packages/junior-plugin-api/src/conversations.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { User } from "./context"; - -/** One text Message exposed through a bounded Conversation runtime port. */ -export interface ConversationTextMessage { - id: string; - role: "assistant" | "user"; - text: string; -} - -/** Result of admitting one retry-stable prompt to a Conversation. */ -export type ConversationPromptAdmission = - | { - afterCursor: number; - messageId: string; - status: "accepted"; - turnId: string; - } - | { status: "active" } - | { status: "not_found" }; - -/** Terminal state for one correlated Conversation Turn. */ -export type ConversationTurnTerminal = - | { outcome: "cancelled" | "completed"; status: "completed" } - | { failureCode: string; status: "failed" }; - -/** One ordered page of Messages and optional terminal Turn state. */ -export interface ConversationTurnPage { - cursor: number; - messages: ConversationTextMessage[]; - terminal?: ConversationTurnTerminal; -} - -/** Bounded Conversation operations available to a protocol adapter. */ -export interface ConversationPort { - cancel(args: { - conversationId: string; - user: User; - }): Promise<"cancelled" | "not_found">; - createConversation(args: { - conversationId: string; - user: User; - }): Promise; - hasConversationAccess(args: { - conversationId: string; - user: User; - }): Promise; - prompt(args: { - conversationId: string; - idempotencyKey: string; - text: string; - user: User; - }): Promise; - readMessages(conversationId: string): Promise; - readTurn(args: { - afterCursor: number; - conversationId: string; - messageId: string; - turnId: string; - }): Promise; -} diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index 0af569aa9b..015c8c70c6 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -1,6 +1,5 @@ export * from "./annotations"; export * from "./conversation-events"; -export * from "./conversations"; export * from "./schemas"; export * from "./context"; export * from "./state"; diff --git a/packages/junior/src/api/acp-conversations.ts b/packages/junior/src/api/acp-conversations.ts index 2c3d6f88d1..2a8a37d2d1 100644 --- a/packages/junior/src/api/acp-conversations.ts +++ b/packages/junior/src/api/acp-conversations.ts @@ -2,24 +2,20 @@ * Bind the protocol adapter port to Junior's Conversation runtime. * * The ACP package cannot reach the mailbox, worker, database, or Conversation - * event store directly. Core imports only the shared ConversationPort type. + * event store directly. Core implements the transport package's narrow port. */ import type { StateAdapter } from "chat"; import type { ConversationPort, ConversationTurnTerminal, - User, -} from "@sentry/junior-plugin-api"; +} from "@sentry/junior-acp"; +import type { User } from "@sentry/junior-plugin-api"; import { readConversationAccessFromSql } from "@/api/conversations/access"; import type { ApiTurnCancellation } from "@/chat/api-turns/cancellation"; -import { - apiConversationMessageExists, - apiConversationMessageIsPending, -} from "@/chat/api-turns/mailbox"; import { apiConversationMessageId, apiTurnIdForMessage, - appendAndEnqueueApiConversationMessage, + buildApiTurnInboundMessage, recordApiConversationActivity, webActorFromEmail, } from "@/chat/api-turns/work"; @@ -28,7 +24,11 @@ import { projectConversationMessages } from "@/chat/conversations/message-projec import type { ConversationStore } from "@/chat/conversations/store"; import { getDb } from "@/chat/db"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; -import { ensureConversationWake } from "@/chat/task-execution/store"; +import { + appendAndEnqueueExclusiveInboundMessage, + ensureConversationWake, + getConversation, +} from "@/chat/task-execution/store"; const EVENT_PAGE_SIZE = 50; @@ -57,28 +57,20 @@ async function hasConversationAccess( return Boolean(access?.isParticipant); } -async function turnHasEnded( - eventStore: ConversationEventStore, - conversationId: string, - turnId: string, -): Promise { - const terminal = await eventStore.loadByIdempotencyKey( - conversationId, - `turn:${turnId}:terminal`, - ); - return Boolean( - (terminal?.data.type === "turn_completed" || - terminal?.data.type === "turn_failed") && - terminal.data.turnId === turnId, - ); -} - async function messageIsPending(args: { conversationId: string; messageId: string; state: StateAdapter; }): Promise { - return await apiConversationMessageIsPending(args); + const conversation = await getConversation({ + conversationId: args.conversationId, + state: args.state, + }); + return Boolean( + conversation?.execution.pendingMessages.some( + (message) => message.inboundMessageId === args.messageId, + ), + ); } async function latestEventCursor( @@ -89,6 +81,56 @@ async function latestEventCursor( return page.events.at(-1)?.seq ?? 0; } +async function appendAndEnqueueAcpPrompt(args: { + conversationId: string; + idempotencyKey: string; + options: AcpConversationOptions; + text: string; + user: User; +}): Promise<{ + messageId: string; + status: "accepted" | "active" | "duplicate"; +}> { + const text = args.text.trim(); + if (!text) throw new Error("ACP prompt must not be empty"); + const actor = actorFromUser(args.user); + const nowMs = Date.now(); + const messageId = apiConversationMessageId({ + conversationId: args.conversationId, + idempotencyKey: args.idempotencyKey, + }); + const destination = await recordApiConversationActivity({ + actor, + conversationId: args.conversationId, + conversationStore: args.options.conversationStore, + nowMs, + }); + const result = await appendAndEnqueueExclusiveInboundMessage({ + beforeAppend: async () => + await args.options.cancellation.register( + args.conversationId, + apiTurnIdForMessage(messageId), + ), + message: buildApiTurnInboundMessage({ + actor, + conversationId: args.conversationId, + destination, + durableCancellation: true, + message: text, + messageId, + nowMs, + }), + conversationStore: args.options.conversationStore, + nowMs, + queue: args.options.queue, + state: args.options.state, + }); + return { + messageId, + status: result.status === "appended" ? "accepted" : result.status, + }; +} + /** Create the narrow Conversation capability consumed by remote ACP. */ export function createAcpConversationPort( options: AcpConversationOptions, @@ -135,73 +177,20 @@ export function createAcpConversationPort( options.eventStore, conversationId, ); - const messageId = apiConversationMessageId({ + const admission = await appendAndEnqueueAcpPrompt({ conversationId, idempotencyKey, + options, + text, + user, }); - const turnId = apiTurnIdForMessage(messageId); - const admit = async () => - await options.cancellation.admit( - { conversationId, messageId, turnId }, - { - messageExists: async (candidateMessageId) => - await apiConversationMessageExists({ - conversationId, - messageId: candidateMessageId, - state: options.state, - }), - run: async (fence) => - await appendAndEnqueueApiConversationMessage( - { - actor: actorFromUser(user), - conversationId, - idempotencyKey, - message: text, - }, - { - conversationStore: options.conversationStore, - fence, - queue: options.queue, - state: options.state, - }, - ), - }, - ); - - let admission = await admit(); - if ( - admission.status === "active" && - (await turnHasEnded( - options.eventStore, - conversationId, - admission.turnId, - )) && - !(await messageIsPending({ - conversationId, - messageId: admission.messageId, - state: options.state, - })) - ) { - await options.cancellation.finish(conversationId, admission.turnId); - admission = await admit(); - } if (admission.status === "active") return { status: "active" }; - if ( - (await turnHasEnded(options.eventStore, conversationId, turnId)) && - !(await messageIsPending({ - conversationId, - messageId, - state: options.state, - })) - ) { - await options.cancellation.finish(conversationId, turnId); - } return { - afterCursor: admission.value.status === "duplicate" ? 0 : currentCursor, - messageId, + afterCursor: admission.status === "duplicate" ? 0 : currentCursor, + messageId: admission.messageId, status: "accepted", - turnId, + turnId: apiTurnIdForMessage(admission.messageId), }; }, diff --git a/packages/junior/src/api/acp-package.ts b/packages/junior/src/api/acp-package.ts index a98b517f8c..e66e022be0 100644 --- a/packages/junior/src/api/acp-package.ts +++ b/packages/junior/src/api/acp-package.ts @@ -1,7 +1,8 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { StateAdapter } from "chat"; -import type { ConversationPort, User } from "@sentry/junior-plugin-api"; +import type { ConversationPort } from "@sentry/junior-acp"; +import type { User } from "@sentry/junior-plugin-api"; const ACP_PACKAGE_NAME = "@sentry/junior-acp"; diff --git a/packages/junior/src/chat/api-turns/cancellation.ts b/packages/junior/src/chat/api-turns/cancellation.ts index 86d8550270..3bfde877cf 100644 --- a/packages/junior/src/chat/api-turns/cancellation.ts +++ b/packages/junior/src/chat/api-turns/cancellation.ts @@ -19,9 +19,7 @@ const CONTROL_LOCK_WAIT_MS = 5_000; const activeApiTurnSchema = z .object({ - accepted: z.boolean(), cancelled: z.boolean(), - messageId: z.string().min(1), turnId: z.string().min(1), }) .strict(); @@ -35,25 +33,8 @@ interface LocalApiTurn { turnId: string; } -export type ApiTurnAdmission = - | { status: "accepted"; value: T } - | { messageId: string; status: "active"; turnId: string }; - /** Durable control and invocation-local abort signals for cancellable API Turns. */ export interface ApiTurnCancellation { - /** Admit one retry-stable mailbox message under the Conversation control lock. */ - admit( - input: { - conversationId: string; - messageId: string; - turnId: string; - }, - callbacks: { - messageExists: (messageId: string) => Promise; - /** Invoke the fence inside the mailbox mutation, before its write or duplicate decision. */ - run: (fence: () => Promise) => Promise; - }, - ): Promise>; /** Create the abort signal used by the execution slice that owns the Turn. */ begin( conversationId: string, @@ -67,6 +48,8 @@ export interface ApiTurnCancellation { turnId: string, signal?: AbortSignal, ): Promise; + /** Register durable cancellation before a cancellable Turn enters the mailbox. */ + register(conversationId: string, turnId: string): Promise; /** Stop local polling without removing durable Turn control. */ release(conversationId: string, signal: AbortSignal): void; } @@ -134,68 +117,17 @@ export function createApiTurnCancellation( }; return { - async admit(input, callbacks) { + async register(conversationId, turnId) { return await mutateControl({ - conversationId: input.conversationId, + conversationId, state, run: async (fence) => { - let existing = await readControl(state, input.conversationId); - if (existing && existing.turnId !== input.turnId) { - if ( - !existing.accepted && - !(await callbacks.messageExists(existing.messageId)) - ) { - await fence(); - await state.delete(controlKey(input.conversationId)); - existing = undefined; - } else { - if (!existing.accepted) { - existing = { ...existing, accepted: true }; - await fence(); - await state.set( - controlKey(input.conversationId), - existing, - API_TURN_CONTROL_TTL_MS, - ); - } - return { - messageId: existing.messageId, - status: "active" as const, - turnId: existing.turnId, - }; - } - } - - if (!existing) { - await fence(); - await state.set( - controlKey(input.conversationId), - { - accepted: false, - cancelled: false, - messageId: input.messageId, - turnId: input.turnId, - } satisfies ActiveApiTurn, - API_TURN_CONTROL_TTL_MS, - ); - } - - const value = await callbacks.run(fence); - const current = await readControl(state, input.conversationId); - if (current && current.turnId !== input.turnId) { - throw new Error( - `API Turn control changed during admission for ${input.conversationId}`, - ); - } - if (current && !current.accepted) { - await fence(); - await state.set( - controlKey(input.conversationId), - { ...current, accepted: true } satisfies ActiveApiTurn, - API_TURN_CONTROL_TTL_MS, - ); - } - return { status: "accepted" as const, value }; + await fence(); + await state.set( + controlKey(conversationId), + { cancelled: false, turnId } satisfies ActiveApiTurn, + API_TURN_CONTROL_TTL_MS, + ); }, }); }, diff --git a/packages/junior/src/chat/api-turns/mailbox.ts b/packages/junior/src/chat/api-turns/mailbox.ts deleted file mode 100644 index 6dcaa9e0e3..0000000000 --- a/packages/junior/src/chat/api-turns/mailbox.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { StateAdapter } from "chat"; -import { getConversation } from "@/chat/task-execution/store"; - -/** Return whether the durable Conversation has accepted one API Message id. */ -export async function apiConversationMessageExists(args: { - conversationId: string; - messageId: string; - state?: StateAdapter; -}): Promise { - const conversation = await getConversation({ - conversationId: args.conversationId, - state: args.state, - }); - return Boolean( - conversation?.execution.inboundMessageIds.includes(args.messageId), - ); -} - -/** Return whether one API Message still waits for mailbox acknowledgement. */ -export async function apiConversationMessageIsPending(args: { - conversationId: string; - messageId: string; - state?: StateAdapter; -}): Promise { - const conversation = await getConversation({ - conversationId: args.conversationId, - state: args.state, - }); - return Boolean( - conversation?.execution.pendingMessages.some( - (message) => message.inboundMessageId === args.messageId, - ), - ); -} diff --git a/packages/junior/src/chat/api-turns/routing.ts b/packages/junior/src/chat/api-turns/routing.ts index 7be19345d8..976af31899 100644 --- a/packages/junior/src/chat/api-turns/routing.ts +++ b/packages/junior/src/chat/api-turns/routing.ts @@ -12,6 +12,7 @@ const apiTurnMailboxMetadataSchema = z authorFullName: z.string().min(1).optional(), authorUserId: z.string().min(1), authorUserName: z.string().min(1).optional(), + durableCancellation: z.literal(true).optional(), kind: z.literal("api_turn"), messageId: z.string().min(1), }) diff --git a/packages/junior/src/chat/api-turns/work.ts b/packages/junior/src/chat/api-turns/work.ts index d2390bd1ae..d3067e05ae 100644 --- a/packages/junior/src/chat/api-turns/work.ts +++ b/packages/junior/src/chat/api-turns/work.ts @@ -107,8 +107,6 @@ export { resolveApiTurnWork } from "@/chat/api-turns/routing"; type EnqueueOptions = { conversationStore?: ConversationStore; - /** Revalidate an outer admission lock at the durable mailbox boundary. */ - fence?: () => Promise; nowMs?: number; queue: ConversationWorkQueue; state?: StateAdapter; @@ -264,6 +262,7 @@ export function buildApiTurnInboundMessage(args: { createdAtMs?: number; /** Existing conversation destination; required when continuing a provider root. */ destination?: Destination; + durableCancellation?: boolean; message: string; messageId: string; nowMs?: number; @@ -294,6 +293,7 @@ export function buildApiTurnInboundMessage(args: { ...(args.actor.fullName ? { authorFullName: args.actor.fullName } : {}), authorUserId: args.actor.userId, ...(args.actor.userName ? { authorUserName: args.actor.userName } : {}), + ...(args.durableCancellation ? { durableCancellation: true } : {}), kind: "api_turn", messageId: args.messageId, } satisfies ApiTurnMailboxMetadata, @@ -401,7 +401,6 @@ export async function appendAndEnqueueApiConversationMessage( nowMs, }), conversationStore: options.conversationStore, - fence: options.fence, nowMs, queue: options.queue, state: options.state, @@ -638,10 +637,13 @@ export function createApiTurnWorker(options: { let modelFailureEventId: string | undefined; let modelFailureCaptureAttempted = false; let reply: AgentRunResult | undefined; - const cancellationSignal = await options.cancellation?.begin( - context.conversationId, - turnId, - ); + const shouldLoadCancellation = + isResume || + (resolved.kind === "mailbox" && + resolved.batch[0]?.metadata.durableCancellation === true); + const cancellationSignal = shouldLoadCancellation + ? await options.cancellation?.begin(context.conversationId, turnId) + : undefined; const finishCancellation = async (): Promise => { if (!cancellationSignal) return; await options.cancellation?.finish( @@ -650,7 +652,7 @@ export function createApiTurnWorker(options: { cancellationSignal, ); }; - // Call only after mailbox acknowledgement; control blocks retryable work. + // Retryable work must keep its durable cancellation control. const finishCancellationAfterTerminal = async (): Promise => { await runBestEffort( finishCancellation, diff --git a/packages/junior/src/chat/task-execution/state.ts b/packages/junior/src/chat/task-execution/state.ts index fdcaa544e4..7a006b3234 100644 --- a/packages/junior/src/chat/task-execution/state.ts +++ b/packages/junior/src/chat/task-execution/state.ts @@ -202,6 +202,16 @@ export interface AppendAndEnqueueInboundMessageResult extends AppendInboundMessa queueMessageId?: string; } +/** Result of an append that rejects new work while a Conversation is runnable. */ +export type AppendExclusiveInboundMessageResult = + | AppendInboundMessageResult + | { status: "active" }; + +/** Exclusive append result with an optional queue delivery id. */ +export type AppendAndEnqueueExclusiveInboundMessageResult = + | AppendAndEnqueueInboundMessageResult + | { status: "active" }; + export interface RequestConversationWorkResult { status: "created" | "updated"; } @@ -1062,14 +1072,13 @@ export function hasRunnableConversationWork( return hasRunnableWork(conversation); } -/** Persist one inbound message idempotently in its conversation mailbox. */ -export async function appendInboundMessage(args: { - /** Revalidate an outer admission lock before this mailbox mutation commits. */ - fence?: () => Promise; +async function appendInboundMessageWithAdmission(args: { + beforeAppend?: () => Promise; + exclusive: boolean; message: InboundMessage; nowMs?: number; state?: StateAdapter; -}): Promise { +}): Promise { const nowMs = args.nowMs ?? now(); return await withConversationMutation( { conversationId: args.message.conversationId, state: args.state }, @@ -1099,7 +1108,6 @@ export async function appendInboundMessage(args: { const existingMessage = current.execution.inboundMessageIds.includes( args.message.inboundMessageId, ); - await args.fence?.(); if (existingMessage) { if (!existingPending) { return { status: "duplicate" }; @@ -1128,6 +1136,9 @@ export async function appendInboundMessage(args: { ); return { status: "duplicate" }; } + if (args.exclusive && hasRunnableWork(current)) { + return { status: "active" }; + } const status = current.execution.lease && current.execution.status === "running" @@ -1141,6 +1152,7 @@ export async function appendInboundMessage(args: { source: current.source ?? args.message.source, lastActivityAtMs: nowMs, }; + await args.beforeAppend?.(); await writeConversation( state, lock, @@ -1166,6 +1178,36 @@ export async function appendInboundMessage(args: { ); } +/** Persist one inbound message idempotently in its conversation mailbox. */ +export async function appendInboundMessage(args: { + message: InboundMessage; + nowMs?: number; + state?: StateAdapter; +}): Promise { + const result = await appendInboundMessageWithAdmission({ + ...args, + exclusive: false, + }); + if (result.status === "active") { + throw new Error("Non-exclusive mailbox append returned active"); + } + return result; +} + +/** Append only when the Conversation has no other runnable work. */ +export async function appendExclusiveInboundMessage(args: { + /** Run after admission while the Conversation mutation lock is held. */ + beforeAppend: () => Promise; + message: InboundMessage; + nowMs?: number; + state?: StateAdapter; +}): Promise { + return await appendInboundMessageWithAdmission({ + ...args, + exclusive: true, + }); +} + /** Mark a conversation runnable when there is no new mailbox message. */ export async function requestConversationWork(args: { conversationId: string; diff --git a/packages/junior/src/chat/task-execution/store.ts b/packages/junior/src/chat/task-execution/store.ts index d3b4a6ed05..02886a9455 100644 --- a/packages/junior/src/chat/task-execution/store.ts +++ b/packages/junior/src/chat/task-execution/store.ts @@ -36,7 +36,9 @@ export { type StartConversationWorkResult, } from "@/chat/task-execution/state"; import type { + AppendAndEnqueueExclusiveInboundMessageResult, AppendAndEnqueueInboundMessageResult, + AppendExclusiveInboundMessageResult, Conversation, InboundMessage, } from "@/chat/task-execution/state"; @@ -220,8 +222,6 @@ export async function ensureConversationWake(args: { /** Persist one inbound message idempotently in its conversation mailbox. */ export async function appendInboundMessage(args: { - /** Revalidate an outer admission lock at the durable mailbox boundary. */ - fence?: () => Promise; message: InboundMessage; conversationStore?: ConversationStore; nowMs?: number; @@ -236,23 +236,17 @@ export async function appendInboundMessage(args: { return result; } -/** Persist inbound work and ensure a worker wake-up. */ -export async function appendAndEnqueueInboundMessage(args: { - /** Revalidate an outer admission lock at the durable mailbox boundary. */ - fence?: () => Promise; +async function enqueueAfterAppend(args: { + appendResult: AppendExclusiveInboundMessageResult; message: InboundMessage; conversationStore?: ConversationStore; nowMs?: number; queue: ConversationWorkQueue; state?: StateAdapter; -}): Promise { +}): Promise { const nowMs = args.nowMs ?? now(); - const appendResult = await workState.appendInboundMessage({ - fence: args.fence, - message: args.message, - nowMs, - state: args.state, - }); + const appendResult = args.appendResult; + if (appendResult.status === "active") return appendResult; let idempotencyKey = args.message.inboundMessageId; if (appendResult.status === "duplicate") { const conversation = await workState.getConversation({ @@ -306,6 +300,53 @@ export async function appendAndEnqueueInboundMessage(args: { }; } +/** Persist inbound work and ensure a worker wake-up. */ +export async function appendAndEnqueueInboundMessage(args: { + message: InboundMessage; + conversationStore?: ConversationStore; + nowMs?: number; + queue: ConversationWorkQueue; + state?: StateAdapter; +}): Promise { + const nowMs = args.nowMs ?? now(); + const result = await enqueueAfterAppend({ + ...args, + appendResult: await workState.appendInboundMessage({ + message: args.message, + nowMs, + state: args.state, + }), + nowMs, + }); + if (result.status === "active") { + throw new Error("Non-exclusive mailbox enqueue returned active"); + } + return result; +} + +/** Persist exclusive inbound work and wake its worker when admitted. */ +export async function appendAndEnqueueExclusiveInboundMessage(args: { + /** Run after admission while the Conversation mutation lock is held. */ + beforeAppend: () => Promise; + message: InboundMessage; + conversationStore?: ConversationStore; + nowMs?: number; + queue: ConversationWorkQueue; + state?: StateAdapter; +}): Promise { + const nowMs = args.nowMs ?? now(); + return await enqueueAfterAppend({ + ...args, + appendResult: await workState.appendExclusiveInboundMessage({ + beforeAppend: args.beforeAppend, + message: args.message, + nowMs, + state: args.state, + }), + nowMs, + }); +} + /** Clear an accepted wake marker after its delivery finds no runnable work. */ export async function clearConsumedConversationWake(args: { conversationId: string; diff --git a/packages/junior/tests/fixtures/acp-http.ts b/packages/junior/tests/fixtures/acp-http.ts index f9582c2796..07153c645b 100644 --- a/packages/junior/tests/fixtures/acp-http.ts +++ b/packages/junior/tests/fixtures/acp-http.ts @@ -17,7 +17,8 @@ export function connectionCookie(response: Response): string { return value; } -async function readSseMessage( +/** Read the next JSON-RPC message from a raw ACP event stream. */ +export async function readAcpSseMessage( reader: ReadableStreamDefaultReader, buffer: { value: string }, ): Promise { @@ -160,7 +161,7 @@ export async function openAuthenticatedAcpConnection(args: { const buffer = { value: "" }; while (true) { - const message = await readSseMessage(reader, buffer); + const message = await readAcpSseMessage(reader, buffer); if ( "method" in message && message.method === acp.methods.client.elicitation.create && diff --git a/packages/junior/tests/integration/acp-http-recovery.test.ts b/packages/junior/tests/integration/acp-http-recovery.test.ts index aeafd1f22d..72487a257e 100644 --- a/packages/junior/tests/integration/acp-http-recovery.test.ts +++ b/packages/junior/tests/integration/acp-http-recovery.test.ts @@ -1,5 +1,6 @@ import * as acp from "@agentclientprotocol/sdk"; import type { StateAdapter } from "chat"; +import { completeAcpAuthorization } from "@sentry/junior-acp"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createApp } from "@/app"; import { @@ -13,19 +14,121 @@ import { } from "../fixtures/api-turn"; import { ACP_TEST_URL, + connectionCookie, createIndependentConversationWork, initializeAndAuthenticate, + initializeRequest, openAuthenticatedAcpConnection, + readAcpSseMessage, withAcpClient, } from "../fixtures/acp-http"; import { deferred, streamReplies } from "../fixtures/conversation-work"; import { createModelStream } from "../fixtures/model-stream"; +import { testViewer } from "../fixtures/user"; describe("remote ACP recovery", () => { afterEach(async () => { await closeApiTurnWorkFixture(); }); + it("finishes an authenticate request when browser sign-in expires", async () => { + const harness = await createConversationWorkWebHarness(); + const app = await createApp({ + conversationWork: harness.conversationWork, + experimental: { acp: true, subagents: true }, + }); + const initialized = await app.fetch(initializeRequest()); + const connectionId = initialized.headers.get("Acp-Connection-Id"); + if (!connectionId) throw new Error("ACP initialize returned no connection"); + const cookie = connectionCookie(initialized); + const stream = await app.request(ACP_TEST_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + "Acp-Connection-Id": connectionId, + Cookie: cookie, + }, + }); + const reader = stream.body?.getReader(); + if (!reader) throw new Error("ACP GET returned no stream body"); + const requestId = "expired-authenticate"; + const accepted = await app.request(ACP_TEST_URL, { + method: "POST", + headers: { + "Acp-Connection-Id": connectionId, + "Content-Type": "application/json", + Cookie: cookie, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: requestId, + method: acp.methods.agent.authenticate, + params: { methodId: "junior" }, + }), + }); + expect(accepted.status).toBe(202); + + const buffer = { value: "" }; + const elicitation = await readAcpSseMessage(reader, buffer); + if ( + !("method" in elicitation) || + elicitation.method !== acp.methods.client.elicitation.create + ) { + throw new Error("ACP authenticate returned no sign-in elicitation"); + } + const params = elicitation.params as acp.CreateElicitationRequest; + if (params.mode !== "url" || typeof params.url !== "string") { + throw new Error("ACP authenticate returned no sign-in URL"); + } + const transactionId = new URL(params.url).pathname.split("/").at(-1); + if (!transactionId) + throw new Error("ACP sign-in URL has no transaction id"); + await reader.cancel(); + reader.releaseLock(); + + const later = Date.now() + 11 * 60 * 1000; + const now = vi.spyOn(Date, "now").mockReturnValue(later); + let completion: Awaited>; + try { + completion = await completeAcpAuthorization({ + state: harness.state, + transactionId, + user: testViewer(harness.actor.email), + }); + } finally { + now.mockRestore(); + } + + expect(completion).toBe("expired"); + const resumedStream = await app.request(ACP_TEST_URL, { + method: "GET", + headers: { + Accept: "text/event-stream", + "Acp-Connection-Id": connectionId, + Cookie: cookie, + }, + }); + expect(resumedStream.status).toBe(200); + const resumedReader = resumedStream.body?.getReader(); + if (!resumedReader) throw new Error("ACP GET returned no stream body"); + const resumedBuffer = { value: "" }; + await expect( + readAcpSseMessage(resumedReader, resumedBuffer), + ).resolves.toMatchObject({ + method: acp.methods.client.elicitation.complete, + }); + await expect( + readAcpSseMessage(resumedReader, resumedBuffer), + ).resolves.toMatchObject({ + id: requestId, + error: { + message: expect.stringContaining("Junior sign-in request expired"), + }, + }); + await resumedReader.cancel(); + resumedReader.releaseLock(); + }); + it("rejects a second active prompt from another app instance", async () => { const modelStarted = deferred(); const releaseModel = deferred(); @@ -296,89 +399,6 @@ describe("remote ACP recovery", () => { ]); }, 20_000); - it("recovers stale prompt control from an old terminal Turn", async () => { - const harness = await createConversationWorkWebHarness({ - modelStream: streamReplies("Recovered old terminal control."), - }); - const app = await createApp({ - conversationWork: harness.conversationWork, - experimental: { acp: true, subagents: true }, - }); - const sessionId = await withAcpClient({ - app, - email: harness.actor.email, - run: async (context) => { - await initializeAndAuthenticate(context); - const session = await context.request(acp.methods.agent.session.new, { - cwd: "/client/workspace", - mcpServers: [], - }); - return session.sessionId; - }, - state: harness.state, - }); - const cancellation = harness.conversationWork.apiTurnCancellation; - if (!cancellation) throw new Error("ACP cancellation is not configured"); - const staleTurnId = "turn-stale-control"; - await cancellation.admit( - { - conversationId: sessionId, - messageId: "api-msg-stale-control", - turnId: staleTurnId, - }, - { - messageExists: async () => true, - run: async () => undefined, - }, - ); - const lifecycle = new ConversationTurnLifecycleService( - getConversationEventStore(), - ); - await lifecycle.complete({ - conversationId: sessionId, - createdAtMs: Date.now(), - outcome: "success", - turnId: staleTurnId, - }); - // Recovery must not depend on the newest 50 terminal events. - for (let index = 0; index < 50; index += 1) { - await lifecycle.complete({ - conversationId: sessionId, - createdAtMs: Date.now(), - outcome: "success", - turnId: `newer-terminal-${index}`, - }); - } - - const prompt = withAcpClient({ - app, - email: harness.actor.email, - run: async (context) => { - await initializeAndAuthenticate(context); - await context.request(acp.methods.agent.session.load, { - sessionId, - cwd: "/client/workspace", - mcpServers: [], - }); - return await context.request(acp.methods.agent.session.prompt, { - sessionId, - prompt: [{ type: "text", text: "Recover old control." }], - }); - }, - state: harness.state, - }); - - await vi.waitFor(() => { - expect(harness.queue.hasQueuedMessages()).toBe(true); - }); - await harness.drain(); - await expect(prompt).resolves.toEqual({ stopReason: "end_turn" }); - await expect(harness.historyTexts(sessionId)).resolves.toEqual([ - "Recover old control.", - "Recovered old terminal control.", - ]); - }, 20_000); - it("recovers prompt admission after the first queue send fails", async () => { const harness = await createConversationWorkWebHarness({ modelStream: streamReplies("Recovered queue reply."), diff --git a/packages/junior/tests/integration/api-turn-work.test.ts b/packages/junior/tests/integration/api-turn-work.test.ts index 4e3b7b00de..01b2ad127c 100644 --- a/packages/junior/tests/integration/api-turn-work.test.ts +++ b/packages/junior/tests/integration/api-turn-work.test.ts @@ -273,22 +273,13 @@ describe("api turn conversation work", () => { actor, conversationId: accepted.conversationId, destination, + durableCancellation: true, message: "Cancel before this Turn starts.", messageId: accepted.messageId, }); const cancellation = createApiTurnCancellation({ state }); const turnId = apiTurnIdForMessage(accepted.messageId); - await cancellation.admit( - { - conversationId: accepted.conversationId, - messageId: accepted.messageId, - turnId, - }, - { - messageExists: async () => true, - run: async () => undefined, - }, - ); + await cancellation.register(accepted.conversationId, turnId); await cancellation.cancel(accepted.conversationId); const agentRuns: AgentRun[] = []; const worker = createApiTurnWorker({ diff --git a/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts b/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts index c0dae9e295..6879955f21 100644 --- a/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts +++ b/packages/junior/tests/unit/chat/api-turn-cancellation.test.ts @@ -1,44 +1,15 @@ import { createMemoryState } from "@chat-adapter/state-memory"; import { describe, expect, it, vi } from "vitest"; -import { - createApiTurnCancellation, - type ApiTurnCancellation, -} from "@/chat/api-turns/cancellation"; - -async function admit( - cancellation: ApiTurnCancellation, - turnId: string, - value = turnId, -) { - return await cancellation.admit( - { - conversationId: "conversation-1", - messageId: `message:${turnId}`, - turnId, - }, - { - messageExists: async () => false, - run: async () => value, - }, - ); -} +import { createApiTurnCancellation } from "@/chat/api-turns/cancellation"; describe("API Turn cancellation", () => { - it("cancels one active Turn and blocks a second Turn through shared state", async () => { + it("cancels one active Turn through shared state", async () => { const state = createMemoryState(); await state.connect(); const ingress = createApiTurnCancellation({ state }); const worker = createApiTurnCancellation({ pollIntervalMs: 5, state }); - await expect(admit(ingress, "turn-1")).resolves.toEqual({ - status: "accepted", - value: "turn-1", - }); - await expect(admit(worker, "turn-2")).resolves.toEqual({ - messageId: "message:turn-1", - status: "active", - turnId: "turn-1", - }); + await ingress.register("conversation-1", "turn-1"); const signal = await worker.begin("conversation-1", "turn-1"); if (!signal) throw new Error("Expected an active Turn signal"); @@ -50,72 +21,11 @@ describe("API Turn cancellation", () => { await state.disconnect(); }); - it("fences a stale admission before its callback can append", async () => { - const state = createMemoryState(); - await state.connect(); - const cancellation = createApiTurnCancellation({ state }); - const appended: string[] = []; - let resumeStale: (() => void) | undefined; - let markStaleStarted: (() => void) | undefined; - const staleStarted = new Promise((resolve) => { - markStaleStarted = resolve; - }); - const stalePaused = new Promise((resolve) => { - resumeStale = resolve; - }); - - const staleAdmission = cancellation.admit( - { - conversationId: "conversation-1", - messageId: "message:turn-1", - turnId: "turn-1", - }, - { - messageExists: async () => false, - run: async (fence) => { - markStaleStarted?.(); - await stalePaused; - await fence(); - appended.push("turn-1"); - }, - }, - ); - await staleStarted; - await state.forceReleaseLock( - "junior:api-turn:v1:control:conversation-1:lock", - ); - - await expect( - cancellation.admit( - { - conversationId: "conversation-1", - messageId: "message:turn-2", - turnId: "turn-2", - }, - { - messageExists: async () => false, - run: async (fence) => { - await fence(); - appended.push("turn-2"); - }, - }, - ), - ).resolves.toMatchObject({ status: "accepted" }); - - resumeStale?.(); - await expect(staleAdmission).rejects.toThrow( - "Lock ownership was lost before write", - ); - expect(appended).toEqual(["turn-2"]); - await cancellation.finish("conversation-1", "turn-2"); - await state.disconnect(); - }); - it("rebuilds a signal after an execution slice yields", async () => { const state = createMemoryState(); await state.connect(); const cancellation = createApiTurnCancellation({ state }); - await admit(cancellation, "turn-1"); + await cancellation.register("conversation-1", "turn-1"); const first = await cancellation.begin("conversation-1", "turn-1"); if (!first) throw new Error("Expected an active Turn signal"); @@ -132,7 +42,7 @@ describe("API Turn cancellation", () => { const state = createMemoryState(); await state.connect(); const cancellation = createApiTurnCancellation({ state }); - await admit(cancellation, "turn-1"); + await cancellation.register("conversation-1", "turn-1"); const signal = await cancellation.begin("conversation-1", "turn-1"); if (!signal) throw new Error("Expected an active Turn signal");