From 9095f0e05c3d3714f94c25ecc41dfee546a95df4 Mon Sep 17 00:00:00 2001 From: "witon.ai" Date: Sun, 9 Aug 2026 09:55:26 +0800 Subject: [PATCH 1/4] fix: update .gitignore to include deno.lock and test-results --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a59ed28..0cd8861 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,10 @@ .DS_Store .pnpm-store/ coverage/ +deno.lock dist/ examples/vite/dist/ node_modules/ playwright-report/ -test-results/ release-artifacts/ +test-results/ \ No newline at end of file From 600dc2cd68dd6b6bab46e3feea229f63359dca08 Mon Sep 17 00:00:00 2001 From: "witon.ai" Date: Sun, 9 Aug 2026 12:02:27 +0800 Subject: [PATCH 2/4] refactor: streamline code formatting and improve readability across multiple files - Consolidated import statements for better clarity in `serialize.ts`, `state.ts`, `types.ts`, `hooks.ts`, `primitives.tsx`, and `useAcpRuntime.ts`. - Simplified function signatures by removing unnecessary line breaks in `state.ts`, `types.ts`, and `hooks.ts`. - Enhanced error handling and message serialization in `serialize.test.ts` and `runtime.test.tsx`. - Added unit tests for internal error normalization in `internal-errors.test.ts`. - Updated conformance tests to ensure proper handling of agent capabilities and session updates in `stream-conformance.test.ts`. - Adjusted TypeScript configuration for improved project structure and type checking. --- .gitignore | 2 +- .prettierignore | 9 ++ .vscode/extensions.json | 3 + .vscode/settings.json | 8 ++ README.md | 16 ++-- docs/protocol-capability-matrix.md | 54 ++++++------ docs/releasing.md | 12 +-- eslint.config.js | 39 ++++++++- examples/vite/src/App.tsx | 6 +- examples/vite/src/style.css | 73 +++++++++++++--- jsr.json | 8 +- package.json | 9 +- pnpm-lock.yaml | 42 +++++++++ prettier.config.mjs | 13 +++ scripts/check-jsr-docs.mjs | 28 ++---- scripts/jsr-repair-check.mjs | 10 +-- scripts/release-check.mjs | 15 +--- scripts/release-lib.mjs | 16 +--- scripts/release-test.mjs | 33 ++------ scripts/release-version.mjs | 8 +- src/acp-extras.ts | 5 +- src/core/controller.ts | 38 +++------ src/core/internal-errors.ts | 26 ++++++ src/core/projection.ts | 65 ++++---------- src/core/sdk-adapter.ts | 89 ++++++-------------- src/core/serialize.ts | 41 +++------ src/core/state.ts | 93 +++++--------------- src/core/types.ts | 31 ++----- src/hooks.ts | 16 ++-- src/primitives/primitives.tsx | 46 ++-------- src/useAcpRuntime.ts | 41 ++++----- tests/controller.test.ts | 6 +- tests/fixture.ts | 28 +++--- tests/internal-errors.test.ts | 25 ++++++ tests/projection.test.ts | 5 +- tests/runtime.test.tsx | 15 +--- tests/serialize.test.ts | 40 +++++---- tests/state.test.ts | 131 +++++++++++++++++------------ tests/stream-conformance.test.ts | 66 ++++++--------- tsconfig.json | 9 +- 40 files changed, 561 insertions(+), 659 deletions(-) create mode 100644 .prettierignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 prettier.config.mjs create mode 100644 src/core/internal-errors.ts create mode 100644 tests/internal-errors.test.ts diff --git a/.gitignore b/.gitignore index 0cd8861..847806b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ examples/vite/dist/ node_modules/ playwright-report/ release-artifacts/ -test-results/ \ No newline at end of file +test-results/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7cff0bd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +coverage/ +deno.lock +dist/ +examples/vite/dist/ +node_modules/ +playwright-report/ +pnpm-lock.yaml +release-artifacts/ +test-results/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1d7ac85 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b3b6fc1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "[css][javascript][javascriptreact][json][jsonc][markdown][typescript][typescriptreact][yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "eslint.useFlatConfig": true, + "prettier.requireConfig": true +} diff --git a/README.md b/README.md index fde3b11..0bbe42e 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,7 @@ export function AcpProvider({ children }: { children: React.ReactNode }) { workspace: { cwd: "/absolute/project/path", mcpServers: [] }, }); - return ( - - {children} - - ); + return {children}; } ``` @@ -48,11 +44,11 @@ export function AcpProvider({ children }: { children: React.ReactNode }) { ## 入口与 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 组件 | +| 入口 | 适用场景 | 主要导出 | +| ------------------------------ | -------------------------------- | ---------------------------------------------------------------------------------------- | +| `@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"; diff --git a/docs/protocol-capability-matrix.md b/docs/protocol-capability-matrix.md index 85b7242..6c6796c 100644 --- a/docs/protocol-capability-matrix.md +++ b/docs/protocol-capability-matrix.md @@ -2,30 +2,30 @@ 状态含义:`实现`为直接支持;`能力门控`为仅在 initialize 声明后开放;`不适用`为协议没有对应语义且不会伪造。 -| ACP v1 能力 | 状态 | 实现位置 | 测试证据 | -| --- | --- | --- | --- | -| initialize / 版本协商 | 实现 | `SdkAcpClientAdapter`、controller | `stream-conformance.test.ts` | -| authenticate / logout | 实现 | controller、认证 hook/primitive | `controller.test.ts` | -| session/new | 实现 | controller | `controller.test.ts` | -| session/prompt / cancel | 实现 | controller | `controller.test.ts` | -| session/update 内容流 | 实现 | reducer/projector | `state.test.ts`、`projection.test.ts` | -| session/load | 能力门控 | `loadSession` | `controller.test.ts` | -| session/list + 全分页 | 能力门控 | `sessionCapabilities.list` | `controller.test.ts` | -| session/delete | 能力门控 | `sessionCapabilities.delete` | `controller.test.ts` | -| session/resume | 能力门控 | `sessionCapabilities.resume` | `controller.test.ts` | -| session/close | 能力门控 | `sessionCapabilities.close` | `controller.test.ts` | -| additionalDirectories | 能力门控 | `sessionCapabilities.additionalDirectories` | `serialize.test.ts` | -| 文本 / resource link prompt | 实现 | serializer | `serialize.test.ts` | -| 图片 / 音频 / embedded resource prompt | 能力门控 | `promptCapabilities` | `serialize.test.ts` | -| tool call / 增量 update | 实现 | reducer/projector | `state.test.ts` | -| permission request | 实现 | controller / tool approval | `controller.test.ts`、`projection.test.ts` | -| plan / commands | 实现 | reducer / hooks / primitives | `state.test.ts` | -| session modes | 实现 | controller / primitives | `controller.test.ts` | -| session config options | 实现 | controller / primitives | `controller.test.ts` | -| usage update | 实现 | reducer / hook / primitive | `state.test.ts` | -| Client fs/read_text_file | 按注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | -| Client fs/write_text_file | 按注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | -| Client terminal 全组方法 | 按整组注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | -| `_meta` / 未知扩展 | 实现 | reducer/projector | `state.test.ts`、`projection.test.ts` | -| rename / archive / edit / regenerate / branch | 不适用 | 对应 runtime 能力关闭 | `runtime.test.tsx`、构建检查 | -| v2 Draft / `UNSTABLE` | 不承诺 | 作为 raw/unsupported 保留 | `state.test.ts` | +| ACP v1 能力 | 状态 | 实现位置 | 测试证据 | +| --------------------------------------------- | -------------- | ------------------------------------------- | ------------------------------------------------- | +| initialize / 版本协商 | 实现 | `SdkAcpClientAdapter`、controller | `stream-conformance.test.ts` | +| authenticate / logout | 实现 | controller、认证 hook/primitive | `controller.test.ts` | +| session/new | 实现 | controller | `controller.test.ts` | +| session/prompt / cancel | 实现 | controller | `controller.test.ts` | +| session/update 内容流 | 实现 | reducer/projector | `state.test.ts`、`projection.test.ts` | +| session/load | 能力门控 | `loadSession` | `controller.test.ts` | +| session/list + 全分页 | 能力门控 | `sessionCapabilities.list` | `controller.test.ts` | +| session/delete | 能力门控 | `sessionCapabilities.delete` | `controller.test.ts` | +| session/resume | 能力门控 | `sessionCapabilities.resume` | `controller.test.ts` | +| session/close | 能力门控 | `sessionCapabilities.close` | `controller.test.ts` | +| additionalDirectories | 能力门控 | `sessionCapabilities.additionalDirectories` | `serialize.test.ts` | +| 文本 / resource link prompt | 实现 | serializer | `serialize.test.ts` | +| 图片 / 音频 / embedded resource prompt | 能力门控 | `promptCapabilities` | `serialize.test.ts` | +| tool call / 增量 update | 实现 | reducer/projector | `state.test.ts` | +| permission request | 实现 | controller / tool approval | `controller.test.ts`、`projection.test.ts` | +| plan / commands | 实现 | reducer / hooks / primitives | `state.test.ts` | +| session modes | 实现 | controller / primitives | `controller.test.ts` | +| session config options | 实现 | controller / primitives | `controller.test.ts` | +| usage update | 实现 | reducer / hook / primitive | `state.test.ts` | +| Client fs/read_text_file | 按注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | +| Client fs/write_text_file | 按注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | +| Client terminal 全组方法 | 按整组注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | +| `_meta` / 未知扩展 | 实现 | reducer/projector | `state.test.ts`、`projection.test.ts` | +| rename / archive / edit / regenerate / branch | 不适用 | 对应 runtime 能力关闭 | `runtime.test.tsx`、构建检查 | +| v2 Draft / `UNSTABLE` | 不承诺 | 作为 raw/unsupported 保留 | `state.test.ts` | diff --git a/docs/releasing.md b/docs/releasing.md index fce4218..829f664 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,12 +4,12 @@ ## 版本与 dist-tag -| Git tag | npm version | npm dist-tag | JSR version | GitHub Release | -| --- | --- | --- | --- | --- | -| `v1.2.3` | `1.2.3` | `latest` | `1.2.3` | 正式版 | -| `v1.2.3-alpha.0` | `1.2.3-alpha.0` | `alpha` | `1.2.3-alpha.0` | prerelease | -| `v1.2.3-beta.0` | `1.2.3-beta.0` | `beta` | `1.2.3-beta.0` | prerelease | -| `v1.2.3-rc.0` | `1.2.3-rc.0` | `rc` | `1.2.3-rc.0` | prerelease | +| Git tag | npm version | npm dist-tag | JSR version | GitHub Release | +| ---------------- | --------------- | ------------ | --------------- | -------------- | +| `v1.2.3` | `1.2.3` | `latest` | `1.2.3` | 正式版 | +| `v1.2.3-alpha.0` | `1.2.3-alpha.0` | `alpha` | `1.2.3-alpha.0` | prerelease | +| `v1.2.3-beta.0` | `1.2.3-beta.0` | `beta` | `1.2.3-beta.0` | prerelease | +| `v1.2.3-rc.0` | `1.2.3-rc.0` | `rc` | `1.2.3-rc.0` | prerelease | 其他 prerelease 格式会被 `release:check` 拒绝。发布 tag 必须是 annotated tag,且目标提交必须属于 `origin/latest`。JSR 没有 npm dist-tag 的对应概念,预发布版本通过完整 SemVer 获取。 diff --git a/eslint.config.js b/eslint.config.js index 22052be..ab091fb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,17 +1,48 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import stylistic from "@stylistic/eslint-plugin"; +import eslintConfigPrettier from "eslint-config-prettier"; import globals from "globals"; import tseslint from "typescript-eslint"; +const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); + export default tseslint.config( - { ignores: ["dist/**", "node_modules/**", "examples/vite/dist/**"] }, - ...tseslint.configs.recommended, { - files: ["**/*.{ts,tsx}"], + ignores: [ + "coverage/**", + "dist/**", + "examples/vite/dist/**", + "node_modules/**", + "playwright-report/**", + "release-artifacts/**", + "test-results/**", + ], + }, + ...tseslint.configs.recommendedTypeChecked, + { languageOptions: { globals: { ...globals.browser, ...globals.node }, - parserOptions: { projectService: true }, + parserOptions: { projectService: true, tsconfigRootDir }, }, rules: { "@typescript-eslint/no-explicit-any": "off", + // Deferred until adapter mocks and callback APIs can be tightened separately. + "@typescript-eslint/require-await": "off", + "@typescript-eslint/unbound-method": "off", + }, + }, + { + ...tseslint.configs.disableTypeChecked, + files: ["**/*.{cjs,js,mjs}"], + }, + eslintConfigPrettier, + { + plugins: { "@stylistic": stylistic }, + rules: { + "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxBOF: 0, maxEOF: 0 }], + "@stylistic/padded-blocks": ["error", "never"], }, }, ); diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index b4daeec..35f2c1e 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,8 +1,4 @@ -import { - AssistantRuntimeProvider, - ComposerPrimitive, - ThreadPrimitive, -} from "@assistant-ui/react"; +import { AssistantRuntimeProvider, ComposerPrimitive, ThreadPrimitive } from "@assistant-ui/react"; import { useMemo, useState } from "react"; import { AcpConfigOptions, diff --git a/examples/vite/src/style.css b/examples/vite/src/style.css index 8215851..5cc0f59 100644 --- a/examples/vite/src/style.css +++ b/examples/vite/src/style.css @@ -1,13 +1,60 @@ -:root { font-family: system-ui, sans-serif; color: #18212f; background: #f4f6f8; } -body { margin: 0; } -main { display: grid; grid-template-columns: 14rem 1fr; min-height: 100vh; } -aside { display: flex; flex-direction: column; gap: .5rem; padding: 1rem; background: #18212f; } -aside button { padding: .65rem; text-align: left; } -aside button[data-active="true"] { outline: 2px solid #5eead4; } -section { padding: 1.5rem; max-width: 60rem; } -header { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } -article { margin: 1rem 0; padding: 1rem; border-radius: .75rem; background: white; } -article[data-role="user"] { background: #dff7f2; } -pre { white-space: pre-wrap; word-break: break-word; } -form { display: flex; gap: .5rem; position: sticky; bottom: 1rem; } -textarea { flex: 1; min-height: 4rem; } +:root { + font-family: system-ui, sans-serif; + color: #18212f; + background: #f4f6f8; +} +body { + margin: 0; +} +main { + display: grid; + grid-template-columns: 14rem 1fr; + min-height: 100vh; +} +aside { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + background: #18212f; +} +aside button { + padding: 0.65rem; + text-align: left; +} +aside button[data-active="true"] { + outline: 2px solid #5eead4; +} +section { + padding: 1.5rem; + max-width: 60rem; +} +header { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; +} +article { + margin: 1rem 0; + padding: 1rem; + border-radius: 0.75rem; + background: white; +} +article[data-role="user"] { + background: #dff7f2; +} +pre { + white-space: pre-wrap; + word-break: break-word; +} +form { + display: flex; + gap: 0.5rem; + position: sticky; + bottom: 1rem; +} +textarea { + flex: 1; + min-height: 4rem; +} diff --git a/jsr.json b/jsr.json index 916ef02..1123b10 100644 --- a/jsr.json +++ b/jsr.json @@ -8,12 +8,6 @@ "./primitives": "./src/primitives/index.ts" }, "publish": { - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "README.md", - "LICENSE", - "package.json" - ] + "include": ["src/**/*.ts", "src/**/*.tsx", "README.md", "LICENSE", "package.json"] } } diff --git a/package.json b/package.json index 2dc74a7..ff89937 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,10 @@ "scripts": { "build": "tsup", "docs:check": "node scripts/check-jsr-docs.mjs", + "format": "prettier --write .", + "format:check": "prettier --check .", "typecheck": "tsc --noEmit", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "test": "vitest run", "test:watch": "vitest", "test:e2e": "pnpm build && playwright test", @@ -56,7 +58,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 docs:check && pnpm test && pnpm release:test && pnpm jsr:check && pnpm build && pnpm example:build && pnpm test:e2e && pnpm pack:check" + "check": "pnpm format: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", @@ -70,15 +72,18 @@ "devDependencies": { "@assistant-ui/react": "^0.15.0", "@playwright/test": "^1.54.0", + "@stylistic/eslint-plugin": "5.10.0", "@testing-library/react": "^16.3.0", "@types/node": "^24.0.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.6.0", "eslint": "^9.32.0", + "eslint-config-prettier": "10.1.8", "globals": "^16.3.0", "jsdom": "^26.1.0", "jsr": "0.14.3", + "prettier": "3.9.6", "react": "^19.1.0", "react-dom": "^19.1.0", "tsup": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4be8d9b..11d51e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@playwright/test': specifier: ^1.54.0 version: 1.62.1 + '@stylistic/eslint-plugin': + specifier: 5.10.0 + version: 5.10.0(eslint@9.39.5) '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -42,6 +45,9 @@ importers: eslint: specifier: ^9.32.0 version: 9.39.5 + eslint-config-prettier: + specifier: 10.1.8 + version: 10.1.8(eslint@9.39.5) globals: specifier: ^16.3.0 version: 16.5.0 @@ -51,6 +57,9 @@ importers: jsr: specifier: 0.14.3 version: 0.14.3 + prettier: + specifier: 3.9.6 + version: 3.9.6 react: specifier: ^19.1.0 version: 19.2.8 @@ -1520,6 +1529,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1884,6 +1899,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2268,6 +2289,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -4040,6 +4066,16 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/types': 8.66.0 + eslint: 9.39.5 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.5 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -4473,6 +4509,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -4833,6 +4873,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.6: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000..b235ad1 --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,13 @@ +export default { + bracketSpacing: true, + endOfLine: "lf", + jsxSingleQuote: false, + printWidth: 100, + proseWrap: "preserve", + quoteProps: "as-needed", + semi: true, + singleQuote: false, + tabWidth: 2, + trailingComma: "all", + useTabs: false, +}; diff --git a/scripts/check-jsr-docs.mjs b/scripts/check-jsr-docs.mjs index 0b02989..78d8618 100644 --- a/scripts/check-jsr-docs.mjs +++ b/scripts/check-jsr-docs.mjs @@ -2,11 +2,7 @@ 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 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"); @@ -16,14 +12,8 @@ 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 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, @@ -55,12 +45,8 @@ for (const [index, absoluteEntrypoint] of absoluteEntrypoints.entries()) { .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(); + symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol; + return !ts.displayPartsToString(target.getDocumentationComment(checker)).trim(); }) .map((symbol) => symbol.name) .sort(); @@ -69,9 +55,7 @@ for (const [index, absoluteEntrypoint] of absoluteEntrypoints.entries()) { .getExportsOfModule(moduleSymbol) .filter((symbol) => symbol.name !== "default").length; if (undocumented.length) { - failures.push( - `${entrypoint}: undocumented exports: ${undocumented.join(", ")}`, - ); + failures.push(`${entrypoint}: undocumented exports: ${undocumented.join(", ")}`); } else { console.log(`${entrypoint}: ${exportedCount}/${exportedCount} exports documented`); } diff --git a/scripts/jsr-repair-check.mjs b/scripts/jsr-repair-check.mjs index 6eb8eff..e0ae83c 100644 --- a/scripts/jsr-repair-check.mjs +++ b/scripts/jsr-repair-check.mjs @@ -1,18 +1,12 @@ import { appendFileSync } from "node:fs"; -import { - assertJsrRepairGit, - assertReleaseManifests, - parseReleaseVersion, -} from "./release-lib.mjs"; +import { assertJsrRepairGit, assertReleaseManifests, parseReleaseVersion } from "./release-lib.mjs"; const versionInput = process.argv[2]; const sourceRef = process.argv[3]; try { if (!versionInput || !sourceRef || process.argv.length !== 4) { - throw new Error( - "Usage: pnpm release:jsr-repair-check ", - ); + throw new Error("Usage: pnpm release:jsr-repair-check "); } const release = parseReleaseVersion(versionInput); assertReleaseManifests(release.version); diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index b5ba8fb..c79ed8b 100644 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -1,19 +1,12 @@ import { appendFileSync } from "node:fs"; -import { - assertReleaseManifests, - assertReleaseTagGit, - parseReleaseTag, -} from "./release-lib.mjs"; +import { assertReleaseManifests, assertReleaseTagGit, parseReleaseTag } from "./release-lib.mjs"; const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME; try { const release = parseReleaseTag(tag); assertReleaseManifests(release.version); - if ( - process.env.GITHUB_ACTIONS === "true" || - process.env.RELEASE_VERIFY_GIT === "true" - ) { + if (process.env.GITHUB_ACTIONS === "true" || process.env.RELEASE_VERIFY_GIT === "true") { assertReleaseTagGit(tag); } if (process.env.GITHUB_OUTPUT) { @@ -22,9 +15,7 @@ try { `version=${release.version}\ndist_tag=${release.distTag}\nprerelease=${release.prerelease}\n`, ); } - console.log( - `Release ${tag} is valid and will publish with npm dist-tag ${release.distTag}.`, - ); + console.log(`Release ${tag} is valid and will publish with npm dist-tag ${release.distTag}.`); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); diff --git a/scripts/release-lib.mjs b/scripts/release-lib.mjs index 95034dd..4b5deae 100644 --- a/scripts/release-lib.mjs +++ b/scripts/release-lib.mjs @@ -122,9 +122,7 @@ export function assertReleaseManifest(expectedVersion, manifest = readManifest() manifest.publishConfig?.access !== "public" || manifest.publishConfig?.registry !== "https://registry.npmjs.org/" ) { - throw new Error( - "@hafbit/react-acp must publish publicly to https://registry.npmjs.org/.", - ); + throw new Error("@hafbit/react-acp must publish publicly to https://registry.npmjs.org/."); } if (manifest.repository?.url !== repositoryUrl) { throw new Error(`package.json repository.url must be ${repositoryUrl}.`); @@ -163,12 +161,7 @@ export function assertReleaseTagGit(tag, runGit = defaultGit) { } const tagCommit = runGit(["rev-list", "-n", "1", ref]); try { - runGit([ - "merge-base", - "--is-ancestor", - tagCommit, - "refs/remotes/origin/latest", - ]); + runGit(["merge-base", "--is-ancestor", tagCommit, "refs/remotes/origin/latest"]); } catch { throw new Error(`${tag} must point to an ancestor of origin/latest.`); } @@ -204,10 +197,7 @@ export const requiredPackedFiles = [ ]; export function assertPackedManifest(packedManifest, sourceManifest) { - if ( - packedManifest.name !== packageName || - packedManifest.version !== sourceManifest.version - ) { + if (packedManifest.name !== packageName || packedManifest.version !== sourceManifest.version) { throw new Error("Packed package name or version does not match package.json."); } assertReleaseManifest(sourceManifest.version, packedManifest); diff --git a/scripts/release-test.mjs b/scripts/release-test.mjs index 7e6bf5f..ec83ad9 100644 --- a/scripts/release-test.mjs +++ b/scripts/release-test.mjs @@ -47,13 +47,7 @@ const jsrManifest = (version = "1.2.3") => ({ "./primitives": "./src/primitives/index.ts", }, publish: { - include: [ - "src/**/*.ts", - "src/**/*.tsx", - "README.md", - "LICENSE", - "package.json", - ], + include: ["src/**/*.ts", "src/**/*.tsx", "README.md", "LICENSE", "package.json"], }, }); @@ -80,10 +74,7 @@ test("rejects missing v prefix and unsupported prereleases", () => { test("validates manifest version, repository, exports, and publish target", () => { assert.equal(assertReleaseManifest("1.2.3", manifest()).version, "1.2.3"); - assert.throws( - () => assertReleaseManifest("1.2.4", manifest()), - /does not match/, - ); + assert.throws(() => assertReleaseManifest("1.2.4", manifest()), /does not match/); assert.throws( () => assertReleaseManifest(undefined, { ...manifest(), repository: {} }), /repository.url/, @@ -92,9 +83,7 @@ test("validates manifest version, repository, exports, and publish target", () = test("validates synchronized npm and JSR manifests", () => { assert.equal(assertJsrManifest("1.2.3", jsrManifest()).version, "1.2.3"); - assert.doesNotThrow(() => - assertReleaseManifests("1.2.3", manifest(), jsrManifest()), - ); + assert.doesNotThrow(() => assertReleaseManifests("1.2.3", manifest(), jsrManifest())); assert.throws( () => assertReleaseManifests(undefined, manifest(), jsrManifest("1.2.4")), /does not match jsr.json/, @@ -122,11 +111,7 @@ test("validates synchronized npm and JSR manifests", () => { }); test("updates npm and JSR versions together", () => { - const updated = updateReleaseManifests( - "2.0.0-rc.1", - manifest(), - jsrManifest(), - ); + const updated = updateReleaseManifests("2.0.0-rc.1", manifest(), jsrManifest()); assert.equal(updated.npmManifest.version, "2.0.0-rc.1"); assert.equal(updated.jsrManifest.version, "2.0.0-rc.1"); assert.equal(updated.npmManifest.name, "@hafbit/react-acp"); @@ -169,10 +154,7 @@ test("restricts JSR repair sources and the 0.1.0 latest backfill", () => { throw new Error(`Unexpected git command: ${args.join(" ")}`); }; assert.equal(assertJsrRepairGit("1.2.3", "v1.2.3", tagged), "abc123"); - assert.throws( - () => assertJsrRepairGit("1.2.3", "latest", tagged), - /must be v1.2.3/, - ); + assert.throws(() => assertJsrRepairGit("1.2.3", "latest", tagged), /must be v1.2.3/); const latest = (args) => { if (args[0] === "rev-parse" && args[1] === "HEAD") return "tip"; @@ -181,10 +163,7 @@ test("restricts JSR repair sources and the 0.1.0 latest backfill", () => { }; assert.equal(assertJsrRepairGit("0.1.0", "latest", latest), "tip"); assert.throws( - () => - assertJsrRepairGit("0.1.0", "latest", (args) => - args[1] === "HEAD" ? "old" : "tip", - ), + () => assertJsrRepairGit("0.1.0", "latest", (args) => (args[1] === "HEAD" ? "old" : "tip")), /tip of origin\/latest/, ); }); diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 6c109ff..a143b34 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -10,9 +10,7 @@ import { const input = process.argv[2]; if (!input || process.argv.length !== 3) { - console.error( - "Usage: pnpm release:version ", - ); + console.error("Usage: pnpm release:version "); process.exit(1); } @@ -25,9 +23,7 @@ try { ); writeFileSync(manifestPath, `${JSON.stringify(npmManifest, null, 2)}\n`); writeFileSync(jsrManifestPath, `${JSON.stringify(jsrManifest, null, 2)}\n`); - console.log( - `Updated ${npmManifest.name} npm and JSR manifests to ${npmManifest.version}.`, - ); + console.log(`Updated ${npmManifest.name} npm and JSR manifests to ${npmManifest.version}.`); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); diff --git a/src/acp-extras.ts b/src/acp-extras.ts index e0e35f9..519492b 100644 --- a/src/acp-extras.ts +++ b/src/acp-extras.ts @@ -1,7 +1,4 @@ -import { - createRuntimeExtras, - type RuntimeExtras, -} from "@assistant-ui/core/react"; +import { createRuntimeExtras, type RuntimeExtras } from "@assistant-ui/core/react"; import type { AcpRuntimeExtras } from "./core/types"; export const acpExtras: RuntimeExtras = diff --git a/src/core/controller.ts b/src/core/controller.ts index d3151b7..8737c17 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -8,12 +8,9 @@ import { } from "@agentclientprotocol/sdk"; import type { AppendMessage } from "@assistant-ui/react"; import { AcpCapabilityError, AcpError } from "./errors"; +import { toError } from "./internal-errors"; import { SdkAcpClientAdapter } from "./sdk-adapter"; -import { - createAcpThreadState, - hasAgentCapability, - reduceAcpThreadState, -} from "./state"; +import { createAcpThreadState, hasAgentCapability, reduceAcpThreadState } from "./state"; import { buildClientCapabilities, buildSessionRequest, @@ -128,8 +125,7 @@ export class AcpThreadController { sessionUpdate: (notification) => { this.dispatch({ type: "session.update", notification }); }, - requestPermission: (request, signal) => - this.waitForPermission(request, signal), + requestPermission: (request, signal) => this.waitForPermission(request, signal), ...(services?.fileSystem?.readTextFile ? { readTextFile: services.fileSystem.readTextFile } : {}), @@ -154,10 +150,7 @@ export class AcpThreadController { const response = await connection.initialize({ protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildClientCapabilities( - services, - this.options.clientCapabilities, - ), + clientCapabilities: buildClientCapabilities(services, this.options.clientCapabilities), clientInfo: this.options.clientInfo ?? { name: "react-acp", version: "0.1.1", @@ -328,10 +321,7 @@ export class AcpThreadController { } /** Sends one serialized ACP prompt turn and records its lifecycle. */ - async prompt( - sessionId: string, - prompt: ContentBlock[], - ): Promise { + async prompt(sessionId: string, prompt: ContentBlock[]): Promise { const session = this.state.sessions[sessionId]; if (session?.runState === "running" || session?.runState === "cancelling") { throw new AcpError("ACP_TURN_RUNNING", "An ACP prompt turn is already running."); @@ -412,11 +402,7 @@ export class AcpThreadController { configId: string, value: string | boolean, ): Promise { - if ( - !this.state.sessions[sessionId]?.configOptions.some( - (option) => option.id === configId, - ) - ) { + if (!this.state.sessions[sessionId]?.configOptions.some((option) => option.id === configId)) { throw new AcpCapabilityError("session/set_config_option"); } const response = await this.requireConnection().setSessionConfigOption({ @@ -438,7 +424,9 @@ export class AcpThreadController { /** Bridges one ACP permission request to a later host reply. */ private waitForPermission( - request: Parameters[0]["handlers"]["requestPermission"]>>[0], + request: Parameters< + NonNullable[0]["handlers"]["requestPermission"]> + >[0], signal: AbortSignal, ): Promise { this.dispatch({ type: "permission.requested", request }); @@ -465,7 +453,7 @@ export class AcpThreadController { }, reject: (error) => { signal.removeEventListener("abort", abort); - reject(error); + reject(toError(error)); }, }); if (signal.aborted) abort(); @@ -474,11 +462,7 @@ export class AcpThreadController { } /** Resolves a pending permission, or cancels it when `optionId` is omitted. */ - async replyToPermission( - sessionId: string, - toolCallId: string, - optionId?: string, - ): Promise { + async replyToPermission(sessionId: string, toolCallId: string, optionId?: string): Promise { const key = `${sessionId}:${toolCallId}`; const waiter = this.permissionWaiters.get(key); if (!waiter) return; diff --git a/src/core/internal-errors.ts b/src/core/internal-errors.ts new file mode 100644 index 0000000..9e1d76e --- /dev/null +++ b/src/core/internal-errors.ts @@ -0,0 +1,26 @@ +const primitiveErrorMessage = (value: bigint | boolean | number | string | symbol): string => + typeof value === "symbol" ? (value.description ?? "Symbol") : String(value); + +export function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if ( + typeof error === "bigint" || + typeof error === "boolean" || + typeof error === "number" || + typeof error === "string" || + typeof error === "symbol" + ) { + return primitiveErrorMessage(error); + } + if (error === null) return "null"; + if (error === undefined) return "undefined"; + + try { + return JSON.stringify(error) ?? "Unknown error"; + } catch { + return "Unknown error"; + } +} + +export const toError = (error: unknown): Error => + error instanceof Error ? error : new Error(errorMessage(error)); diff --git a/src/core/projection.ts b/src/core/projection.ts index cce437f..4a0523a 100644 --- a/src/core/projection.ts +++ b/src/core/projection.ts @@ -1,10 +1,6 @@ import { ExportedMessageRepository } from "@assistant-ui/react"; -import type { - ContentBlock, - PermissionOptionKind, - ToolCallContent, - ToolCallStatus, -} from "@agentclientprotocol/sdk"; +import type { ContentBlock, PermissionOptionKind, ToolCallContent } from "@agentclientprotocol/sdk"; +import { errorMessage } from "./internal-errors"; import type { AcpMessagePiece, AcpMessageRecord, @@ -22,13 +18,9 @@ const dataPart = (name: string, data: unknown): ProjectedPart => ({ data, }); -const toDataUrl = (mimeType: string, data: string) => - `data:${mimeType};base64,${data}`; +const toDataUrl = (mimeType: string, data: string) => `data:${mimeType};base64,${data}`; -function projectContent( - content: ContentBlock, - reasoning: boolean, -): ProjectedPart { +function projectContent(content: ContentBlock, reasoning: boolean): ProjectedPart { switch (content.type) { case "text": return reasoning @@ -62,8 +54,7 @@ function projectContent( type: "file", filename: content.resource.uri, data: content.resource.blob, - mimeType: - content.resource.mimeType ?? "application/octet-stream", + mimeType: content.resource.mimeType ?? "application/octet-stream", } : dataPart("acp-resource", content); default: @@ -75,11 +66,7 @@ function projectContent( } const approvalKind = (kind: PermissionOptionKind) => - kind.replaceAll("_", "-") as - | "allow-once" - | "allow-always" - | "reject-once" - | "reject-always"; + kind.replaceAll("_", "-") as "allow-once" | "allow-always" | "reject-once" | "reject-always"; const projectToolApproval = (tool: AcpToolCallRecord) => { const permission = tool.permission; @@ -95,10 +82,7 @@ const projectToolApproval = (tool: AcpToolCallRecord) => { return { id: tool.toolCallId, options }; } - if ( - permission.status === "cancelled" || - permission.response?.outcome.outcome === "cancelled" - ) { + if (permission.status === "cancelled" || permission.response?.outcome.outcome === "cancelled") { return { id: tool.toolCallId, options, resolution: "cancelled" as const }; } @@ -106,9 +90,7 @@ const projectToolApproval = (tool: AcpToolCallRecord) => { permission.response?.outcome.outcome === "selected" ? permission.response.outcome.optionId : undefined; - const selected = permission.request.options.find( - (option) => option.optionId === optionId, - ); + const selected = permission.request.options.find((option) => option.optionId === optionId); return { id: tool.toolCallId, options, @@ -133,7 +115,7 @@ const normalizeToolContent = (content: readonly ToolCallContent[] | null | undef function projectTool(tool: AcpToolCallRecord): ProjectedPart { const value = tool.value; - const status = value.status as ToolCallStatus | null | undefined; + const status = value.status; const rawInput = value.rawInput; const rawOutput = value.rawOutput; const args = normalizeObject(rawInput); @@ -166,22 +148,14 @@ function projectTool(tool: AcpToolCallRecord): ProjectedPart { rawUpdates: tool.rawUpdates, }, }, - ...(projectToolApproval(tool) - ? { approval: projectToolApproval(tool) } - : {}), + ...(projectToolApproval(tool) ? { approval: projectToolApproval(tool) } : {}), }; } -const projectPiece = ( - session: AcpSessionState, - piece: AcpMessagePiece, -): ProjectedPart => { +const projectPiece = (session: AcpSessionState, piece: AcpMessagePiece): ProjectedPart => { switch (piece.type) { case "content": - return projectContent( - piece.content, - piece.raw.sessionUpdate === "agent_thought_chunk", - ); + return projectContent(piece.content, piece.raw.sessionUpdate === "agent_thought_chunk"); case "tool": { const tool = session.tools[piece.toolCallId]; return tool @@ -214,7 +188,7 @@ const statusForMessage = (message: AcpMessageRecord) => { return { type: "incomplete" as const, reason: status.error ? ("error" as const) : ("other" as const), - ...(status.error ? { error: String(status.error) } : {}), + ...(status.error ? { error: errorMessage(status.error) } : {}), }; } }; @@ -223,9 +197,7 @@ const projectMessage = ( session: AcpSessionState, message: AcpMessageRecord, ): AcpProjectedMessage => { - const raw = message.pieces.map((piece) => - piece.type === "content" ? piece.raw : piece, - ); + const raw = message.pieces.map((piece) => (piece.type === "content" ? piece.raw : piece)); return { id: message.id, role: message.role, @@ -240,11 +212,10 @@ const projectMessage = ( raw, notifications: session.rawNotifications, stopReason: - message.status?.type === "complete" || - message.status?.type === "incomplete" + message.status?.type === "complete" || message.status?.type === "incomplete" ? message.status.stopReason : undefined, - error: message.error ? String(message.error) : undefined, + error: message.error ? errorMessage(message.error) : undefined, }, }, }, @@ -267,7 +238,5 @@ export function projectAcpThreadRepository( state: AcpThreadState, sessionId = state.activeSessionId, ): ExportedMessageRepository { - return ExportedMessageRepository.fromArray( - projectAcpThreadMessages(state, sessionId), - ); + return ExportedMessageRepository.fromArray(projectAcpThreadMessages(state, sessionId)); } diff --git a/src/core/sdk-adapter.ts b/src/core/sdk-adapter.ts index 290ede1..332688b 100644 --- a/src/core/sdk-adapter.ts +++ b/src/core/sdk-adapter.ts @@ -1,9 +1,4 @@ -import { - client, - methods, - type ClientConnection, - type KillTerminalRequest, -} from "@agentclientprotocol/sdk"; +import { client, methods, type ClientConnection } from "@agentclientprotocol/sdk"; import type { AcpAdapterConnectOptions, AcpClientAdapter, @@ -20,61 +15,40 @@ export class SdkAcpClientAdapter implements AcpClientAdapter { ) {} /** Opens the stream, installs client request handlers, and returns a connection facade. */ - async connect({ - handlers, - signal, - }: AcpAdapterConnectOptions): Promise { + async connect({ handlers, signal }: AcpAdapterConnectOptions): Promise { let app = client({ name: this.name }) - .onNotification(methods.client.session.update, ({ params }) => - handlers.sessionUpdate(params), - ) - .onRequest( - methods.client.session.requestPermission, - ({ params, signal: requestSignal }) => - handlers.requestPermission(params, requestSignal), + .onNotification(methods.client.session.update, ({ params }) => handlers.sessionUpdate(params)) + .onRequest(methods.client.session.requestPermission, ({ params, signal: requestSignal }) => + handlers.requestPermission(params, requestSignal), ); if (handlers.readTextFile) { - app = app.onRequest( - methods.client.fs.readTextFile, - ({ params, signal: requestSignal }) => - handlers.readTextFile!(params, requestSignal), + app = app.onRequest(methods.client.fs.readTextFile, ({ params, signal: requestSignal }) => + handlers.readTextFile!(params, requestSignal), ); } if (handlers.writeTextFile) { - app = app.onRequest( - methods.client.fs.writeTextFile, - ({ params, signal: requestSignal }) => - handlers.writeTextFile!(params, requestSignal), + app = app.onRequest(methods.client.fs.writeTextFile, ({ params, signal: requestSignal }) => + handlers.writeTextFile!(params, requestSignal), ); } if (handlers.terminal) { const terminal = handlers.terminal; app = app - .onRequest( - methods.client.terminal.create, - ({ params, signal: requestSignal }) => - terminal.create(params, requestSignal), + .onRequest(methods.client.terminal.create, ({ params, signal: requestSignal }) => + terminal.create(params, requestSignal), ) - .onRequest( - methods.client.terminal.output, - ({ params, signal: requestSignal }) => - terminal.output(params, requestSignal), + .onRequest(methods.client.terminal.output, ({ params, signal: requestSignal }) => + terminal.output(params, requestSignal), ) - .onRequest( - methods.client.terminal.release, - ({ params, signal: requestSignal }) => - terminal.release(params, requestSignal), + .onRequest(methods.client.terminal.release, ({ params, signal: requestSignal }) => + terminal.release(params, requestSignal), ) - .onRequest( - methods.client.terminal.waitForExit, - ({ params, signal: requestSignal }) => - terminal.waitForExit(params, requestSignal), + .onRequest(methods.client.terminal.waitForExit, ({ params, signal: requestSignal }) => + terminal.waitForExit(params, requestSignal), ) - .onRequest( - methods.client.terminal.kill, - ({ params, signal: requestSignal }) => - terminal.kill(params as KillTerminalRequest, requestSignal), + .onRequest(methods.client.terminal.kill, ({ params, signal: requestSignal }) => + terminal.kill(params, requestSignal), ); } @@ -84,9 +58,7 @@ export class SdkAcpClientAdapter implements AcpClientAdapter { if (signal.aborted) close(); else signal.addEventListener("abort", close, { once: true }); - return createConnectionFacade(connection, () => - signal.removeEventListener("abort", close), - ); + return createConnectionFacade(connection, () => signal.removeEventListener("abort", close)); } } @@ -95,22 +67,17 @@ const createConnectionFacade = ( cleanup: () => void, ): AcpClientConnection => ({ signal: connection.signal, - initialize: (request) => - connection.agent.request(methods.agent.initialize, request), + initialize: (request) => connection.agent.request(methods.agent.initialize, request), authenticate: async (methodId) => { await connection.agent.request(methods.agent.authenticate, { methodId }); }, logout: () => connection.agent.request(methods.agent.logout, {}), - newSession: (request) => - connection.agent.request(methods.agent.session.new, request), - loadSession: (request) => - connection.agent.request(methods.agent.session.load, request), - listSessions: (request) => - connection.agent.request(methods.agent.session.list, request), + newSession: (request) => connection.agent.request(methods.agent.session.new, request), + loadSession: (request) => connection.agent.request(methods.agent.session.load, request), + listSessions: (request) => connection.agent.request(methods.agent.session.list, request), deleteSession: (sessionId) => connection.agent.request(methods.agent.session.delete, { sessionId }), - resumeSession: (request) => - connection.agent.request(methods.agent.session.resume, request), + resumeSession: (request) => connection.agent.request(methods.agent.session.resume, request), closeSession: async (sessionId) => { await connection.agent.request(methods.agent.session.close, { sessionId }); }, @@ -119,10 +86,8 @@ const createConnectionFacade = ( }, setSessionConfigOption: (request) => connection.agent.request(methods.agent.session.setConfigOption, request), - prompt: (request) => - connection.agent.request(methods.agent.session.prompt, request), - cancel: (sessionId) => - connection.agent.notify(methods.agent.session.cancel, { sessionId }), + prompt: (request) => connection.agent.request(methods.agent.session.prompt, request), + cancel: (sessionId) => connection.agent.notify(methods.agent.session.cancel, { sessionId }), close: (error) => { cleanup(); connection.close(error); diff --git a/src/core/serialize.ts b/src/core/serialize.ts index 633c19c..79785d6 100644 --- a/src/core/serialize.ts +++ b/src/core/serialize.ts @@ -5,16 +5,8 @@ import type { McpServer, } from "@agentclientprotocol/sdk"; import type { AppendMessage } from "@assistant-ui/react"; -import { - AcpCapabilityError, - AcpInvalidWorkspaceError, - AcpUnsupportedContentError, -} from "./errors"; -import type { - AcpClientServices, - AcpTerminalServices, - AcpWorkspace, -} from "./types"; +import { AcpCapabilityError, AcpInvalidWorkspaceError, AcpUnsupportedContentError } from "./errors"; +import type { AcpClientServices, AcpTerminalServices, AcpWorkspace } from "./types"; /** Returns whether every terminal operation required by ACP is implemented. */ export const hasCompleteTerminalServices = ( @@ -22,17 +14,15 @@ export const hasCompleteTerminalServices = ( ): terminal is AcpTerminalServices => Boolean( terminal && - typeof terminal.create === "function" && - typeof terminal.output === "function" && - typeof terminal.release === "function" && - typeof terminal.waitForExit === "function" && - typeof terminal.kill === "function", + typeof terminal.create === "function" && + typeof terminal.output === "function" && + typeof terminal.release === "function" && + typeof terminal.waitForExit === "function" && + typeof terminal.kill === "function", ); const isAbsolutePath = (value: string) => - value.startsWith("/") || - /^[A-Za-z]:[\\/]/.test(value) || - value.startsWith("\\\\"); + value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); /** * Validates that the workspace and additional directories use absolute paths. @@ -112,10 +102,7 @@ const parseDataUrl = (value: string) => { return match ? { mimeType: match[1]!, data: match[2]! } : undefined; }; -const ensureCapability = ( - supported: boolean | undefined, - contentType: string, -) => { +const ensureCapability = (supported: boolean | undefined, contentType: string) => { if (!supported) { throw new AcpUnsupportedContentError( contentType, @@ -160,10 +147,7 @@ function serializePart( mimeType: part.mimeType, }; } - ensureCapability( - capabilities?.promptCapabilities?.embeddedContext, - "embedded resource", - ); + ensureCapability(capabilities?.promptCapabilities?.embeddedContext, "embedded resource"); return { type: "resource", resource: { @@ -186,10 +170,7 @@ function serializePart( return part.data as ContentBlock; } if (part.name === "acp-resource") { - ensureCapability( - capabilities?.promptCapabilities?.embeddedContext, - "embedded resource", - ); + ensureCapability(capabilities?.promptCapabilities?.embeddedContext, "embedded resource"); return part.data as ContentBlock; } throw new AcpUnsupportedContentError(`data:${part.name}`); diff --git a/src/core/state.ts b/src/core/state.ts index 636b35b..76a1160 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -23,9 +23,7 @@ export const createAcpThreadState = (): AcpThreadState => ({ }); /** Creates empty protocol-authoritative state for an ACP session ID. */ -export const createAcpSessionState = ( - sessionId: string, -): AcpSessionState => ({ +export const createAcpSessionState = (sessionId: string): AcpSessionState => ({ sessionId, runState: "idle", messages: [], @@ -53,10 +51,7 @@ const updateSession = ( }; }; -const appendMessage = ( - session: AcpSessionState, - message: AcpMessageRecord, -): AcpSessionState => ({ +const appendMessage = (session: AcpSessionState, message: AcpMessageRecord): AcpSessionState => ({ ...session, messages: [...session.messages, message], }); @@ -72,10 +67,8 @@ const patchMessage = ( ), }); -const localMessageId = ( - session: AcpSessionState, - role: "user" | "assistant", -) => `${session.sessionId}:turn:${session.turn}:${role}:${session.messages.length}`; +const localMessageId = (session: AcpSessionState, role: "user" | "assistant") => + `${session.sessionId}:turn:${session.turn}:${role}:${session.messages.length}`; const ensureMessage = ( session: AcpSessionState, @@ -109,11 +102,7 @@ const ensureMessage = ( ]; }; -const appendPiece = ( - session: AcpSessionState, - messageId: string, - piece: AcpMessagePiece, -) => +const appendPiece = (session: AcpSessionState, messageId: string, piece: AcpMessagePiece) => patchMessage(session, messageId, (message) => ({ ...message, pieces: [...message.pieces, piece], @@ -124,9 +113,7 @@ const mergeTool = ( incoming: ToolCall | ToolCallUpdate, messageId: string, ): AcpToolCallRecord => { - const value = existing - ? ({ ...existing.value, ...incoming } as ToolCall | ToolCallUpdate) - : incoming; + const value = existing ? { ...existing.value, ...incoming } : incoming; return { toolCallId: incoming.toolCallId, messageId, @@ -136,21 +123,13 @@ const mergeTool = ( }; }; -const reduceUpdate = ( - session: AcpSessionState, - update: SessionUpdate, -): AcpSessionState => { +const reduceUpdate = (session: AcpSessionState, update: SessionUpdate): AcpSessionState => { switch (update.sessionUpdate) { case "user_message_chunk": case "agent_message_chunk": case "agent_thought_chunk": { - const role = - update.sessionUpdate === "user_message_chunk" ? "user" : "assistant"; - const [withMessage, messageId] = ensureMessage( - session, - role, - update.messageId, - ); + const role = update.sessionUpdate === "user_message_chunk" ? "user" : "assistant"; + const [withMessage, messageId] = ensureMessage(session, role, update.messageId); return appendPiece(withMessage, messageId, { type: "content", content: update.content, @@ -160,9 +139,7 @@ const reduceUpdate = ( case "tool_call": case "tool_call_update": { let current = session; - let messageId = - session.tools[update.toolCallId]?.messageId ?? - session.lastAssistantMessageId; + let messageId = session.tools[update.toolCallId]?.messageId ?? session.lastAssistantMessageId; if (!messageId) { [current, messageId] = ensureMessage(session, "assistant"); } @@ -173,10 +150,7 @@ const reduceUpdate = ( }; const alreadyLinked = current.messages .find((message) => message.id === messageId) - ?.pieces.some( - (piece) => - piece.type === "tool" && piece.toolCallId === update.toolCallId, - ); + ?.pieces.some((piece) => piece.type === "tool" && piece.toolCallId === update.toolCallId); const linked = alreadyLinked ? current : appendPiece(current, messageId, { @@ -213,9 +187,7 @@ const reduceUpdate = ( cwd: session.info?.cwd ?? "", ...session.info, ...(update.title !== undefined ? { title: update.title } : {}), - ...(update.updatedAt !== undefined - ? { updatedAt: update.updatedAt } - : {}), + ...(update.updatedAt !== undefined ? { updatedAt: update.updatedAt } : {}), }, }; case "usage_update": @@ -232,12 +204,8 @@ const reduceUpdate = ( } }; -const statusFromStopReason = ( - stopReason: StopReason, -): AcpMessageRecord["status"] => - stopReason === "end_turn" - ? { type: "complete", stopReason } - : { type: "incomplete", stopReason }; +const statusFromStopReason = (stopReason: StopReason): AcpMessageRecord["status"] => + stopReason === "end_turn" ? { type: "complete", stopReason } : { type: "incomplete", stopReason }; const finalizeAssistantMessage = ( session: AcpSessionState, @@ -256,10 +224,7 @@ const finalizeAssistantMessage = ( * The reducer is pure apart from locally generated message timestamps and is * suitable for deterministic projection tests with controlled time. */ -export function reduceAcpThreadState( - state: AcpThreadState, - event: AcpStateEvent, -): AcpThreadState { +export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent): AcpThreadState { switch (event.type) { case "connection.status": return { @@ -273,10 +238,7 @@ export function reduceAcpThreadState( initializeResponse: event.response, capabilities: event.response.agentCapabilities, authMethods: event.response.authMethods ?? [], - connectionStatus: - (event.response.authMethods?.length ?? 0) > 0 - ? "auth-required" - : "ready", + connectionStatus: (event.response.authMethods?.length ?? 0) > 0 ? "auth-required" : "ready", }; case "sessions.listed": { let next = state; @@ -308,9 +270,7 @@ export function reduceAcpThreadState( ...state, sessions, sessionOrder: state.sessionOrder.filter((id) => id !== event.sessionId), - ...(state.activeSessionId === event.sessionId - ? { activeSessionId: undefined } - : {}), + ...(state.activeSessionId === event.sessionId ? { activeSessionId: undefined } : {}), }; } case "session.loading": @@ -350,17 +310,10 @@ export function reduceAcpThreadState( runState: "cancelling", })); case "session.update": - return updateSession( - state, - event.notification.sessionId, - (session) => ({ - ...reduceUpdate(session, event.notification.update), - rawNotifications: [ - ...session.rawNotifications, - event.notification, - ], - }), - ); + return updateSession(state, event.notification.sessionId, (session) => ({ + ...reduceUpdate(session, event.notification.update), + rawNotifications: [...session.rawNotifications, event.notification], + })); case "message.optimistic": return updateSession(state, event.sessionId, (session) => appendMessage(session, event.message), @@ -384,9 +337,7 @@ export function reduceAcpThreadState( const tool = mergeTool(existing, event.request.toolCall, messageId); const alreadyLinked = current.messages .find((message) => message.id === messageId) - ?.pieces.some( - (piece) => piece.type === "tool" && piece.toolCallId === toolCallId, - ); + ?.pieces.some((piece) => piece.type === "tool" && piece.toolCallId === toolCallId); if (!alreadyLinked) { current = appendPiece(current, messageId, { type: "tool", diff --git a/src/core/types.ts b/src/core/types.ts index d8bc2b9..82fd222 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -57,12 +57,7 @@ export type MaybePromise = T | Promise; /** Lifecycle state of the ACP client connection. */ export type AcpConnectionStatus = - | "idle" - | "connecting" - | "auth-required" - | "ready" - | "error" - | "closed"; + "idle" | "connecting" | "auth-required" | "ready" | "error" | "closed"; /** Workspace data supplied when creating, loading, or resuming ACP sessions. */ export type AcpWorkspace = { @@ -93,10 +88,7 @@ export type AcpFileSystemServices = { signal: AbortSignal, ) => MaybePromise; /** Writes a text file after the host applies its own access policy. */ - writeTextFile?: ( - request: WriteTextFileRequest, - signal: AbortSignal, - ) => MaybePromise; + writeTextFile?: (request: WriteTextFileRequest, signal: AbortSignal) => MaybePromise; }; /** Complete host terminal service required before terminal capability is advertised. */ @@ -112,10 +104,7 @@ export type AcpTerminalServices = { signal: AbortSignal, ) => MaybePromise; /** Releases host resources associated with a terminal. */ - release: ( - request: ReleaseTerminalRequest, - signal: AbortSignal, - ) => MaybePromise; + release: (request: ReleaseTerminalRequest, signal: AbortSignal) => MaybePromise; /** Waits for a terminal process to exit. */ waitForExit: ( request: WaitForTerminalExitRequest, @@ -151,10 +140,7 @@ export type AcpClientHandlers = { signal: AbortSignal, ): MaybePromise; /** Handles an agent request to write a text file. */ - writeTextFile?( - request: WriteTextFileRequest, - signal: AbortSignal, - ): MaybePromise; + writeTextFile?(request: WriteTextFileRequest, signal: AbortSignal): MaybePromise; /** Handles the complete set of agent terminal requests. */ terminal?: AcpTerminalServices; }; @@ -214,7 +200,7 @@ export type AcpMessagePiece = | { type: "content"; content: ContentBlock; raw: SessionUpdate } | { type: "tool"; toolCallId: string } | { type: "plan"; plan: Extract } - | { type: "unsupported"; update: SessionUpdate | unknown }; + | { type: "unsupported"; update: unknown }; /** Protocol-authoritative message record retained in ACP session state. */ export type AcpMessageRecord = { @@ -262,12 +248,7 @@ export type AcpPermissionRecord = { }; /** Lifecycle state of the current prompt turn in an ACP session. */ -export type AcpSessionRunState = - | "idle" - | "loading" - | "running" - | "cancelling" - | "error"; +export type AcpSessionRunState = "idle" | "loading" | "running" | "cancelling" | "error"; /** Protocol-authoritative state retained for one ACP session. */ export type AcpSessionState = { diff --git a/src/hooks.ts b/src/hooks.ts index 916583b..e13e2d6 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -39,9 +39,7 @@ export const useAcpSession = (): AcpSessionState | undefined => export function useAcpThreadState(): AcpThreadState; /** Selects a derived value from the complete ACP thread state. */ export function useAcpThreadState(selector: (state: AcpThreadState) => T): T; -export function useAcpThreadState( - selector?: (state: AcpThreadState) => T, -): AcpThreadState | T { +export function useAcpThreadState(selector?: (state: AcpThreadState) => T): AcpThreadState | T { return acpExtras.use( (extras) => (selector ? selector(extras.state) : extras.state), selector ? selector(EMPTY_STATE) : EMPTY_STATE, @@ -85,17 +83,13 @@ export const useAcpPermissions = (): AcpPermissionsHookState => { }; /** Returns the latest plan update for the active session. */ -export const useAcpPlan = (): AcpSessionState["plan"] => - useAcpSession()?.plan; +export const useAcpPlan = (): AcpSessionState["plan"] => useAcpSession()?.plan; /** Returns the commands currently advertised by the active ACP session. */ -export const useAcpCommands = (): AcpSessionState["commands"] => - useAcpSession()?.commands ?? []; +export const useAcpCommands = (): AcpSessionState["commands"] => useAcpSession()?.commands ?? []; /** Returns the available and selected modes for the active session. */ -export const useAcpModes = (): AcpSessionState["modes"] => - useAcpSession()?.modes; +export const useAcpModes = (): AcpSessionState["modes"] => useAcpSession()?.modes; /** Returns the configuration options currently advertised by the active session. */ export const useAcpConfigOptions = (): AcpSessionState["configOptions"] => useAcpSession()?.configOptions ?? []; /** Returns the latest ACP usage update for the active session. */ -export const useAcpUsage = (): AcpSessionState["usage"] => - useAcpSession()?.usage; +export const useAcpUsage = (): AcpSessionState["usage"] => useAcpSession()?.usage; diff --git a/src/primitives/primitives.tsx b/src/primitives/primitives.tsx index 4bfb913..2c36315 100644 --- a/src/primitives/primitives.tsx +++ b/src/primitives/primitives.tsx @@ -1,10 +1,6 @@ "use client"; -import type { - ComponentPropsWithoutRef, - ReactElement, - ReactNode, -} from "react"; +import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; import { useAcpAuth, useAcpCommands, @@ -22,21 +18,14 @@ type DivProps = ComponentPropsWithoutRef<"div">; * Renders advertised authentication methods as buttons. * Returns `null` when authentication is not required. */ -export function AcpAuthMethods({ - children, - ...props -}: DivProps): ReactElement | null { +export function AcpAuthMethods({ children, ...props }: DivProps): ReactElement | null { const auth = useAcpAuth(); if (!auth.required) return null; return (
{children} {auth.methods.map((method) => ( - ))} @@ -48,10 +37,7 @@ export function AcpAuthMethods({ * Renders pending ACP tool-permission options for the active session. * Returns `null` when no permission is pending. */ -export function AcpPermissionList({ - children, - ...props -}: DivProps): ReactElement | null { +export function AcpPermissionList({ children, ...props }: DivProps): ReactElement | null { const { pending, reply } = useAcpPermissions(); if (!pending.length) return null; return ( @@ -64,17 +50,12 @@ export function AcpPermissionList({ ))} - @@ -128,10 +109,7 @@ export function AcpModeSelect( } /** Renders advertised boolean and select configuration options for the session. */ -export function AcpConfigOptions({ - children, - ...props -}: DivProps): ReactElement | null { +export function AcpConfigOptions({ children, ...props }: DivProps): ReactElement | null { const options = useAcpConfigOptions(); const extras = useAcpRuntimeExtras(); if (!options.length) return null; @@ -267,10 +245,7 @@ export function AcpDataPart({ ); } -export function AcpDiff({ - diff, - ...props -}: DivProps & { diff: unknown }): ReactElement { +export function AcpDiff({ diff, ...props }: DivProps & { diff: unknown }): ReactElement { return (
{typeof diff === "string" ? diff : JSON.stringify(diff, null, 2)}
@@ -315,10 +290,7 @@ export function AcpResource({ ); } -export function AcpUnsupported({ - value, - ...props -}: DivProps & { value: unknown }): ReactElement { +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..c4ac220 100644 --- a/src/useAcpRuntime.ts +++ b/src/useAcpRuntime.ts @@ -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; }; /** @@ -66,11 +61,15 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime { ) { void controller.selectSession(options.threadId).catch(options.onError); } - }, [controller, options.threadId, options.onError, state.connectionStatus, state.activeSessionId]); + }, [ + controller, + options.threadId, + options.onError, + 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, @@ -97,20 +96,13 @@ 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( () => ({ @@ -129,8 +121,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,8 +131,7 @@ 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" || @@ -150,8 +140,7 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime { state.connectionStatus !== "ready" || 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/tests/controller.test.ts b/tests/controller.test.ts index 0b8178f..a24d89d 100644 --- a/tests/controller.test.ts +++ b/tests/controller.test.ts @@ -46,8 +46,10 @@ describe("AcpThreadController conformance fixture", () => { 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.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" }], 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..26e5648 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", () => { diff --git a/tests/runtime.test.tsx b/tests/runtime.test.tsx index 2b491bf..d6f6259 100644 --- a/tests/runtime.test.tsx +++ b/tests/runtime.test.tsx @@ -4,16 +4,9 @@ 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"; +import type { AcpAdapterConnectOptions, AcpClientAdapter, AcpClientConnection } from "../src/core"; -const connectionFor = ( - signal: AbortSignal, - close: () => void, -): AcpClientConnection => ({ +const connectionFor = (signal: AbortSignal, close: () => void): AcpClientConnection => ({ signal, initialize: async () => ({ protocolVersion: PROTOCOL_VERSION }), authenticate: async () => {}, @@ -66,9 +59,7 @@ 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(); 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..83ef518 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({ @@ -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,24 +125,33 @@ 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, + 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" }); 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)); + const state = reduceAcpThreadState( + createAcpThreadState(), + update("s1", { + sessionUpdate: "vendor_extension", + payload: { answer: 42 }, + } as unknown as SessionUpdate), + ); expect(state.sessions.s1?.unhandledEvents).toHaveLength(1); expect(state.sessions.s1?.messages[0]?.pieces[0]).toMatchObject({ type: "unsupported", @@ -155,9 +179,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" }, { @@ -207,10 +229,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"] } From c4ec327d8904753314bf8288fb1312f49e731a6e Mon Sep 17 00:00:00 2001 From: "witon.ai" Date: Sun, 9 Aug 2026 16:14:18 +0800 Subject: [PATCH 3/4] release: v0.1.2 lifecycle hardening --- README.md | 18 +- docs/architecture.md | 18 +- docs/manual-smoke.md | 10 +- docs/protocol-capability-matrix.md | 8 +- docs/requirements.md | 12 +- e2e/workbench.spec.ts | 33 +- examples/vite/src/App.tsx | 7 +- examples/vite/src/mock-adapter.ts | 27 +- jsr.json | 2 +- package.json | 2 +- scripts/release-check.mjs | 4 +- scripts/release-lib.mjs | 23 ++ scripts/release-test.mjs | 10 + scripts/release-version.mjs | 7 +- src/core/controller.ts | 509 +++++++++++++++++++++-------- src/core/projection.ts | 14 +- src/core/serialize.ts | 2 +- src/core/state.ts | 247 ++++++++------ src/core/types.ts | 65 ++-- src/primitives/index.ts | 4 + src/primitives/primitives.tsx | 4 + src/useAcpRuntime.ts | 29 +- src/version.ts | 2 + tests/controller.test.ts | 342 ++++++++++++++++++- tests/exports.test.ts | 19 ++ tests/projection.test.ts | 61 ++++ tests/runtime.test.tsx | 101 +++++- tests/state.test.ts | 16 +- 28 files changed, 1294 insertions(+), 302 deletions(-) create mode 100644 src/version.ts create mode 100644 tests/exports.test.ts diff --git a/README.md b/README.md index 0bbe42e..fd3c58b 100644 --- a/README.md +++ b/README.md @@ -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.1` 开发版。兼容承诺覆盖官方 TypeScript SDK 标记为稳定的 ACP v1 API;实验 API 与 ACP v2 Draft 不在承诺范围内。 +> 当前状态:`0.1.2` 开发版。兼容承诺覆盖官方 TypeScript SDK 标记为稳定的 ACP v1 API;实验 API 与 ACP v2 Draft 不在承诺范围内。 ## 安装 @@ -42,13 +42,27 @@ export function AcpProvider({ children }: { children: React.ReactNode }) { 高层 adapter、纯 reducer/projector 和结构化错误从 `@hafbit/react-acp/core` 导出;认证、计划、模式、配置、命令、权限和 ACP artifact 的无样式组件从 `@hafbit/react-acp/primitives` 导出。主入口同时提供对应 hooks。 +`connection`、`workspace`、`clientServices`、`clientCapabilities` 和 `clientInfo` 是 Provider 的身份配置。切换 Agent 或 Workspace 时用 React `key` 重建 Provider;`threadId` 是标准受控属性,`onError`、`onThreadIdChange` 等回调可动态更新: + +```tsx +function AgentRuntime({ agent, workspace, children }: Props) { + return ( + + {children} + + ); +} +``` + +`useAcpRuntimeExtras()` 提供 `reconnect`、完整分页的 `refreshSessions` 和 session 生命周期方法。消息 metadata 只保留该消息自己的完整 ACP notifications;session 最新状态、工具通知和未知扩展通过 extras 中的公开 core state 读取。 + ## 入口与 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 组件 | +| `@hafbit/react-acp/primitives` | 自定义 ACP 交互界面 | 认证、权限、计划、模式、配置、命令、用量、Diff、Terminal、Resource 和 Unsupported 组件 | ```tsx import { useAcpRuntime } from "@hafbit/react-acp"; diff --git a/docs/architecture.md b/docs/architecture.md index 7dbf09d..d48d062 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,15 +19,31 @@ flowchart LR `AcpThreadController` 只维护客户端投影,不创建第二份会话真相。每个 session 的消息、工具、权限、计划、命令、模式、配置、用量和未处理扩展完全隔离。reducer 不执行 I/O,因此历史重放、乱序更新和失败恢复可独立测试。 +原始协议数据按归属保存:消息和工具保留自身完整 `SessionNotification`;plan、mode、config、commands、usage 与 session-info 保存最新完整通知;未知扩展保存完整通知。session 不再维护无限增长的全量 raw log,消息 metadata 也不复制 session 日志。 + ## 连接边界 - `stream`:`SdkAcpClientAdapter` 在连接前注册全部 Agent→Client handler,再调用官方 SDK 的 `ClientApp`。 - `adapter`:宿主负责实际传输和 Agent 生命周期,但必须提供同一套稳定方法及事件分发。 - 浏览器应用通常需要宿主或网关把 stdio Agent 转换为可用 Stream;这不属于本包职责。 +- 每次连接有独立 generation。旧连接通知、关闭回调和异步结果不会进入新连接状态;重连 initialize/auth 后必须重新 load 当前 session,缺少 load 时才使用 resume。 + +## Session 生命周期 + +- controller 记录当前连接已挂载的 session;未挂载或挂载失败的 session 禁止发送 prompt。 +- session 选择使用递增 generation,只有最新用户选择可以更新 active session;较晚完成的旧 load 历史仍保存在其原 session。 +- load 前保存快照并暂时清空重放区域;失败恢复快照与 settled active session,允许重试。 +- close 取消该 session 未决权限并解除挂载,但保留缓存历史;delete 才移除本地 session。 +- `session/list` 完整遍历分页并对账非 active session;远端列表暂时缺少 active session 时仍保留当前界面状态。 ## 投影规则 - 文本、图片、音频、reasoning 优先使用 assistant-ui 原生 part。 - tool call 使用 `acp:` 的稳定名称,原始输入、输出、内容和更新放入 artifact。 - permission 映射为 tool approval;plan、非 HTTP resource 和未知事件使用命名 `data-acp-*` part。 -- 所有消息的原始 update 和 `_meta` 保存在 `metadata.custom.acp`。 +- `metadata.custom.acp` 只包含当前消息的 session ID、协议 message ID、完整原始 notifications、stop reason 和错误。 +- 乐观用户消息保存真实 prompt content;live `user_message_chunk` 内容匹配时确认同一条本地消息并记录 `protocolMessageId`,保持 assistant-ui message ID 稳定。 + +## React 配置身份 + +连接、工作区、客户端服务、能力和 client info 在 controller 创建时固定。调用方改变这些身份配置时必须通过 React `key` 重建 Provider;回调使用 latest ref 动态生效。受控 `threadId` 同步不会回显 `onThreadIdChange`。 diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index a62bc30..e1d3f12 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -4,9 +4,11 @@ smoke 不进入常规 CI,也不在缺少本地 Agent 时报告成功。 1. 准备能产生 ACP Stream 的宿主,设置 `ACP_SMOKE_AGENT=codex` 或 `opencode`,并设置对应宿主命令。 2. 完成 initialize,记录 protocolVersion、agentInfo 和 capabilities。 -3. 创建 session,发送文本 prompt,确认 chunk、tool、permission、plan、usage 和 stop reason 投影。 -4. 若声明 list/load/resume/delete/close,逐项执行;确认 resume 不显示伪造历史。 -5. 触发取消并确认所有未决 permission 收到 cancelled response。 -6. 关闭连接,确认 terminal、文件句柄与子进程全部释放。 +3. 创建 session,发送文本 prompt,确认 chunk、tool、permission、plan、usage 和 stop reason 投影;Agent 回传 `user_message_chunk` 时界面只能出现一条用户消息,协议 ID 与本地 ID 分别保留。 +4. 断开并重连 transport,确认重新执行 initialize/auth→load;缓存历史保持可见且下一次 prompt 发往已挂载 session。Agent 不支持 load/resume 时应明确报错并禁止发送。 +5. 快速发起 A→B session 切换并让 A 较晚完成,确认 active 始终为 B,A 历史只归属 A;制造 load 失败后确认快照恢复且可重试。 +6. 若声明 list/load/resume/delete/close,逐项执行;确认分页刷新能发现新增/删除 session,active session 不因瞬时列表缺失而消失,resume 不显示伪造历史。 +7. 触发取消和 close,确认该 session 所有未决 permission 收到 cancelled response;受控 thread 回调与 active ID 同步。 +8. 关闭连接,确认 terminal、文件句柄与子进程全部释放;旧连接随后到达的 notification 不改变界面。 仓库脚本只做前置条件检查并输出明确的 `SKIP` 或待执行命令,不会替代宿主集成。 diff --git a/docs/protocol-capability-matrix.md b/docs/protocol-capability-matrix.md index 6c6796c..260ae5d 100644 --- a/docs/protocol-capability-matrix.md +++ b/docs/protocol-capability-matrix.md @@ -7,10 +7,10 @@ | initialize / 版本协商 | 实现 | `SdkAcpClientAdapter`、controller | `stream-conformance.test.ts` | | authenticate / logout | 实现 | controller、认证 hook/primitive | `controller.test.ts` | | session/new | 实现 | controller | `controller.test.ts` | -| session/prompt / cancel | 实现 | controller | `controller.test.ts` | +| session/prompt / cancel | 实现 | controller | `controller.test.ts`、`workbench.spec.ts` | | session/update 内容流 | 实现 | reducer/projector | `state.test.ts`、`projection.test.ts` | -| session/load | 能力门控 | `loadSession` | `controller.test.ts` | -| session/list + 全分页 | 能力门控 | `sessionCapabilities.list` | `controller.test.ts` | +| session/load / 重连重新挂载 | 能力门控 | `loadSession` / `resumeSession` | `controller.test.ts`、`workbench.spec.ts` | +| session/list + 全分页对账 | 能力门控 | `sessionCapabilities.list` | `controller.test.ts` | | session/delete | 能力门控 | `sessionCapabilities.delete` | `controller.test.ts` | | session/resume | 能力门控 | `sessionCapabilities.resume` | `controller.test.ts` | | session/close | 能力门控 | `sessionCapabilities.close` | `controller.test.ts` | @@ -27,5 +27,7 @@ | Client fs/write_text_file | 按注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | | Client terminal 全组方法 | 按整组注入声明 | SDK adapter | `stream-conformance.test.ts`、`serialize.test.ts` | | `_meta` / 未知扩展 | 实现 | reducer/projector | `state.test.ts`、`projection.test.ts` | +| 乐观用户消息 / live echo 合并 | 实现 | controller/projector | `controller.test.ts`、`workbench.spec.ts` | +| session 快速切换 latest-wins | 实现 | controller | `controller.test.ts`、`workbench.spec.ts` | | rename / archive / edit / regenerate / branch | 不适用 | 对应 runtime 能力关闭 | `runtime.test.tsx`、构建检查 | | v2 Draft / `UNSTABLE` | 不承诺 | 作为 raw/unsupported 保留 | `state.test.ts` | diff --git a/docs/requirements.md b/docs/requirements.md index 145dcba..49c354a 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -1,4 +1,4 @@ -# react-acp 0.1.0 需求基线 +# react-acp 0.1.x 需求基线 本文是首版实现的中文权威需求记录。目标是提供单一可发布包,将 ACP v1 session 的事件流投影为 assistant-ui runtime、message 和 thread。 @@ -22,3 +22,13 @@ ## 发布边界 本仓库生成 `@hafbit/react-acp@0.1.0` 发布产物。无 scope 的 `react-acp` 被 npm 相似名称策略拒绝后,维护者已确认改用组织 scope;首次发布仍由维护者通过 2FA 手工执行。 + +## 0.1.2 生命周期加固补充 + +- 每个新连接都必须重新挂载 active session;旧连接的通知、关闭回调和异步结果全部丢弃。 +- session 选择遵循 latest-selection-wins;load 失败恢复消息快照和原 active session,并可重试。 +- 未挂载或挂载失败的 session 禁止 prompt;prompt 传输失败回到 idle,消息保留错误供重试。 +- 乐观用户消息不伪造协议通知;匹配的 live user echo 合并到稳定本地消息,协议 ID 单独保存。 +- 原始通知按消息、工具和 session 最新状态归属保存,不保留 session 全量日志,不向每条消息复制全量通知。 +- session list 完整分页并对账非 active session;close 保留缓存,delete 才移除;生命周期方法一致更新受控 thread 回调。 +- Provider identity 配置通过 React `key` 重建,动态回调无需重建;不增加 ACP/assistant-ui 均不存在的公开状态。 diff --git a/e2e/workbench.spec.ts b/e2e/workbench.spec.ts index 0af2156..a258dab 100644 --- a/e2e/workbench.spec.ts +++ b/e2e/workbench.spec.ts @@ -1,10 +1,12 @@ import { expect, test } from "@playwright/test"; -test("消息、工具、计划、配置和多会话流程", async ({ page }) => { +test.beforeEach(async ({ page }) => { await page.goto("/"); await expect(page.getByTestId("connection")).toHaveText("ready"); - await expect(page.getByText("Loaded demo-1", { exact: false })).toBeVisible(); + await expect(page.getByText("Loaded demo-1 on connection 2", { exact: false })).toBeVisible(); +}); +test("消息回传合并、工具、计划和配置流程", async ({ page }) => { await page.getByLabel("Message").fill("hello ACP"); await page.getByRole("button", { name: "Send" }).click(); await expect( @@ -12,11 +14,34 @@ test("消息、工具、计划、配置和多会话流程", async ({ page }) => ).toBeVisible(); await expect(page.getByText("Inspect workspace", { exact: false })).toBeVisible(); await expect(page.getByText("Respond", { exact: true })).toBeVisible(); + await expect(page.locator('article[data-role="user"]')).toHaveCount(1); + await expect(page.locator('article[data-role="user"]')).toContainText("hello ACP"); await expect(page.getByRole("checkbox")).toBeChecked(); await page.getByRole("checkbox").click(); await expect(page.getByRole("checkbox")).not.toBeChecked(); +}); + +test("重连后重新挂载当前 session", async ({ page }) => { + await page.getByRole("button", { name: "Drop connection" }).click(); + await expect(page.getByTestId("connection")).toHaveText("closed"); + await page.getByRole("button", { name: "Reconnect" }).click(); + await expect(page.getByTestId("connection")).toHaveText("ready"); + await expect(page.getByText("Loaded demo-1 on connection 3", { exact: false })).toBeVisible(); +}); + +test("快速切换 session 时最新选择获胜", async ({ page }) => { + await page.getByRole("button", { name: "Slow session" }).click(); + await page.getByRole("button", { name: "Fast session" }).click(); - await page.getByRole("button", { name: "Second session" }).click(); - await expect(page.getByText("Loaded demo-2", { exact: false })).toBeVisible(); + await expect(page.getByRole("button", { name: "Fast session" })).toHaveAttribute( + "data-active", + "true", + ); + await expect(page.getByText("Loaded demo-fast", { exact: false })).toBeVisible(); + await page.waitForTimeout(200); + await expect(page.getByRole("button", { name: "Fast session" })).toHaveAttribute( + "data-active", + "true", + ); }); diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 35f2c1e..eabac31 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -34,13 +34,16 @@ function ConnectionStatus() { return {connection.status}; } -function Workbench() { +function Workbench({ adapter }: { adapter: MockAcpAdapter }) { + const extras = useAcpRuntimeExtras(); return (
+ +
@@ -78,7 +81,7 @@ export function App() { }); return ( - + ); } diff --git a/examples/vite/src/mock-adapter.ts b/examples/vite/src/mock-adapter.ts index 9dd03ee..1949ed0 100644 --- a/examples/vite/src/mock-adapter.ts +++ b/examples/vite/src/mock-adapter.ts @@ -7,11 +7,19 @@ import type { export class MockAcpAdapter implements AcpClientAdapter { private sequence = 2; + private connectionSequence = 0; private handlers?: AcpAdapterConnectOptions["handlers"]; + private lifecycle?: AbortController; + + dropConnection(): void { + this.lifecycle?.abort(new Error("mock transport dropped")); + } async connect(options: AcpAdapterConnectOptions): Promise { this.handlers = options.handlers; const lifecycle = new AbortController(); + this.lifecycle = lifecycle; + const connectionId = ++this.connectionSequence; options.signal.addEventListener("abort", () => lifecycle.abort(), { once: true }); return { @@ -30,16 +38,20 @@ export class MockAcpAdapter implements AcpClientAdapter { sessions: [ { sessionId: "demo-1", cwd: "/mock", title: "First session" }, { sessionId: "demo-2", cwd: "/mock", title: "Second session" }, + { sessionId: "demo-slow", cwd: "/mock", title: "Slow session" }, + { sessionId: "demo-fast", cwd: "/mock", title: "Fast session" }, ], }), newSession: async () => ({ sessionId: `demo-${++this.sequence}` }), loadSession: async ({ sessionId }) => { + if (sessionId === "demo-slow") await new Promise((resolve) => setTimeout(resolve, 150)); + if (sessionId === "demo-fast") await new Promise((resolve) => setTimeout(resolve, 10)); await this.handlers?.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", messageId: `${sessionId}-welcome`, - content: { type: "text", text: `Loaded ${sessionId}` }, + content: { type: "text", text: `Loaded ${sessionId} on connection ${connectionId}` }, }, }); return { @@ -83,7 +95,18 @@ export class MockAcpAdapter implements AcpClientAdapter { ], }; }, - prompt: async ({ sessionId }) => { + prompt: async ({ sessionId, prompt }) => { + const messageId = `user-${Date.now()}`; + for (const content of prompt) { + await this.handlers?.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "user_message_chunk", + messageId, + content, + }, + }); + } await this.handlers?.sessionUpdate({ sessionId, update: { diff --git a/jsr.json b/jsr.json index 1123b10..71bf374 100644 --- a/jsr.json +++ b/jsr.json @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@hafbit/react-acp", - "version": "0.1.1", + "version": "0.1.2", "exports": { ".": "./src/index.ts", "./core": "./src/core/index.ts", diff --git a/package.json b/package.json index ff89937..7850558 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hafbit/react-acp", - "version": "0.1.1", + "version": "0.1.2", "description": "Transport-agnostic ACP v1 runtime adapter for assistant-ui, with React hooks and headless primitives.", "repository": { "type": "git", diff --git a/scripts/release-check.mjs b/scripts/release-check.mjs index c79ed8b..a121733 100644 --- a/scripts/release-check.mjs +++ b/scripts/release-check.mjs @@ -1,11 +1,11 @@ import { appendFileSync } from "node:fs"; -import { assertReleaseManifests, assertReleaseTagGit, parseReleaseTag } from "./release-lib.mjs"; +import { assertReleaseArtifacts, assertReleaseTagGit, parseReleaseTag } from "./release-lib.mjs"; const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME; try { const release = parseReleaseTag(tag); - assertReleaseManifests(release.version); + assertReleaseArtifacts(release.version); if (process.env.GITHUB_ACTIONS === "true" || process.env.RELEASE_VERIFY_GIT === "true") { assertReleaseTagGit(tag); } diff --git a/scripts/release-lib.mjs b/scripts/release-lib.mjs index 4b5deae..e1ee5ea 100644 --- a/scripts/release-lib.mjs +++ b/scripts/release-lib.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export const manifestPath = resolve(repoRoot, "package.json"); export const jsrManifestPath = resolve(repoRoot, "jsr.json"); +export const versionSourcePath = resolve(repoRoot, "src/version.ts"); export const packageName = "@hafbit/react-acp"; export const repositoryUrl = "git+https://github.com/hafbit/react-acp.git"; @@ -42,6 +43,12 @@ export function readJsrManifest(path = jsrManifestPath) { return JSON.parse(readFileSync(path, "utf8")); } +export function readSourceVersion(path = versionSourcePath) { + const match = /REACT_ACP_VERSION\s*=\s*["']([^"']+)["']/.exec(readFileSync(path, "utf8")); + if (!match) throw new Error("src/version.ts must export REACT_ACP_VERSION."); + return match[1]; +} + export const jsrExports = { ".": "./src/index.ts", "./core": "./src/core/index.ts", @@ -95,6 +102,21 @@ export function assertReleaseManifests( return { npmManifest: npmPackage, jsrManifest: jsrPackage }; } +export function assertReleaseArtifacts( + expectedVersion, + npmManifest = readManifest(), + jsrManifest = readJsrManifest(), + sourceVersion = readSourceVersion(), +) { + const manifests = assertReleaseManifests(expectedVersion, npmManifest, jsrManifest); + if (sourceVersion !== manifests.npmManifest.version) { + throw new Error( + `Source version ${sourceVersion} does not match package.json version ${manifests.npmManifest.version}.`, + ); + } + return { ...manifests, sourceVersion }; +} + export function updateReleaseManifests( versionInput, npmManifest = readManifest(), @@ -105,6 +127,7 @@ export function updateReleaseManifests( return { npmManifest: { ...current.npmManifest, version }, jsrManifest: { ...current.jsrManifest, version }, + sourceVersion: version, }; } diff --git a/scripts/release-test.mjs b/scripts/release-test.mjs index ec83ad9..ca61932 100644 --- a/scripts/release-test.mjs +++ b/scripts/release-test.mjs @@ -8,6 +8,7 @@ import { assertPackedManifest, assertReleaseManifest, assertReleaseManifests, + assertReleaseArtifacts, assertReleaseTagGit, parseReleaseTag, parseReleaseVersion, @@ -116,6 +117,15 @@ test("updates npm and JSR versions together", () => { assert.equal(updated.jsrManifest.version, "2.0.0-rc.1"); assert.equal(updated.npmManifest.name, "@hafbit/react-acp"); assert.equal(updated.jsrManifest.exports["./core"], "./src/core/index.ts"); + assert.equal(updated.sourceVersion, "2.0.0-rc.1"); +}); + +test("validates the source client version with both manifests", () => { + assert.doesNotThrow(() => assertReleaseArtifacts("1.2.3", manifest(), jsrManifest(), "1.2.3")); + assert.throws( + () => assertReleaseArtifacts("1.2.3", manifest(), jsrManifest(), "1.2.2"), + /Source version 1.2.2/, + ); }); test("rejects dirty worktrees", () => { diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index a143b34..fbda380 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -3,6 +3,7 @@ import { assertCleanWorkingTree, jsrManifestPath, manifestPath, + versionSourcePath, readManifest, readJsrManifest, updateReleaseManifests, @@ -16,13 +17,17 @@ if (!input || process.argv.length !== 3) { try { assertCleanWorkingTree(); - const { npmManifest, jsrManifest } = updateReleaseManifests( + const { npmManifest, jsrManifest, sourceVersion } = updateReleaseManifests( input, readManifest(), readJsrManifest(), ); writeFileSync(manifestPath, `${JSON.stringify(npmManifest, null, 2)}\n`); writeFileSync(jsrManifestPath, `${JSON.stringify(jsrManifest, null, 2)}\n`); + writeFileSync( + versionSourcePath, + `/** Package version used for the default ACP client identity. */\nexport const REACT_ACP_VERSION = "${sourceVersion}";\n`, + ); console.log(`Updated ${npmManifest.name} npm and JSR manifests to ${npmManifest.version}.`); } catch (error) { console.error(error instanceof Error ? error.message : error); diff --git a/src/core/controller.ts b/src/core/controller.ts index 8737c17..6b280bb 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -5,12 +5,19 @@ import { type RequestPermissionResponse, type SetSessionConfigOptionRequest, type SessionInfo, + type SessionNotification, } from "@agentclientprotocol/sdk"; import type { AppendMessage } from "@assistant-ui/react"; +import { REACT_ACP_VERSION } from "../version"; import { AcpCapabilityError, AcpError } from "./errors"; import { toError } from "./internal-errors"; import { SdkAcpClientAdapter } from "./sdk-adapter"; -import { createAcpThreadState, hasAgentCapability, reduceAcpThreadState } from "./state"; +import { + createAcpSessionState, + createAcpThreadState, + hasAgentCapability, + reduceAcpThreadState, +} from "./state"; import { buildClientCapabilities, buildSessionRequest, @@ -31,12 +38,83 @@ 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}. - */ +type SelectSessionOptions = { + notify?: boolean; + force?: boolean; + method?: "auto" | "resume"; +}; + +type PendingOutbound = { + messageId: string; + prompt: readonly ContentBlock[]; + buffered: SessionNotification[]; + protocolMessageId?: string; + echoDisabled: boolean; + confirmed: boolean; +}; + +const comparableContent = (content: ContentBlock): unknown => { + const value = { ...content } as Record; + Reflect.deleteProperty(value, "_meta"); + return value; +}; + +const coalesceText = (blocks: readonly ContentBlock[]): ContentBlock[] => { + const result: ContentBlock[] = []; + for (const block of blocks) { + const previous = result.at(-1); + if (block.type === "text" && previous?.type === "text") { + result[result.length - 1] = { ...previous, text: previous.text + block.text }; + } else { + result.push(block); + } + } + return result; +}; + +const equalNonTextContent = (left: ContentBlock, right: ContentBlock): boolean => + JSON.stringify(comparableContent(left)) === JSON.stringify(comparableContent(right)); + +const echoRelation = ( + prompt: readonly ContentBlock[], + notifications: readonly SessionNotification[], +): "prefix" | "equal" | "different" => { + const expected = coalesceText(prompt); + const actual = coalesceText( + notifications.map((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "user_message_chunk") { + throw new AcpError("ACP_INTERNAL", "Expected buffered user message chunks."); + } + return update.content; + }), + ); + if (actual.length > expected.length) return "different"; + + for (let index = 0; index < actual.length; index += 1) { + const incoming = actual[index]!; + const target = expected[index]; + if (!target || incoming.type !== target.type) return "different"; + if (incoming.type === "text" && target.type === "text") { + const last = index === actual.length - 1; + if (last ? !target.text.startsWith(incoming.text) : target.text !== incoming.text) { + return "different"; + } + } else if (!equalNonTextContent(incoming, target)) { + return "different"; + } + } + + if (actual.length !== expected.length) return "prefix"; + const lastActual = actual.at(-1); + const lastExpected = expected.at(-1); + if (lastActual?.type === "text" && lastExpected?.type === "text") { + return lastActual.text === lastExpected.text ? "equal" : "prefix"; + } + return "equal"; +}; + +/** Owns one ACP connection and the protocol-authoritative session repository. */ export class AcpThreadController { private state = createAcpThreadState(); private readonly listeners = new Set<() => void>(); @@ -44,6 +122,13 @@ export class AcpThreadController { private abortController?: AbortController; private connectPromise?: Promise; private readonly permissionWaiters = new Map(); + private readonly attachedSessions = new Set(); + private readonly attachmentPromises = new Map>(); + private readonly loadingSessions = new Set(); + private readonly pendingOutbound = new Map(); + private connectionGeneration = 0; + private selectionGeneration = 0; + private settledActiveSessionId?: string; private disposed = false; /** Creates a controller and validates the configured workspace paths. */ @@ -57,23 +142,18 @@ export class AcpThreadController { /** Subscribes to state changes and returns an unsubscribe function. */ subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); - return () => { - this.listeners.delete(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 @@ -92,15 +172,9 @@ export class AcpThreadController { try { await pending; } catch { - // A StrictMode cleanup can abort the first setup while it is connecting. - } - if ( - this.abortController?.signal.aborted || - !this.connection || - this.connection.signal.aborted - ) { - return this.connect(); + // React StrictMode can abort the first setup before the replacement starts. } + if (!this.connection || this.connection.signal.aborted) return this.connect(); return; } @@ -110,11 +184,14 @@ export class AcpThreadController { return this.connectPromise; } - /** Performs one ACP transport and initialization attempt. */ private async doConnect(): Promise { this.abortController?.abort(); + const generation = ++this.connectionGeneration; const abortController = new AbortController(); this.abortController = abortController; + this.attachedSessions.clear(); + this.attachmentPromises.clear(); + this.loadingSessions.clear(); this.dispatch({ type: "connection.status", status: "connecting" }); try { @@ -122,10 +199,14 @@ export class AcpThreadController { const connection = await this.adapter.connect({ signal: abortController.signal, handlers: { - sessionUpdate: (notification) => { - this.dispatch({ type: "session.update", notification }); + sessionUpdate: (notification) => this.handleSessionUpdate(generation, notification), + requestPermission: (request, signal) => { + if (generation !== this.connectionGeneration) { + return { outcome: { outcome: "cancelled" } }; + } + this.flushEchoBoundary(request.sessionId); + return this.waitForPermission(request, signal); }, - requestPermission: (request, signal) => this.waitForPermission(request, signal), ...(services?.fileSystem?.readTextFile ? { readTextFile: services.fileSystem.readTextFile } : {}), @@ -137,11 +218,17 @@ export class AcpThreadController { : {}), }, }); + if (generation !== this.connectionGeneration) { + connection.close(); + throw new AcpError("ACP_DISCONNECTED", "ACP connection was superseded."); + } this.connection = connection; connection.signal.addEventListener( "abort", () => { - if (!this.disposed) { + if (!this.disposed && generation === this.connectionGeneration) { + this.connection = undefined; + this.attachedSessions.clear(); this.dispatch({ type: "connection.status", status: "closed" }); } }, @@ -153,10 +240,10 @@ export class AcpThreadController { clientCapabilities: buildClientCapabilities(services, this.options.clientCapabilities), clientInfo: this.options.clientInfo ?? { name: "react-acp", - version: "0.1.1", + version: REACT_ACP_VERSION, }, }); - if (abortController.signal.aborted) { + if (generation !== this.connectionGeneration || abortController.signal.aborted) { connection.close(abortController.signal.reason); throw new AcpError("ACP_DISCONNECTED", "ACP connection was aborted."); } @@ -167,8 +254,9 @@ export class AcpThreadController { ); } this.dispatch({ type: "connection.initialized", response }); - if (!(response.authMethods?.length ?? 0)) await this.afterAuthentication(); + if (!(response.authMethods?.length ?? 0)) await this.afterAuthentication(generation); } catch (error) { + if (generation !== this.connectionGeneration) throw error; if (abortController.signal.aborted) { this.dispatch({ type: "connection.status", status: "closed" }); } else { @@ -181,17 +269,18 @@ export class AcpThreadController { /** Closes the current connection and starts a fresh initialization. */ async reconnect(): Promise { - this.connection?.close(); - this.connection = undefined; + this.disconnectTransport(); await this.connect(); } /** Authenticates with an advertised method and completes session setup. */ async authenticate(methodId: string): Promise { + const generation = this.connectionGeneration; const connection = this.requireConnection(); await connection.authenticate(methodId); + if (generation !== this.connectionGeneration) return; this.dispatch({ type: "connection.status", status: "ready" }); - await this.afterAuthentication(); + await this.afterAuthentication(generation); } /** Logs out when the agent advertises the ACP logout capability. */ @@ -206,84 +295,155 @@ export class AcpThreadController { }); } - /** Refreshes sessions and applies a controlled session after authentication. */ - private async afterAuthentication(): Promise { - if (hasAgentCapability(this.state.capabilities, "list")) { - await this.refreshSessions(); + private async afterAuthentication(generation: number): Promise { + if (hasAgentCapability(this.state.capabilities, "list")) await this.refreshSessions(); + if (generation !== this.connectionGeneration) return; + const activeSessionId = this.state.activeSessionId; + if (!activeSessionId) return; + try { + await this.attachSession(activeSessionId, { force: true }); + this.settledActiveSessionId = activeSessionId; + } catch (error) { + this.reportError(error); } - 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 { + const generation = this.connectionGeneration; const connection = this.requireConnection(); const sessions: SessionInfo[] = []; let cursor: string | undefined; do { - const response = await connection.listSessions({ - ...(cursor ? { cursor } : {}), - }); + const response = await connection.listSessions(cursor ? { cursor } : {}); + if (generation !== this.connectionGeneration) return; sessions.push(...response.sessions); cursor = response.nextCursor ?? undefined; } while (cursor); this.dispatch({ type: "sessions.listed", sessions }); } - /** Creates, selects, and returns a new ACP session ID. */ + /** Creates, attaches, and selects a new ACP session. */ async createSession(): Promise { - const base = buildSessionRequest(this.options.workspace, this.state.capabilities); - const response = await this.requireConnection().newSession(base); + const token = ++this.selectionGeneration; + const generation = this.connectionGeneration; + const response = await this.requireConnection().newSession( + buildSessionRequest(this.options.workspace, this.state.capabilities), + ); + if (generation !== this.connectionGeneration) { + throw new AcpError("ACP_DISCONNECTED", "ACP connection changed while creating a session."); + } + this.attachedSessions.add(response.sessionId); this.dispatch({ - type: "session.opened", + type: "session.attached", sessionId: response.sessionId, modes: response.modes, configOptions: response.configOptions, }); - this.options.onThreadIdChange?.(response.sessionId); + if (token === this.selectionGeneration) { + this.dispatch({ type: "session.selected", sessionId: response.sessionId }); + this.settledActiveSessionId = response.sessionId; + this.options.onThreadIdChange?.(response.sessionId); + } return response.sessionId; } - /** Selects a known session and loads or resumes it when necessary. */ - async selectSession(sessionId: string): Promise { - if (this.state.activeSessionId === sessionId) return; + /** Selects a session; only the latest in-flight selection may become active. */ + async selectSession(sessionId: string, options: SelectSessionOptions = {}): Promise { + const notify = options.notify ?? true; + if ( + !options.force && + this.state.activeSessionId === sessionId && + this.attachedSessions.has(sessionId) + ) { + return; + } + + const token = ++this.selectionGeneration; + const fallbackSessionId = this.settledActiveSessionId; + try { + await this.attachSession(sessionId, options); + } catch (error) { + if (token === this.selectionGeneration) { + this.dispatch({ type: "session.selected", sessionId: fallbackSessionId }); + } + throw error; + } + if (token !== this.selectionGeneration) return; + this.dispatch({ type: "session.selected", sessionId }); + this.settledActiveSessionId = sessionId; + if (notify) this.options.onThreadIdChange?.(sessionId); + } + + private async attachSession( + sessionId: string, + options: Pick = {}, + ): Promise { + if (!options.force && this.attachedSessions.has(sessionId)) return; + const pending = this.attachmentPromises.get(sessionId); + if (pending) return pending; + + const promise = this.performAttach(sessionId, options); + this.attachmentPromises.set(sessionId, promise); + try { + await promise; + } finally { + if (this.attachmentPromises.get(sessionId) === promise) { + this.attachmentPromises.delete(sessionId); + } + } + } + + private async performAttach( + sessionId: string, + options: Pick, + ): Promise { + const generation = this.connectionGeneration; const connection = this.requireConnection(); + const snapshot = this.state.sessions[sessionId] ?? createAcpSessionState(sessionId); const base = buildSessionRequest(this.options.workspace, this.state.capabilities); - const known = this.state.sessions[sessionId]; - this.dispatch({ type: "session.selected", sessionId }); + const useResume = + options.method === "resume" || + (!hasAgentCapability(this.state.capabilities, "load") && + hasAgentCapability(this.state.capabilities, "resume")); - if (known?.messages.length) { - this.options.onThreadIdChange?.(sessionId); - return; + if (options.method === "resume" && !hasAgentCapability(this.state.capabilities, "resume")) { + throw new AcpCapabilityError("session/resume"); + } + if (!useResume && !hasAgentCapability(this.state.capabilities, "load")) { + const error = new AcpCapabilityError( + "session/load or session/resume", + "This agent cannot reopen an existing ACP session.", + ); + this.dispatch({ type: "session.attach_failed", sessionId, error }); + throw error; } - if (hasAgentCapability(this.state.capabilities, "load")) { - this.dispatch({ type: "session.loading", sessionId }); - const response = await connection.loadSession({ sessionId, ...base }); - this.dispatch({ - type: "session.opened", - sessionId, - info: known?.info, - modes: response.modes, - configOptions: response.configOptions, - loading: true, - }); - this.dispatch({ type: "session.loaded", sessionId }); - } else if (hasAgentCapability(this.state.capabilities, "resume")) { - const response = await connection.resumeSession({ sessionId, ...base }); + this.dispatch({ type: "session.loading", sessionId, clearHistory: !useResume }); + if (!useResume) this.loadingSessions.add(sessionId); + try { + const response = useResume + ? await connection.resumeSession({ sessionId, ...base }) + : await connection.loadSession({ sessionId, ...base }); + if (generation !== this.connectionGeneration) { + throw new AcpError("ACP_DISCONNECTED", "ACP connection changed while attaching a session."); + } + this.attachedSessions.add(sessionId); this.dispatch({ - type: "session.opened", + type: "session.attached", sessionId, - info: known?.info, + info: snapshot.info, modes: response.modes, configOptions: response.configOptions, }); - } else { - throw new AcpCapabilityError( - "session/load or session/resume", - "This agent cannot reopen an existing ACP session.", - ); + } catch (error) { + if (generation === this.connectionGeneration) { + this.dispatch({ type: "session.restored", session: snapshot, error }); + } + throw error; + } finally { + this.loadingSessions.delete(sessionId); } - this.options.onThreadIdChange?.(sessionId); } /** Permanently deletes a session when the agent advertises support. */ @@ -292,36 +452,45 @@ export class AcpThreadController { throw new AcpCapabilityError("session/delete"); } await this.requireConnection().deleteSession(sessionId); + this.attachedSessions.delete(sessionId); this.dispatch({ type: "session.deleted", sessionId }); + if (this.settledActiveSessionId === sessionId) { + ++this.selectionGeneration; + this.settledActiveSessionId = undefined; + this.options.onThreadIdChange?.(undefined); + } } - /** Explicitly resumes a session when the agent advertises support. */ + /** Explicitly resumes and selects a session. */ async resumeSession(sessionId: string): Promise { - if (!hasAgentCapability(this.state.capabilities, "resume")) { - throw new AcpCapabilityError("session/resume"); - } - const response = await this.requireConnection().resumeSession({ - sessionId, - ...buildSessionRequest(this.options.workspace, this.state.capabilities), - }); - this.dispatch({ - type: "session.opened", - sessionId, - modes: response.modes, - configOptions: response.configOptions, - }); + await this.selectSession(sessionId, { force: true, method: "resume" }); } - /** Closes a session without deleting it when the agent advertises support. */ + /** Closes a session without deleting its cached history. */ async closeSession(sessionId: string): Promise { if (!hasAgentCapability(this.state.capabilities, "close")) { throw new AcpCapabilityError("session/close"); } + await this.cancelPendingPermissions(sessionId); await this.requireConnection().closeSession(sessionId); + this.attachedSessions.delete(sessionId); + this.dispatch({ type: "session.closed", sessionId }); + if (this.state.activeSessionId === sessionId) { + ++this.selectionGeneration; + this.dispatch({ type: "session.selected", sessionId: undefined }); + this.settledActiveSessionId = undefined; + this.options.onThreadIdChange?.(undefined); + } } /** Sends one serialized ACP prompt turn and records its lifecycle. */ async prompt(sessionId: string, prompt: ContentBlock[]): Promise { + if (!this.attachedSessions.has(sessionId)) { + throw new AcpError( + "ACP_SESSION_NOT_ATTACHED", + `ACP session '${sessionId}' is not attached to the current connection.`, + ); + } const session = this.state.sessions[sessionId]; if (session?.runState === "running" || session?.runState === "cancelling") { throw new AcpError("ACP_TURN_RUNNING", "An ACP prompt turn is already running."); @@ -332,7 +501,7 @@ export class AcpThreadController { this.dispatch({ type: "session.prompt_stopped", sessionId, response }); return response; } catch (error) { - this.dispatch({ type: "session.failed", sessionId, error }); + this.dispatch({ type: "session.turn_failed", sessionId, error }); this.reportError(error); throw error; } @@ -351,41 +520,116 @@ export class AcpThreadController { role: "user", createdAt: Date.now(), optimistic: true, - pieces: prompt.map((content) => ({ - type: "content" as const, - content, - raw: { - sessionUpdate: "user_message_chunk" as const, - content, - messageId, - }, - })), + pieces: prompt.map((content) => ({ type: "content" as const, content })), + rawNotifications: [], }, }); + this.pendingOutbound.set(sessionId, { + messageId, + prompt, + buffered: [], + echoDisabled: false, + confirmed: false, + }); try { await this.prompt(sessionId, prompt); + this.finishPendingOutbound(sessionId, true); } catch (error) { + this.finishPendingOutbound(sessionId, false); + this.dispatch({ type: "message.optimistic_failed", sessionId, messageId, error }); + throw error; + } + } + + private handleSessionUpdate(generation: number, notification: SessionNotification): void { + if (generation !== this.connectionGeneration) return; + const sessionId = notification.sessionId; + const update = notification.update; + const pending = this.pendingOutbound.get(sessionId); + + if ( + pending && + !this.loadingSessions.has(sessionId) && + update.sessionUpdate === "user_message_chunk" && + !pending.echoDisabled + ) { + const incomingId = update.messageId ?? undefined; + if (pending.protocolMessageId && incomingId && pending.protocolMessageId !== incomingId) { + this.flushBufferedOutbound(sessionId); + pending.echoDisabled = true; + this.dispatch({ type: "session.update", notification }); + return; + } + pending.protocolMessageId ??= incomingId; + pending.buffered.push(notification); + const relation = echoRelation(pending.prompt, pending.buffered); + if (relation === "equal") { + this.dispatch({ + type: "message.optimistic_confirmed", + sessionId, + messageId: pending.messageId, + protocolMessageId: pending.protocolMessageId, + notifications: pending.buffered, + }); + pending.buffered = []; + pending.confirmed = true; + pending.echoDisabled = true; + } else if (relation === "different") { + this.flushBufferedOutbound(sessionId); + pending.echoDisabled = true; + } + return; + } + + if (pending && update.sessionUpdate !== "user_message_chunk") { + this.flushEchoBoundary(sessionId); + } + this.dispatch({ type: "session.update", notification }); + } + + private flushEchoBoundary(sessionId: string): void { + const pending = this.pendingOutbound.get(sessionId); + if (!pending?.buffered.length) return; + this.flushBufferedOutbound(sessionId); + pending.echoDisabled = true; + } + + private flushBufferedOutbound(sessionId: string): void { + const pending = this.pendingOutbound.get(sessionId); + if (!pending) return; + for (const notification of pending.buffered) { + this.dispatch({ type: "session.update", notification }); + } + pending.buffered = []; + } + + private finishPendingOutbound(sessionId: string, succeeded: boolean): void { + const pending = this.pendingOutbound.get(sessionId); + if (!pending) return; + this.flushBufferedOutbound(sessionId); + if (succeeded && !pending.confirmed) { this.dispatch({ - type: "message.optimistic_failed", + type: "message.optimistic_confirmed", sessionId, - messageId, - error, + messageId: pending.messageId, }); - throw error; } + this.pendingOutbound.delete(sessionId); } /** Cancels pending permissions and the active prompt turn for a session. */ async cancel(sessionId: string): Promise { this.dispatch({ type: "session.cancel_started", sessionId }); + await this.cancelPendingPermissions(sessionId); + await this.requireConnection().cancel(sessionId); + } + + private async cancelPendingPermissions(sessionId: string): Promise { for (const [toolCallId, permission] of Object.entries( this.state.sessions[sessionId]?.permissions ?? {}, )) { - if (permission.status === "pending") { - await this.replyToPermission(sessionId, toolCallId); - } + if (permission.status === "pending") await this.replyToPermission(sessionId, toolCallId); } - await this.requireConnection().cancel(sessionId); } /** Changes a session mode when modes were advertised by the agent. */ @@ -411,18 +655,12 @@ export class AcpThreadController { value, } as SetSessionConfigOptionRequest); this.dispatch({ - type: "session.update", - notification: { - sessionId, - update: { - sessionUpdate: "config_option_update", - configOptions: response.configOptions, - }, - }, + type: "session.config_options", + sessionId, + configOptions: response.configOptions, }); } - /** Bridges one ACP permission request to a later host reply. */ private waitForPermission( request: Parameters< NonNullable[0]["handlers"]["requestPermission"]> @@ -433,9 +671,7 @@ export class AcpThreadController { return new Promise((resolve, reject) => { const key = `${request.sessionId}:${request.toolCall.toolCallId}`; const abort = () => { - const response: RequestPermissionResponse = { - outcome: { outcome: "cancelled" }, - }; + const response: RequestPermissionResponse = { outcome: { outcome: "cancelled" } }; this.permissionWaiters.delete(key); this.dispatch({ type: "permission.resolved", @@ -461,7 +697,7 @@ export class AcpThreadController { }); } - /** Resolves a pending permission, or cancels it when `optionId` is omitted. */ + /** Resolves a pending permission, or cancels it when optionId is omitted. */ async replyToPermission(sessionId: string, toolCallId: string, optionId?: string): Promise { const key = `${sessionId}:${toolCallId}`; const waiter = this.permissionWaiters.get(key); @@ -470,39 +706,34 @@ export class AcpThreadController { ? { outcome: { outcome: "selected", optionId } } : { outcome: { outcome: "cancelled" } }; this.permissionWaiters.delete(key); - this.dispatch({ - type: "permission.resolved", - sessionId, - toolCallId, - response, - }); + this.dispatch({ type: "permission.resolved", sessionId, toolCallId, response }); 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")); - this.connection?.close(); - for (const waiter of this.permissionWaiters.values()) { - waiter.reject(new AcpError("ACP_DISPOSED", "Controller disposed")); - } - this.permissionWaiters.clear(); + this.disconnectTransport(new AcpError("ACP_DISPOSED", "Controller disposed")); this.listeners.clear(); } /** Disconnects the current transport while allowing a later reconnect. */ disconnect(): void { - this.abortController?.abort(); - this.connection?.close(); + this.disconnectTransport(new AcpError("ACP_DISCONNECTED", "ACP disconnected")); + } + + private disconnectTransport(reason?: unknown): void { + ++this.connectionGeneration; + this.abortController?.abort(reason); + this.connection?.close(reason); this.connection = undefined; - for (const waiter of this.permissionWaiters.values()) { - waiter.reject(new AcpError("ACP_DISCONNECTED", "ACP disconnected")); - } + this.attachedSessions.clear(); + this.attachmentPromises.clear(); + this.loadingSessions.clear(); + for (const waiter of this.permissionWaiters.values()) waiter.reject(reason); 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."); diff --git a/src/core/projection.ts b/src/core/projection.ts index 4a0523a..b004b86 100644 --- a/src/core/projection.ts +++ b/src/core/projection.ts @@ -145,7 +145,7 @@ function projectTool(tool: AcpToolCallRecord): ProjectedPart { locations: value.locations, rawInput, rawOutput, - rawUpdates: tool.rawUpdates, + rawNotifications: tool.rawNotifications, }, }, ...(projectToolApproval(tool) ? { approval: projectToolApproval(tool) } : {}), @@ -155,7 +155,10 @@ function projectTool(tool: AcpToolCallRecord): ProjectedPart { const projectPiece = (session: AcpSessionState, piece: AcpMessagePiece): ProjectedPart => { switch (piece.type) { case "content": - return projectContent(piece.content, piece.raw.sessionUpdate === "agent_thought_chunk"); + return projectContent( + piece.content, + piece.notification?.update.sessionUpdate === "agent_thought_chunk", + ); case "tool": { const tool = session.tools[piece.toolCallId]; return tool @@ -168,7 +171,7 @@ const projectPiece = (session: AcpSessionState, piece: AcpMessagePiece): Project case "plan": return dataPart("acp-plan", piece.plan); case "unsupported": - return dataPart("acp-unsupported", piece.update); + return dataPart("acp-unsupported", piece.notification.update); } }; @@ -197,7 +200,6 @@ const projectMessage = ( session: AcpSessionState, message: AcpMessageRecord, ): AcpProjectedMessage => { - const raw = message.pieces.map((piece) => (piece.type === "content" ? piece.raw : piece)); return { id: message.id, role: message.role, @@ -209,8 +211,8 @@ const projectMessage = ( custom: { acp: { sessionId: session.sessionId, - raw, - notifications: session.rawNotifications, + protocolMessageId: message.protocolMessageId, + notifications: message.rawNotifications, stopReason: message.status?.type === "complete" || message.status?.type === "incomplete" ? message.status.stopReason diff --git a/src/core/serialize.ts b/src/core/serialize.ts index 79785d6..1b69516 100644 --- a/src/core/serialize.ts +++ b/src/core/serialize.ts @@ -83,7 +83,7 @@ export function buildSessionRequest( if (server.type === "acp") { throw new AcpCapabilityError( "MCP ACP transport", - "The ACP MCP transport is UNSTABLE and is not enabled by react-acp 0.1.1.", + "The ACP MCP transport is UNSTABLE and is not enabled by react-acp.", ); } } diff --git a/src/core/state.ts b/src/core/state.ts index 76a1160..412af21 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -1,6 +1,6 @@ import type { AgentCapabilities, - SessionUpdate, + SessionNotification, StopReason, ToolCall, ToolCallUpdate, @@ -32,8 +32,8 @@ export const createAcpSessionState = (sessionId: string): AcpSessionState => ({ commands: [], configOptions: [], turn: 0, - unhandledEvents: [], - rawNotifications: [], + latestNotifications: {}, + unhandledNotifications: [], }); const updateSession = ( @@ -76,9 +76,12 @@ const ensureMessage = ( protocolMessageId?: string | null, ): [AcpSessionState, string] => { const exactId = protocolMessageId ?? undefined; - if (exactId && session.messages.some((message) => message.id === exactId)) { - return [session, exactId]; - } + const exactMessage = exactId + ? session.messages.find( + (message) => message.id === exactId || message.protocolMessageId === exactId, + ) + : undefined; + if (exactMessage) return [session, exactMessage.id]; if (!exactId && session.lastChunk?.role === role) { return [session, session.lastChunk.messageId]; @@ -87,9 +90,11 @@ const ensureMessage = ( const messageId = exactId ?? localMessageId(session, role); const next = appendMessage(session, { id: messageId, + ...(exactId ? { protocolMessageId: exactId } : {}), role, createdAt: Date.now(), pieces: [], + rawNotifications: [], ...(role === "assistant" ? { status: { type: "running" } } : {}), }); return [ @@ -108,10 +113,21 @@ const appendPiece = (session: AcpSessionState, messageId: string, piece: AcpMess pieces: [...message.pieces, piece], })); +const appendMessageNotification = ( + session: AcpSessionState, + messageId: string, + notification: SessionNotification, +) => + patchMessage(session, messageId, (message) => ({ + ...message, + rawNotifications: [...message.rawNotifications, notification], + })); + const mergeTool = ( existing: AcpToolCallRecord | undefined, incoming: ToolCall | ToolCallUpdate, messageId: string, + notification?: SessionNotification, ): AcpToolCallRecord => { const value = existing ? { ...existing.value, ...incoming } : incoming; return { @@ -119,86 +135,117 @@ const mergeTool = ( messageId, value, ...(existing?.permission ? { permission: existing.permission } : {}), - rawUpdates: [...(existing?.rawUpdates ?? []), incoming], + rawNotifications: notification + ? [...(existing?.rawNotifications ?? []), notification] + : (existing?.rawNotifications ?? []), }; }; -const reduceUpdate = (session: AcpSessionState, update: SessionUpdate): AcpSessionState => { +const recordLatestNotification = ( + session: AcpSessionState, + notification: SessionNotification, +): AcpSessionState => ({ + ...session, + latestNotifications: { + ...session.latestNotifications, + [notification.update.sessionUpdate]: notification, + }, +}); + +const reduceNotification = ( + session: AcpSessionState, + notification: SessionNotification, +): AcpSessionState => { + const update = notification.update; switch (update.sessionUpdate) { case "user_message_chunk": case "agent_message_chunk": case "agent_thought_chunk": { const role = update.sessionUpdate === "user_message_chunk" ? "user" : "assistant"; const [withMessage, messageId] = ensureMessage(session, role, update.messageId); - return appendPiece(withMessage, messageId, { + const withPiece = appendPiece(withMessage, messageId, { type: "content", content: update.content, - raw: update, + notification, }); + return appendMessageNotification(withPiece, messageId, notification); } case "tool_call": case "tool_call_update": { let current = session; let messageId = session.tools[update.toolCallId]?.messageId ?? session.lastAssistantMessageId; - if (!messageId) { - [current, messageId] = ensureMessage(session, "assistant"); - } - const exists = current.tools[update.toolCallId]; + if (!messageId) [current, messageId] = ensureMessage(session, "assistant"); + const existing = current.tools[update.toolCallId]; const nextTools = { ...current.tools, - [update.toolCallId]: mergeTool(exists, update, messageId), + [update.toolCallId]: mergeTool(existing, update, messageId, notification), }; const alreadyLinked = current.messages .find((message) => message.id === messageId) ?.pieces.some((piece) => piece.type === "tool" && piece.toolCallId === update.toolCallId); const linked = alreadyLinked ? current - : appendPiece(current, messageId, { - type: "tool", - toolCallId: update.toolCallId, - }); + : appendPiece(current, messageId, { type: "tool", toolCallId: update.toolCallId }); return { ...linked, tools: nextTools, lastAssistantMessageId: messageId }; } case "plan": { let current = session; let messageId = session.lastAssistantMessageId; if (!messageId) [current, messageId] = ensureMessage(session, "assistant"); - return { - ...appendPiece(current, messageId, { type: "plan", plan: update }), + const withPlan = appendPiece(current, messageId, { + type: "plan", plan: update, - }; + notification, + }); + return recordLatestNotification( + appendMessageNotification({ ...withPlan, plan: update }, messageId, notification), + notification, + ); } case "available_commands_update": - return { ...session, commands: update.availableCommands }; + return recordLatestNotification( + { ...session, commands: update.availableCommands }, + notification, + ); case "current_mode_update": - return session.modes - ? { - ...session, - modes: { ...session.modes, currentModeId: update.currentModeId }, - } - : session; + return recordLatestNotification( + session.modes + ? { ...session, modes: { ...session.modes, currentModeId: update.currentModeId } } + : session, + notification, + ); case "config_option_update": - return { ...session, configOptions: update.configOptions }; + return recordLatestNotification( + { ...session, configOptions: update.configOptions }, + notification, + ); case "session_info_update": - return { - ...session, - info: { - sessionId: session.sessionId, - cwd: session.info?.cwd ?? "", - ...session.info, - ...(update.title !== undefined ? { title: update.title } : {}), - ...(update.updatedAt !== undefined ? { updatedAt: update.updatedAt } : {}), + return recordLatestNotification( + { + ...session, + info: { + sessionId: session.sessionId, + cwd: session.info?.cwd ?? "", + ...session.info, + ...(update.title !== undefined ? { title: update.title ?? undefined } : {}), + ...(update.updatedAt !== undefined ? { updatedAt: update.updatedAt ?? undefined } : {}), + }, }, - }; + notification, + ); case "usage_update": - return { ...session, usage: update }; + return recordLatestNotification({ ...session, usage: update }, notification); default: { let current = session; let messageId = session.lastAssistantMessageId; if (!messageId) [current, messageId] = ensureMessage(session, "assistant"); + const withUnsupported = appendPiece(current, messageId, { + type: "unsupported", + notification, + }); return { - ...appendPiece(current, messageId, { type: "unsupported", update }), - unhandledEvents: [...current.unhandledEvents, update], + ...appendMessageNotification(withUnsupported, messageId, notification), + unhandledNotifications: [...current.unhandledNotifications, notification], }; } } @@ -218,12 +265,7 @@ const finalizeAssistantMessage = ( })); }; -/** - * Applies one connection, session, message, tool, or permission event. - * - * The reducer is pure apart from locally generated message timestamps and is - * suitable for deterministic projection tests with controlled time. - */ +/** Applies one connection, session, message, tool, or permission event. */ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent): AcpThreadState { switch (event.type) { case "connection.status": @@ -241,26 +283,33 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent connectionStatus: (event.response.authMethods?.length ?? 0) > 0 ? "auth-required" : "ready", }; case "sessions.listed": { - let next = state; + const sessions: Record = {}; for (const info of event.sessions) { - next = updateSession(next, info.sessionId, (session) => ({ - ...session, + sessions[info.sessionId] = { + ...(state.sessions[info.sessionId] ?? createAcpSessionState(info.sessionId)), info, - })); + }; } - return next; - } - case "session.opened": + const active = state.activeSessionId; + if (active && !sessions[active] && state.sessions[active]) + sessions[active] = state.sessions[active]; + const listedOrder = event.sessions.map((info) => info.sessionId); return { - ...updateSession(state, event.sessionId, (session) => ({ - ...session, - ...(event.info ? { info: event.info } : {}), - ...(event.modes !== undefined ? { modes: event.modes } : {}), - configOptions: event.configOptions ?? session.configOptions, - runState: event.loading ? "loading" : "idle", - })), - activeSessionId: event.sessionId, + ...state, + sessions, + sessionOrder: + active && !listedOrder.includes(active) ? [...listedOrder, active] : listedOrder, }; + } + case "session.attached": + return updateSession(state, event.sessionId, (session) => ({ + ...session, + ...(event.info ? { info: event.info } : {}), + ...(event.modes !== undefined ? { modes: event.modes } : {}), + configOptions: event.configOptions ?? session.configOptions, + runState: "idle", + error: undefined, + })); case "session.selected": return { ...state, activeSessionId: event.sessionId }; case "session.deleted": { @@ -275,14 +324,33 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent } case "session.loading": return updateSession(state, event.sessionId, (session) => ({ - ...createAcpSessionState(event.sessionId), - info: session.info, + ...(event.clearHistory + ? { ...createAcpSessionState(event.sessionId), info: session.info } + : session), runState: "loading", + error: undefined, + })); + case "session.restored": + return updateSession(state, event.session.sessionId, () => ({ + ...event.session, + ...(event.error !== undefined ? { runState: "error", error: event.error } : {}), + })); + case "session.attach_failed": + return updateSession(state, event.sessionId, (session) => ({ + ...session, + runState: "error", + error: event.error, })); - case "session.loaded": + case "session.closed": return updateSession(state, event.sessionId, (session) => ({ ...session, runState: "idle", + error: undefined, + })); + case "session.config_options": + return updateSession(state, event.sessionId, (session) => ({ + ...session, + configOptions: event.configOptions, })); case "session.prompt_started": return updateSession(state, event.sessionId, (session) => ({ @@ -298,10 +366,10 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent runState: "idle", lastChunk: undefined, })); - case "session.failed": + case "session.turn_failed": return updateSession(state, event.sessionId, (session) => ({ ...session, - runState: "error", + runState: "idle", error: event.error, })); case "session.cancel_started": @@ -310,10 +378,9 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent runState: "cancelling", })); case "session.update": - return updateSession(state, event.notification.sessionId, (session) => ({ - ...reduceUpdate(session, event.notification.update), - rawNotifications: [...session.rawNotifications, event.notification], - })); + return updateSession(state, event.notification.sessionId, (session) => + reduceNotification(session, event.notification), + ); case "message.optimistic": return updateSession(state, event.sessionId, (session) => appendMessage(session, event.message), @@ -326,6 +393,15 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent optimistic: false, })), ); + case "message.optimistic_confirmed": + return updateSession(state, event.sessionId, (session) => + patchMessage(session, event.messageId, (message) => ({ + ...message, + optimistic: false, + ...(event.protocolMessageId ? { protocolMessageId: event.protocolMessageId } : {}), + rawNotifications: [...message.rawNotifications, ...(event.notifications ?? [])], + })), + ); case "permission.requested": return updateSession(state, event.request.sessionId, (session) => { const record = { request: event.request, status: "pending" as const }; @@ -338,22 +414,11 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent const alreadyLinked = current.messages .find((message) => message.id === messageId) ?.pieces.some((piece) => piece.type === "tool" && piece.toolCallId === toolCallId); - if (!alreadyLinked) { - current = appendPiece(current, messageId, { - type: "tool", - toolCallId, - }); - } + if (!alreadyLinked) current = appendPiece(current, messageId, { type: "tool", toolCallId }); return { ...current, - permissions: { - ...current.permissions, - [toolCallId]: record, - }, - tools: { - ...current.tools, - [toolCallId]: { ...tool, permission: record }, - }, + permissions: { ...current.permissions, [toolCallId]: record }, + tools: { ...current.tools, [toolCallId]: { ...tool, permission: record } }, lastAssistantMessageId: messageId, }; }); @@ -372,15 +437,9 @@ export function reduceAcpThreadState(state: AcpThreadState, event: AcpStateEvent const tool = session.tools[event.toolCallId]; return { ...session, - permissions: { - ...session.permissions, - [event.toolCallId]: permission, - }, + permissions: { ...session.permissions, [event.toolCallId]: permission }, tools: tool - ? { - ...session.tools, - [event.toolCallId]: { ...tool, permission }, - } + ? { ...session.tools, [event.toolCallId]: { ...tool, permission } } : session.tools, }; }); diff --git a/src/core/types.ts b/src/core/types.ts index 82fd222..55390db 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -197,10 +197,14 @@ export interface AcpClientAdapter { /** Ordered protocol fragment retained inside a projected ACP message. */ export type AcpMessagePiece = - | { type: "content"; content: ContentBlock; raw: SessionUpdate } + | { type: "content"; content: ContentBlock; notification?: SessionNotification } | { type: "tool"; toolCallId: string } - | { type: "plan"; plan: Extract } - | { type: "unsupported"; update: unknown }; + | { + type: "plan"; + plan: Extract; + notification: SessionNotification; + } + | { type: "unsupported"; notification: SessionNotification }; /** Protocol-authoritative message record retained in ACP session state. */ export type AcpMessageRecord = { @@ -208,10 +212,14 @@ export type AcpMessageRecord = { id: string; /** Message author. */ role: "user" | "assistant"; + /** Opaque ACP message ID associated with a locally stable projected ID. */ + protocolMessageId?: string; /** Local creation time in Unix milliseconds. */ createdAt: number; /** Ordered raw content, tool, plan, and unsupported protocol pieces. */ pieces: readonly AcpMessagePiece[]; + /** Complete ACP notifications owned directly by this message. */ + rawNotifications: readonly SessionNotification[]; /** Assistant turn completion state. */ status?: | { type: "running" } @@ -223,7 +231,7 @@ export type AcpMessageRecord = { error?: unknown; }; -/** Latest state and full raw update history for one ACP tool call. */ +/** Latest state and full raw notification history for one ACP tool call. */ export type AcpToolCallRecord = { /** ACP tool call identifier. */ toolCallId: string; @@ -233,8 +241,8 @@ export type AcpToolCallRecord = { value: ToolCall | ToolCallUpdate; /** Associated permission request, when approval is required. */ permission?: AcpPermissionRecord; - /** Tool call and update values in arrival order. */ - rawUpdates: readonly (ToolCall | ToolCallUpdate)[]; + /** Tool call notifications in arrival order. */ + rawNotifications: readonly SessionNotification[]; }; /** Lifecycle state for an ACP tool permission request. */ @@ -283,10 +291,10 @@ export type AcpSessionState = { lastChunk?: { role: "user" | "assistant"; messageId: string }; /** Most recent assistant message used to associate tools and plans. */ lastAssistantMessageId?: string; - /** Forward-compatible updates not interpreted by this version. */ - unhandledEvents: readonly unknown[]; - /** Original ACP notifications retained in arrival order. */ - rawNotifications: readonly SessionNotification[]; + /** Latest full notification for each interpreted session-level update kind. */ + latestNotifications: Readonly>; + /** Forward-compatible notifications not interpreted by this version. */ + unhandledNotifications: readonly SessionNotification[]; /** Latest session-level failure. */ error?: unknown; }; @@ -317,24 +325,30 @@ export type AcpStateEvent = | { type: "connection.initialized"; response: InitializeResponse } | { type: "sessions.listed"; sessions: readonly SessionInfo[] } | { - type: "session.opened"; + type: "session.attached"; sessionId: string; info?: SessionInfo; modes?: SessionModeState | null; configOptions?: readonly SessionConfigOption[] | null; - loading?: boolean; } - | { type: "session.selected"; sessionId: string } + | { type: "session.selected"; sessionId: string | undefined } | { type: "session.deleted"; sessionId: string } - | { type: "session.loading"; sessionId: string } - | { type: "session.loaded"; sessionId: string } + | { type: "session.loading"; sessionId: string; clearHistory: boolean } + | { type: "session.restored"; session: AcpSessionState; error?: unknown } + | { type: "session.attach_failed"; sessionId: string; error: unknown } + | { type: "session.closed"; sessionId: string } + | { + type: "session.config_options"; + sessionId: string; + configOptions: readonly SessionConfigOption[]; + } | { type: "session.prompt_started"; sessionId: string } | { type: "session.prompt_stopped"; sessionId: string; response: PromptResponse; } - | { type: "session.failed"; sessionId: string; error: unknown } + | { type: "session.turn_failed"; sessionId: string; error: unknown } | { type: "session.cancel_started"; sessionId: string } | { type: "session.update"; notification: SessionNotification } | { @@ -348,6 +362,13 @@ export type AcpStateEvent = messageId: string; error: unknown; } + | { + type: "message.optimistic_confirmed"; + sessionId: string; + messageId: string; + protocolMessageId?: string; + notifications?: readonly SessionNotification[]; + } | { type: "permission.requested"; request: RequestPermissionRequest; @@ -367,6 +388,8 @@ export type AcpRuntimeExtras = { session: AcpSessionState | undefined; /** Reopens the ACP transport and repeats initialization. */ reconnect(): Promise; + /** Reloads the complete paginated ACP session list. */ + refreshSessions(): Promise; /** Authenticates using one advertised method ID. */ authenticate(methodId: string): Promise; /** Logs out when supported by the agent. */ @@ -423,13 +446,13 @@ export type AcpPermissionsHookState = { /** Options accepted by {@link useAcpRuntime} and {@link AcpThreadController}. */ export type AcpRuntimeOptions = ExternalStoreSharedOptions & { - /** ACP stream factory or custom client adapter. */ + /** Provider identity: ACP stream factory or custom adapter. Rebuild with a React key to change. */ connection: AcpConnectionSource; - /** Workspace supplied to ACP session operations. */ + /** Provider identity: workspace supplied to session operations. Rebuild with a React key to change. */ workspace: AcpWorkspace; - /** Host filesystem and terminal services exposed to the agent. */ + /** Provider identity: host services exposed to the agent. Rebuild with a React key to change. */ clientServices?: AcpClientServices; - /** Additional client capabilities merged with capabilities derived from services. */ + /** Provider identity: additional client capabilities. Rebuild with a React key to change. */ clientCapabilities?: ClientCapabilities; /** Controlled ACP session ID to select after connection. */ threadId?: string; @@ -439,7 +462,7 @@ export type AcpRuntimeOptions = ExternalStoreSharedOptions & { onError?: (error: unknown) => void; /** Additional assistant-ui runtime adapters. */ adapters?: RuntimeAdapters; - /** ACP client identity; defaults to `react-acp` and the package version. */ + /** Provider identity: ACP client info. Defaults to `react-acp` and the package version. */ clientInfo?: { name: string; version: string }; }; diff --git a/src/primitives/index.ts b/src/primitives/index.ts index e36f666..331eb20 100644 --- a/src/primitives/index.ts +++ b/src/primitives/index.ts @@ -10,9 +10,13 @@ export { AcpCommandMenu, AcpConfigOptions, AcpDataPart, + AcpDiff, AcpModeSelect, AcpPermissionList, AcpPlan, + AcpResource, + AcpTerminal, AcpToolArtifact, + AcpUnsupported, AcpUsage, } from "./primitives"; diff --git a/src/primitives/primitives.tsx b/src/primitives/primitives.tsx index 2c36315..bcd6ef1 100644 --- a/src/primitives/primitives.tsx +++ b/src/primitives/primitives.tsx @@ -245,6 +245,7 @@ export function AcpDataPart({ ); } +/** Renders a protocol or tool diff value without applying visual styling. */ export function AcpDiff({ diff, ...props }: DivProps & { diff: unknown }): ReactElement { return (
@@ -253,6 +254,7 @@ export function AcpDiff({ diff, ...props }: DivProps & { diff: unknown }): React ); } +/** Renders retained ACP terminal data as readable JSON. */ export function AcpTerminal({ terminal, ...props @@ -271,6 +273,7 @@ type ResourceLike = { title?: string | null; }; +/** Renders an ACP resource link or retained resource value. */ export function AcpResource({ resource, ...props @@ -290,6 +293,7 @@ export function AcpResource({ ); } +/** Renders a forward-compatible ACP value not interpreted by this package. */ export function AcpUnsupported({ value, ...props }: DivProps & { value: unknown }): ReactElement { return (
diff --git a/src/useAcpRuntime.ts b/src/useAcpRuntime.ts index c4ac220..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, @@ -45,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(() => { @@ -59,15 +68,11 @@ 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 extras = useMemo( @@ -75,6 +80,7 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime { state, session, reconnect: () => controller.reconnect(), + refreshSessions: () => controller.refreshSessions(), authenticate: (methodId) => controller.authenticate(methodId), logout: () => controller.logout(), selectSession: (sessionId) => controller.selectSession(sessionId), @@ -107,6 +113,7 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime { const threadList = useMemo( () => ({ threadId: state.activeSessionId, + isLoading: state.connectionStatus === "connecting", threads: state.sessionOrder.map((sessionId) => { const item = state.sessions[sessionId]; return { @@ -138,6 +145,8 @@ export function useAcpRuntime(options: AcpRuntimeOptions): AssistantRuntime { 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", 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 a24d89d..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,7 +112,8 @@ 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.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", @@ -137,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/projection.test.ts b/tests/projection.test.ts index 26e5648..972cec3 100644 --- a/tests/projection.test.ts +++ b/tests/projection.test.ts @@ -96,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 d6f6259..85b3e3b 100644 --- a/tests/runtime.test.tsx +++ b/tests/runtime.test.tsx @@ -3,7 +3,7 @@ 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 { useAcpConnection, useAcpRuntime, useAcpRuntimeExtras } from "../src"; import type { AcpAdapterConnectOptions, AcpClientAdapter, AcpClientConnection } from "../src/core"; const connectionFor = (signal: AbortSignal, close: () => void): AcpClientConnection => ({ @@ -66,4 +66,103 @@ describe("useAcpRuntime", () => { 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 ( + <> + {connection.status} + + + ); + } + + 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/state.test.ts b/tests/state.test.ts index 83ef518..2d3b7af 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -71,7 +71,7 @@ describe("reduceAcpThreadState", () => { rawInput: { path: "/tmp/a" }, rawOutput: { partial: true }, }); - expect(tool?.rawUpdates).toHaveLength(2); + expect(tool?.rawNotifications).toHaveLength(2); }); it("权限可先于工具事件到达并投影到占位工具", () => { @@ -139,12 +139,16 @@ describe("reduceAcpThreadState", () => { content: { type: "text", text: "keep" }, }), ); - state = reduceAcpThreadState(state, { type: "session.loading", sessionId: "s1" }); + 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 而不崩溃", () => { + it("未知扩展进入 unhandledNotifications 而不崩溃", () => { const state = reduceAcpThreadState( createAcpThreadState(), update("s1", { @@ -152,7 +156,8 @@ describe("reduceAcpThreadState", () => { payload: { answer: 42 }, } as unknown as SessionUpdate), ); - expect(state.sessions.s1?.unhandledEvents).toHaveLength(1); + 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", }); @@ -160,7 +165,7 @@ describe("reduceAcpThreadState", () => { it("保存计划、命令、模式、配置和用量状态", () => { let state = reduceAcpThreadState(createAcpThreadState(), { - type: "session.opened", + type: "session.attached", sessionId: "s1", modes: { currentModeId: "ask", @@ -221,6 +226,7 @@ describe("reduceAcpThreadState", () => { createdAt: 1, optimistic: true, pieces: [], + rawNotifications: [], }, }); state = reduceAcpThreadState(state, { From f06536e0123d8f171ae6325e6940de880e9948ea Mon Sep 17 00:00:00 2001 From: "witon.ai" Date: Sun, 9 Aug 2026 16:18:11 +0800 Subject: [PATCH 4/4] fix: resolve example source in clean checkouts --- examples/vite/tsconfig.json | 6 ++++++ examples/vite/vite.config.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/examples/vite/tsconfig.json b/examples/vite/tsconfig.json index a9feed5..1051575 100644 --- a/examples/vite/tsconfig.json +++ b/examples/vite/tsconfig.json @@ -4,6 +4,12 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", + "baseUrl": "../..", + "paths": { + "@hafbit/react-acp": ["src/index.ts"], + "@hafbit/react-acp/core": ["src/core/index.ts"], + "@hafbit/react-acp/primitives": ["src/primitives/index.ts"] + }, "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/examples/vite/vite.config.ts b/examples/vite/vite.config.ts index 1f75676..ae7e501 100644 --- a/examples/vite/vite.config.ts +++ b/examples/vite/vite.config.ts @@ -1,7 +1,24 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; export default defineConfig({ plugins: [react()], + resolve: { + alias: [ + { + find: "@hafbit/react-acp/core", + replacement: fileURLToPath(new URL("../../src/core/index.ts", import.meta.url)), + }, + { + find: "@hafbit/react-acp/primitives", + replacement: fileURLToPath(new URL("../../src/primitives/index.ts", import.meta.url)), + }, + { + find: "@hafbit/react-acp", + replacement: fileURLToPath(new URL("../../src/index.ts", import.meta.url)), + }, + ], + }, server: { port: 4173 }, });