@@ -278,6 +254,7 @@ export function AcpDiff({
);
}
+/** Renders retained ACP terminal data as readable JSON. */
export function AcpTerminal({
terminal,
...props
@@ -296,6 +273,7 @@ type ResourceLike = {
title?: string | null;
};
+/** Renders an ACP resource link or retained resource value. */
export function AcpResource({
resource,
...props
@@ -315,10 +293,8 @@ export function AcpResource({
);
}
-export function AcpUnsupported({
- value,
- ...props
-}: DivProps & { value: unknown }): ReactElement {
+/** Renders a forward-compatible ACP value not interpreted by this package. */
+export function AcpUnsupported({ value, ...props }: DivProps & { value: unknown }): ReactElement {
return (
{JSON.stringify(value, null, 2)}
diff --git a/src/useAcpRuntime.ts b/src/useAcpRuntime.ts
index bae50a1..59cf21d 100644
--- a/src/useAcpRuntime.ts
+++ b/src/useAcpRuntime.ts
@@ -8,7 +8,7 @@ import {
type RespondToToolApprovalOptions,
type ThreadMessage,
} from "@assistant-ui/react";
-import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
+import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { acpExtras } from "./acp-extras";
import {
AcpCapabilityError,
@@ -20,11 +20,7 @@ import {
} from "./core";
const useControllerState = (controller: AcpThreadController) =>
- useSyncExternalStore(
- controller.subscribe,
- controller.getState,
- controller.getState,
- );
+ useSyncExternalStore(controller.subscribe, controller.getState, controller.getState);
const choosePermissionOption = (
extras: AcpRuntimeExtras,
@@ -34,8 +30,7 @@ const choosePermissionOption = (
const session = extras.session;
const request = session?.permissions[response.approvalId]?.request;
const prefix = response.approved ? "allow" : "reject";
- return request?.options.find((option) => option.kind.startsWith(prefix))
- ?.optionId;
+ return request?.options.find((option) => option.kind.startsWith(prefix))?.optionId;
};
/**
@@ -50,7 +45,16 @@ const choosePermissionOption = (
* @throws {AcpError} When the workspace or ACP connection is invalid.
*/
export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime {
- const [controller] = useState(() => new AcpThreadController(options));
+ const latestOptions = useRef(options);
+ latestOptions.current = options;
+ const [controller] = useState(
+ () =>
+ new AcpThreadController({
+ ...options,
+ onError: (error) => latestOptions.current.onError?.(error),
+ onThreadIdChange: (threadId) => latestOptions.current.onThreadIdChange?.(threadId),
+ }),
+ );
const state = useControllerState(controller);
useEffect(() => {
@@ -64,18 +68,19 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime {
state.connectionStatus === "ready" &&
state.activeSessionId !== options.threadId
) {
- void controller.selectSession(options.threadId).catch(options.onError);
+ void controller
+ .selectSession(options.threadId, { notify: false })
+ .catch((error) => latestOptions.current.onError?.(error));
}
- }, [controller, options.threadId, options.onError, state.connectionStatus, state.activeSessionId]);
+ }, [controller, options.threadId, state.connectionStatus, state.activeSessionId]);
- const session = state.activeSessionId
- ? state.sessions[state.activeSessionId]
- : undefined;
+ const session = state.activeSessionId ? state.sessions[state.activeSessionId] : undefined;
const extras = useMemo
(
() => ({
state,
session,
reconnect: () => controller.reconnect(),
+ refreshSessions: () => controller.refreshSessions(),
authenticate: (methodId) => controller.authenticate(methodId),
logout: () => controller.logout(),
selectSession: (sessionId) => controller.selectSession(sessionId),
@@ -97,24 +102,18 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime {
},
replyToPermission: async (toolCallId, optionId) => {
if (!state.activeSessionId) return;
- await controller.replyToPermission(
- state.activeSessionId,
- toolCallId,
- optionId,
- );
+ await controller.replyToPermission(state.activeSessionId, toolCallId, optionId);
},
}),
[controller, session, state],
);
- const messageRepository = useMemo(
- () => projectAcpThreadRepository(state),
- [state],
- );
+ const messageRepository = useMemo(() => projectAcpThreadRepository(state), [state]);
const threadList = useMemo(
() => ({
threadId: state.activeSessionId,
+ isLoading: state.connectionStatus === "connecting",
threads: state.sessionOrder.map((sessionId) => {
const item = state.sessions[sessionId];
return {
@@ -129,8 +128,7 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime {
onSwitchToNewThread: async () => {
await controller.createSession();
},
- onSwitchToThread: (sessionId: string) =>
- controller.selectSession(sessionId),
+ onSwitchToThread: (sessionId: string) => controller.selectSession(sessionId),
...(hasAgentCapability(state.capabilities, "delete")
? { onDelete: (sessionId: string) => controller.deleteSession(sessionId) }
: {}),
@@ -140,18 +138,18 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime {
return useExternalStoreRuntime({
...pickExternalStoreSharedOptions(options),
- isLoading:
- state.connectionStatus === "connecting" || session?.runState === "loading",
+ isLoading: state.connectionStatus === "connecting" || session?.runState === "loading",
isDisabled:
state.connectionStatus === "auth-required" ||
state.connectionStatus === "error" ||
state.connectionStatus === "closed",
isSendDisabled:
state.connectionStatus !== "ready" ||
+ session?.runState === "loading" ||
+ session?.runState === "error" ||
session?.runState === "running" ||
session?.runState === "cancelling",
- isRunning:
- session?.runState === "running" || session?.runState === "cancelling",
+ isRunning: session?.runState === "running" || session?.runState === "cancelling",
messageRepository,
extras: acpExtras.provide(extras),
adapters: {
diff --git a/src/version.ts b/src/version.ts
new file mode 100644
index 0000000..d86e995
--- /dev/null
+++ b/src/version.ts
@@ -0,0 +1,2 @@
+/** Package version used for the default ACP client identity. */
+export const REACT_ACP_VERSION = "0.1.2";
diff --git a/tests/controller.test.ts b/tests/controller.test.ts
index 0b8178f..161050d 100644
--- a/tests/controller.test.ts
+++ b/tests/controller.test.ts
@@ -2,8 +2,75 @@ import type { AppendMessage } from "@assistant-ui/react";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { AcpThreadController } from "../src/core/controller";
+import type {
+ AcpAdapterConnectOptions,
+ AcpClientAdapter,
+ AcpClientConnection,
+} from "../src/core/types";
import { ConformanceAdapter } from "./fixture";
+const deferred = () => {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+class ReconnectingAdapter implements AcpClientAdapter {
+ readonly handlers: AcpAdapterConnectOptions["handlers"][] = [];
+ readonly connections: AcpClientConnection[] = [];
+ readonly loads: string[] = [];
+ supportsLoad = true;
+
+ async connect(options: AcpAdapterConnectOptions): Promise {
+ const index = this.connections.length;
+ const lifecycle = new AbortController();
+ this.handlers.push(options.handlers);
+ const connection: AcpClientConnection = {
+ signal: lifecycle.signal,
+ initialize: vi.fn(async () => ({
+ protocolVersion: PROTOCOL_VERSION,
+ agentCapabilities: {
+ loadSession: this.supportsLoad,
+ sessionCapabilities: { list: {} },
+ },
+ })),
+ authenticate: vi.fn(async () => {}),
+ logout: vi.fn(async () => {}),
+ newSession: vi.fn(async () => ({ sessionId: "created" })),
+ loadSession: vi.fn(async ({ sessionId }: { sessionId: string }) => {
+ this.loads.push(`${index}:${sessionId}`);
+ await options.handlers.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: `history-${index}`,
+ content: { type: "text", text: `history-${index}` },
+ },
+ });
+ return {};
+ }),
+ listSessions: vi.fn(async () => ({
+ sessions: [{ sessionId: "s1", cwd: "/workspace" }],
+ })),
+ deleteSession: vi.fn(async () => {}),
+ resumeSession: vi.fn(async () => ({})),
+ closeSession: vi.fn(async () => {}),
+ setSessionMode: vi.fn(async () => {}),
+ setSessionConfigOption: vi.fn(async () => ({ configOptions: [] })),
+ prompt: vi.fn(async () => ({ stopReason: "end_turn" as const })),
+ cancel: vi.fn(async () => {}),
+ close: vi.fn(() => lifecycle.abort()),
+ };
+ options.signal.addEventListener("abort", () => lifecycle.abort(), { once: true });
+ this.connections.push(connection);
+ return connection;
+ }
+}
+
describe("AcpThreadController conformance fixture", () => {
it("初始化、遍历 session/list 分页并加载历史", async () => {
const adapter = new ConformanceAdapter();
@@ -45,9 +112,12 @@ describe("AcpThreadController conformance fixture", () => {
} as unknown as AppendMessage);
const session = controller.getState().sessions.s1!;
- expect(session.messages.some((message) => message.optimistic)).toBe(true);
- expect(session.messages.find((message) => message.id === "agent-answer")?.status)
- .toEqual({ type: "complete", stopReason: "end_turn" });
+ expect(session.messages.filter((message) => message.role === "user")).toHaveLength(2);
+ expect(session.messages.at(-2)).toMatchObject({ role: "user", optimistic: false });
+ expect(session.messages.find((message) => message.id === "agent-answer")?.status).toEqual({
+ type: "complete",
+ stopReason: "end_turn",
+ });
expect(adapter.connection.prompt).toHaveBeenCalledWith({
sessionId: "s1",
prompt: [{ type: "text", text: "hello" }],
@@ -135,4 +205,276 @@ describe("AcpThreadController conformance fixture", () => {
await controller.logout();
expect(controller.getState().connectionStatus).toBe("auth-required");
});
+
+ it("重连后强制 load 当前 session,并忽略旧连接通知", async () => {
+ const adapter = new ReconnectingAdapter();
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.selectSession("s1");
+ expect(adapter.loads).toEqual(["0:s1"]);
+
+ await controller.reconnect();
+ expect(adapter.loads).toEqual(["0:s1", "1:s1"]);
+ expect(controller.getState().sessions.s1?.messages[0]?.id).toBe("history-1");
+
+ await adapter.handlers[0]!.sessionUpdate({
+ sessionId: "s1",
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "stale",
+ content: { type: "text", text: "stale" },
+ },
+ });
+ expect(
+ controller.getState().sessions.s1?.messages.some((message) => message.id === "stale"),
+ ).toBe(false);
+ });
+
+ it("重连后没有 load/resume 能力时保留缓存并禁止 prompt", async () => {
+ const adapter = new ReconnectingAdapter();
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.selectSession("s1");
+ adapter.supportsLoad = false;
+
+ await controller.reconnect();
+ expect(controller.getState().sessions.s1?.messages[0]?.id).toBe("history-0");
+ expect(controller.getState().sessions.s1?.runState).toBe("error");
+ await expect(
+ controller.prompt("s1", [{ type: "text", text: "blocked" }]),
+ ).rejects.toMatchObject({
+ code: "ACP_SESSION_NOT_ATTACHED",
+ });
+ });
+
+ it("快速切换只允许最新 session 生效,同时保留较晚完成的历史", async () => {
+ const adapter = new ConformanceAdapter();
+ const loads = new Map>>();
+ adapter.connection.loadSession = vi.fn(async ({ sessionId }: { sessionId: string }) => {
+ const gate = deferred();
+ loads.set(sessionId, gate);
+ await gate.promise;
+ await adapter.handlers?.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: `history-${sessionId}`,
+ content: { type: "text", text: sessionId },
+ },
+ });
+ return {};
+ });
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+
+ const selectA = controller.selectSession("s1");
+ const selectB = controller.selectSession("s2");
+ loads.get("s2")!.resolve();
+ await selectB;
+ loads.get("s1")!.resolve();
+ await selectA;
+
+ expect(controller.getState().activeSessionId).toBe("s2");
+ expect(controller.getState().sessions.s1?.messages[0]?.id).toBe("history-s1");
+ expect(controller.getState().sessions.s2?.messages[0]?.id).toBe("history-s2");
+ });
+
+ it("load 失败恢复快照和原 active session,并允许重试", async () => {
+ const adapter = new ConformanceAdapter();
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.createSession();
+ adapter.connection.loadSession = vi.fn(async () => {
+ throw new Error("load failed");
+ });
+
+ await expect(controller.selectSession("s1")).rejects.toThrow("load failed");
+ expect(controller.getState().activeSessionId).toBe("new-session");
+ expect(controller.getState().sessions.s1?.info?.title).toBe("One");
+
+ adapter.connection.loadSession = vi.fn(async ({ sessionId }: { sessionId: string }) => {
+ await adapter.handlers?.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "user_message_chunk",
+ content: { type: "text", text: "retried" },
+ },
+ });
+ return {};
+ });
+ await controller.selectSession("s1");
+ expect(controller.getState().activeSessionId).toBe("s1");
+ expect(controller.getState().sessions.s1?.messages[0]?.pieces[0]).toMatchObject({
+ type: "content",
+ content: { type: "text", text: "retried" },
+ });
+ });
+
+ it("合并分块 user echo,保留本地 ID 和协议 ID", async () => {
+ const adapter = new ConformanceAdapter();
+ adapter.connection.prompt = vi.fn(async ({ sessionId }: { sessionId: string }) => {
+ for (const text of ["hel", "lo"]) {
+ await adapter.handlers?.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: "protocol-user",
+ content: { type: "text", text },
+ _meta: { echoed: true },
+ },
+ _meta: { envelope: text },
+ });
+ }
+ await adapter.handlers?.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "answer" },
+ },
+ });
+ return { stopReason: "end_turn" as const };
+ });
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.selectSession("s1");
+ await controller.sendMessage({
+ role: "user",
+ content: [{ type: "text", text: "hello" }],
+ } as unknown as AppendMessage);
+
+ const userMessages = controller
+ .getState()
+ .sessions.s1!.messages.filter((message) => message.role === "user");
+ expect(userMessages).toHaveLength(2);
+ expect(userMessages[1]?.id).toMatch(/^local:/);
+ expect(userMessages[1]).toMatchObject({
+ protocolMessageId: "protocol-user",
+ optimistic: false,
+ });
+ expect(userMessages[1]?.rawNotifications).toHaveLength(2);
+ });
+
+ it("不合并内容不同的协议用户消息", async () => {
+ const adapter = new ConformanceAdapter();
+ adapter.connection.prompt = vi.fn(async ({ sessionId }: { sessionId: string }) => {
+ await adapter.handlers?.sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "user_message_chunk",
+ messageId: "different-user",
+ content: { type: "text", text: "different" },
+ },
+ });
+ return { stopReason: "end_turn" as const };
+ });
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.selectSession("s1");
+ await controller.sendMessage({
+ role: "user",
+ content: [{ type: "text", text: "hello" }],
+ } as unknown as AppendMessage);
+
+ const users = controller
+ .getState()
+ .sessions.s1!.messages.filter((message) => message.role === "user");
+ expect(users).toHaveLength(3);
+ expect(users.at(-1)?.protocolMessageId).toBe("different-user");
+ });
+
+ it("prompt 传输失败后 turn 回到 idle 并保留可重试用户消息", async () => {
+ const adapter = new ConformanceAdapter();
+ adapter.connection.prompt = vi.fn(async () => {
+ throw new Error("transport failed");
+ });
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.selectSession("s1");
+
+ await expect(
+ controller.sendMessage({
+ role: "user",
+ content: [{ type: "text", text: "retry me" }],
+ } as unknown as AppendMessage),
+ ).rejects.toThrow("transport failed");
+
+ const session = controller.getState().sessions.s1!;
+ expect(session.runState).toBe("idle");
+ expect(session.error).toEqual(new Error("transport failed"));
+ expect(session.messages.at(-1)).toMatchObject({
+ role: "user",
+ optimistic: false,
+ error: new Error("transport failed"),
+ });
+ });
+
+ it("close 取消未决权限并清空 active,resume 通知受控 thread", async () => {
+ const adapter = new ConformanceAdapter();
+ const onThreadIdChange = vi.fn();
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ onThreadIdChange,
+ });
+ await controller.connect();
+ await controller.resumeSession("s1");
+ expect(onThreadIdChange).toHaveBeenLastCalledWith("s1");
+ const pending = adapter.handlers!.requestPermission(
+ {
+ sessionId: "s1",
+ toolCall: { toolCallId: "pending-close", title: "Wait" },
+ options: [{ optionId: "no", name: "Reject", kind: "reject_once" }],
+ },
+ new AbortController().signal,
+ );
+
+ await controller.closeSession("s1");
+ await expect(pending).resolves.toEqual({ outcome: { outcome: "cancelled" } });
+ expect(controller.getState().activeSessionId).toBeUndefined();
+ expect(controller.getState().sessions.s1).toBeDefined();
+ expect(onThreadIdChange).toHaveBeenLastCalledWith(undefined);
+ });
+
+ it("refreshSessions 对账非 active session 并保留 active", async () => {
+ const adapter = new ConformanceAdapter();
+ const controller = new AcpThreadController({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ await controller.connect();
+ await controller.createSession();
+ adapter.connection.listSessions = vi.fn(async () => ({
+ sessions: [
+ { sessionId: "s2", cwd: "/workspace", title: "Two updated" },
+ { sessionId: "s3", cwd: "/workspace", title: "Three" },
+ ],
+ }));
+
+ await controller.refreshSessions();
+ expect(controller.getState().sessionOrder).toEqual(["s2", "s3", "new-session"]);
+ expect(controller.getState().sessions.s1).toBeUndefined();
+ expect(controller.getState().sessions.s2?.info?.title).toBe("Two updated");
+ expect(controller.getState().sessions["new-session"]).toBeDefined();
+ });
});
diff --git a/tests/exports.test.ts b/tests/exports.test.ts
new file mode 100644
index 0000000..751822a
--- /dev/null
+++ b/tests/exports.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+import { AcpDiff, AcpResource, AcpTerminal, AcpUnsupported } from "../src";
+import {
+ AcpDiff as PrimitiveDiff,
+ AcpResource as PrimitiveResource,
+ AcpTerminal as PrimitiveTerminal,
+ AcpUnsupported as PrimitiveUnsupported,
+} from "../src/primitives";
+
+describe("public primitive exports", () => {
+ it("从主入口和 primitives 入口导出全部无样式组件", () => {
+ expect([AcpDiff, AcpTerminal, AcpResource, AcpUnsupported]).toEqual([
+ PrimitiveDiff,
+ PrimitiveTerminal,
+ PrimitiveResource,
+ PrimitiveUnsupported,
+ ]);
+ });
+});
diff --git a/tests/fixture.ts b/tests/fixture.ts
index 5beca28..ee4944b 100644
--- a/tests/fixture.ts
+++ b/tests/fixture.ts
@@ -47,7 +47,7 @@ export class ConformanceAdapter implements AcpClientAdapter {
},
],
})),
- loadSession: vi.fn(async ({ sessionId }) => {
+ loadSession: vi.fn(async ({ sessionId }) => {
await this.handlers?.sessionUpdate({
sessionId,
update: {
@@ -71,18 +71,20 @@ export class ConformanceAdapter implements AcpClientAdapter {
resumeSession: vi.fn(async () => ({})),
closeSession: vi.fn(async () => {}),
setSessionMode: vi.fn(async () => {}),
- setSessionConfigOption: vi.fn(async ({ configId, value }) => ({
- configOptions: [
- {
- type: "boolean" as const,
- id: configId,
- name: configId,
- category: "mode",
- currentValue: Boolean(value),
- },
- ],
- })),
- prompt: vi.fn(async ({ sessionId }) => {
+ setSessionConfigOption: vi.fn(
+ async ({ configId, value }) => ({
+ configOptions: [
+ {
+ type: "boolean" as const,
+ id: configId,
+ name: configId,
+ category: "mode",
+ currentValue: Boolean(value),
+ },
+ ],
+ }),
+ ),
+ prompt: vi.fn(async ({ sessionId }) => {
await this.handlers?.sessionUpdate({
sessionId,
update: {
diff --git a/tests/internal-errors.test.ts b/tests/internal-errors.test.ts
new file mode 100644
index 0000000..89f3c40
--- /dev/null
+++ b/tests/internal-errors.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { errorMessage, toError } from "../src/core/internal-errors";
+
+describe("internal error normalization", () => {
+ it("preserves Error messages and serializes structured failures", () => {
+ expect(errorMessage(new Error("network"))).toBe("network");
+ expect(errorMessage({ code: "ACP_FAILURE", retryable: false })).toBe(
+ '{"code":"ACP_FAILURE","retryable":false}',
+ );
+ });
+
+ it("falls back safely for cyclic objects", () => {
+ const cyclic: { self?: unknown } = {};
+ cyclic.self = cyclic;
+
+ expect(errorMessage(cyclic)).toBe("Unknown error");
+ });
+
+ it("converts non-Error rejection values into Error instances", () => {
+ const error = toError({ code: "ACP_FAILURE" });
+
+ expect(error).toBeInstanceOf(Error);
+ expect(error.message).toBe('{"code":"ACP_FAILURE"}');
+ });
+});
diff --git a/tests/projection.test.ts b/tests/projection.test.ts
index 926c7f6..972cec3 100644
--- a/tests/projection.test.ts
+++ b/tests/projection.test.ts
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
-import {
- createAcpThreadState,
- reduceAcpThreadState,
-} from "../src/core/state";
+import { createAcpThreadState, reduceAcpThreadState } from "../src/core/state";
import { projectAcpThreadMessages } from "../src/core/projection";
describe("ACP message projection", () => {
@@ -99,4 +96,65 @@ describe("ACP message projection", () => {
artifact: { acp: { title: "Edit file", kind: "edit" } },
});
});
+
+ it("按消息和 session 归属保留完整 notification,不复制 session 全量日志", () => {
+ let state = createAcpThreadState();
+ const messageNotification = {
+ sessionId: "s1",
+ update: {
+ sessionUpdate: "agent_message_chunk" as const,
+ messageId: "m1",
+ content: { type: "text" as const, text: "answer" },
+ _meta: { update: "message" },
+ },
+ _meta: { envelope: "message" },
+ };
+ const usageNotification = {
+ sessionId: "s1",
+ update: {
+ sessionUpdate: "usage_update" as const,
+ used: 1,
+ size: 10,
+ _meta: { update: "usage" },
+ },
+ _meta: { envelope: "usage" },
+ };
+ const unknownNotification = {
+ sessionId: "s1",
+ update: {
+ sessionUpdate: "vendor_extension",
+ value: 42,
+ _meta: { update: "extension" },
+ },
+ _meta: { envelope: "extension" },
+ };
+
+ state = reduceAcpThreadState(state, {
+ type: "session.update",
+ notification: messageNotification,
+ });
+ state = reduceAcpThreadState(state, {
+ type: "session.update",
+ notification: usageNotification,
+ });
+ state = reduceAcpThreadState(state, {
+ type: "session.update",
+ notification: unknownNotification as never,
+ });
+
+ const session = state.sessions.s1!;
+ expect(session.messages[0]?.rawNotifications).toEqual([
+ messageNotification,
+ unknownNotification,
+ ]);
+ expect(session.latestNotifications.usage_update).toEqual(usageNotification);
+ expect(session.unhandledNotifications).toEqual([unknownNotification]);
+
+ const projected = projectAcpThreadMessages(state, "s1");
+ const acpMetadata = projected[0]?.metadata?.custom?.acp as
+ { notifications?: unknown[] } | undefined;
+ expect(acpMetadata?.notifications).toEqual([messageNotification, unknownNotification]);
+ expect(JSON.stringify(projected[0]?.metadata?.custom?.acp)).not.toContain('"usage"');
+ expect(JSON.stringify(projected[0]?.metadata?.custom?.acp)).toContain('"extension"');
+ });
});
diff --git a/tests/runtime.test.tsx b/tests/runtime.test.tsx
index 2b491bf..85b3e3b 100644
--- a/tests/runtime.test.tsx
+++ b/tests/runtime.test.tsx
@@ -3,17 +3,10 @@ import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { render, screen, waitFor } from "@testing-library/react";
import { StrictMode } from "react";
import { describe, expect, it, vi } from "vitest";
-import { useAcpConnection, useAcpRuntime } from "../src";
-import type {
- AcpAdapterConnectOptions,
- AcpClientAdapter,
- AcpClientConnection,
-} from "../src/core";
-
-const connectionFor = (
- signal: AbortSignal,
- close: () => void,
-): AcpClientConnection => ({
+import { useAcpConnection, useAcpRuntime, useAcpRuntimeExtras } from "../src";
+import type { AcpAdapterConnectOptions, AcpClientAdapter, AcpClientConnection } from "../src/core";
+
+const connectionFor = (signal: AbortSignal, close: () => void): AcpClientConnection => ({
signal,
initialize: async () => ({ protocolVersion: PROTOCOL_VERSION }),
authenticate: async () => {},
@@ -66,13 +59,110 @@ describe("useAcpRuntime", () => {
,
);
- await waitFor(() =>
- expect(screen.getByTestId("status").textContent).toBe("ready"),
- );
+ await waitFor(() => expect(screen.getByTestId("status").textContent).toBe("ready"));
expect(adapter.connect).toHaveBeenCalledTimes(2);
expect(closes[0]).toHaveBeenCalled();
view.unmount();
expect(closes.at(-1)).toHaveBeenCalled();
});
+
+ it("运行时使用最新回调而无需重建 identity 配置", async () => {
+ const lifecycle = new AbortController();
+ const adapter: AcpClientAdapter = {
+ connect: vi.fn(async () => connectionFor(lifecycle.signal, () => lifecycle.abort())),
+ };
+ const first = vi.fn();
+ const latest = vi.fn();
+
+ function Probe() {
+ const extras = useAcpRuntimeExtras();
+ const connection = useAcpConnection();
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ function Runtime({ onChange }: { onChange: (id?: string) => void }) {
+ const runtime = useAcpRuntime({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ onThreadIdChange: onChange,
+ });
+ return (
+
+
+
+ );
+ }
+
+ const view = render();
+ view.rerender();
+ await waitFor(() => expect(screen.getByTestId("callback-status").textContent).toBe("ready"));
+ screen.getByRole("button", { name: "Create" }).click();
+ await waitFor(() => expect(latest).toHaveBeenCalledWith("s1"));
+ expect(first).not.toHaveBeenCalled();
+ });
+
+ it("受控 threadId 同步不回显 onThreadIdChange", async () => {
+ const lifecycle = new AbortController();
+ const loadSession = vi.fn(async () => ({}));
+ const adapter: AcpClientAdapter = {
+ connect: vi.fn(async () => ({
+ ...connectionFor(lifecycle.signal, () => lifecycle.abort()),
+ initialize: async () => ({
+ protocolVersion: PROTOCOL_VERSION,
+ agentCapabilities: { loadSession: true },
+ }),
+ loadSession,
+ })),
+ };
+ const onThreadIdChange = vi.fn();
+
+ function Runtime() {
+ const runtime = useAcpRuntime({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ threadId: "s1",
+ onThreadIdChange,
+ });
+ return {null};
+ }
+
+ render();
+ await waitFor(() =>
+ expect(loadSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "s1" })),
+ );
+ expect(onThreadIdChange).not.toHaveBeenCalled();
+ });
+
+ it("identity 配置变化时通过 React key 重建 controller", async () => {
+ const connectA = vi.fn();
+ const connectB = vi.fn();
+ const makeAdapter = (connect: ReturnType): AcpClientAdapter => ({
+ connect: async () => {
+ connect();
+ const lifecycle = new AbortController();
+ return connectionFor(lifecycle.signal, () => lifecycle.abort());
+ },
+ });
+ const adapterA = makeAdapter(connectA);
+ const adapterB = makeAdapter(connectB);
+
+ function Runtime({ adapter }: { adapter: AcpClientAdapter }) {
+ const runtime = useAcpRuntime({
+ connection: { type: "adapter", adapter },
+ workspace: { cwd: "/workspace" },
+ });
+ return {null};
+ }
+
+ const view = render();
+ await waitFor(() => expect(connectA).toHaveBeenCalledTimes(1));
+ view.rerender();
+ await waitFor(() => expect(connectB).toHaveBeenCalledTimes(1));
+ });
});
diff --git a/tests/serialize.test.ts b/tests/serialize.test.ts
index 0c80f80..f2a44c5 100644
--- a/tests/serialize.test.ts
+++ b/tests/serialize.test.ts
@@ -14,18 +14,14 @@ import {
describe("ACP serialization and capabilities", () => {
it("拒绝相对工作区路径", () => {
- expect(() => validateWorkspace({ cwd: "relative/path" })).toThrow(
- AcpInvalidWorkspaceError,
- );
+ expect(() => validateWorkspace({ cwd: "relative/path" })).toThrow(AcpInvalidWorkspaceError);
});
it("拒绝使用相对命令的 stdio MCP server", () => {
expect(() =>
validateWorkspace({
cwd: "/workspace",
- mcpServers: [
- { name: "mcp", command: "node", args: [], env: [] },
- ],
+ mcpServers: [{ name: "mcp", command: "node", args: [], env: [] }],
}),
).toThrow(AcpInvalidWorkspaceError);
});
@@ -33,9 +29,11 @@ describe("ACP serialization and capabilities", () => {
it("仅在 Agent 声明能力时发送 additionalDirectories", () => {
const workspace = { cwd: "/workspace", additionalDirectories: ["/shared"] };
expect(buildSessionRequest(workspace)).not.toHaveProperty("additionalDirectories");
- expect(buildSessionRequest(workspace, {
- sessionCapabilities: { additionalDirectories: {} },
- })).toHaveProperty("additionalDirectories", ["/shared"]);
+ expect(
+ buildSessionRequest(workspace, {
+ sessionCapabilities: { additionalDirectories: {} },
+ }),
+ ).toHaveProperty("additionalDirectories", ["/shared"]);
});
it("按 Agent capability 门控 HTTP/SSE MCP transport", () => {
@@ -50,12 +48,12 @@ describe("ACP serialization and capabilities", () => {
},
],
};
- expect(() => buildSessionRequest(workspace, {})).toThrow(
- AcpCapabilityError,
- );
- expect(buildSessionRequest(workspace, {
- mcpCapabilities: { http: true },
- }).mcpServers).toEqual(workspace.mcpServers);
+ expect(() => buildSessionRequest(workspace, {})).toThrow(AcpCapabilityError);
+ expect(
+ buildSessionRequest(workspace, {
+ mcpCapabilities: { http: true },
+ }).mcpServers,
+ ).toEqual(workspace.mcpServers);
});
it("只声明实际注入的文件系统和整组终端能力", () => {
@@ -87,9 +85,11 @@ describe("ACP serialization and capabilities", () => {
},
],
} as unknown as AppendMessage;
- expect(serializeAppendMessage(message, {
- promptCapabilities: { image: true, audio: true },
- })).toMatchObject([
+ expect(
+ serializeAppendMessage(message, {
+ promptCapabilities: { image: true, audio: true },
+ }),
+ ).toMatchObject([
{ type: "text", text: "hello" },
{ type: "image", mimeType: "image/png", data: "YQ==" },
{ type: "resource_link", uri: "https://example.test/docs" },
@@ -102,8 +102,6 @@ describe("ACP serialization and capabilities", () => {
role: "user",
content: [{ type: "image", image: "data:image/png;base64,YQ==" }],
} as unknown as AppendMessage;
- expect(() => serializeAppendMessage(message, {})).toThrow(
- AcpUnsupportedContentError,
- );
+ expect(() => serializeAppendMessage(message, {})).toThrow(AcpUnsupportedContentError);
});
});
diff --git a/tests/state.test.ts b/tests/state.test.ts
index 09cffee..2d3b7af 100644
--- a/tests/state.test.ts
+++ b/tests/state.test.ts
@@ -1,9 +1,6 @@
import type { SessionUpdate } from "@agentclientprotocol/sdk";
import { describe, expect, it } from "vitest";
-import {
- createAcpThreadState,
- reduceAcpThreadState,
-} from "../src/core/state";
+import { createAcpThreadState, reduceAcpThreadState } from "../src/core/state";
const update = (sessionId: string, value: SessionUpdate) => ({
type: "session.update" as const,
@@ -13,21 +10,30 @@ const update = (sessionId: string, value: SessionUpdate) => ({
describe("reduceAcpThreadState", () => {
it("按 messageId 合并分块,并在缺失时生成稳定本地 ID", () => {
let state = createAcpThreadState();
- state = reduceAcpThreadState(state, update("s1", {
- sessionUpdate: "agent_message_chunk",
- messageId: "m1",
- content: { type: "text", text: "hel" },
- _meta: { trace: "a" },
- }));
- state = reduceAcpThreadState(state, update("s1", {
- sessionUpdate: "agent_message_chunk",
- messageId: "m1",
- content: { type: "text", text: "lo" },
- }));
- state = reduceAcpThreadState(state, update("s2", {
- sessionUpdate: "agent_message_chunk",
- content: { type: "text", text: "isolated" },
- }));
+ state = reduceAcpThreadState(
+ state,
+ update("s1", {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "m1",
+ content: { type: "text", text: "hel" },
+ _meta: { trace: "a" },
+ }),
+ );
+ state = reduceAcpThreadState(
+ state,
+ update("s1", {
+ sessionUpdate: "agent_message_chunk",
+ messageId: "m1",
+ content: { type: "text", text: "lo" },
+ }),
+ );
+ state = reduceAcpThreadState(
+ state,
+ update("s2", {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "isolated" },
+ }),
+ );
expect(state.sessions.s1?.messages).toHaveLength(1);
expect(state.sessions.s1?.messages[0]?.id).toBe("m1");
@@ -37,20 +43,26 @@ describe("reduceAcpThreadState", () => {
it("接受先于 tool_call 到达的 update,并增量合并", () => {
let state = createAcpThreadState();
- state = reduceAcpThreadState(state, update("s1", {
- sessionUpdate: "tool_call_update",
- toolCallId: "tool-1",
- status: "in_progress",
- rawOutput: { partial: true },
- }));
- state = reduceAcpThreadState(state, update("s1", {
- sessionUpdate: "tool_call",
- toolCallId: "tool-1",
- title: "Read file",
- kind: "read",
- status: "completed",
- rawInput: { path: "/tmp/a" },
- }));
+ state = reduceAcpThreadState(
+ state,
+ update("s1", {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "tool-1",
+ status: "in_progress",
+ rawOutput: { partial: true },
+ }),
+ );
+ state = reduceAcpThreadState(
+ state,
+ update("s1", {
+ sessionUpdate: "tool_call",
+ toolCallId: "tool-1",
+ title: "Read file",
+ kind: "read",
+ status: "completed",
+ rawInput: { path: "/tmp/a" },
+ }),
+ );
const tool = state.sessions.s1?.tools["tool-1"];
expect(tool?.value).toMatchObject({
@@ -59,7 +71,7 @@ describe("reduceAcpThreadState", () => {
rawInput: { path: "/tmp/a" },
rawOutput: { partial: true },
});
- expect(tool?.rawUpdates).toHaveLength(2);
+ expect(tool?.rawNotifications).toHaveLength(2);
});
it("权限可先于工具事件到达并投影到占位工具", () => {
@@ -95,10 +107,13 @@ describe("reduceAcpThreadState", () => {
["max_turn_requests", "incomplete"],
] as const)("映射 stop reason %s", (stopReason, expected) => {
let state = createAcpThreadState();
- state = reduceAcpThreadState(state, update("s1", {
- sessionUpdate: "agent_message_chunk",
- content: { type: "text", text: "done" },
- }));
+ state = reduceAcpThreadState(
+ state,
+ update("s1", {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "done" },
+ }),
+ );
state = reduceAcpThreadState(state, {
type: "session.prompt_stopped",
sessionId: "s1",
@@ -110,25 +125,39 @@ describe("reduceAcpThreadState", () => {
it("加载历史前替换本地投影且不污染其他会话", () => {
let state = createAcpThreadState();
- state = reduceAcpThreadState(state, update("s1", {
- sessionUpdate: "user_message_chunk",
- content: { type: "text", text: "old" },
- }));
- state = reduceAcpThreadState(state, update("s2", {
- sessionUpdate: "user_message_chunk",
- content: { type: "text", text: "keep" },
- }));
- state = reduceAcpThreadState(state, { type: "session.loading", sessionId: "s1" });
+ state = reduceAcpThreadState(
+ state,
+ update("s1", {
+ sessionUpdate: "user_message_chunk",
+ content: { type: "text", text: "old" },
+ }),
+ );
+ state = reduceAcpThreadState(
+ state,
+ update("s2", {
+ sessionUpdate: "user_message_chunk",
+ content: { type: "text", text: "keep" },
+ }),
+ );
+ state = reduceAcpThreadState(state, {
+ type: "session.loading",
+ sessionId: "s1",
+ clearHistory: true,
+ });
expect(state.sessions.s1?.messages).toEqual([]);
expect(state.sessions.s2?.messages).toHaveLength(1);
});
- it("未知扩展进入 unhandledEvents 而不崩溃", () => {
- const state = reduceAcpThreadState(createAcpThreadState(), update("s1", {
- sessionUpdate: "vendor_extension",
- payload: { answer: 42 },
- } as unknown as SessionUpdate));
- expect(state.sessions.s1?.unhandledEvents).toHaveLength(1);
+ it("未知扩展进入 unhandledNotifications 而不崩溃", () => {
+ const state = reduceAcpThreadState(
+ createAcpThreadState(),
+ update("s1", {
+ sessionUpdate: "vendor_extension",
+ payload: { answer: 42 },
+ } as unknown as SessionUpdate),
+ );
+ expect(state.sessions.s1?.unhandledNotifications).toHaveLength(1);
+ expect(state.sessions.s1?.unhandledNotifications[0]?.sessionId).toBe("s1");
expect(state.sessions.s1?.messages[0]?.pieces[0]).toMatchObject({
type: "unsupported",
});
@@ -136,7 +165,7 @@ describe("reduceAcpThreadState", () => {
it("保存计划、命令、模式、配置和用量状态", () => {
let state = reduceAcpThreadState(createAcpThreadState(), {
- type: "session.opened",
+ type: "session.attached",
sessionId: "s1",
modes: {
currentModeId: "ask",
@@ -155,9 +184,7 @@ describe("reduceAcpThreadState", () => {
},
{
sessionUpdate: "available_commands_update" as const,
- availableCommands: [
- { name: "review", description: "Review changes", input: null },
- ],
+ availableCommands: [{ name: "review", description: "Review changes", input: null }],
},
{ sessionUpdate: "current_mode_update" as const, currentModeId: "code" },
{
@@ -199,6 +226,7 @@ describe("reduceAcpThreadState", () => {
createdAt: 1,
optimistic: true,
pieces: [],
+ rawNotifications: [],
},
});
state = reduceAcpThreadState(state, {
@@ -207,10 +235,11 @@ describe("reduceAcpThreadState", () => {
messageId: "local-1",
error: new Error("network"),
});
- expect(state.sessions.s1?.messages[0]).toMatchObject({
+ const message = state.sessions.s1?.messages[0];
+ expect(message).toMatchObject({
id: "local-1",
optimistic: false,
- error: expect.any(Error),
});
+ expect(message?.error).toBeInstanceOf(Error);
});
});
diff --git a/tests/stream-conformance.test.ts b/tests/stream-conformance.test.ts
index 0220d35..58aecfa 100644
--- a/tests/stream-conformance.test.ts
+++ b/tests/stream-conformance.test.ts
@@ -1,17 +1,12 @@
-import {
- PROTOCOL_VERSION,
- agent,
- methods,
- ndJsonStream,
-} from "@agentclientprotocol/sdk";
+import { PROTOCOL_VERSION, agent, methods, ndJsonStream } from "@agentclientprotocol/sdk";
import type { AppendMessage } from "@assistant-ui/react";
import { describe, expect, it, vi } from "vitest";
import { AcpThreadController } from "../src/core/controller";
describe("in-process ACP Agent conformance", () => {
it("通过官方 Stream SDK 完成 initialize、Client 服务、权限和消息流", async () => {
- const clientToAgent = new TransformStream();
- const agentToClient = new TransformStream();
+ const clientToAgent = new TransformStream();
+ const agentToClient = new TransformStream();
const agentConnection = agent({ name: "in-process-fixture" })
.onRequest(methods.agent.initialize, ({ params }) => ({
@@ -29,15 +24,12 @@ describe("in-process ACP Agent conformance", () => {
path: "/workspace/output.txt",
content: file.content,
});
- const terminal = await context.client.request(
- methods.client.terminal.create,
- {
- sessionId: context.params.sessionId,
- command: "echo",
- args: ["ok"],
- cwd: "/workspace",
- },
- );
+ const terminal = await context.client.request(methods.client.terminal.create, {
+ sessionId: context.params.sessionId,
+ command: "echo",
+ args: ["ok"],
+ cwd: "/workspace",
+ });
await context.client.request(methods.client.terminal.output, {
sessionId: context.params.sessionId,
terminalId: terminal.terminalId,
@@ -54,20 +46,15 @@ describe("in-process ACP Agent conformance", () => {
sessionId: context.params.sessionId,
terminalId: terminal.terminalId,
});
- const permission = await context.client.request(
- methods.client.session.requestPermission,
- {
- sessionId: context.params.sessionId,
- toolCall: {
- toolCallId: "stream-tool",
- title: "Write fixture",
- kind: "edit",
- },
- options: [
- { optionId: "allow", name: "Allow", kind: "allow_once" },
- ],
+ const permission = await context.client.request(methods.client.session.requestPermission, {
+ sessionId: context.params.sessionId,
+ toolCall: {
+ toolCallId: "stream-tool",
+ title: "Write fixture",
+ kind: "edit",
},
- );
+ options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
+ });
await context.client.notify(methods.client.session.update, {
sessionId: context.params.sessionId,
update: {
@@ -100,26 +87,22 @@ describe("in-process ACP Agent conformance", () => {
const controller = new AcpThreadController({
connection: {
type: "stream",
- createStream: () =>
- ndJsonStream(clientToAgent.writable, agentToClient.readable),
+ createStream: () => ndJsonStream(clientToAgent.writable, agentToClient.readable),
},
workspace: { cwd: "/workspace" },
clientServices: services,
});
await controller.connect();
- expect(controller.getState().initializeResponse?.protocolVersion).toBe(
- PROTOCOL_VERSION,
- );
+ expect(controller.getState().initializeResponse?.protocolVersion).toBe(PROTOCOL_VERSION);
const sending = controller.sendMessage({
role: "user",
content: [{ type: "text", text: "go" }],
} as unknown as AppendMessage);
await vi.waitFor(() => {
- expect(
- controller.getState().sessions["stream-s1"]?.permissions["stream-tool"]
- ?.status,
- ).toBe("pending");
+ expect(controller.getState().sessions["stream-s1"]?.permissions["stream-tool"]?.status).toBe(
+ "pending",
+ );
});
await controller.replyToPermission("stream-s1", "stream-tool", "allow");
await sending;
@@ -129,8 +112,9 @@ describe("in-process ACP Agent conformance", () => {
expect(services.terminal.create).toHaveBeenCalled();
expect(services.terminal.release).toHaveBeenCalled();
const session = controller.getState().sessions["stream-s1"]!;
- expect(session.messages.find((message) => message.id === "stream-answer"))
- .toMatchObject({ status: { type: "complete", stopReason: "end_turn" } });
+ expect(session.messages.find((message) => message.id === "stream-answer")).toMatchObject({
+ status: { type: "complete", stopReason: "end_turn" },
+ });
controller.dispose();
agentConnection.close();
diff --git a/tsconfig.json b/tsconfig.json
index 0fb6ddc..17357b7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,13 +15,6 @@
"resolveJsonModule": true,
"types": ["node", "vitest/globals"]
},
- "include": [
- "src",
- "tests",
- "e2e",
- "tsup.config.ts",
- "vitest.config.ts",
- "playwright.config.ts"
- ],
+ "include": ["src", "tests", "e2e", "tsup.config.ts", "vitest.config.ts", "playwright.config.ts"],
"exclude": ["dist", "node_modules", "examples"]
}