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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`react-acp` 将 [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) 会话投影为 [assistant-ui](https://www.assistant-ui.com/) runtime。ACP session 是线程权威来源;消息、推理、工具调用、权限、计划、模式、配置与用量由协议事件驱动。

> 当前状态:`0.1.0` 开发版。兼容承诺覆盖官方 TypeScript SDK 标记为稳定的 ACP v1 API;实验 API 与 ACP v2 Draft 不在承诺范围内。
> 当前状态:`0.1.1` 开发版。兼容承诺覆盖官方 TypeScript SDK 标记为稳定的 ACP v1 API;实验 API 与 ACP v2 Draft 不在承诺范围内。

## 安装

Expand Down Expand Up @@ -46,6 +46,20 @@ export function AcpProvider({ children }: { children: React.ReactNode }) {

高层 adapter、纯 reducer/projector 和结构化错误从 `@hafbit/react-acp/core` 导出;认证、计划、模式、配置、命令、权限和 ACP artifact 的无样式组件从 `@hafbit/react-acp/primitives` 导出。主入口同时提供对应 hooks。

## 入口与 API

| 入口 | 适用场景 | 主要导出 |
| --- | --- | --- |
| `@hafbit/react-acp` | React 应用的常规集成 | `useAcpRuntime`、ACP hooks、常用无样式组件与公开类型 |
| `@hafbit/react-acp/core` | 自定义宿主、transport 或状态投影 | `AcpThreadController`、`SdkAcpClientAdapter`、reducer、projector、serializer、错误和类型 |
| `@hafbit/react-acp/primitives` | 自定义 ACP 交互界面 | 认证、权限、计划、模式、配置、命令、用量和 tool artifact 组件 |

```tsx
import { useAcpRuntime } from "@hafbit/react-acp";
import { AcpThreadController } from "@hafbit/react-acp/core";
import { AcpPermissionList } from "@hafbit/react-acp/primitives";
```

设计与验收资料:

- [需求基线](./docs/requirements.md)
Expand All @@ -67,6 +81,10 @@ pnpm --dir examples/vite dev

The package does not launch agents, provide a gateway, persist sessions, or grant filesystem/terminal access. Applications inject either an ACP `Stream` factory or an `AcpClientAdapter`.

The package exposes three entrypoints: the default React runtime and hooks,
`@hafbit/react-acp/core` for headless integration, and
`@hafbit/react-acp/primitives` for unstyled ACP UI components.

Install and use the same minimal provider API:

```bash
Expand Down
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@hafbit/react-acp",
"version": "0.1.0",
"version": "0.1.1",
"exports": {
".": "./src/index.ts",
"./core": "./src/core/index.ts",
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@hafbit/react-acp",
"version": "0.1.0",
"description": "Agent Client Protocol runtime adapter for assistant-ui",
"version": "0.1.1",
"description": "Transport-agnostic ACP v1 runtime adapter for assistant-ui, with React hooks and headless primitives.",
"repository": {
"type": "git",
"url": "git+https://github.com/hafbit/react-acp.git"
Expand Down Expand Up @@ -40,6 +40,7 @@
"packageManager": "pnpm@10.15.1",
"scripts": {
"build": "tsup",
"docs:check": "node scripts/check-jsr-docs.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
Expand All @@ -55,7 +56,7 @@
"release:pack": "node scripts/release-pack.mjs",
"smoke:codex": "node scripts/smoke-agent.mjs codex",
"smoke:opencode": "node scripts/smoke-agent.mjs opencode",
"check": "pnpm lint && pnpm typecheck && pnpm test && pnpm release:test && pnpm jsr:check && pnpm build && pnpm example:build && pnpm test:e2e && pnpm pack:check"
"check": "pnpm lint && pnpm typecheck && pnpm docs:check && pnpm test && pnpm release:test && pnpm jsr:check && pnpm build && pnpm example:build && pnpm test:e2e && pnpm pack:check"
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.3.0",
Expand Down
86 changes: 86 additions & 0 deletions scripts/check-jsr-docs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import path from "node:path";
import process from "node:process";
import ts from "typescript";

const entrypoints = [
"src/index.ts",
"src/core/index.ts",
"src/primitives/index.ts",
];

const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("tsconfig.json was not found");

const config = ts.readConfigFile(configPath, ts.sys.readFile);
if (config.error) {
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n"));
}

const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
path.dirname(configPath),
);
const absoluteEntrypoints = entrypoints.map((entrypoint) =>
path.resolve(entrypoint),
);
const program = ts.createProgram(absoluteEntrypoints, {
...parsed.options,
noEmit: true,
});
const checker = program.getTypeChecker();
const failures = [];

for (const [index, absoluteEntrypoint] of absoluteEntrypoints.entries()) {
const entrypoint = entrypoints[index];
const sourceFile = program.getSourceFile(absoluteEntrypoint);
if (!sourceFile) {
failures.push(`${entrypoint}: entrypoint was not loaded`);
continue;
}

const leadingComment = sourceFile.getFullText().match(/^\s*\/\*\*[\s\S]*?\*\//)?.[0];
if (!leadingComment?.includes("@module")) {
failures.push(`${entrypoint}: missing a leading @module JSDoc comment`);
}

const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
if (!moduleSymbol) {
failures.push(`${entrypoint}: module symbol was not resolved`);
continue;
}

const undocumented = checker
.getExportsOfModule(moduleSymbol)
.filter((symbol) => symbol.name !== "default")
.filter((symbol) => {
const target =
symbol.flags & ts.SymbolFlags.Alias
? checker.getAliasedSymbol(symbol)
: symbol;
return !ts
.displayPartsToString(target.getDocumentationComment(checker))
.trim();
})
.map((symbol) => symbol.name)
.sort();

const exportedCount = checker
.getExportsOfModule(moduleSymbol)
.filter((symbol) => symbol.name !== "default").length;
if (undocumented.length) {
failures.push(
`${entrypoint}: undocumented exports: ${undocumented.join(", ")}`,
);
} else {
console.log(`${entrypoint}: ${exportedCount}/${exportedCount} exports documented`);
}
}

if (failures.length) {
console.error("JSR documentation coverage check failed:\n");
for (const failure of failures) console.error(`- ${failure}`);
process.exit(1);
}

console.log("All JSR entrypoints have module docs and 100% symbol documentation.");
36 changes: 35 additions & 1 deletion src/core/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ type PermissionWaiter = {
reject(error: unknown): void;
};

/**
* Owns one ACP connection and the protocol-authoritative session repository.
*
* The controller negotiates capabilities, gates optional operations, reduces
* protocol events, and exposes lifecycle methods used by {@link useAcpRuntime}.
*/
export class AcpThreadController {
private state = createAcpThreadState();
private readonly listeners = new Set<() => void>();
Expand All @@ -43,28 +49,34 @@ export class AcpThreadController {
private readonly permissionWaiters = new Map<string, PermissionWaiter>();
private disposed = false;

/** Creates a controller and validates the configured workspace paths. */
constructor(private readonly options: AcpRuntimeOptions) {
validateWorkspace(options.workspace);
}

/** Returns the current immutable thread-state snapshot. */
getState = (): AcpThreadState => this.state;

/** Subscribes to state changes and returns an unsubscribe function. */
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};

/** Reduces an event and notifies state subscribers. */
private dispatch(event: AcpStateEvent): void {
this.state = reduceAcpThreadState(this.state, event);
for (const listener of this.listeners) listener();
}

/** Reports an asynchronous controller failure to the host callback. */
private reportError(error: unknown): void {
this.options.onError?.(error);
}

/** Resolves the configured adapter or creates the default SDK adapter. */
private get adapter(): AcpClientAdapter {
return this.options.connection.type === "adapter"
? this.options.connection.adapter
Expand All @@ -74,6 +86,7 @@ export class AcpThreadController {
);
}

/** Opens and initializes the ACP connection; concurrent calls share one attempt. */
async connect(): Promise<void> {
if (this.disposed) throw new AcpError("ACP_DISPOSED", "Controller disposed");
if (this.connection && !this.connection.signal.aborted) return;
Expand All @@ -100,6 +113,7 @@ export class AcpThreadController {
return this.connectPromise;
}

/** Performs one ACP transport and initialization attempt. */
private async doConnect(): Promise<void> {
this.abortController?.abort();
const abortController = new AbortController();
Expand Down Expand Up @@ -146,7 +160,7 @@ export class AcpThreadController {
),
clientInfo: this.options.clientInfo ?? {
name: "react-acp",
version: "0.1.0",
version: "0.1.1",
},
});
if (abortController.signal.aborted) {
Expand All @@ -172,19 +186,22 @@ export class AcpThreadController {
}
}

/** Closes the current connection and starts a fresh initialization. */
async reconnect(): Promise<void> {
this.connection?.close();
this.connection = undefined;
await this.connect();
}

/** Authenticates with an advertised method and completes session setup. */
async authenticate(methodId: string): Promise<void> {
const connection = this.requireConnection();
await connection.authenticate(methodId);
this.dispatch({ type: "connection.status", status: "ready" });
await this.afterAuthentication();
}

/** Logs out when the agent advertises the ACP logout capability. */
async logout(): Promise<void> {
if (!hasAgentCapability(this.state.capabilities, "logout")) {
throw new AcpCapabilityError("logout");
Expand All @@ -196,13 +213,15 @@ export class AcpThreadController {
});
}

/** Refreshes sessions and applies a controlled session after authentication. */
private async afterAuthentication(): Promise<void> {
if (hasAgentCapability(this.state.capabilities, "list")) {
await this.refreshSessions();
}
if (this.options.threadId) await this.selectSession(this.options.threadId);
}

/** Loads every page of the agent's session list into local state. */
async refreshSessions(): Promise<void> {
const connection = this.requireConnection();
const sessions: SessionInfo[] = [];
Expand All @@ -217,6 +236,7 @@ export class AcpThreadController {
this.dispatch({ type: "sessions.listed", sessions });
}

/** Creates, selects, and returns a new ACP session ID. */
async createSession(): Promise<string> {
const base = buildSessionRequest(this.options.workspace, this.state.capabilities);
const response = await this.requireConnection().newSession(base);
Expand All @@ -230,6 +250,7 @@ export class AcpThreadController {
return response.sessionId;
}

/** Selects a known session and loads or resumes it when necessary. */
async selectSession(sessionId: string): Promise<void> {
if (this.state.activeSessionId === sessionId) return;
const connection = this.requireConnection();
Expand Down Expand Up @@ -272,6 +293,7 @@ export class AcpThreadController {
this.options.onThreadIdChange?.(sessionId);
}

/** Permanently deletes a session when the agent advertises support. */
async deleteSession(sessionId: string): Promise<void> {
if (!hasAgentCapability(this.state.capabilities, "delete")) {
throw new AcpCapabilityError("session/delete");
Expand All @@ -280,6 +302,7 @@ export class AcpThreadController {
this.dispatch({ type: "session.deleted", sessionId });
}

/** Explicitly resumes a session when the agent advertises support. */
async resumeSession(sessionId: string): Promise<void> {
if (!hasAgentCapability(this.state.capabilities, "resume")) {
throw new AcpCapabilityError("session/resume");
Expand All @@ -296,13 +319,15 @@ export class AcpThreadController {
});
}

/** Closes a session without deleting it when the agent advertises support. */
async closeSession(sessionId: string): Promise<void> {
if (!hasAgentCapability(this.state.capabilities, "close")) {
throw new AcpCapabilityError("session/close");
}
await this.requireConnection().closeSession(sessionId);
}

/** Sends one serialized ACP prompt turn and records its lifecycle. */
async prompt(
sessionId: string,
prompt: ContentBlock[],
Expand All @@ -323,6 +348,7 @@ export class AcpThreadController {
}
}

/** Serializes and sends an assistant-ui user message with optimistic projection. */
async sendMessage(message: AppendMessage): Promise<void> {
const sessionId = this.state.activeSessionId ?? (await this.createSession());
const prompt = serializeAppendMessage(message, this.state.capabilities);
Expand Down Expand Up @@ -359,6 +385,7 @@ export class AcpThreadController {
}
}

/** Cancels pending permissions and the active prompt turn for a session. */
async cancel(sessionId: string): Promise<void> {
this.dispatch({ type: "session.cancel_started", sessionId });
for (const [toolCallId, permission] of Object.entries(
Expand All @@ -371,13 +398,15 @@ export class AcpThreadController {
await this.requireConnection().cancel(sessionId);
}

/** Changes a session mode when modes were advertised by the agent. */
async setMode(sessionId: string, modeId: string): Promise<void> {
if (!this.state.sessions[sessionId]?.modes) {
throw new AcpCapabilityError("session/set_mode");
}
await this.requireConnection().setSessionMode({ sessionId, modeId });
}

/** Changes an advertised session configuration option. */
async setConfigOption(
sessionId: string,
configId: string,
Expand Down Expand Up @@ -407,6 +436,7 @@ export class AcpThreadController {
});
}

/** Bridges one ACP permission request to a later host reply. */
private waitForPermission(
request: Parameters<NonNullable<Parameters<AcpClientAdapter["connect"]>[0]["handlers"]["requestPermission"]>>[0],
signal: AbortSignal,
Expand Down Expand Up @@ -443,6 +473,7 @@ export class AcpThreadController {
});
}

/** Resolves a pending permission, or cancels it when `optionId` is omitted. */
async replyToPermission(
sessionId: string,
toolCallId: string,
Expand All @@ -464,6 +495,7 @@ export class AcpThreadController {
waiter.resolve(response);
}

/** Permanently disposes the controller and rejects pending permission requests. */
dispose(): void {
this.disposed = true;
this.abortController?.abort(new AcpError("ACP_DISPOSED", "Controller disposed"));
Expand All @@ -475,6 +507,7 @@ export class AcpThreadController {
this.listeners.clear();
}

/** Disconnects the current transport while allowing a later reconnect. */
disconnect(): void {
this.abortController?.abort();
this.connection?.close();
Expand All @@ -485,6 +518,7 @@ export class AcpThreadController {
this.permissionWaiters.clear();
}

/** Returns the live connection or throws a structured lifecycle error. */
private requireConnection(): AcpClientConnection {
if (!this.connection || this.connection.signal.aborted) {
throw new AcpError("ACP_NOT_CONNECTED", "ACP is not connected.");
Expand Down
Loading