Skip to content

feat(acp): support serverless remote sessions - #1589

Open
gricha wants to merge 2 commits into
t3code/api-turn-durable-controlfrom
t3code/acp-serverless
Open

feat(acp): support serverless remote sessions#1589
gricha wants to merge 2 commits into
t3code/api-turn-durable-controlfrom
t3code/acp-serverless

Conversation

@gricha

@gricha gricha commented Aug 15, 2026

Copy link
Copy Markdown
Member

Stacked on #1643. Remote ACP is opt-in and runs on Junior's existing durable Conversation runtime. An ACP session id is the Conversation id. Prompts use the mailbox, queue, lease, checkpoint, and event log, so accepted work continues after an SSE disconnect and session/load replays stored Messages after reconnect.

Authentication uses ACP URL elicitation and the existing dashboard Google OAuth session. The client shows a one-time verification code that the signed-in user must enter in the browser before Junior binds the canonical User to the ACP connection. Personal tokens and other Authorization headers do not grant ACP access.

@sentry/junior-acp owns JSON-RPC, SSE, browser authorization, and short-lived transport records behind a narrow ConversationPort. Junior implements that port in one adapter. Production transport state uses the configured Redis StateAdapter, so requests can reach any app instance without process affinity or a separate ACP service. This PR has no production changes under packages/junior/src/chat.

ACP v1 still requires live SSE requests. If the hosting request limit closes a stream, the client must reconnect and load the Conversation; accepted work keeps running. Review packages/junior-acp first, then packages/junior/src/api/acp-conversations.ts and the app mount.

@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
junior-docs Ready Ready Preview Aug 22, 2026 2:00am

Request Review

@gricha
gricha marked this pull request as ready for review August 15, 2026 19:00
@github-actions github-actions Bot added the risk: high PR risk score: high label Aug 15, 2026
@gricha
gricha requested a review from dcramer August 15, 2026 19:03
Comment thread packages/junior/src/chat/api-turns/work.ts Outdated

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direction looks right: sessionId = Conversation id, durable mailbox/worker path, and StateAdapter-backed transport/cancellation instead of inventing a side ACP runtime. The multi-instance recovery coverage is also the right shape.

One real contract gap before I'd merge this:

streamPrompt returns the prompt end_turn as soon as it sees turn_completed, but API Turn control is only cleared in finishCancellationAfterTerminal after mailbox acknowledge. A client that immediately sends the next session/prompt can still observe the previous control as active. The recovery path in acceptPrompt refuses to clear that control while the message remains pending, so the follow-up fails with "already has an active prompt" even though the previous Turn already completed. The old in-process path finished cancellation before returning the prompt result, so this is a back-to-back prompt regression.

Please release durable control at/after the terminal Turn event (or otherwise make admit treat terminal+pending-ack as finished), and add an integration case that sends the next prompt as soon as end_turn arrives without waiting for worker cleanup.

Comment thread packages/junior/src/api/acp/route.ts Outdated

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me now. The terminal handoff waits until the mailbox input is acknowledged before emitting the prompt result, so a follow-up cannot overlap retryable work; once acknowledgement is visible, acceptPrompt can safely clear any lagging control and admit the next Turn. The cross-instance integration coverage exercises both sides of that boundary.

Comment thread packages/junior/src/api/acp/route.ts Outdated
Comment thread packages/junior/src/api/acp/route.ts Outdated
Comment thread packages/junior/src/chat/api-turns/work.ts Outdated
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Dashboard visual evidence

Mode: path-selected
Selected: gallery-index, conversations, conversation-detail, conversation-detail-focused

Triggered by:

  • packages/junior-dashboard/e2e/conversations.spec.ts
  • packages/junior-dashboard/package.json
  • packages/junior-dashboard/src/acp-authorization.ts
  • packages/junior-dashboard/src/app.ts
  • packages/junior-dashboard/src/auth.ts
  • packages/junior-dashboard/src/client/conversations/ConversationPage.tsx
  • packages/junior-dashboard/src/client/conversations/ConversationWorkspace.tsx
  • packages/junior-dashboard/src/client/conversations/queries.ts

Component gallery index · desktop

Component gallery index · desktop

Conversations · desktop

Conversations · desktop

Conversations · mobile

Conversations · mobile

Conversation detail · desktop

Conversation detail · desktop

Conversation detail · mobile

Conversation detail · mobile

Conversation detail · focused composer · mobile

Conversation detail · focused composer · mobile

Full-page screenshots from the mock dashboard. Not a pixel-diff gate.

@blacksmith-sh

This comment has been minimized.

Comment thread packages/junior-acp/src/transport.ts
Comment thread packages/junior-dashboard/src/acp-authorization.ts
Comment thread packages/junior-acp/src/auth.ts
Comment thread packages/junior-acp/src/route.ts
Comment thread packages/junior-acp/src/state.ts Outdated
Comment thread packages/junior-acp/src/auth.ts
Comment thread packages/junior-acp/src/auth.ts Outdated
Comment thread packages/junior-acp/src/route.ts Outdated
Comment thread packages/junior-acp/src/transport.ts Outdated
Comment thread packages/junior-acp/src/auth.ts Outdated
Comment thread packages/junior/src/app.ts

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple edge contracts still need tightening:

  • Enabling experimental.acp without a mounted dashboard still exposes /api/acp, but every authentication URL points to /api/acp/auth/:id, which is only registered by the dashboard app. That configuration initializes successfully and then all remote sign-ins 404. Please either require an enabled dashboard when ACP is configured or mount the authorization handler independently, with coverage for the supported configuration.
  • acceptAcpRequest() creates the receipt before checking stream capacity. For session/prompt, that admits durable mailbox work, then queueReceipt() can return full and the HTTP request responds 503. The client is told the request failed even though the Turn is running and blocks another prompt. Please reserve/check output capacity before the side effect, or return an accepted response that preserves a reliable way to recover the stored receipt.

gricha added 2 commits August 21, 2026 18:36
Add the ACP transport, browser OAuth flow, dashboard authorization, and Junior conversation adapter on top of the provider-neutral runtime contracts.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b4ccf63. Configure here.

conversationId,
state: options.state,
}))
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminal gated on other turns

Medium Severity

readTurn only surfaces a turn’s terminal event when the whole Conversation has no runnable work. That check is conversation-wide, not scoped to the turn being streamed, so a follow-up admitted after cleanup can keep the prior prompt from ever getting end_turn / cancelled while later work stays active. Concurrent connections or a fast reconnect on the same session can hit this window and leave the earlier prompt hanging.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b4ccf63. Configure here.

@sentry-junior
sentry-junior Bot dismissed their stale review August 22, 2026 02:11

Addressed in follow-up review.

@sentry-junior sentry-junior Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. ACP now fails closed without a dashboard, and prompt admission reserves stream capacity before creating the durable receipt. The new unit/integration coverage locks both contracts.


function isJsonContentType(contentType: string | null): boolean {
return (
contentType?.split(";", 1)[0]?.trim().toLowerCase() === ACP_JSON_MIME_TYPE

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isJsonContentType crashes on missing Content-Type due to unguarded array access after optional chain

When the Content-Type header is absent on ACP POST requests, isJsonContentType(null) throws a TypeError because the optional chain short-circuits to undefined, and the subsequent [0] member access is not guarded. This causes the handler to return HTTP 500 instead of the intended 415 Unsupported Media Type.

Evidence
  • request.headers.get("Content-Type") returns null when the header is absent.
  • null?.split(";", 1) short-circuits to undefined.
  • [0] on undefined throws TypeError: Cannot read properties of undefined (reading '0').
  • The outer catch in createAcpHttpHandler catches this and returns HTTP 500 "ACP transport failed", bypassing the intended 415 response.

Identified by Warden · code-review · SJB-S7V

reserveRoute?: AcpStreamRoute;
requestKey: string;
state: StateAdapter;
}): Promise<"accepted" | "busy" | "full"> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

acceptAcpRequest creates side effects on expired connections

acceptAcpRequest never checks whether the ACP connection is still live before calling the side-effecting createReceipt callback, so a retried accept can spawn a Conversation turn and queue output for a connection that was already deleted.

Evidence
  • completeAcpRequest at line 512 guards every path with readAcpConnection and returns "expired" when the connection is gone.
  • acceptAcpRequest at line 548 jumps straight to reading stored and calling createReceipt() without any connection-liveness guard.
  • The caller (route.ts handleConnectedPost) checks requireConnection before invoking acceptAcpRequest, but that check is outside the lock, so the connection can be deleted in the race window before the lock is acquired.
  • The missing guard means createReceipt()—which calls conversations.prompt() and creates durable Conversation work—runs for a dead connection, leaving orphaned Turns.

Identified by Warden · code-review · 4CH-62M

Comment on lines +943 to +960
conversations: args.conversations,
connectionId: args.route.connectionId,
emit,
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-emit lease renewal failure causes duplicate delivery on reconnect

After a message, replay, or prompt is successfully emitted, a thrown retainLease() aborts pump() before the stream cursor is updated. The stale cursor causes the same item to be re-delivered when the client reconnects.

Evidence
  • emit(), streamSessionReplay(), and streamPrompt() all enqueue SSE data and return true before pump() reaches the first await retainLease() on line 943.
  • retainLease() explicitly throws when extendLock returns false or when extendLock throws after the lease expiry window.
  • If this call throws, the exception propagates out of pump(), fail() tears the stream down, and the streamCursorKey write on line 954 is skipped.
  • On the next SSE connection with the same route, readStreamCursor returns the old cursor, so the already-delivered item is still treated as pending and re-emitted.
  • There is no guard or idempotency check in streamSessionReplay or streamPrompt to prevent re-emitting the same chunks on a later pump() invocation.

Identified by Warden · code-review · UM4-644

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: high PR risk score: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant