Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/example/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const [
]);

const app = await createApp({
experimental: { acp: process.env.NODE_ENV === "development" },
dashboard: {
authRequired: exampleDashboardAuthRequired(),
allowedGoogleDomains: ["sentry.io"],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"private": true,
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26",
"scripts": {
"acp:local": "node scripts/acp-local.mjs",
"dev": "node scripts/dev-server.mjs",
"dev:env": "pnpx vercel env pull .env.local --environment=development && pnpm run cloudflare:token",
"cli": "node scripts/cli-with-root-env.mjs",
Expand Down
12 changes: 10 additions & 2 deletions packages/docs/src/content/docs/reference/config-and-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ related:
| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for the internal heartbeat route; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for a non-Vercel heartbeat caller. |
| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring when the scheduler plugin is enabled. Defaults to `America/Los_Angeles`. |
| `AI_GATEWAY_API_KEY` | No | Fallback AI Gateway auth when Vercel OIDC is unavailable (local/CI/non-Vercel hosts). On Vercel, prefer project OIDC so usage attributes to the project. |
| `BLOB_STORE_ID` | Conditional | Vercel Blob store for durable conversation attachments and published public artifacts. Vercel sets this when an OIDC-enabled Blob store is connected to the project. |
| `BLOB_READ_WRITE_TOKEN` | Conditional | Static Vercel Blob credential when OIDC is unavailable. Vercel sets this for a token-connected store. |
| `BLOB_STORE_ID` | Conditional | Vercel Blob store for durable conversation attachments and published public artifacts. Vercel sets this when an OIDC-enabled Blob store is connected to the project. |
| `BLOB_READ_WRITE_TOKEN` | Conditional | Static Vercel Blob credential when OIDC is unavailable. Vercel sets this for a token-connected store. |

For Vercel deployments, create a private Blob store and connect it to the
project before using `sendFiles` or `publishImage`. Prefer an OIDC connection.
Expand Down Expand Up @@ -139,6 +139,8 @@ import { createApp } from "@sentry/junior";

const app = await createApp({
experimental: {
// ACP v1 Streamable HTTP for one-process development and testing.
acp: true,
// Model-facing spawnAgent for durable child agent work. Incomplete; keep off
// unless you are testing the #879 runtime.
subagents: true,
Expand All @@ -149,6 +151,12 @@ 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 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.

## Install-wide config defaults

Pass `configDefaults` to `createApp()` to set provider defaults across all conversations:
Expand Down
6 changes: 6 additions & 0 deletions packages/docs/src/content/docs/reference/handler-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ Handled `POST` routes:
- `/api/internal/plugin/tasks`
- `/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
keeps connection state in one Node process. Do not enable it on a multi-process
deployment.

## Expected behavior

- Unknown routes return `404`.
Expand Down
3 changes: 3 additions & 0 deletions packages/junior/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"prepare": "pnpm run build",
"prepack": "pnpm run build",
"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",
"lint:fix": "oxlint --config .oxlintrc.json --deny-warnings --fix src tests scripts bin tsup.config.ts",
Expand All @@ -66,6 +67,7 @@
"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",
Expand Down Expand Up @@ -101,6 +103,7 @@
"zod": "catalog:"
},
"devDependencies": {
"@hono/node-server": "1.19.14",
"@emnapi/core": "^1.10.0",
"@emnapi/runtime": "^1.10.0",
"@sentry/junior-github": "workspace:*",
Expand Down
136 changes: 136 additions & 0 deletions packages/junior/scripts/acp-local-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* 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.
*/
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 { migrateSchema } from "@/chat/conversations/sql/migrations";
import { getSqlExecutor } from "@/chat/db";
import {
createPersonalToken,
revokePersonalToken,
} from "@/personal-tokens/store";
import {
closeApiTurnWorkFixture,
createConversationWorkWebHarness,
} from "../tests/fixtures/api-turn";
import { streamScript } from "../tests/fixtures/conversation-work";

const DEFAULT_PORT = 3099;
const DEFAULT_REPLY = "Local Junior ACP completed this Turn.";

function localPort(): number {
const raw = process.env.JUNIOR_ACP_LOCAL_PORT?.trim();
if (!raw) return DEFAULT_PORT;
const port = Number.parseInt(raw, 10);
if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {
throw new Error("JUNIOR_ACP_LOCAL_PORT must be an integer from 0 to 65535");
}
return port;
}

await migrateSchema(getSqlExecutor());
const harness = await createConversationWorkWebHarness({
modelStream: streamScript(
process.env.JUNIOR_ACP_LOCAL_REPLY?.trim() || DEFAULT_REPLY,
),
});
const app = await createApp({
conversationWork: harness.conversationWork,
experimental: { acp: true, subagents: true },
});
let drainActive = false;

/** Drain queued API Turn work while the smoke client waits for its response. */
async function drainQueuedWork(): Promise<void> {
if (drainActive || !harness.queue.hasQueuedMessages()) return;
drainActive = true;
try {
await harness.drain();
} catch (error) {
console.error("Local ACP queue drain failed", error);
exitAfterShutdown(1);
} finally {
drainActive = false;
}
}

const drainTimer = setInterval(() => void drainQueuedWork(), 10);
const server = serve({
fetch: app.fetch,
hostname: "127.0.0.1",
port: localPort(),
});
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}`);

let smoke: ReturnType<typeof spawn> | undefined;
let shutdownPromise: Promise<void> | undefined;

/** Stop the HTTP client and server, revoke the token, and close test adapters. */
function shutdown(): Promise<void> {
shutdownPromise ??= (async () => {
clearInterval(drainTimer);
if (smoke?.exitCode === null && smoke.signalCode === null) {
smoke.kill("SIGTERM");
}
server.close();
await once(server, "close");
await revokePersonalToken({ email: harness.actor.email, id: token.id });
await closeApiTurnWorkFixture();
})();
return shutdownPromise;
}

/** Finish cleanup and exit from a terminal runtime edge. */
function exitAfterShutdown(code: number): void {
void shutdown().then(
() => process.exit(code),
(error) => {
Comment thread
gricha marked this conversation as resolved.
console.error("Local ACP shutdown failed", error);
process.exit(1);
},
);
}

for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
exitAfterShutdown(signal === "SIGINT" ? 130 : 143);
});
}

console.log("Running the official SDK smoke client...");
let smokeExitCode = 1;
try {
smoke = spawn(process.execPath, ["--import", "tsx", "scripts/acp-smoke.ts"], {
cwd: process.cwd(),
env: {
...process.env,
JUNIOR_ACP_FOLLOW_UP:
process.env.JUNIOR_ACP_FOLLOW_UP?.trim() || "Send a follow-up.",
JUNIOR_ACP_TOKEN: token.token,
JUNIOR_ACP_URL: url,
},
stdio: "inherit",
});
const [code, signal] = await once(smoke, "exit");
if (signal) {
throw new Error(`Local ACP smoke client stopped with ${signal}`);
}
smokeExitCode = code ?? 1;
} finally {
await shutdown();
}
if (smokeExitCode !== 0) process.exitCode = smokeExitCode;
100 changes: 100 additions & 0 deletions packages/junior/scripts/acp-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as acp from "@agentclientprotocol/sdk";
import { createHttpStream } from "@agentclientprotocol/sdk/experimental/http-client";

function requiredEnvironment(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}

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();

async function withConnection<T>(
run: (context: acp.ClientContext) => Promise<T>,
): Promise<T> {
const stream = createHttpStream(url, {
headers: { Authorization: `Bearer ${token}` },
});
try {
return await acp
.client({ name: "junior-acp-smoke" })
.onNotification(acp.methods.client.session.update, (context) => {
const update = context.params.update;
if (
(update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "agent_message_chunk") &&
update.content.type === "text"
) {
process.stdout.write(
`[${update.sessionUpdate}] ${update.content.text}\n`,
);
}
})
.connectWith(stream, run);
} finally {
await stream.writable.close().catch(() => undefined);
}
}

async function initialize(context: acp.ClientContext): Promise<void> {
const result = await context.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: "junior-acp-smoke", version: "1" },
});
if (result.agentCapabilities?.loadSession !== true) {
throw new Error("Junior did not advertise session/load support");
}
}

const sessionId = await withConnection(async (context) => {
await initialize(context);
if (savedSessionId) {
await context.request(acp.methods.agent.session.load, {
sessionId: savedSessionId,
cwd: process.cwd(),
mcpServers: [],
});
}
const activeSessionId =
savedSessionId ??
(
await context.request(acp.methods.agent.session.new, {
cwd: process.cwd(),
mcpServers: [],
})
).sessionId;
const result = await context.request(acp.methods.agent.session.prompt, {
sessionId: activeSessionId,
prompt: [{ type: "text", text: prompt }],
});
process.stdout.write(`[stop] ${result.stopReason}\n`);
return activeSessionId;
});

process.stdout.write(`[session] ${sessionId}\n`);

await withConnection(async (context) => {
await initialize(context);
await context.request(acp.methods.agent.session.load, {
sessionId,
cwd: process.cwd(),
mcpServers: [],
});
process.stdout.write("[reconnect] load complete\n");
if (followUp) {
const result = await context.request(acp.methods.agent.session.prompt, {
sessionId,
prompt: [{ type: "text", text: followUp }],
});
process.stdout.write(`[follow-up stop] ${result.stopReason}\n`);
}
});
46 changes: 46 additions & 0 deletions packages/junior/src/api/acp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Remote ACP

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 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.
Client paths do not select the Junior sandbox. Client MCP servers, resource
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.

Run the official-SDK smoke client against a single local process through the
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
```

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.

## Local Validation

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.

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.
Loading
Loading