Skip to content
Draft
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
64 changes: 61 additions & 3 deletions packages/acpx-ai-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,62 @@ const provider = createAcpxProvider({
> TCP-callback story from `acp-ai-provider`) are **not** supported in
> v0.1. See [Known limitations](#known-limitations).

## Per-call permissions

By default, every permission request the agent issues (write a file,
run a shell command, delete, etc.) is resolved by the up-front
`permissionMode` setting. To intercept individual requests with your
own UI, pass an `onPermissionRequest` callback:

```ts
const provider = createAcpxProvider({
agent: 'codex',
cwd: '/path/to/repo',
permissionMode: 'approve-reads', // fallback for unhandled cases
onPermissionRequest: async (req, { signal }) => {
// The agent is paused mid-turn waiting for your decision.
// Honor `signal` so a turn cancel doesn't leave it hanging.
const decision = await myUi.prompt({
title: req.raw.toolCall.title,
kind: req.inferredKind, // 'edit' | 'shell' | 'delete' | …
args: req.raw.toolCall.input,
})
return decision
// Returning `undefined` falls through to the mode-based resolver.
},
})
```

The callback receives:

| Field | Meaning |
|---|---|
| `req.sessionId` | ACP session id (handy for multi-session hosts) |
| `req.raw` | Full original `RequestPermissionRequest` from the ACP SDK |
| `req.inferredKind` | One of `'read' \| 'search' \| 'edit' \| 'delete' \| 'move' \| 'execute' \| 'fetch' \| 'think' \| 'other'` — best-effort classification from the tool's title |
| `ctx.signal` | Aborts when the turn is cancelled or the session closes |

Return one of:

- `{ outcome: 'allow_once' }` — approve this single call
- `{ outcome: 'allow_always' }` — approve this kind for the rest of the turn
- `{ outcome: 'reject_once' }` — deny this call; agent continues with the rest of its task
- `{ outcome: 'reject_always' }` — deny and remember for the rest of the turn
- `{ outcome: 'cancel' }` — agent treats the call as cancelled (often ends the turn)
- `undefined` — fall through to the mode-based resolver

**Important caveats:**

- The callback is invoked **only** when the provider builds its own
runtime. If you pass a pre-built `runtime` via the `runtime`
setting, set `onPermissionRequest` on that runtime instead.
- Throwing inside the callback falls through to mode-based logic and
is logged by the runtime. Don't let UI errors take the whole turn
down.
- The agent is **paused** until your promise resolves. There's no
timeout enforced by the provider — wire your own (or rely on the
agent's internal timeout, typically 5–10 minutes).

## Structured output (JSON)

`generateObject` / `streamObject` work via JSON mode. The provider
Expand Down Expand Up @@ -277,9 +333,11 @@ This is alpha software. Most rough edges flow through from
are `undefined`. Per-token cost calculation won't work.
- **No streaming usage updates.** Only the most recent
`usage_update` from the runtime survives onto the `finish` part.
- **Permissions are mode-based, not callback-based.** No per-call
user prompt — pick `approve-all`, `approve-reads`, or `deny-all` up
front.
- **Permission policy is mode-based by default.** When you don't
provide an `onPermissionRequest` callback, requests fall through to
`permissionMode` + `nonInteractivePermissions` — same as before.
Hosts wanting per-call gating should set the callback (see
[Per-call permissions](#per-call-permissions)).
- **Auth is env-var / config-file driven, no lazy retry.** Missing
credentials throw at first use.
- **`npx` cold start on first agent use.** Built-in agents
Expand Down
6 changes: 3 additions & 3 deletions packages/acpx-ai-provider/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acpx-ai-provider",
"version": "0.0.1",
"version": "0.1.0",
"description": "Vercel AI SDK provider on top of acpx/runtime — bring any ACP agent (Claude, Codex, Gemini, Copilot, Cursor…) to AI SDK with one install.",
"license": "MIT",
"author": "Dani Akash",
Expand Down Expand Up @@ -48,13 +48,13 @@
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"acpx": ">=0.6.1",
"acpx": ">=0.8.0",
"ai": ">=6.0.0"
},
"devDependencies": {
"@ai-sdk/provider": "^3.0.10",
"@ai-sdk/provider-utils": "^4.0.26",
"acpx": "^0.6.1",
"acpx": ">=0.8.0",
"ai": "^6.0.175",
"bunup": "^0.16.31",
"typescript": "^6.0.3",
Expand Down
2 changes: 2 additions & 0 deletions packages/acpx-ai-provider/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export { AcpxLanguageModel } from './language-model.ts'
export type { EnsureHandleResult } from './provider.ts'
export { AcpxProvider, createAcpxProvider } from './provider.ts'
export type {
AcpPermissionDecision,
AcpPermissionRequest,
AcpRuntime,
AcpRuntimeDoctorReport,
AcpRuntimeEvent,
Expand Down
1 change: 1 addition & 0 deletions packages/acpx-ai-provider/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class AcpxProvider {
DEFAULT_NON_INTERACTIVE) as AcpRuntimeOptions['nonInteractivePermissions'],
timeoutMs: this.settings.turnTimeoutMs,
mcpServers: this.settings.mcpServers as AcpRuntimeOptions['mcpServers'],
onPermissionRequest: this.settings.onPermissionRequest,
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions packages/acpx-ai-provider/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
AcpPermissionDecision,
AcpPermissionRequest,
AcpRuntime,
AcpRuntimeDoctorReport,
AcpRuntimeEvent,
Expand Down Expand Up @@ -35,6 +37,25 @@ export interface AcpxProviderSettings {
sessionMode?: AcpxSessionMode
permissionMode?: AcpxPermissionMode
nonInteractivePermissions?: AcpxNonInteractivePermissions
/**
* Async callback invoked when the agent issues a per-call permission
* request (e.g. write, shell, delete). Return a decision to gate the
* call with host UI. Return `undefined` to fall through to the
* existing `permissionMode` + `nonInteractivePermissions` logic.
*
* The callback is invoked while the agent is paused mid-turn waiting
* for the JSON-RPC response — resolve quickly or honor the abort
* signal so the agent doesn't hang.
*
* Note: this option is *only* honored when `runtime` is left
* undefined (so the provider builds its own runtime). When the host
* passes a pre-built `runtime`, the callback must be set on that
* runtime directly.
*/
onPermissionRequest?: (
req: AcpPermissionRequest,
ctx: { signal: AbortSignal },
) => Promise<AcpPermissionDecision | undefined>
mcpServers?: AcpxMcpServerConfig[]
agentRegistryOverrides?: Record<string, string>
stateDir?: string
Expand All @@ -54,6 +75,8 @@ export interface AcpxLanguageModelOptions {
}

export type {
AcpPermissionDecision,
AcpPermissionRequest,
AcpRuntime,
AcpRuntimeDoctorReport,
AcpRuntimeEvent,
Expand Down
83 changes: 83 additions & 0 deletions packages/acpx-ai-provider/test/unit/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import type {
AcpPermissionDecision,
AcpPermissionRequest,
AcpRuntime,
AcpRuntimeOptions,
} from 'acpx/runtime'

const createAcpRuntimeMock = mock(
(_options: AcpRuntimeOptions): AcpRuntime =>
({
ensureSession: async () => ({}),
startTurn: () => ({
requestId: 'r',
events: { [Symbol.asyncIterator]: async function* () {} },
result: Promise.resolve({ status: 'completed' }),
cancel: async () => {},
closeStream: async () => {},
}),
cancel: async () => {},
close: async () => {},
}) as unknown as AcpRuntime,
)

mock.module('acpx/runtime', () => ({
createAcpRuntime: createAcpRuntimeMock,
createAgentRegistry: () => ({}),
createFileSessionStore: () => ({}),
}))

// Imported AFTER `mock.module` so the provider sees our stubs.
const { createAcpxProvider } = await import('../../src/provider.ts')

beforeEach(() => {
createAcpRuntimeMock.mockClear()
})

afterEach(() => {
createAcpRuntimeMock.mockClear()
})

describe('AcpxProvider — onPermissionRequest', () => {
test('forwards the callback into AcpRuntimeOptions', () => {
const cb = async (
_req: AcpPermissionRequest,
): Promise<AcpPermissionDecision | undefined> => undefined
const provider = createAcpxProvider({
agent: 'codex',
onPermissionRequest: cb,
})
void provider.runtime // force lazy build

expect(createAcpRuntimeMock).toHaveBeenCalledTimes(1)
const opts = createAcpRuntimeMock.mock.calls[0]?.[0]
expect(opts?.onPermissionRequest).toBe(cb)
})

test('omits the callback when not configured', () => {
const provider = createAcpxProvider({ agent: 'codex' })
void provider.runtime

const opts = createAcpRuntimeMock.mock.calls.at(-1)?.[0]
expect(opts?.onPermissionRequest).toBeUndefined()
})

test('skips runtime construction when a pre-built runtime is provided', () => {
const fakeRuntime = {
ensureSession: async () => ({}),
} as unknown as AcpRuntime
const cb = async (
_req: AcpPermissionRequest,
): Promise<AcpPermissionDecision | undefined> => undefined

const provider = createAcpxProvider({
agent: 'codex',
runtime: fakeRuntime,
onPermissionRequest: cb,
})

expect(provider.runtime).toBe(fakeRuntime)
expect(createAcpRuntimeMock).not.toHaveBeenCalled()
})
})