Compare commits

...

8 Commits

Author SHA1 Message Date
acp-release-bot[bot]
a1d83c6031 chore(main): release 0.103.0 (#1559)
🤖 I have created a release *beep* *boop*
---


##
[0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0)
(2026-11-10)


### ⚠ BREAKING CHANGES

* deep imports of `dist/` files fail with
`ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`,
`AUTH_STATUS_UPDATE_METHOD` and their types.

### Features

* serve ACP v2 with a direct v2 agent and remove the v1 bridge
([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560))
([cb285cd](cb285cd285))


### Bug Fixes

* give subagent checklists their own plan ID
([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558))
([2f7e386](2f7e386f36))


### Code Refactoring

* export only lib.ts, with serveAcp and the authStatus wire names
([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566))
([566e265](566e2652a9))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-11-10 09:35:53 +01:00
Ben Brandt
966aee0f04 refactor: move the test-only tool render and metadata wrappers into src/tests/ (#1569)
`toolInfoFromToolUse` and `toolUpdateFromToolResult` in `src/tools.ts`
and `isLocalCommandMetadata` in `src/message-filters.ts` were public
`lib.ts` exports. #1566 removed them from `lib.ts`, so now only tests
call them. They're thin wrappers: the first two build an
`AcpToolCallRenderer` for one tool use or result, and the third checks
that `stripLocalCommandMetadata` drops everything.

They move unchanged to `src/tests/tool-render.ts`. `tools.test.ts`,
`diff-patch.test.ts` and `v1/acp-agent.test.ts` import them from there.
Production code renders through `AcpToolCallRenderer` directly, as
before. Code changes only: no test changes and no behavior change.

A scan of `src/` for exports finds no export that nothing uses. The only
remaining test-only exports are the deliberate test hooks
`clearDiffMemo` (`diff.ts`) and `sleep` (`utils.ts`).

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped
2026-11-10 08:31:08 +00:00
Ben Brandt
66adc1a4c9 refactor: share the subagent replay's transcript scan between v1 and v2 (#1568)
`src/subagent-replay.ts` (v1, `RfdSubagentReplay`) and
`src/v2/subagent-replay.ts` (`SubagentReplay`) each kept a copy of the
transcript scan. The copies differed only in formatting and variable
names. The scan does four things:
- It registers each Agent or Task launch that has metadata as a child,
and records a launch without metadata as omitted.
- It records each SendMessage and SubagentHandback call.
- It marks a failed hand-back.
- It sets a foreground launch's end from its tool result.

That scan, the `ReplayChild` and `SendMessageCall` types, the child's
end state, and the `contentBlocks`, `inputOf`, `textOf` and
`isDirectory` helpers move to `ReplayTranscript` in
`subagent-replay.ts`. Each replay holds one and reads `children`,
`sendMessages`, `handbacks` and `omitted` from it.

What each replay sends stays where it was. v1 publishes asynchronously
through `Publish`, and v2 sends synchronously through `SessionOutput`,
with the v2-only rules: each child's end also goes on the child's own
stream, and live children get their current state after the response.
Sharing those parts would tie the two protocols together, so I left them
separate. The scan only parses transcripts, so sharing it adds no
dependency between v1 and v2.

v2's `ReplayChild` is the shared type plus `output?: SessionOutput`. Its
`launchEnd` was a narrower `"completed" | "failed" | "cancelled"`, which
`endState` passed through `nativeSubagentState`. That call returns those
three values unchanged, so `replayEndState` gives the same result in
both versions.

No behavior change: 128 lines added and 242 removed, with no test
changes.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped. The v1 and v2 replay
scenario goldens are unchanged.
- `RUN_INTEGRATION_TESTS=1 npx vitest run`: passes. One run hit the
known load flake "absorbs the interrupt's lagged trailer after
cancelling a held turn", which passes on its own.
2026-11-10 08:19:11 +00:00
Ben Brandt
bc031f362c test: split src/tests/ into v1/, v2/ and shared tests (#1567)
`src/tests/` was flat, so it didn't follow the `src/v1/` / `src/v2/`
layout.

- **`src/tests/v1/`** holds the 37 tests that import `src/v1/`, plus
`session-doubles.ts`, which builds v1 sessions.
- **`src/tests/v2/`** holds the tests of `src/v2/` and `serve.ts`:
`v2.test.ts`, `v2-integration.test.ts`, `acp-v2.test.ts` and
`acp-scenarios-v2.test.ts`.
- **The top level** keeps the tests of shared modules, plus
`helpers.ts`, `setup-no-network.ts`, the scenario harness
(`acp-scenarios/`) and `fixtures/`.

A test goes to `v1/` when it imports the v1 agent, even if it is mostly
about a shared module. For example, `providers.test.ts` and
`fast-mode-config.test.ts` drive `ClaudeAcpAgent` for some of their
cases.

Only paths change. Imports and `vi.mock`/`vi.importActual` specifiers
gain one `../`. The `here`-relative paths to the goldens, the
origin/main recordings, `package.json` and `fixtures/` were updated too.
`AGENTS.md`, `docs/air-extensions.md` and the scenario files' doc
comments now give the new paths. No test, golden file or recording
changed. A script checked that every relative module specifier under
`src/tests/` resolves, because a `vi.mock` path that doesn't resolve
mocks nothing and raises no error.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped, the same as before the
move.
- `RUN_INTEGRATION_TESTS=1 npx vitest run`: 3964 passed / 8 skipped.
2026-11-10 09:07:09 +01:00
Ben Brandt
566e2652a9 refactor!: export only lib.ts, with serveAcp and the authStatus wire names (#1566)
Stacked on #1564.

The package's `exports` map listed `"./*": "./*"`, so every file under
`dist/` was public, and any file move could break a deep import. It now
lists only `.` (`dist/lib.js`) and `./package.json`.

**`lib.ts` exports:**
- `serveAcp`, which serves ACP v1 and v2 on stdio through the SDK's
protocol router. This is what `index.ts` runs.
- The `AcpProtocols` and `Logger` types.
- `AUTH_STATUS_UPDATE_METHOD` and the `authStatus` payload types, which
a client needs to read the extension's traffic.

**No longer exported:**
- `ClaudeAcpAgent`
- `runAcp`: v1 only and unused, replaced by `serveAcp`, and now deleted.
- `toAcpNotifications`, `streamEventToAcpNotifications`,
`isLocalCommandMetadata`, `stripLocalCommandMetadata`
- `toolInfoFromToolUse`, `toolUpdateFromToolResult`, `toDisplayPath`,
`planEntries`
- `SettingsManager`
- `nodeToWebReadable`, `nodeToWebWritable`, `Pushable`, `unreachable`
- The types `NewSessionMeta`, `SDKMessageFilter`, `ToolUpdateMeta` and
`ClaudePlanEntry`.

`src/acp-agent.ts`, which re-exported `src/v1/acp-agent.ts` for the
`dist/acp-agent.js` path, is gone. So are the re-exports in
`src/v1/acp-agent.ts` that kept moved names on that path. Tests now
import those names from their own modules. The ESLint boundary rules and
`AGENTS.md` drop their `acp-agent.ts` exemptions.

`release-please-config.json` sets `bump-minor-pre-major`, so the `!`
bumps the minor version, the same as a `feat:`.

BREAKING CHANGE: deep imports of `dist/` files fail with
`ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`,
`AUTH_STATUS_UPDATE_METHOD` and their types.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped. `module-exports.test.ts`
pins the `lib.ts` names and the `exports` map.
- `node scripts/smoke-binary.mjs -- node dist/index.js`: pass
- Resolving the package from its root:
`import("@agentclientprotocol/claude-agent-acp")` gives `serveAcp` and
`AUTH_STATUS_UPDATE_METHOD`, `…/dist/acp-agent.js` throws
`ERR_PACKAGE_PATH_NOT_EXPORTED`, and `…/package.json` resolves.
2026-11-10 07:56:24 +00:00
Ben Brandt
0e7e74d424 chore: lint the import boundaries between v1, v2 and shared modules (#1564)
Stacked on #1562.

Adds ESLint rules for the layout from #1562:

- `src/v2/**` must not import `src/v1/` or `src/acp-agent.ts` (the v1
re-export).
- `src/v1/**` must not import `src/v2/`.
- Shared modules (everything in `src/` except `v1/`, `v2/`, `tests/` and
the entry points `index.ts`, `lib.ts`, `serve.ts`, `binary-entry.ts`,
`acp-agent.ts`) must not import `src/v1/`, `src/v2/` or
`src/acp-agent.ts`.

This uses core `no-restricted-imports` with a `regex` pattern, which
covers static imports and re-exports, type-only ones too. Core
`no-restricted-syntax` covers `import()` calls and `import("...")`
types, which that rule does not check. No new dependencies. AGENTS.md
describes the rule in one sentence.

The tree already passes, so nothing is allow-listed. I checked the rules
with throwaway files covering each import form, at `src/`,
`src/tool-calls/` and `src/v2/` depth. They flagged every violation and
passed the shared import next to them.

No behavior change. Export pins unchanged.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped, goldens unchanged. The
first run hit the known session-index load flake "reads only the changed
transcript for a subscription". That file passed on its own, and so did
a full rerun.
- `node scripts/build-binary.mjs && node scripts/smoke-binary.mjs`: pass

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 07:49:27 +00:00
Ben Brandt
f115b90161 refactor: move the v1 agent into src/v1 (#1562)
Stacked on #1561.

Moves the modules that only the v1 agent uses into `src/v1/` with `git
mv`, so the tree reads as `src/v1/` (v1 agent), `src/v2/` (v2 agent) and
the rest of `src/` (shared). No behavior change.

The v1-only set comes from the import graph: modules reachable from
`acp-agent.ts` whose every non-test importer is itself v1-only (entry
points `lib.ts`/`serve.ts` aside).

Moved:
- `acp-agent.ts`, `background-work.ts`, `goal-extension.ts`,
`turn-events.ts`
- `tool-calls/changed-meta-filter.ts` ->
`v1/tool-calls/changed-meta-filter.ts`
- `session-index/*` -> `v1/session-index/*`, except
`session-index/explicit-title.ts`, which stays because
`session-titles.ts` (shared with v2) imports it

Stay shared: `clear-context-coordinator.ts`, `native-subagents.ts` and
`acp-subagents.ts`. v2 and shared modules import all three.

- `src/acp-agent.ts` is now `export * from "./v1/acp-agent.js"`.
`dist/acp-agent.js` exports the same names, and `module-exports.test.ts`
passes unchanged. `lib.ts` and `serve.ts` import `v1/acp-agent.js`
directly.
- Imports in `src/` and tests, path mentions in comments, and AGENTS.md
are updated. Scripts, vitest config and docs referenced no moved path.
- History: `git log --follow src/v1/acp-agent.ts` follows the move.
Because `src/acp-agent.ts` still exists as the re-export, blame needs
`git blame -C`.

Test results:
- `npx tsc --noEmit`, `npm run check`, `npm run build`: pass
- `npx vitest run`: 3939 passed / 33 skipped, goldens unchanged
- `node scripts/build-binary.mjs && node scripts/smoke-binary.mjs`: pass

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:44:57 +01:00
Ben Brandt
617d5351ed refactor: move the Claude Code to ACP mapping out of acp-agent.ts (#1561)
Stacked on #1560.

The v2 agent in `src/v2/` imported the Claude Code to ACP mapping,
shared types and a few helpers from `acp-agent.ts` (17 imports), so it
loaded the whole v1 agent module. This moves that code into modules that
own it. `acp-agent.ts` imports it back and keeps re-exporting every name
it exported before, so `dist/acp-agent.js` deep imports still work.

What moved where:

- `src/sdk-to-acp.ts`: `toAcpNotifications`,
`streamEventToAcpNotifications`, `messageIdForGrouping`,
`stampParentToolUseId`, the `ToolUseCache` / `StreamedToolInputCache`
types and the streamed tool input scanner.
- `src/message-filters.ts`: `stripLocalCommandMetadata`,
`isLocalCommandMetadata`, `isSyntheticLoginMessage`,
`isReplayHiddenMetaMessage`, `LOCAL_ONLY_COMMANDS` and the transcript
marker scanner.
- `src/prompt-to-claude.ts`: `promptToClaude`,
`SUPPORTED_PROMPT_CONTENT_TYPES`.
- `src/slash-commands.ts`: `getAvailableSlashCommands`.
- `src/result-end.ts` (existing): `AUTONOMOUS_RESULT_ORIGINS`,
`isAbortedResult`.
- `src/logger.ts`, `src/acp-client.ts`, `src/new-session-meta.ts`: the
`Logger`, `AcpClient`, `NewSessionMeta` and `SDKMessageFilter` types.

About 1,360 lines leave `acp-agent.ts` (12,718 to 11,354). The code
moved as-is. Only a few comments that named `acp-agent.ts` as the home
of the shared code were reworded. `src/v2/`, `utils.ts`, `serve.ts`,
`lib.ts` and `session-index/connection.ts` now import from the new
modules. Tests import the moved code from the new modules too. None of
the new modules imports `acp-agent.ts`. `grep -rn acp-agent src/v2`
returns nothing, and a walk of the static imports in `dist/v2` reaches
119 modules, none of them `acp-agent.js`.

No behavior change. The export pins in
`src/tests/module-exports.test.ts` for `lib.js` and `acp-agent.js` pass
unchanged.

Test results:

- `npx tsc --noEmit`, `npm run check` and `npm run build` pass.
- `npx vitest run`: 3939 passed, 33 skipped. That is the 3935 before,
plus the 4 provider tests now on the base branch.
- Goldens unchanged.
- `node scripts/build-binary.mjs && node scripts/smoke-binary.mjs` pass.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-10 08:40:42 +01:00
108 changed files with 2199 additions and 2215 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.102.0"
".": "0.103.0"
}

View File

@@ -1,7 +1,11 @@
# Agent guide
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
TypeScript, ESM, Node >= 22.
TypeScript, ESM, Node >= 22. `src/v1/` holds the v1 agent (`ClaudeAcpAgent` in
`src/v1/acp-agent.ts`), `src/v2/` the v2 agent, and the rest of `src/` the modules
both share. The package exports only `src/lib.ts`. ESLint rejects imports between `src/v1/` and
`src/v2/`, and from shared modules into either agent. The entry points (`index.ts`, `lib.ts`,
`serve.ts`, `binary-entry.ts`) are exempt.
## Commands
@@ -40,9 +44,9 @@ opening a PR.
- Before coding, read the code the change plugs into and find the module that
owns the state or concept. Build on that module, not beside it.
- Don't add new features or components to `acp-agent.ts`. If no existing module fits the new
code cleanly, create a dedicated one that owns the feature's state and logic,
and keep the change in `acp-agent.ts` to the calls that wire it in.
- Don't add new features or components to `src/v1/acp-agent.ts`. If no existing module fits
the new code cleanly, create a dedicated one that owns the feature's state and logic,
and keep the change in `src/v1/acp-agent.ts` to the calls that wire it in.
- Prefer extending an existing mechanism over adding a new one, even if it takes
a small refactor (moving a helper, generalizing a function). Add a parallel
path, special case or flag only when the existing structure can't support the
@@ -81,9 +85,13 @@ opening a PR.
## Testing guidelines
- Before adding a test, search `src/tests/` for existing tests of the same API
and scenario, and add to their file and `describe` block. Use the helpers in
`helpers.ts` and `session-doubles.ts` instead of copying setup.
- Tests live in `src/tests/`: `v1/` holds the tests that drive the v1 agent,
`v2/` the tests of the v2 agent, and the top level the tests of shared
modules, with `helpers.ts`, the scenario harness (`acp-scenarios/`) and
`fixtures/`. Before adding a test, search `src/tests/` for existing tests of
the same API and scenario, and add to their file and `describe` block. Use
the helpers in `helpers.ts` and `v1/session-doubles.ts` instead of copying
setup.
- Don't add cases that take the same code path. Tests that differ only in input
and expected output belong in one `it.each`. Don't repeat another test's
assertions; assert only what this test is about.

View File

@@ -1,5 +1,26 @@
# Changelog
## [0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0) (2026-11-10)
### ⚠ BREAKING CHANGES
* deep imports of `dist/` files fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`, `AUTH_STATUS_UPDATE_METHOD` and their types.
### Features
* serve ACP v2 with a direct v2 agent and remove the v1 bridge ([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560)) ([cb285cd](https://github.com/agentclientprotocol/claude-agent-acp/commit/cb285cd2857924c07038c129cc836da007e2737f))
### Bug Fixes
* give subagent checklists their own plan ID ([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558)) ([2f7e386](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f7e386f3600658d905906f3b37301120b07605f))
### Code Refactoring
* export only lib.ts, with serveAcp and the authStatus wire names ([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566)) ([566e265](https://github.com/agentclientprotocol/claude-agent-acp/commit/566e2652a9da5a31dab57a23ea1b06e9fff2ead8))
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)

View File

@@ -78,7 +78,7 @@ A page with more sessions after it has a `nextCursor`, as ACP defines.
A client that does not follow `nextCursor` sees only the newest 1000 sessions of the directory.
An unknown cursor gives an `invalidParams` error.
`src/tests/acp-scenarios.test.ts` compares the traffic of a plain client and of Zed with recordings of origin/main.
`src/tests/v1/acp-scenarios.test.ts` compares the traffic of a plain client and of Zed with recordings of origin/main.
## Negotiation
@@ -557,7 +557,7 @@ AIR puts the hooks into `_meta.claudeCode.options.settings.hooks` of `session/ne
```
- The settings go to the flag settings tier of that session only. Other sessions and the settings files do not change.
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/session-hooks.test.ts` checks this with the Claude Code binary.
- Claude Code runs the hooks of every settings tier, so the user, project and IDE hooks of one event all run. `src/tests/v1/session-hooks.test.ts` checks this with the Claude Code binary.
- `CLAUDE_MODEL_CONFIG` stays in effect (see [Model configuration](model-configuration.md#precedence)).
- A `session/load` with different settings recreates the query, so it applies the new hooks.
- Claude asks for no hook trust step.

View File

@@ -4,6 +4,23 @@ import tsparser from "@typescript-eslint/parser";
import prettierConfig from "eslint-config-prettier";
import globals from "globals";
/**
* Rules that reject a module specifier matching `regex` in static imports and
* re-exports (type-only ones too), `import()` calls and `import("...")` types.
*/
function protocolBoundary(regex, message) {
// esquery regex literals end at the first `/`, so spell it `\x2F`.
const literal = `/${regex.replaceAll("/", "\\x2F")}/`;
return {
"no-restricted-imports": ["error", { patterns: [{ regex, message }] }],
"no-restricted-syntax": [
"error",
{ selector: `ImportExpression[source.value=${literal}]`, message },
{ selector: `TSImportType[argument.literal.value=${literal}]`, message },
],
};
}
export default [
{
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
@@ -44,5 +61,38 @@ export default [
curly: ["error", "all"],
},
},
// `src/v2/` must not import the v1 agent.
{
files: ["src/v2/**/*.ts"],
rules: protocolBoundary(
"^(\\.\\./)+v1/",
"src/v2/ must not import the v1 agent. Move what both versions need into a shared module in src/.",
),
},
// `src/v1/` must not import the v2 agent.
{
files: ["src/v1/**/*.ts"],
rules: protocolBoundary(
"^(\\.\\./)+v2/",
"src/v1/ must not import the v2 agent. Move what both versions need into a shared module in src/.",
),
},
// Shared modules must not import either agent. The entry points wire the agents up.
{
files: ["src/**/*.ts"],
ignores: [
"src/v1/**",
"src/v2/**",
"src/tests/**",
"src/index.ts",
"src/lib.ts",
"src/serve.ts",
"src/binary-entry.ts",
],
rules: protocolBoundary(
"^(\\./|(\\.\\./)+)(v1|v2)/",
"Shared modules must not import src/v1/ or src/v2/. Move the code into the shared module, or pass it in from the agent.",
),
},
prettierConfig,
];

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.102.0",
"version": "0.103.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.102.0",
"version": "0.103.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.5.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.102.0",
"version": "0.103.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -16,7 +16,7 @@
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js"
},
"./*": "./*"
"./package.json": "./package.json"
},
"files": [
"dist/",

43
src/acp-client.ts Normal file
View File

@@ -0,0 +1,43 @@
import type {
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionResponse,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification } from "./acp-subagents.js";
import type { AcpPermissionRequest } from "./permissions/presentation.js";
/**
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In the v1 agent it is backed by
* `ClientConnection` (`v1/acp-agent.ts`) over the SDK's typed `AgentContext`.
* The v2 session output passes one that only takes `sessionUpdate`
* (`v2/stream.ts`).
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}

View File

@@ -2,7 +2,7 @@
* `--hide-claude-auth`: this integration must never bill a claude.ai
* subscription. Under the flag the agent hides the claude.ai login method from
* `initialize`, and refuses with `authRequired` every session and every turn
* that a subscription would pay for. `acp-agent.ts` decides when the guard
* that a subscription would pay for. `v1/acp-agent.ts` decides when the guard
* applies (flag on, no provider override); this module decides what it refuses.
*
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
@@ -208,14 +208,14 @@ type GuardOptions = {
* covers a session created under a provider override that `providers/disable`
* later removed, so the create-time check never ran for the routing in force
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
* `v1/acp-agent.ts`), so the next turn recreates it and `initialize` reports the
* real account. This guard does not see the residual case the module header
* names either.
*
* A turn is refused when the account bills a subscription, and also when it
* holds nothing this integration accepts, which is the user signing out in
* mid-session. The refusal mirrors the signed-out path exactly (see
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
* `failActiveWithSessionFailure` for `auth_required` in `v1/acp-agent.ts`): every
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
* flow. The guard sends no separate access message.
*

View File

@@ -83,7 +83,7 @@ export class IdleCompaction {
this.setTimer = options.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
}
/** Whether an idle compaction it started is still running. `acp-agent.ts` reports
/** Whether an idle compaction it started is still running. `v1/acp-agent.ts` reports
* `trigger: "idle"` while it is true. A client's `/compact` queued behind is read
* only after the adapter's turn's `result`, which clears `compacting`; this relies
* on the CLI emitting that compaction's `compact_boundary` after the previous

View File

@@ -1,25 +1,7 @@
// Export the main agent class and utilities for library usage
export {
ClaudeAcpAgent,
isLocalCommandMetadata,
stripLocalCommandMetadata,
runAcp,
toAcpNotifications,
streamEventToAcpNotifications,
type ToolUpdateMeta,
type NewSessionMeta,
type SDKMessageFilter,
} from "./acp-agent.js";
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
export {
toolInfoFromToolUse,
toDisplayPath,
planEntries,
toolUpdateFromToolResult,
} from "./tools.js";
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
// The `authStatus` extension's wire surface: the notification's method name and
// the payload types needed to read the traffic. The mappers stay internal.
// The package's public API: serve ACP (v1 and v2) on stdio, and the wire
// names of the `authStatus` extension that a client needs to read its traffic.
export { serveAcp, type AcpProtocols } from "./serve.js";
export type { Logger } from "./logger.js";
export {
AUTH_STATUS_UPDATE_METHOD,
type AuthStatus,
@@ -27,6 +9,3 @@ export {
type AuthStatusKind,
type AuthStatusUpdateNotification,
} from "./auth-status.js";
// Export types
export type { ClaudePlanEntry } from "./tools.js";

9
src/logger.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}

213
src/message-filters.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* The Claude Code messages and content that a client never sees as the user's
* or the agent's text: local slash command markers, context the CLI injects,
* the synthetic `/login` message and meta messages from other agents. The live
* stream and the `session/load` replay both filter with these.
*/
import { isLoginRequiredText } from "./result-end.js";
import { isSyntheticUsageLimitMessage } from "./session-failure-extension.js";
import { markerTagText, TASK_NOTIFICATION_TAG } from "./task-notifications.js";
// Slash commands that the SDK handles locally without replaying the user
// message and without invoking the model.
export const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
// Commands whose marker-only transcript records are ACP/client-local UI noise,
// not model-visible user prompts. Marker-only custom slash skills are not in
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
const REPLAY_HIDDEN_COMMANDS = new Set([
...LOCAL_ONLY_COMMANDS,
"/compact",
"/model",
"/mcp",
"/status",
"/usage",
]);
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
// their output as user messages in the session transcript, wrapping the
// payload in these XML-like markers that the CLI uses for its own display.
// The live prompt loop drops them; replay must strip them too or they leak
// into the UI on session/load.
const LOCAL_COMMAND_MARKERS = [
"command-name",
"command-message",
"command-args",
"local-command-stdout",
"local-command-stderr",
].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
// Context the CLI injects into a user turn to steer the model, appended to
// whatever the user typed. Nobody wrote it and no client sees it live — the
// prompt loop's user-message skip covers the whole message — but it is
// persisted alongside that prose, so replay would hand the client a prompt
// with instructions in it the user never gave. A `task-notification` tells the
// model that a background task stopped; live, the SDK `task_notification`
// frame reports the same stop, so replay restores the task state from it (see
// taskNotificationsOf) instead of showing it.
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
open: `<${tag}>`,
close: `</${tag}>`,
}));
// Everything a replayed user message may be wrapped in that is not speech.
const TRANSCRIPT_MARKERS = [...LOCAL_COMMAND_MARKERS, ...INJECTED_CONTEXT_MARKERS];
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
// nearest closing tag of the same name, like a lazy regex would).
function stripMarkerTags(text: string): string {
const dead = new Set<string>();
let result = "";
let copiedUpTo = 0;
let i = 0;
while (i < text.length) {
if (text[i] === "<") {
const marker = TRANSCRIPT_MARKERS.find(
(m) => !dead.has(m.open) && text.startsWith(m.open, i),
);
if (marker) {
const end = text.indexOf(marker.close, i + marker.open.length);
if (end !== -1) {
result += text.slice(copiedUpTo, i);
i = copiedUpTo = end + marker.close.length;
continue;
}
// No closing marker remains anywhere ahead, and `indexOf` only ever
// searches forward from here on, so stop treating this tag as an
// opener — that avoids rescanning the tail for it on every match.
dead.add(marker.open);
}
}
i++;
}
return result + text.slice(copiedUpTo);
}
function hasMarkerTag(text: string, tag: string): boolean {
return markerTagText(text, tag) !== undefined;
}
function commandInvocationFromMarkerOnlyText(text: string): string | null {
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
return null;
}
const commandName = markerTagText(text, "command-name")?.trim();
if (!commandName?.startsWith("/")) return null;
if (REPLAY_HIDDEN_COMMANDS.has(commandName.split(" ", 1)[0])) return null;
const commandArgs = markerTagText(text, "command-args")?.trim();
return commandArgs ? `${commandName} ${commandArgs}` : commandName;
}
function stripLocalCommandMetadataText(text: string): string | null {
const stripped = stripMarkerTags(text);
if (stripped.trim() !== "") return stripped;
return commandInvocationFromMarkerOnlyText(text);
}
/**
* Return user-message content with local-command and injected-context marker
* tags removed, or `null` if nothing meaningful remains (caller should skip
* the message). Preserves real prose that's mixed in alongside the markers —
* e.g. a message like `<command-name>…</command-name>hi` becomes `hi`, and
* `hi<system-reminder>…</system-reminder>` becomes `hi`.
*/
export function stripLocalCommandMetadata(content: unknown): unknown | null {
if (typeof content === "string") {
return stripLocalCommandMetadataText(content);
}
if (!Array.isArray(content)) return content;
const kept: unknown[] = [];
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as { type: unknown }).type === "text" &&
"text" in block &&
typeof (block as { text: unknown }).text === "string"
) {
const stripped = stripLocalCommandMetadataText((block as { text: string }).text);
if (stripped === null) continue;
kept.push({ ...(block as object), text: stripped });
} else {
kept.push(block);
}
}
if (kept.length === 0) return null;
return kept;
}
/**
* True for the synthetic assistant message the CLI injects into the transcript
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
* "Session expired. Please run /login to sign in again."). The `/login`
* instruction is Claude Code TUI-specific and meaningless to ACP clients
* (issue #863). The live prompt loop suppresses the text and fails the turn
* with `authRequired` so the client can run its own auth flow; replay must
* skip it too — both for parity with what the client saw live and because the
* message stays in the transcript forever, so it would resurface on every
* session/load even after the user has logged back in.
*
* Takes the API message (`message.message`), which replay only knows as
* `unknown`. The persisted record's structured `error` marker is stripped by
* `getSessionMessages`, and the CLI does not always set it to
* `authentication_failed` anyway: an expired login with no refresh token
* ("Login expired · Run /login to sign in again, …") arrives as
* `invalid_request`. So the synthetic model + text is what both paths match on.
* Usage-limit banners are excluded because some of them also mention `/login`
* (to switch accounts) and have their own handling.
*/
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
if (!apiMessage || typeof apiMessage !== "object") {
return false;
}
const { model, content } = apiMessage as { model?: unknown; content?: unknown };
if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
return false;
}
const block = content[0] as { type?: unknown; text?: unknown } | null;
return (
!!block &&
block.type === "text" &&
typeof block.text === "string" &&
isLoginRequiredText(block.text) &&
!isSyntheticUsageLimitMessage(apiMessage)
);
}
/** Origin kinds of the meta user messages `getSessionMessages` returns since
* SDK 0.3.284 (messages from other agents, sessions and channels). */
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
"peer",
"channel",
"observer",
"observer-activity",
"slack-ping",
]);
/**
* True for a transcript message delivered to the model from another agent,
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
* `is_meta` user messages whose content is the full harness framing (envelope
* XML plus the "not typed by your user" preamble). The live prompt loop never
* renders them, so replay skips them too rather than presenting them as text
* the user typed. Other `is_meta` messages (compact summaries) are kept.
*
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
*/
export function isReplayHiddenMetaMessage(message: unknown): boolean {
if (!message || typeof message !== "object") return false;
const { type, is_meta, origin } = message as {
type?: unknown;
is_meta?: unknown;
origin?: { kind?: unknown } | null;
};
return (
type === "user" &&
is_meta === true &&
typeof origin?.kind === "string" &&
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
);
}

41
src/new-session-meta.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { Options, SDKMessageOrigin } from "@anthropic-ai/claude-agent-sdk";
export type SDKMessageFilter = {
type: string;
subtype?: string;
origin?: SDKMessageOrigin["kind"];
};
/**
* Extra metadata that can be given when creating a new session.
*/
export type NewSessionMeta = {
claudeCode?: {
/**
* Options forwarded to Claude Code when starting a new session.
* Those parameters will be ignored and managed by ACP:
* - cwd
* - includePartialMessages
* - permissionMode
* - canUseTool
* - executable
* Those parameters will be used and updated to work with ACP:
* - hooks (merged with ACP's hooks)
* - mcpServers (merged with ACP's mcpServers)
* - disallowedTools (merged with ACP's disallowedTools)
* - tools (passed through; defaults to claude_code preset if not provided)
* - allowDangerouslySkipPermissions (set to `false` to remove bypassPermissions
* from this session; repeat it on session/load. `true` cannot override root)
*/
options?: Options;
/**
* When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message)
* in addition to normal processing.
* - true: emit all messages
* - false/undefined: emit nothing (default)
* - SDKMessageFilter[]: emit only messages matching at least one filter
*/
emitRawSDKMessages?: boolean | SDKMessageFilter[];
};
additionalRoots?: string[];
};

129
src/prompt-to-claude.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* The conversion of an ACP prompt into the Claude Code user message.
*/
import type { PromptRequest } from "@agentclientprotocol/sdk/unstable";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { blobResourceToClaude } from "./blob-resources.js";
function formatUriAsLink(uri: string): string {
try {
if (uri.startsWith("file://")) {
const path = uri.slice(7); // Remove "file://"
const name = path.split("/").pop() || path;
return `[@${name}](${uri})`;
} else if (uri.startsWith("zed://")) {
const parts = uri.split("/");
const name = parts[parts.length - 1] || uri;
return `[@${name}](${uri})`;
}
return uri;
} catch {
return uri;
}
}
/** The prompt content block types that `promptToClaude` converts. The agent
* advertises no `audio` prompt capability. */
export const SUPPORTED_PROMPT_CONTENT_TYPES: readonly string[] = [
"text",
"image",
"resource_link",
"resource",
];
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
const content: any[] = [];
const context: any[] = [];
const firstText = prompt.prompt.find((chunk) => chunk.type === "text");
for (const chunk of prompt.prompt) {
switch (chunk.type) {
case "text": {
let text = chunk.text;
// A slash command is the first text block. Change
// `/mcp:server:command args` to `/server:command (MCP) args`; the
// arguments can span lines.
const mcpMatch =
chunk === firstText ? text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s+([\s\S]*))?$/) : null;
if (mcpMatch) {
const [, server, command, args] = mcpMatch;
text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
}
content.push({ type: "text", text });
break;
}
case "resource_link": {
const formattedUri = formatUriAsLink(chunk.uri);
content.push({
type: "text",
text: formattedUri,
});
break;
}
case "resource": {
const { uri } = chunk.resource;
// A blob resource becomes an image or document block, or its decoded
// text. A v1 prompt skips a blob of any other type, e.g.
// `application/zip`; a v2 prompt rejects it (see `v1PromptRequest`).
const blob = "blob" in chunk.resource ? blobResourceToClaude(chunk.resource) : undefined;
const text =
"text" in chunk.resource
? chunk.resource.text
: blob?.kind === "text"
? blob.text
: undefined;
if (blob?.kind === "image" || blob?.kind === "document") {
content.push({ type: "text", text: formatUriAsLink(uri) }, blob.block);
} else if (text !== undefined) {
content.push({ type: "text", text: formatUriAsLink(uri) });
context.push({
type: "text",
text: `\n<context ref="${uri}">\n${text}\n</context>`,
});
}
break;
}
case "image":
if (chunk.data) {
content.push({
type: "image",
source: {
type: "base64",
data: chunk.data,
media_type: chunk.mimeType,
},
});
} else if (chunk.uri && chunk.uri.startsWith("http")) {
content.push({
type: "image",
source: {
type: "url",
url: chunk.uri,
},
});
}
break;
// Ignore audio and other unsupported types
default:
break;
}
}
content.push(...context);
return {
type: "user",
message: {
role: "user",
content: content,
},
session_id: prompt.sessionId,
parent_tool_use_id: null,
// ACP prompts are the user's own input relayed by the client. Stamp the
// provenance explicitly: per the SDK, a host wrapping keyboard input must
// send `{kind: "human"}` — an absent `origin` is treated as unattributed
// and fails closed at the CLI's strict isHuman() trust gates (e.g. the
// ultracode keyword opt-in honors only human-originated turns).
origin: { kind: "human" },
};
}

View File

@@ -1,10 +1,15 @@
/**
* How Claude Code's `result` ends the work that it reports: the rules that
* both cores read a turn's result with (the v1 prompt loop in `acp-agent.ts`
* and `v2/outcome.ts`). Each core adds its own side effects, such as v1's
* How Claude Code's `result` ends the work that it reports, and whether that
* work was the user's turn or a cycle Claude Code started on its own: the
* rules that both cores read a turn's result with (the v1 prompt loop in
* `v1/acp-agent.ts` and `v2/`). Each core adds its own side effects, such as v1's
* session failure category.
*/
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
import type {
SDKMessage,
SDKMessageOrigin,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
export type ResultEnd =
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
@@ -60,3 +65,41 @@ export function resultEnd(result: SDKResultMessage): ResultEnd {
function failed(result: Exclude<SDKResultMessage, { subtype: "success" }>): ResultEnd {
return { error: result.errors.join(", ") || result.subtype, loginRequired: false };
}
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
* model did on its own (a task-notification followup, a peer/coordinator/
* observer message it handled) rather than the user's prompt. Absent,
* `human`, and `channel` origins are the user's own turn (this adapter's
* prompts arrive as the ACP channel on some CLI configurations — ALL
* channel servers are treated as user, so a foreign channel integration's
* autonomously-handled result is misclassified as the user's; accepted,
* see below), and `auto-continuation` continues the user's turn, so its
* result is the turn's real terminal.
*
* Deliberately fail-OPEN: an unknown future kind defaults to the user
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
* attribute this" marker, which gets the same safe default. Misrouting a
* USER result into the autonomous lane hangs the prompt un-detectably
* (the result is skipped, its trailing idle absorbed as owed, so the
* #825 detector can't fire); misrouting an autonomous result into the
* user lane is the bounded misattribution class this set exists to
* reduce.
*
* Exception: a result from one of these origins that names a pending prompt
* in `user_message_uuids` consumed that folded prompt and takes the user
* lane (see the result handler of each core). */
export const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set([
"task-notification",
"peer",
"coordinator",
"observer",
"observer-activity",
]);
/** Whether Claude Code ended the work of `result` because an interrupt
* stopped it, while the model streamed or while a tool ran. */
export function isAbortedResult(result: Extract<SDKMessage, { type: "result" }>): boolean {
return (
result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools"
);
}

853
src/sdk-to-acp.ts Normal file
View File

@@ -0,0 +1,853 @@
/**
* The mapping of Claude Code output to ACP session updates: content blocks
* (`toAcpNotifications`) and partial stream events
* (`streamEventToAcpNotifications`) become message chunks, thoughts, tool
* calls and plans. The live stream and the `session/load` replay of both
* protocol versions use it; v2 converts the v1 updates it returns.
*/
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import type {
BetaContentBlock,
BetaRawContentBlockDelta,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import type { AcpClient } from "./acp-client.js";
import { clientSupportsSubagents } from "./acp-subagents.js";
import { approvedPlan, planModeUpdate } from "./exit-plan.js";
import type { Logger } from "./logger.js";
import { checklistPlanUpdate, todoWritePlanUpdate, type SubagentChecklists } from "./plans.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
import type { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
import { AcpToolCallRenderer, type ToolUpdateMeta } from "./tool-calls/renderer.js";
import { parseToolResultMeta } from "./tool-result-meta.js";
import {
applyTaskCreate,
applyTaskList,
applyTaskUpdate,
changedTaskPlanEntries,
type ClaudePlanEntry,
completeHookCallback,
hasHookCallback,
parseTaskCreateOutput,
parseTaskListOutput,
parseTaskUpdateOutput,
planEntries,
registerHookCallback,
type TaskState,
unregisterHookCallback,
} from "./tools.js";
import { unreachable } from "./utils.js";
/** Attributes a report to the Agent/Task tool call of the subagent that made it. */
export function stampParentToolUseId(
update: SessionNotification["update"],
parentToolUseId: string,
) {
update._meta = {
...update._meta,
claudeCode: {
...((update._meta?.claudeCode as Record<string, unknown> | undefined) ?? {}),
parentToolUseId,
},
};
}
export type ToolUseCache = {
[key: string]: {
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
id: string;
name: string;
input: unknown;
};
};
type StreamedToolInput = {
id: string;
name: string;
partialJson: string;
inString: boolean;
escaped: boolean;
objectDepth: number;
arrayDepth: number;
/** Offset of the most recent comma at the top level of the input object
* (-1 before the first). Everything before it is a complete field. */
lastTopLevelComma: number;
/** The comma offset the last emitted refinement was sliced at (-1 before the
* first), so a field boundary only triggers one recovery attempt. */
emittedThroughComma: number;
};
export type StreamedToolInputCache = Map<string, Map<number, StreamedToolInput>>;
/**
* Advance the lexer state across the fragment appended since the last delta:
* just enough JSON awareness (string/escape, nesting depth) to spot commas
* that sit at the top level of the input object — everything before such a
* comma is a set of complete fields. Returns true once the input object's
* closing brace arrives.
*
* The lexer reads the fragment itself, never `partialJson` by index. V8 keeps a
* string built with `+=` as a chain of parts, and the first index access
* copies the whole chain into one flat string. An index scan of `partialJson`
* would copy the whole input on each delta, so a large Write would cost
* quadratic time. A test in `acp-agent.test.ts` checks this.
*/
function scanStreamedToolInput(state: StreamedToolInput, fragment: string): boolean {
const offset = state.partialJson.length;
state.partialJson += fragment;
let complete = false;
for (let index = 0; index < fragment.length; index++) {
const character = fragment[index];
if (state.inString) {
if (state.escaped) {
state.escaped = false;
} else if (character === "\\") {
state.escaped = true;
} else if (character === '"') {
state.inString = false;
}
continue;
}
if (character === '"') {
state.inString = true;
} else if (character === "{") {
state.objectDepth++;
} else if (character === "}") {
state.objectDepth--;
if (state.objectDepth === 0) {
complete = true;
}
} else if (character === "[") {
state.arrayDepth++;
} else if (character === "]") {
state.arrayDepth--;
} else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
state.lastTopLevelComma = offset + index;
}
}
return complete;
}
/** Parse the complete top-level fields before a top-level comma by closing the
* object at that boundary. */
function recoveredToolInput(prefix: string): Record<string, unknown> | undefined {
try {
const value: unknown = JSON.parse(prefix + "}");
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
}
/**
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
* transcript message (replay) so chunk grouping is identical in both views.
*
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
* which is identical at `message_start`, on the consolidated assistant message,
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
* unique per event and never persisted. User messages have no API id, but they
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
* ids are opaque strings, so no particular format is required.
*/
export function messageIdForGrouping(message: {
type?: string;
uuid?: string | null;
message?: unknown;
}): string | undefined {
if (message.type === "assistant") {
const inner = message.message;
const apiId =
inner && typeof inner === "object" && "id" in inner
? (inner as { id?: unknown }).id
: undefined;
if (typeof apiId === "string" && apiId.length > 0) {
return apiId;
}
}
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
}
/**
* Stamps an ACP `messageId` onto a session update, but only on the message/
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
*/
function applyMessageId(
update: SessionNotification["update"],
messageId: string | undefined,
): void {
if (
messageId &&
(update.sessionUpdate === "agent_message_chunk" ||
update.sessionUpdate === "user_message_chunk" ||
update.sessionUpdate === "agent_thought_chunk")
) {
update.messageId = messageId;
}
}
/** Built-in tools that drive the task list (headless/SDK sessions use these
* instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
* snapshots rather than as tool_calls. */
function isTaskTool(toolName: string): boolean {
return (
toolName === "TaskCreate" ||
toolName === "TaskUpdate" ||
toolName === "TaskList" ||
toolName === "TaskGet"
);
}
/** Whether the streamed tool_use path surfaces this tool as a standalone
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
* suppressed (their plan snapshot is emitted at tool_result time), so neither
* produces a streamed tool_call/tool_call_update — which means a
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
* resolved explicitly at tool_result time. */
function shouldEmitToolCall(toolName: string): boolean {
return toolName !== "TodoWrite" && !isTaskTool(toolName);
}
/**
* The renderer of the notification functions: the agent's tool call
* capabilities when they are passed, else those of the ACP capabilities.
*/
function toolCallRenderer(options?: {
clientCapabilities?: ClientCapabilities;
toolCallCapabilities?: ToolCallClientCapabilities;
replay?: boolean;
}): AcpToolCallRenderer {
return options?.toolCallCapabilities
? new AcpToolCallRenderer(options.toolCallCapabilities, options.replay)
: AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
}
/**
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
* Only handles text, image, and thinking chunks for now.
*/
export function toAcpNotifications(
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
role: "assistant" | "user",
sessionId: string,
toolUseCache: ToolUseCache,
client: AcpClient,
logger: Logger,
options?: {
registerHooks?: boolean;
clientCapabilities?: ClientCapabilities;
// The agent's tool call capabilities. They win over clientCapabilities,
// and carry what ACP capabilities cannot (ToolCallClientCapabilities.v2).
toolCallCapabilities?: ToolCallClientCapabilities;
parentToolUseId?: string | null;
cwd?: string;
taskState?: TaskState;
// The subagent TodoWrite checklists that the client holds in the session.
// Mutated in place.
subagentChecklists?: SubagentChecklists;
// Tracks tool_use ids already emitted as a `tool_call` so a permission
// request (which emits the tool_call eagerly) and the streamed tool_use
// chunk don't both emit one — whichever arrives second emits a
// `tool_call_update` instead. Mutated in place. When omitted, the
// tool_call/update decision falls back to `toolUseCache` presence (the
// historical single-source behavior).
emittedToolCalls?: Set<string>;
// False while the input of a streamed tool_use still streams: the first
// tool_call then leaves `rawInput` out, and the consolidated message
// sends it once it is complete.
inputComplete?: boolean;
// Remembers the fields sent for each open tool call. When present, a
// tool_call_update carries only the fields that changed, and an update
// with nothing new is dropped. Mutated in place.
toolCallFields?: ToolCallFieldTracker;
// Opaque id identifying the message these chunks belong to (ACP message ids
// are opaque strings — no particular format is required). Attached to
// user/agent message and thought chunks so clients can group streamed chunks
// into a single message. Omit it (leave undefined) when unknown — never send
// an explicit `null`.
messageId?: string;
// The SDK user message's `tool_use_result`: the structured Output object of
// the tool_result this message carries (shape is per-tool). Used to render
// Agent/Task results from the structured subagent report instead of the raw
// text (which ends in a model-directed agentId/usage trailer).
toolUseResult?: unknown;
// The SDK user message's `tool_result_meta` sidecar, passed raw (it's
// untyped in sdk.d.ts) and validated by `parseToolResultMeta`. Stamps
// denied/interrupted tool_call_updates with why the tool never ran.
toolResultMeta?: unknown;
// True when the content is a result of the work that a `session/cancel`
// stopped. An error result then ends a run that the cancel aborted,
// whichever kind the SDK stamped on it (see `resultStatus`).
cancelled?: boolean;
// True when the content comes from the history of a loaded session.
replay?: boolean;
},
): SessionNotification[] {
const taskState = options?.taskState ?? new Map();
const registerHooks = options?.registerHooks !== false;
const renderer = toolCallRenderer(options);
if (typeof content === "string") {
if (content.length === 0) {
return [];
}
const update: SessionNotification["update"] = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
},
};
applyMessageId(update, options?.messageId);
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
return [{ sessionId, update }];
}
// `tool_use_result` is message-level and carries no tool_use_id of its own:
// it describes "the" tool_result block of the message it rode in on. If
// several tool_result blocks were ever batched into one message it couldn't
// be attributed, so it is only honored when the message carries exactly one.
const toolUseResult =
options?.toolUseResult !== undefined &&
content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
.length === 1
? options.toolUseResult
: undefined;
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
// messages need no single-block guard.
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
const output = [];
// Only handle the first chunk for streaming; extend as needed for batching
for (const chunk of content) {
let update: SessionNotification["update"] | null = null;
// The tool call that this chunk finishes, if it is a tool result.
let finishedToolCallId: string | undefined;
// The plan that this chunk approves, if it is an ExitPlanMode result.
let approvedPlanUpdate: SessionNotification["update"] | undefined;
switch (chunk.type) {
case "text":
case "text_delta": {
if (chunk.text) {
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: chunk.text,
},
};
}
break;
}
case "image":
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "image",
data: chunk.source.type === "base64" ? chunk.source.data : "",
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
},
};
break;
case "thinking":
case "thinking_delta": {
// Recent models default `thinking.display` to "omitted", which streams
// signature-only thinking blocks whose text is empty.
if (chunk.thinking) {
update = {
sessionUpdate: "agent_thought_chunk",
content: {
type: "text",
text: chunk.thinking,
},
};
}
break;
}
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {
const alreadyCached = chunk.id in toolUseCache;
toolUseCache[chunk.id] = chunk;
if (chunk.name === "TodoWrite") {
// @ts-expect-error - sometimes input is empty object or undefined
if (Array.isArray(chunk.input?.todos)) {
update = todoWritePlanUpdate(planEntries(chunk.input as { todos: ClaudePlanEntry[] }), {
planOperations: options?.clientCapabilities?.plan != null,
parentToolUseId: options?.parentToolUseId,
// With native subagent sessions, an update of a subagent goes
// to the subagent's session (see NativeSubagentRuntime).
childSessions: clientSupportsSubagents(options?.clientCapabilities),
shown: options?.subagentChecklists,
});
}
} else if (isTaskTool(chunk.name)) {
// Task* tool_use is suppressed; the plan update is emitted at
// tool_result time once we have the task ID (for TaskCreate) and
// confirmation that the change took effect.
} else {
// Only register hooks on first encounter to avoid double-firing
if (registerHooks && !alreadyCached) {
// Capture the tool name in the closure rather than re-reading the
// cache when the hook fires. The cache entry is pruned at
// tool_result time, and a PostToolUse hook can fire after that, so
// closing over the name keeps the diff working without depending on
// (or pinning) the cache entry's lifetime.
const toolName = chunk.name;
const hookToolCallId = chunk.id;
registerHookCallback(
chunk.id,
{
onPostToolUseHook: async (toolUseId, _toolInput, toolResponse) => {
// The final diff of an Edit or a Write replaces the
// optimistic content built from the input. Only the marker
// fields of the tool_response travel: the rest repeats
// output that the content already carries.
const update = await renderer.hookResult(
{ id: toolUseId, name: toolName },
toolResponse,
options?.cwd,
);
if (!update) return;
if (options?.parentToolUseId) {
stampParentToolUseId(update, options.parentToolUseId);
}
// The final result may replace a pinned approval patch.
if (
options?.toolCallFields &&
!options.toolCallFields.apply(update, { replacePinnedContent: true })
) {
return;
}
await client.sessionUpdate({ sessionId, update });
},
onRelease: () => options?.toolCallFields?.finishHook(hookToolCallId),
},
sessionId,
);
}
let rawInput;
try {
rawInput = JSON.parse(JSON.stringify(chunk.input));
} catch {
// ignore if we can't turn it to JSON
}
const toolUse = { id: chunk.id, name: chunk.name, input: rawInput };
// Emit a `tool_call` only the first time this id surfaces to the
// client; afterwards refine it with a `tool_call_update`. The first
// surface may be this stream chunk OR an earlier permission request
// (see `ensureToolCallEmitted`), so emission is tracked separately
// from `toolUseCache`. Without an `emittedToolCalls` set we fall back
// to cache presence — the historical streaming-only behavior.
const emittedToolCalls = options?.emittedToolCalls;
const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
emittedToolCalls?.add(chunk.id);
// A permission request surfaced the tool call with its complete
// input. The empty input at the stream start has nothing to add.
if (alreadyEmitted && options?.inputComplete === false) break;
update = alreadyEmitted
? // Already surfaced (full assistant message after streaming, or a
// permission request emitted it first): refine it with the
// complete input.
renderer.refinement(toolUse, options?.cwd)
: // First surface (streaming content_block_start or replay).
renderer.toolCall(toolUse, {
cwd: options?.cwd,
inputComplete: options?.inputComplete,
});
}
break;
}
case "advisor_tool_result": {
// Advisor results arrive in assistant content, including encrypted advice.
// Resolve the server tool through the normal result path without rendering
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
const result = chunk.content;
unregisterHookCallback(chunk.tool_use_id);
output.push(
...toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: chunk.tool_use_id,
is_error: result.type === "advisor_tool_result_error",
content:
result.type === "advisor_result"
? result.text
: result.type === "advisor_redacted_result"
? "Advisor guidance applied server-side."
: `Advisor error: ${result.error_code}`,
},
],
role,
sessionId,
toolUseCache,
client,
logger,
options,
),
);
break;
}
case "tool_result":
case "tool_search_tool_result":
case "web_fetch_tool_result":
case "web_search_tool_result":
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
case "mcp_tool_result": {
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
options?.emittedToolCalls?.delete(chunk.tool_use_id);
completeHookCallback(chunk.tool_use_id);
finishedToolCallId = chunk.tool_use_id;
// Why this is_error result carries harness prose instead of tool
// output (user-rejected / interrupted / …), when the SDK said so.
// Spread into the claudeCode meta of every update emitted below; the
// untracked-tool fallback can't carry it (claudeCode metas always
// carry `toolName`, which is unknown there).
const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
const toolUse = toolUseCache[chunk.tool_use_id];
if (!toolUse) {
// The permission flow may have surfaced this tool_call even though
// its tool_use never reached the cache (e.g. the assistant message
// carrying it was dropped by the cancelled-turn guard and a straggler
// result landed later). Resolve the surfaced call anyway so it can't
// stay pending in the client forever; without the cache entry the
// tool name is unknown, so no claudeCode meta is attached.
if (wasEmitted) {
output.push({
sessionId,
update: {
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
status: renderer.resultStatus(
"is_error" in chunk && chunk.is_error === true,
nonExecution,
{ cancelled: options?.cancelled },
),
rawOutput: chunk.content,
},
});
}
logger.error(
`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
);
break;
}
// A permission request may have surfaced a plan-rendered (TodoWrite) or
// suppressed (Task*) tool as a real tool_call so the request referenced
// a tool call the client knows about (see `ensureToolCallEmitted`,
// issue #851). The branches below never emit a tool_call_update for
// those tools, which would leave the surfaced call pending in the
// client forever — resolve it here. `wasEmitted` is only ever true for
// these tools via the permission flow: the streamed plan/suppressed
// branches don't record emissions.
if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
output.push({
sessionId,
update: {
_meta: {
claudeCode: {
toolName: toolUse.name,
...(nonExecution ?? {}),
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
},
} satisfies ToolUpdateMeta,
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
status: renderer.resultStatus(
"is_error" in chunk && chunk.is_error === true,
nonExecution,
{ cancelled: options?.cancelled },
),
rawOutput: chunk.content,
},
});
}
if (isTaskTool(toolUse.name)) {
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
// reconciles it from the SDK's authoritative snapshot, which repairs
// resumed or compacted sessions whose creating calls are no longer in
// replay history. TaskGet is read-only and remains suppressed. Plan
// updates always carry the full accumulated snapshot, mirroring the
// legacy TodoWrite behavior.
const isError = "is_error" in chunk && chunk.is_error;
let shouldEmitTaskPlan = false;
if (!isError) {
if (toolUse.name === "TaskCreate") {
applyTaskCreate(
taskState,
toolUse.input as Parameters<typeof applyTaskCreate>[1],
parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content),
);
shouldEmitTaskPlan = true;
} else if (toolUse.name === "TaskUpdate") {
const input = toolUse.input as Parameters<typeof applyTaskUpdate>[1];
const output =
parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
parseTaskUpdateOutput(chunk.content, input?.taskId);
// Older CLI transcripts have no structured output, so retain the
// input-based fallback. When an output is available, only apply a
// confirmed update for the same task.
if (!output || (output.success && output.taskId === input?.taskId)) {
applyTaskUpdate(taskState, input);
shouldEmitTaskPlan = true;
}
} else if (toolUse.name === "TaskList") {
const output =
parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
if (output) {
applyTaskList(taskState, output);
shouldEmitTaskPlan = true;
}
}
}
const entries = shouldEmitTaskPlan
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
: undefined;
if (entries) {
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
}
} else if (toolUse.name !== "TodoWrite") {
// A command sends its output first, then the exit and the status.
const [finalUpdate, ...rest] = renderer
.result(toolUse, chunk as Parameters<AcpToolCallRenderer["result"]>[1], {
structured: toolUseResult,
nonExecution,
cancelled: options?.cancelled,
})
.reverse();
for (const outputUpdate of rest.reverse()) {
if (options?.parentToolUseId) {
stampParentToolUseId(outputUpdate, options.parentToolUseId);
}
output.push({ sessionId, update: outputUpdate });
}
// An approved plan of the main conversation becomes the session's
// markdown plan, after the tool call's result. An approval that
// clears the context ends the tool with an error result instead, and
// the restart into the fresh context publishes that plan (see
// ExitPlanCoordinator.restart).
if (
toolUse.name === "ExitPlanMode" &&
options?.clientCapabilities?.plan &&
!options.parentToolUseId &&
!("is_error" in chunk && chunk.is_error)
) {
const plan = approvedPlan(toolUse.input, toolUseResult);
if (plan) {
approvedPlanUpdate = planModeUpdate(
plan,
renderer.capabilities.air.client ? toolUse.id : undefined,
);
}
}
update = finalUpdate;
}
// The tool_use is fully resolved now — drop it so a long session doesn't
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
// over the tool name and no longer reads the cache, so pruning here is
// safe regardless of hook/result ordering.
delete toolUseCache[chunk.tool_use_id];
break;
}
case "document":
case "search_result":
case "redacted_thinking":
case "input_json_delta":
case "citations_delta":
case "signature_delta":
case "container_upload":
case "compaction":
case "compaction_delta":
case "fallback":
case "mcp_tool_listing":
break;
default:
unreachable(chunk, logger);
break;
}
if (update) {
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
applyMessageId(update, options?.messageId);
// A tool result is final, so it may replace a pinned approval patch,
// for example with the error text of a rejected Edit.
if (
!options?.toolCallFields ||
options.toolCallFields.apply(update, {
replacePinnedContent: finishedToolCallId !== undefined,
})
) {
output.push({ sessionId, update });
}
}
if (approvedPlanUpdate) output.push({ sessionId, update: approvedPlanUpdate });
if (finishedToolCallId !== undefined) {
// The PostToolUse hook can still send the final diff, so the fields
// stay tracked until its callback leaves the registry.
options?.toolCallFields?.finishResult(
finishedToolCallId,
hasHookCallback(finishedToolCallId),
);
}
}
return output;
}
export function streamEventToAcpNotifications(
message: SDKPartialAssistantMessage,
sessionId: string,
toolUseCache: ToolUseCache,
client: AcpClient,
logger: Logger,
options?: {
clientCapabilities?: ClientCapabilities;
// See toAcpNotifications.
toolCallCapabilities?: ToolCallClientCapabilities;
cwd?: string;
taskState?: TaskState;
subagentChecklists?: SubagentChecklists;
emittedToolCalls?: Set<string>;
toolCallFields?: ToolCallFieldTracker;
messageId?: string;
streamedToolInputs?: StreamedToolInputCache;
},
): SessionNotification[] {
const event = message.event;
const streamKey = message.parent_tool_use_id ?? "";
const streamedToolInputs = options?.streamedToolInputs;
const forwardedOptions = {
clientCapabilities: options?.clientCapabilities,
toolCallCapabilities: options?.toolCallCapabilities,
parentToolUseId: message.parent_tool_use_id,
cwd: options?.cwd,
taskState: options?.taskState,
subagentChecklists: options?.subagentChecklists,
emittedToolCalls: options?.emittedToolCalls,
toolCallFields: options?.toolCallFields,
messageId: options?.messageId,
};
switch (event.type) {
case "content_block_start": {
const block = event.content_block;
if (
streamedToolInputs &&
(block.type === "tool_use" ||
block.type === "server_tool_use" ||
block.type === "mcp_tool_use")
) {
let inputsForMessage = streamedToolInputs.get(streamKey);
if (!inputsForMessage) {
inputsForMessage = new Map();
streamedToolInputs.set(streamKey, inputsForMessage);
}
inputsForMessage.set(event.index, {
id: block.id,
name: block.name,
partialJson: "",
inString: false,
escaped: false,
objectDepth: 0,
arrayDepth: 0,
lastTopLevelComma: -1,
emittedThroughComma: -1,
});
}
// The input of a streamed tool_use starts empty and streams after this
// event, so the tool_call waits for the complete input.
return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, {
...forwardedOptions,
inputComplete: false,
});
}
case "content_block_delta": {
if (event.delta.type === "input_json_delta") {
const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
if (!streamedInput) return [];
if (scanStreamedToolInput(streamedInput, event.delta.partial_json)) {
// Input complete: the consolidated assistant message replays the
// block with its full input and refines the call there; emitting
// here too would send a duplicate identical update.
const inputsForMessage = streamedToolInputs?.get(streamKey);
inputsForMessage?.delete(event.index);
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
return [];
}
if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
return [];
}
streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
const input = recoveredToolInput(
streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma),
);
if (!input) return [];
// TodoWrite and the Task* tools never surfaced a tool_call to refine.
if (!shouldEmitToolCall(streamedInput.name)) return [];
const update: SessionNotification["update"] = toolCallRenderer(options).partialRefinement(
streamedInput,
input,
options?.cwd,
);
if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id);
applyMessageId(update, options?.messageId);
// A refinement resends only what changed: rawInput grows with every
// field, and title, kind, and locations usually stay the same.
if (options?.toolCallFields && !options.toolCallFields.apply(update)) return [];
return [{ sessionId, update }];
}
return toAcpNotifications(
[event.delta],
"assistant",
sessionId,
toolUseCache,
client,
logger,
forwardedOptions,
);
}
// No content. `ping` is a Messages-API keep-alive event that the SDK's
// `BetaRawMessageStreamEvent` union doesn't include even though the
// wire format emits it; the `as never` cast lets us no-op it here
// instead of letting it fall through to `unreachable`.
case "ping" as never:
case "message_delta":
return [];
// A message boundary ends every input stream on this lane: message_stop is
// the normal end, and a message_start clears anything a prior message on
// the lane left behind (e.g. a stream cut short mid-block).
case "message_start":
case "message_stop":
streamedToolInputs?.delete(streamKey);
return [];
case "content_block_stop": {
const inputsForMessage = streamedToolInputs?.get(streamKey);
inputsForMessage?.delete(event.index);
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
return [];
}
default:
unreachable(event, logger);
return [];
}
}

View File

@@ -1,5 +1,6 @@
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
import { v1AgentApp, type ClaudeAcpAgent } from "./v1/acp-agent.js";
import type { Logger } from "./logger.js";
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
import { v2AgentApp, type AgentHandle } from "./v2/agent.js";

View File

@@ -12,7 +12,7 @@
* `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` set), the same request generates it.
*
* {@link SessionTitles} holds the per-session state and is owned by the
* session, in the v1 core (`acp-agent.ts`) and the v2 core
* session, in the v1 core (`v1/acp-agent.ts`) and the v2 core
* (`v2/session.ts`). Each drives it from the same places:
* {@link SessionTitles.onPrompt} and {@link SessionTitles.onAssistantText} to
* collect text, {@link SessionTitles.onPromptQueued} once a prompt is sent to

View File

@@ -33,7 +33,7 @@ export const ZERO_USAGE = Object.freeze({
export const DEFAULT_CONTEXT_WINDOW = 200000;
/** `_meta.quota` for work that spent `usage` in its main agent loop and
* `modelUsage` by model (see `turnQuotaMeta` in `acp-agent.ts`). */
* `modelUsage` by model (see `turnQuotaMeta` in `v1/acp-agent.ts`). */
export function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) {
return {
quota: {

124
src/slash-commands.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* The `available_commands_update` list: the slash commands Claude Code reports,
* minus the ones ACP clients can't run, plus the commands the adapter adds.
*/
import type { AvailableCommand } from "@agentclientprotocol/sdk/unstable";
import type { Query, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
import { AIR_KIND_KEY, AIR_SKILL_PATH_KEY, withAirMeta } from "./air-extension.js";
import { MCP_AVAILABLE_COMMAND } from "./mcp-command.js";
import {
claudeCodeOffersCommand,
REMOTE_CONTROL_COMMAND_NAME,
supportsRemoteControl,
} from "./remote-control.js";
import { resolveSkillPath, type SkillScanCache } from "./tool-calls/reporters/interaction.js";
export function getAvailableSlashCommands(
commands: SlashCommand[],
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
// the static list. Raw CLI names, matched before the MCP rename.
terminalCommands?: readonly string[],
// The session cwd of an AIR client, or undefined for every other client.
// AIR gets the kind and the SKILL.md path of each skill command.
airSkillCwd?: string,
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
skillPaths?: Map<string, string | undefined>,
query?: Query,
// The skill files Claude Code reports (`getContextUsage().skills.skillFrontmatter`).
// A listed skill gets its path from here; one that is not listed, or has no
// path (remote connections), is looked up on disk.
skillFiles?: ReadonlyMap<string, { path?: string }>,
): AvailableCommand[] {
// The container listings of this one pass, so a list of a hundred commands lists each container once.
const skillScans: SkillScanCache = new Map();
const skillPath = (name: string, cwd: string): string | undefined => {
const key = `${cwd}\0${name}`;
if (skillPaths?.has(key)) return skillPaths.get(key);
const resolved = resolveSkillPath(name, cwd, skillScans);
skillPaths?.set(key, resolved);
return resolved;
};
const UNSUPPORTED_COMMANDS = [
"clear",
"cost",
"keybindings-help",
"login",
"logout",
"output-style:new",
"release-notes",
"todos",
];
const remoteControlCommands: AvailableCommand[] =
query && supportsRemoteControl(query)
? [
{
name: REMOTE_CONTROL_COMMAND_NAME,
description:
"Continue this session from claude.ai/code or the Claude mobile app; `off` disconnects",
input: { hint: "[session name | off]" },
},
]
: [];
// The adapter replaces the terminal text of `/mcp` and runs its actions,
// even when Claude Code tags the command terminal-bound.
const advertised = commands
.filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
.map((command) => {
const input = command.argumentHint
? {
hint: Array.isArray(command.argumentHint)
? command.argumentHint.join(" ")
: command.argumentHint,
}
: null;
let name = command.name;
const mcpPrompt = command.name.endsWith(" (MCP)");
if (mcpPrompt) {
name = `mcp:${name.replace(" (MCP)", "")}`;
}
const skillFile = airSkillCwd && !mcpPrompt ? skillFiles?.get(command.name) : undefined;
const skillMdPath =
skillFile?.path ??
(airSkillCwd && !command.builtin && !mcpPrompt
? skillPath(command.name, airSkillCwd)
: undefined);
return {
name,
description: command.description || "",
input,
...(skillMdPath
? {
_meta: withAirMeta(
withAirMeta(undefined, AIR_KIND_KEY, "skill"),
AIR_SKILL_PATH_KEY,
skillMdPath,
),
}
: skillFile
? // A skill without a file (bundled with the CLI, like `simplify`, or a remote connection): kind only.
{ _meta: withAirMeta(undefined, AIR_KIND_KEY, "skill") }
: {}),
};
})
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
// Command names must not contain whitespace and must be unique. A name
// like "my command" is dropped, and of two commands named "review" only the
// first is kept.
const names = new Set<string>();
return [
...advertised,
MCP_AVAILABLE_COMMAND,
...remoteControlCommands.filter(
(command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name),
),
].filter((command) => {
if (/\s/.test(command.name) || names.has(command.name)) return false;
names.add(command.name);
return true;
});
}

View File

@@ -84,7 +84,7 @@ export type ReplayChild = {
responseModel?: string;
};
type SendMessageCall = {
export type SendMessageCall = {
toolUseId: string;
to: string;
message: string;
@@ -97,34 +97,27 @@ type SendMessageCall = {
outgoingSent: boolean;
};
export class RfdSubagentReplay {
/** The children by the id of the tool use that launched each. */
private readonly children = new Map<string, ReplayChild>();
private readonly byAgentId = new Map<string, ReplayChild>();
private readonly sendMessages: SendMessageCall[] = [];
/** Launches without metadata, which the replay leaves out. */
private readonly omitted = new Set<string>();
/** Children whose transcript could not be read: only their association is replayed. */
private withoutHistory = 0;
/** The SubagentHandback calls, with their report and whether their result failed. */
private readonly handbacks = new Map<
string,
{ message: string; failed: boolean; sent: boolean }
>();
/** A SubagentHandback call: its report, whether its result failed, and whether the replay sent it. */
type ReplayHandback = { message: string; failed: boolean; sent: boolean };
constructor(
private readonly history: SubagentHistory,
private readonly publish: Publish,
private readonly models?: SubagentModels,
/**
* Whether the replay maps a model to the session's model list as it goes.
* A load lacks the list until the CLI started, so it maps only in
* {@link currentStates}, after the load response.
*/
private readonly mapsDuringReplay = true,
/** The working directory of the root session, which a child inherits unless it reports its own. */
private readonly rootCwd?: string,
) {}
/**
* What both replays read from the transcripts before they send anything: the
* children that launches with metadata started, the launches without metadata
* (left out), the SendMessage calls, and the SubagentHandback calls. It sends
* nothing; `RfdSubagentReplay` (v1) and `src/v2/subagent-replay.ts` send the
* replay from it.
*/
export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
/** The children by the id of the tool use that launched each. */
readonly children = new Map<string, Child>();
readonly byAgentId = new Map<string, Child>();
readonly sendMessages: SendMessageCall[] = [];
/** Launches without metadata, which the replay leaves out. */
readonly omitted = new Set<string>();
/** The SubagentHandback calls, by tool use id. */
readonly handbacks = new Map<string, ReplayHandback>();
constructor(private readonly history: SubagentHistory) {}
/**
* Registers the launches, the launch results, and the SendMessage calls in
@@ -165,9 +158,64 @@ export class RfdSubagentReplay {
}
}
private registerLaunch(
toolUseId: string,
input: Record<string, unknown>,
parentSessionId: string,
): void {
if (this.children.has(toolUseId)) return;
const record = this.history.agents.get(toolUseId);
if (!record) {
this.omitted.add(toolUseId);
return;
}
// A child type adds only optional fields, which a new child leaves unset.
const child = {
launchId: toolUseId,
sessionId: record.agentId,
record,
parentSessionId,
input,
announced: false,
promptSent: false,
} as Child;
this.children.set(toolUseId, child);
this.byAgentId.set(record.agentId, child);
}
}
export class RfdSubagentReplay {
private readonly transcript: ReplayTranscript;
/** Children whose transcript could not be read: only their association is replayed. */
private withoutHistory = 0;
constructor(
private readonly history: SubagentHistory,
private readonly publish: Publish,
private readonly models?: SubagentModels,
/**
* Whether the replay maps a model to the session's model list as it goes.
* A load lacks the list until the CLI started, so it maps only in
* {@link currentStates}, after the load response.
*/
private readonly mapsDuringReplay = true,
/** The working directory of the root session, which a child inherits unless it reports its own. */
private readonly rootCwd?: string,
) {
this.transcript = new ReplayTranscript(history);
}
/**
* Registers the launches, the launch results, and the SendMessage calls in
* `messages`, the transcript of the session `ownerSessionId`.
*/
scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
this.transcript.scan(messages, ownerSessionId);
}
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
childOfLaunch(toolUseId: string): ReplayChild | undefined {
return this.children.get(toolUseId);
return this.transcript.children.get(toolUseId);
}
/**
@@ -211,11 +259,11 @@ export class RfdSubagentReplay {
*/
private effectiveCwd(sessionId: string): string | undefined {
const seen = new Set<string>();
let child = this.byAgentId.get(sessionId);
let child = this.transcript.byAgentId.get(sessionId);
while (child && !seen.has(child.sessionId)) {
if (child.cwd) return child.cwd;
seen.add(child.sessionId);
child = this.byAgentId.get(child.parentSessionId);
child = this.transcript.byAgentId.get(child.parentSessionId);
}
return this.rootCwd;
}
@@ -299,7 +347,9 @@ export class RfdSubagentReplay {
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
return false;
}
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
const sendMessage = this.transcript.sendMessages.find(
(call) => call.toolUseId === update.toolCallId,
);
if (sendMessage) {
if (sendMessage.recipientSessionId === undefined) {
sendMessage.shownAsToolCall = true;
@@ -317,7 +367,7 @@ export class RfdSubagentReplay {
}
return true;
}
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
if (owner && handback && !handback.failed) {
if (!handback.sent) {
handback.sent = true;
@@ -333,7 +383,7 @@ export class RfdSubagentReplay {
}
return true;
}
const child = this.children.get(update.toolCallId);
const child = this.transcript.children.get(update.toolCallId);
if (!child) return false;
if (
update.status === "completed" &&
@@ -352,29 +402,29 @@ export class RfdSubagentReplay {
* its task id is the agent id.
*/
taskNotification(taskId: string, status: string): boolean {
const child = this.byAgentId.get(taskId);
const child = this.transcript.byAgentId.get(taskId);
if (!child) return false;
child.notified = nativeSubagentState(status) ?? child.notified;
return true;
}
/**
* Sends the end of every announced child (see `endState`), and a notice when
* Sends the end of every announced child (see `replayEndState`), and a notice when
* subagent history is missing.
*/
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
for (const child of [...this.children.values()].reverse()) {
for (const child of [...this.transcript.children.values()].reverse()) {
if (!child.announced) continue;
await this.publish({
sessionId: child.parentSessionId,
update: {
sessionUpdate: "subagent_update",
sessionId: child.sessionId,
state: endState(child),
state: replayEndState(child),
},
});
}
const missing = this.omitted.size + this.withoutHistory;
const missing = this.transcript.omitted.size + this.withoutHistory;
if (missing > 0 && supportsNotices) {
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
}
@@ -399,12 +449,14 @@ export class RfdSubagentReplay {
live: (sessionId: string) => WorkState["state"] | undefined,
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
): AcpSessionNotification[] {
return [...this.children.values()]
return [...this.transcript.children.values()]
.filter((child) => child.announced)
.flatMap((child): AcpSessionNotification[] => {
const liveState = live(child.sessionId);
const state: WorkState =
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
liveState === undefined || liveState === "idle"
? replayEndState(child)
: { state: liveState };
// The session's model list may be known only now, after the load response.
const entry = responseModelEntry(child.responseModel, this.models);
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
@@ -435,30 +487,6 @@ export class RfdSubagentReplay {
});
}
private registerLaunch(
toolUseId: string,
input: Record<string, unknown>,
parentSessionId: string,
): void {
if (this.children.has(toolUseId)) return;
const record = this.history.agents.get(toolUseId);
if (!record) {
this.omitted.add(toolUseId);
return;
}
const child: ReplayChild = {
launchId: toolUseId,
sessionId: record.agentId,
record,
parentSessionId,
input,
announced: false,
promptSent: false,
};
this.children.set(toolUseId, child);
this.byAgentId.set(record.agentId, child);
}
/** The prompt of the launch, with the launch's id, to the parent and the child, as live. */
private async sendPrompt(child: ReplayChild, text: string): Promise<void> {
child.promptSent = true;
@@ -489,7 +517,7 @@ export class RfdSubagentReplay {
text: string,
): Promise<void> {
const call = deliveredCall(
this.sendMessages,
this.transcript.sendMessages,
[child.sessionId, child.record.name, child.input.name],
text,
);
@@ -553,7 +581,7 @@ export function replaySubagentTerminalState(
* `cancelled` for a child the user stopped, else the result of its foreground
* launch, else `idle` with no stop reason.
*/
function endState(child: ReplayChild): WorkState {
export function replayEndState(child: ReplayChild): WorkState {
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
return end ? rfdEndState(end, undefined) : { state: "idle" };
}
@@ -586,7 +614,7 @@ function inputOf(block: Record<string, unknown>): Record<string, unknown> {
}
/** The text of user message content: a string, or its text blocks. */
function textOf(content: unknown): string {
export function textOf(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
@@ -598,7 +626,7 @@ function textOf(content: unknown): string {
}
/** Whether `dir` is an existing directory. */
function isDirectory(dir: string): boolean {
export function isDirectory(dir: string): boolean {
try {
return statSync(dir).isDirectory();
} catch {

View File

@@ -1,7 +1,7 @@
/**
* The `<task-notification>` inputs that Claude Code writes to a transcript
* when a background task stops. The replay of a session (`acp-agent.ts`) and
* the session index (`session-index/background-tasks.ts`) read them here, so
* when a background task stops. The replay of a session (`v1/acp-agent.ts`) and
* the session index (`v1/session-index/background-tasks.ts`) read them here, so
* both read the same fields.
*/

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { Pushable } from "../utils.js";
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../session-config-options.js";
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the

View File

@@ -15,8 +15,8 @@ import {
} from "../diff.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
import { buildClaudePermissionPresentation } from "../permissions/presentation.js";
import { toolInfoFromToolUse } from "../tools.js";
import { WriteReporter } from "../tool-calls/reporters/file-edit.js";
import { toolInfoFromToolUse } from "./tool-render.js";
const tempDirectories: string[] = [];

View File

@@ -3,13 +3,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { AcpClient } from "../acp-client.js";
import {
AcpClient,
StreamedToolInputCache,
streamEventToAcpNotifications,
toAcpNotifications,
ToolUseCache,
} from "../acp-agent.js";
} from "../sdk-to-acp.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { createPostToolUseHook } from "../tools.js";

View File

@@ -1,74 +1,22 @@
import { describe, expect, it } from "vitest";
import packageJson from "../../package.json" with { type: "json" };
describe("module runtime exports", () => {
/**
* The public runtime exports of lib.ts must change only deliberately; consumers import these names.
* The public runtime exports of lib.ts, the package's only entry point, must change only deliberately; consumers import these names.
*/
it("lib.ts exports the expected names", async () => {
expect(Object.keys(await import("../lib.js")).sort()).toEqual([
"AUTH_STATUS_UPDATE_METHOD",
"ClaudeAcpAgent",
"Pushable",
"SettingsManager",
"isLocalCommandMetadata",
"nodeToWebReadable",
"nodeToWebWritable",
"planEntries",
"runAcp",
"streamEventToAcpNotifications",
"stripLocalCommandMetadata",
"toAcpNotifications",
"toDisplayPath",
"toolInfoFromToolUse",
"toolUpdateFromToolResult",
"unreachable",
"serveAcp",
]);
});
/**
* Moving code out of acp-agent.ts must keep every name re-exported, since consumers deep-import
* dist/acp-agent.js; change this list only deliberately.
*/
it("acp-agent.ts exports the expected names", async () => {
expect(Object.keys(await import("../acp-agent.js")).sort()).toEqual([
"AGENT_CONFIG_ID",
"AUTONOMOUS_RESULT_ORIGINS",
"BUILTIN_AGENT_NAMES",
"ClaudeAcpAgent",
"DEFAULT_AGENT_ID",
"EFFORT_CONFIG_ID",
"FAST_MODE_CONFIG_ID",
"FAST_MODE_OFF",
"FAST_MODE_ON",
"MODEL_CONFIG_ID",
"MODE_CONFIG_ID",
"applyAvailableModelsAllowlist",
"buildConfigOptions",
"clientSupportsBooleanConfigOptions",
"clientSupportsRecommendedConfigValue",
"computeSessionFingerprint",
"createFastModeConfigOption",
"discoverCustomAgents",
"fastModeStateEnabled",
"getAvailableSlashCommands",
"isAbortedResult",
"isLocalCommandMetadata",
"isLoginRequiredText",
"isReplayHiddenMetaMessage",
"isSyntheticLoginMessage",
"matchResumedModel",
"messageIdForGrouping",
"normalizeFastModeDisabledReason",
"promptToClaude",
"resolveFastModeEnabled",
"resolveModelPreference",
"runAcp",
"runPromptWithCancellation",
"settingsEffortForModel",
"streamEventToAcpNotifications",
"stripLocalCommandMetadata",
"toAcpNotifications",
"v1AgentApp",
]);
it("package.json exposes lib.ts as the only module entry point", () => {
expect(Object.keys(packageJson.exports)).toEqual([".", "./package.json"]);
expect(packageJson.exports["."]).toEqual({
types: "./dist/lib.d.ts",
import: "./dist/lib.js",
});
});
});

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../acp-agent.js";
import {
buildConfigOptions,
clientSupportsRecommendedConfigValue,
} from "../session-config-options.js";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
const MODES = {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import { resolvePermissionMode } from "../permissions/modes.js";
function mockLogger() {

63
src/tests/tool-render.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Test shorthands for the shared tool call renderer: one tool use or tool
* result, rendered for a client with the given capabilities. Production code
* renders through {@link AcpToolCallRenderer} directly.
*/
import type {
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk/unstable";
import { stripLocalCommandMetadata } from "../message-filters.js";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import type { ToolResultBlock } from "../tool-calls/content.js";
import { AcpToolCallRenderer, type RenderedResult } from "../tool-calls/renderer.js";
/**
* The title, kind, content, and locations of a tool use, for a client with the
* given terminal and patch capabilities. The {@link AcpToolCallRenderer} builds
* them from the facts of the tool reporter.
*/
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
supportsDiffPatch: boolean = false,
): {
title: string;
kind: ToolKind;
content: ToolCallContent[];
locations?: ToolCallLocation[];
} {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, false, supportsDiffPatch),
);
return renderer.toolInfo({ id: toolUse?.id, name: toolUse?.name, input: toolUse?.input }, cwd);
}
/**
* The result fields of a tool result, for a client with the given terminal
* capabilities. The {@link AcpToolCallRenderer} builds them from the facts of
* the tool reporter.
*/
export function toolUpdateFromToolResult(
toolResult: ToolResultBlock,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
toolUseResult?: unknown,
preferTerminalOutputDelta: boolean = false,
): RenderedResult {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, preferTerminalOutputDelta),
);
return renderer.resultFields(
{ id: toolUse?.id, name: toolUse?.name ?? "", input: toolUse?.input },
toolResult as Parameters<AcpToolCallRenderer["resultFields"]>[1],
toolUseResult,
);
}
/** Whether `content` is only Claude Code's local-command metadata, which the agent drops. */
export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}

View File

@@ -22,14 +22,14 @@ import {
BetaBashCodeExecutionResultBlock,
BetaBashCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { AcpClient, toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
import { AcpClient } from "../acp-client.js";
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
import { Logger } from "../logger.js";
import {
toolUpdateFromToolResult,
createPostToolUseHook,
createTaskHook,
clearHookCallbacks,
registerHookCallback,
toolInfoFromToolUse,
planEntries,
applyTaskCreate,
applyTaskList,
@@ -41,6 +41,7 @@ import {
} from "../tools.js";
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "./tool-render.js";
/** An AIR client: it gets the ACP tool call contract (`docs/air-extensions.md`). */
const AIR_CLIENT: ClientCapabilities = {

View File

@@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { makeMockQuery } from "../helpers.js";
const { querySpy } = vi.hoisted(() => ({
querySpy: vi.fn(),
@@ -84,7 +85,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -128,7 +129,7 @@ describe("ClaudeAcpAgent settings", () => {
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
if (recommended) {
(agent as any).clientCapabilities = {
@@ -179,7 +180,7 @@ describe("ClaudeAcpAgent settings", () => {
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
@@ -226,7 +227,7 @@ describe("ClaudeAcpAgent settings", () => {
applyFlagSettings,
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
(agent as any).clientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
@@ -270,7 +271,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -295,7 +296,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -318,7 +319,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
@@ -362,7 +363,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
@@ -387,7 +388,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -413,7 +414,7 @@ describe("ClaudeAcpAgent settings", () => {
mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
@@ -447,7 +448,7 @@ describe("ClaudeAcpAgent settings", () => {
);
const { getCapturedOptions, setModelSpy } = mockQuery(models);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
(agent as any).logger = { log: () => {}, error: () => {} };
return { agent, projectDir, getCapturedOptions, setModelSpy };
@@ -646,7 +647,7 @@ describe("ClaudeAcpAgent settings", () => {
const { getCapturedOptions } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -673,7 +674,7 @@ describe("ClaudeAcpAgent settings", () => {
const { setModelSpy } = mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -728,7 +729,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -755,7 +756,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -807,7 +808,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "claude-haiku-4-5", displayName: "Claude Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -844,7 +845,7 @@ describe("ClaudeAcpAgent settings", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -891,7 +892,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -956,7 +957,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -996,7 +997,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1028,7 +1029,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1050,7 +1051,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1084,7 +1085,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "haiku", displayName: "Haiku", description: "Fast" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1135,7 +1136,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1196,7 +1197,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1248,7 +1249,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1303,7 +1304,7 @@ describe("ClaudeAcpAgent settings", () => {
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1354,7 +1355,7 @@ describe("ClaudeAcpAgent settings", () => {
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1409,7 +1410,7 @@ describe("ClaudeAcpAgent settings", () => {
}) as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1450,7 +1451,7 @@ describe("ClaudeAcpAgent settings", () => {
});
try {
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
@@ -1501,7 +1502,7 @@ describe("ClaudeAcpAgent settings", () => {
}) as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({

View File

@@ -22,44 +22,44 @@ import {
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { NativeSubagentRuntime } from "../native-subagents.js";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import type { AcpSessionNotification } from "../../acp-subagents.js";
import { NativeSubagentRuntime } from "../../native-subagents.js";
import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
import {
createPostToolUseHook,
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
toolUpdateFromToolResult,
} from "../tools.js";
import { toolUpdateFromDiffToolResponse } from "../diff.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
ClaudeAcpAgent,
computeSessionFingerprint,
runPromptWithCancellation,
type SteerRequest,
} from "../../v1/acp-agent.js";
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
import { createFastModeConfigOption } from "../../session-fast-mode.js";
import {
toAcpNotifications,
promptToClaude,
isLocalCommandMetadata,
streamEventToAcpNotifications,
messageIdForGrouping,
type StreamedToolInputCache,
} from "../../sdk-to-acp.js";
import { promptToClaude } from "../../prompt-to-claude.js";
import {
isReplayHiddenMetaMessage,
isSyntheticLoginMessage,
stripLocalCommandMetadata,
ClaudeAcpAgent,
computeSessionFingerprint,
streamEventToAcpNotifications,
messageIdForGrouping,
buildConfigOptions,
createFastModeConfigOption,
discoverCustomAgents,
runPromptWithCancellation,
type AcpClient,
type SDKMessageFilter,
type SteerRequest,
type StreamedToolInputCache,
} from "../acp-agent.js";
import { SessionTitles } from "../session-titles.js";
import { SessionBackgroundWork } from "../background-work.js";
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
import { Pushable } from "../utils.js";
import { heldRequest, initializeClient, makeMockQuery } from "./helpers.js";
import { acpProtocolRouter } from "../serve.js";
} from "../../message-filters.js";
import type { AcpClient } from "../../acp-client.js";
import type { SDKMessageFilter } from "../../new-session-meta.js";
import { SessionTitles } from "../../session-titles.js";
import { SessionBackgroundWork } from "../../v1/background-work.js";
import {
formatUsageResponse,
isUsageCommandText,
parseUsageResponse,
} from "../../usage-markdown.js";
import { Pushable } from "../../utils.js";
import { heldRequest, initializeClient, makeMockQuery } from "../helpers.js";
import { acpProtocolRouter } from "../../serve.js";
import * as agentSdk from "@anthropic-ai/claude-agent-sdk";
import {
deleteSession,
@@ -91,7 +91,7 @@ import {
goalUpdateFromPrompt,
parseGoalRequest,
toGoalSnapshot,
} from "../goal-extension.js";
} from "../../v1/goal-extension.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
@@ -116,7 +116,12 @@ import type {
BetaWebFetchToolResultBlockParam,
BetaCodeExecutionToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import { validateRecorded } from "./acp-scenarios/schema.js";
import { validateRecorded } from "../acp-scenarios/schema.js";
import {
isLocalCommandMetadata,
toolInfoFromToolUse,
toolUpdateFromToolResult,
} from "../tool-render.js";
/** The capabilities of an AIR client. Only AIR gets the AIR extensions of
* `docs/air-extensions.md`. */
@@ -3601,7 +3606,7 @@ describe("usage-limit failure replay", () => {
it("recognizes a sanitized raw transcript through the real SDK parser", async () => {
const raw = await readFile(
new URL("./fixtures/usage-limit-session.jsonl", import.meta.url),
new URL("../fixtures/usage-limit-session.jsonl", import.meta.url),
"utf8",
);
const entries = raw

View File

@@ -5,11 +5,11 @@
* - Recordings are JSON Lines: one outbound message on each line, with sorted
* keys. `normalize` in `harness.ts` replaces only exact run-specific values:
* the generated ids, the paths, and the argv and version of this process.
* - AIR golden files: `acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
* Run `npx vitest run src/tests/acp-scenarios.test.ts -u` to update them.
* - AIR golden files: `../acp-scenarios/__snapshots__/air/<scenario>.jsonl`.
* Run `npx vitest run src/tests/v1/acp-scenarios.test.ts -u` to update them.
* - Schema: every message is valid against the ACP schema of the SDK.
* - Plain and Zed: the same information as origin/main
* (`acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
* (`../acp-scenarios/origin-main/<profile>/<scenario>.jsonl`). `compare.ts`
* allows only the documented differences. These profiles have no golden
* files of their own. `origin-main/zed/` holds a scenario only when its
* recording differs from the plain client.
@@ -17,9 +17,9 @@
* - AIR: the AIR extensions of `docs/air-extensions.md`, each fact once.
*
* To record the origin/main baseline again, copy `src/tests/acp-scenarios/`
* and this file into a checkout of origin/main, and run this file there with
* and this file to the same paths in a checkout of origin/main, and run this file there with
* `ACP_SCENARIO_BASELINE_DIR=<dir>`. Then copy `<dir>/plain` and `<dir>/zed`
* to `acp-scenarios/origin-main/`.
* to `src/tests/acp-scenarios/origin-main/`.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
@@ -41,20 +41,20 @@ import {
streamMessage,
toolCall,
toolResult,
} from "./acp-scenarios/harness.js";
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
} from "../acp-scenarios/harness.js";
import { SCENARIOS } from "../acp-scenarios/scenarios.js";
import {
AIR_ONLY_CLAUDE_CODE_KEYS,
AIR_ONLY_META_KEYS,
canonical,
compareWithBaseline,
} from "./acp-scenarios/compare.js";
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
} from "../acp-scenarios/compare.js";
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "../acp-scenarios/schema.js";
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../../air-extension.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
const harness = await import("./acp-scenarios/harness.js");
const harness = await import("../acp-scenarios/harness.js");
return {
...actual,
query: harness.mockedQuery,
@@ -66,7 +66,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
// learns each id that `randomUUID` returns.
vi.mock("node:crypto", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:crypto")>();
const harness = await import("./acp-scenarios/harness.js");
const harness = await import("../acp-scenarios/harness.js");
return {
...actual,
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
@@ -97,7 +97,7 @@ beforeAll(async () => {
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
Agent = ClaudeAcpAgent;
for (const profile of profiles) {
for (const scenario of SCENARIOS) {
@@ -189,7 +189,7 @@ function metaObjects(value: unknown, at = "$"): { at: string; meta: Record<strin
/** The origin/main recording of a scenario. Zed has a file only where it differs from the plain client. */
function readBaseline(profile: "plain" | "zed", scenario: string): string {
const file = (name: string) =>
path.join(here, "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
path.join(here, "..", "acp-scenarios", "origin-main", name, `${scenario}.jsonl`);
return fs.readFileSync(
profile === "zed" && !fs.existsSync(file("zed")) ? file("plain") : file(profile),
"utf8",
@@ -219,7 +219,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
const second = "cccccccc-0000-4000-8000-000000000003";
const scripted = "00000000-0000-4000-8000-00000000c001";
const version = JSON.parse(
fs.readFileSync(path.join(here, "..", "..", "package.json"), "utf8"),
fs.readFileSync(path.join(here, "..", "..", "..", "package.json"), "utf8"),
).version;
const argv = process.argv.slice(1);
@@ -322,7 +322,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
describe("air golden files", () => {
it.each(SCENARIOS.map((scenario) => scenario.name))("%s", async (scenario) => {
await expect(toJsonLines(run("air", scenario).normalized)).toMatchFileSnapshot(
path.join(here, "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
path.join(here, "..", "acp-scenarios", "__snapshots__", "air", `${scenario}.jsonl`),
);
});
});

View File

@@ -3,7 +3,8 @@ import * as os from "node:os";
import * as path from "node:path";
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
@@ -11,7 +12,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
const { makeMockQuery } = await import("../helpers.js");
return {
...actual,
query: ({ options }: { options: Options }) => {
@@ -31,8 +32,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
},
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
vi.mock("../../tools.js", async () => ({
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
registerHookCallback: vi.fn(),
}));
@@ -46,7 +47,7 @@ describe("additionalRoots", () => {
capturedOptions = undefined;
tempDirs.length = 0;
vi.resetModules();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
agent = new ClaudeAcpAgent({
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),

View File

@@ -1,10 +1,17 @@
import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../acp-agent.js";
import type { AuthStatus } from "../auth-status.js";
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import type { Logger } from "../../logger.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AuthStatus } from "../../auth-status.js";
import {
fromAccountInfo,
fromCliStatus,
mergeAuthStatus,
sameIdentity,
} from "../../auth-status.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "../helpers.js";
import { randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";

View File

@@ -4,15 +4,16 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import { AcpClient } from "../../acp-client.js";
import {
billsClaudeSubscription,
CLAUDE_LOGIN_REQUIRED_MESSAGE,
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_MESSAGE,
CLAUDE_SUBSCRIPTION_NOT_SUPPORTED_REASON,
} from "../hide-claude-auth.js";
import { SessionFailureController } from "../session-failure-extension.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
} from "../../hide-claude-auth.js";
import { SessionFailureController } from "../../session-failure-extension.js";
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "../helpers.js";
const mockQuery = vi.hoisted(() => vi.fn());
@@ -1712,7 +1713,7 @@ describe("authorization", () => {
describe("session failure reason field", () => {
it("carries reason only when the publisher passes one", async () => {
const { SessionFailureController, createSessionFailureState } =
await import("../session-failure-extension.js");
await import("../../session-failure-extension.js");
const capabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["sessionFailure"] } } },
} as any;

View File

@@ -11,11 +11,11 @@ import * as fsSync from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
import type { ActivityState } from "../session-index/activity.js";
import { endedTaskId, searchBackgroundTasks } from "../session-index/background-tasks.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import { SessionIndex } from "../session-index/session-index.js";
import { SessionIndexService } from "../session-index/service.js";
import type { ActivityState } from "../../v1/session-index/activity.js";
import { endedTaskId, searchBackgroundTasks } from "../../v1/session-index/background-tasks.js";
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
import { SessionIndex } from "../../v1/session-index/session-index.js";
import { SessionIndexService } from "../../v1/session-index/service.js";
let configDir: string;
let workspace: string;

View File

@@ -6,11 +6,12 @@ import {
} from "@agentclientprotocol/sdk/unstable";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { ALLOW_BYPASS } from "../permissions/modes.js";
import { NativeSubagentRuntime } from "../native-subagents.js";
import { startupFailure } from "./helpers.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import type { AcpSessionNotification } from "../../acp-subagents.js";
import { ALLOW_BYPASS } from "../../permissions/modes.js";
import { NativeSubagentRuntime } from "../../native-subagents.js";
import { startupFailure } from "../helpers.js";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -37,7 +38,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js");
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("../helpers.js");
return {
...actual,
query: (args: { prompt: unknown; options: Options }) => {
@@ -76,8 +77,8 @@ function initializationResponse() {
};
}
vi.mock("../tools.js", async () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
vi.mock("../../tools.js", async () => {
const actual = await vi.importActual<typeof import("../../tools.js")>("../../tools.js");
return {
...actual,
registerHookCallback: vi.fn(),
@@ -117,7 +118,7 @@ describe("createSession options merging", () => {
});
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
const acpAgent = await import("../../v1/acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());

View File

@@ -1,9 +1,10 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { Writable } from "node:stream";
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../utils.js";
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../../utils.js";
import {
mockSessionState,
successfulResultMessage,

View File

@@ -1,9 +1,12 @@
import { describe, it, expect, vi } from "vitest";
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { FastModeDisabledReason, ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import {
buildConfigOptions,
clientSupportsBooleanConfigOptions,
} from "../../session-config-options.js";
import {
createFastModeConfigOption,
fastModeStateEnabled,
normalizeFastModeDisabledReason,
@@ -11,9 +14,8 @@ import {
FAST_MODE_CONFIG_ID,
FAST_MODE_ON,
FAST_MODE_OFF,
ClaudeAcpAgent,
type AcpClient,
} from "../acp-agent.js";
} from "../../session-fast-mode.js";
import type { AcpClient } from "../../acp-client.js";
const MODES = {
currentModeId: "default",

View File

@@ -3,12 +3,13 @@ import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { randomUUID } from "node:crypto";
import * as path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import {
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
type AgentFileChangeReportResult,
} from "../file-change-audit.js";
import type { Pushable } from "../utils.js";
} from "../../file-change-audit.js";
import type { Pushable } from "../../utils.js";
type Scenario =
| "reported"
@@ -88,8 +89,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
vi.mock("../../tools.js", async () => ({
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
registerHookCallback: vi.fn(),
}));
@@ -257,7 +258,7 @@ describe("native file-change report integration", () => {
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
log: () => {},
error: () => {},

View File

@@ -20,11 +20,11 @@ import {
type Scenario,
system,
toolCall,
} from "./acp-scenarios/harness.js";
} from "../acp-scenarios/harness.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
const harness = await import("./acp-scenarios/harness.js");
const harness = await import("../acp-scenarios/harness.js");
return {
...actual,
query: harness.mockedQuery,
@@ -34,7 +34,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
vi.mock("node:crypto", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:crypto")>();
const harness = await import("./acp-scenarios/harness.js");
const harness = await import("../acp-scenarios/harness.js");
return {
...actual,
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
@@ -170,7 +170,7 @@ beforeAll(async () => {
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
for (const profile of runProfiles) {
resetIds();
const run = await unlessHung(runScenario(ClaudeAcpAgent, profile, scenario));

View File

@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { Pushable } from "../../utils.js";
import {
mockSessionState,
successfulResultMessage,

View File

@@ -4,9 +4,9 @@ import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { messageIdForGrouping } from "../acp-agent.js";
import { forkSession } from "../fork-session.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import { messageIdForGrouping } from "../../sdk-to-acp.js";
import { forkSession } from "../../fork-session.js";
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
// Use the real SDK against temporary transcripts, without starting Claude Code.
let root: string;

View File

@@ -9,8 +9,9 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { initializeClient } from "./helpers.js";
import type { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { initializeClient } from "../helpers.js";
import { successfulResultMessage } from "./session-doubles.js";
const execFileSpy = vi.hoisted(() => vi.fn());
@@ -36,7 +37,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
const { makeMockQuery } = await import("../helpers.js");
return {
...actual,
query: ({ prompt, options }: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => {
@@ -51,8 +52,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
vi.mock("../../tools.js", async () => ({
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
registerHookCallback: vi.fn(),
}));
@@ -227,7 +228,7 @@ function scriptClaudeCode({
async function connect(clientCapabilities: Record<string, unknown> = {}) {
vi.resetModules();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const updates: Array<{ update: Record<string, any> }> = [];
const client = {
sessionUpdate: async (notification: { update: Record<string, any> }) => {

View File

@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
import { Pushable } from "../utils.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../../tools.js";
import { Pushable } from "../../utils.js";
import {
mockSessionState,
successfulResultMessage,

View File

@@ -9,7 +9,8 @@ import type {
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { describe, expect, it } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
type JsonObject = Record<string, unknown>;

View File

@@ -9,7 +9,8 @@ import type {
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { describe, expect, it } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
type JsonObject = Record<string, unknown>;

View File

@@ -2,15 +2,17 @@ import { randomUUID } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import type { McpServerStatus } from "@anthropic-ai/claude-agent-sdk";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, stripLocalCommandMetadata, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import { stripLocalCommandMetadata } from "../../message-filters.js";
import type { AcpClient } from "../../acp-client.js";
import {
cleanMcpError,
formatMcpStatus,
MCP_AVAILABLE_COMMAND,
parseMcpCommand,
} from "../mcp-command.js";
import { Pushable } from "../utils.js";
import { initializeClient } from "./helpers.js";
} from "../../mcp-command.js";
import { Pushable } from "../../utils.js";
import { initializeClient } from "../helpers.js";
import {
mockSessionState,
successfulResultMessage,

View File

@@ -1,8 +1,9 @@
import { describe, it, expect } from "vitest";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { promptSuggestionsMeta, promptSuggestionText } from "../prompt-suggestions.js";
import { Pushable } from "../utils.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { promptSuggestionsMeta, promptSuggestionText } from "../../prompt-suggestions.js";
import { Pushable } from "../../utils.js";
import {
mockSessionState,
successfulResultMessage,

View File

@@ -3,9 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
import { initializeClient, makeMockQuery } from "./helpers.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import { FAST_MODE_CONFIG_ID } from "../../session-fast-mode.js";
import type { AcpClient } from "../../acp-client.js";
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../../air-extension.js";
import { initializeClient, makeMockQuery } from "../helpers.js";
import { successfulResultMessage, userEcho } from "./session-doubles.js";
const sdk = vi.hoisted(() => ({

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { loggableBaseUrl } from "../providers.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import { AcpClient } from "../../acp-client.js";
import { loggableBaseUrl } from "../../providers.js";
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { mockSessionState } from "./session-doubles.js";
/** A stream that stays open, like the real one: a consumer that reaches its end

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { Pushable } from "../../utils.js";
import { mockSessionState, successfulResultMessage, wrapQuery } from "./session-doubles.js";
const HUMAN = { kind: "human" };

View File

@@ -1,15 +1,16 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { makeMockQuery } from "../helpers.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
}));
vi.mock("../tools.js", async () => {
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
vi.mock("../../tools.js", async () => {
const actual = await vi.importActual<typeof import("../../tools.js")>("../../tools.js");
return {
...actual,
registerHookCallback: registerHookCallbackSpy,
@@ -132,7 +133,7 @@ describe("session config options", () => {
registerHookCallbackSpy.mockClear();
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
const acpAgent = await import("../../v1/acp-agent.js");
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
agent = new ClaudeAcpAgent(createMockClient());
@@ -1133,7 +1134,7 @@ describe("session config options", () => {
// model option lists it while it is current, so a client can send it back,
// and drops it once the session switches to a listed model.
it("lists an off-list current model until the session switches away", async () => {
const { buildModelConfigOption } = await import("../session-model.js");
const { buildModelConfigOption } = await import("../../session-model.js");
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.models = { ...session.models, currentModelId: "claude-offlist-9" };
session.modelInfos = [

View File

@@ -7,9 +7,9 @@
import { vi } from "vitest";
import { randomUUID } from "crypto";
import { SessionBackgroundWork } from "../background-work.js";
import { PromptSuggestions } from "../prompt-suggestions.js";
import { SessionTitles } from "../session-titles.js";
import { SessionBackgroundWork } from "../../v1/background-work.js";
import { PromptSuggestions } from "../../prompt-suggestions.js";
import { SessionTitles } from "../../session-titles.js";
/** Stand-in agent for a `SessionTitles` whose test doesn't care about titles:
* swallows the publish and the log, and never matches the identity check. */

View File

@@ -11,7 +11,8 @@ import { tmpdir } from "node:os";
import path from "node:path";
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
function sse(type: string, data: object): string {
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;

View File

@@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { deriveActivity, selectCost, type ActivityState } from "../session-index/activity.js";
import { LiveSessionRegistry, type LiveRecord } from "../session-index/live-registry.js";
import { deriveActivity, selectCost, type ActivityState } from "../../v1/session-index/activity.js";
import { LiveSessionRegistry, type LiveRecord } from "../../v1/session-index/live-registry.js";
import {
encodeProjectPath,
isSessionId,
pathAndAncestors,
projectDirMatches,
} from "../session-index/project-dirs.js";
} from "../../v1/session-index/project-dirs.js";
import {
decodeListCursor,
encodeListCursor,
parseListOptions,
parseRenameSessionRequest,
type ListScope,
} from "../session-index/service.js";
} from "../../v1/session-index/service.js";
import {
effectiveTitle,
isArchivedTitle,
storedTitle,
titleRecords,
visibleTitle,
} from "../session-index/archive-title.js";
} from "../../v1/session-index/archive-title.js";
import {
agentWorktreeOf,
continuedInSessionId,
@@ -33,11 +33,11 @@ import {
sdkTitles,
transcriptAgentName,
type TranscriptFacts,
} from "../session-index/transcript-scan.js";
import { firstPrompt } from "../session-index/first-prompt.js";
import { DirListings, statFiles } from "../session-index/dir-listing.js";
import { SessionIndex, type IndexRow } from "../session-index/session-index.js";
import { changeSignature, sessionInfoOf } from "../session-index/session-info.js";
} from "../../v1/session-index/transcript-scan.js";
import { firstPrompt } from "../../v1/session-index/first-prompt.js";
import { DirListings, statFiles } from "../../v1/session-index/dir-listing.js";
import { SessionIndex, type IndexRow } from "../../v1/session-index/session-index.js";
import { changeSignature, sessionInfoOf } from "../../v1/session-index/session-info.js";
const SESSION = "11111111-1111-4111-8111-111111111111";
const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n");

View File

@@ -16,13 +16,14 @@ import {
listSessions,
} from "@anthropic-ai/claude-agent-sdk";
import { spawn } from "node:child_process";
import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js";
import { ClaudeAcpAgent, v1AgentApp } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import {
client as acpClient,
type InitializeResponse,
type SessionInfo,
} from "@agentclientprotocol/sdk/unstable";
import { encodeProjectPath } from "../session-index/project-dirs.js";
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
import {
archiveInsteadOfDelete,
archiveTo,
@@ -30,25 +31,25 @@ import {
parseBatchGetSessionsRequest,
SessionIndexService,
writeCustomTitleSidecar,
} from "../session-index/service.js";
import { scanTranscriptFile } from "../session-index/transcript-scan.js";
import { repositoryWorktrees } from "../session-index/worktrees.js";
} from "../../v1/session-index/service.js";
import { scanTranscriptFile } from "../../v1/session-index/transcript-scan.js";
import { repositoryWorktrees } from "../../v1/session-index/worktrees.js";
import {
ListSubscriptions,
MAX_SUBSCRIPTIONS,
parseListSubscribeRequest,
parseListUnsubscribeRequest,
type ListChanges,
} from "../session-index/list-subscriptions.js";
import { SessionTitles } from "../session-titles.js";
import { initializeClient } from "./helpers.js";
} from "../../v1/session-index/list-subscriptions.js";
import { SessionTitles } from "../../session-titles.js";
import { initializeClient } from "../helpers.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
import { Pushable } from "../utils.js";
import { Pushable } from "../../utils.js";
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
@@ -1853,7 +1854,7 @@ describe("_session/list/subscribe", () => {
});
it("clears the failed turn of a session that runs here when a new turn starts", async () => {
const { noteSessionState } = await import("../session-index/connection.js");
const { noteSessionState } = await import("../../v1/session-index/connection.js");
const session = { lastTurnFailed: true } as any;
noteSessionState(session, "idle", "running");
expect(session.lastTurnFailed).toBe(false);

View File

@@ -2,8 +2,9 @@ import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import { AcpClient } from "../../acp-client.js";
import { Pushable } from "../../utils.js";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
function createMockClient(

View File

@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { makeMockQuery } from "../helpers.js";
const SESSION_ID = "test-session-id";

View File

@@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { appendTitleContext, sanitizeTitle } from "../session-titles.js";
import { Pushable } from "../utils.js";
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import { appendTitleContext, sanitizeTitle } from "../../session-titles.js";
import { Pushable } from "../../utils.js";
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
import {
mockSessionState,

View File

@@ -3,13 +3,14 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import { AcpClient, toAcpNotifications, ToolUseCache } from "../acp-agent.js";
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { AcpToolCallRenderer, mcpToolName } from "../tool-calls/renderer.js";
import { changedTaskPlanEntries, createTaskHook, type TaskState } from "../tools.js";
import { AcpClient } from "../../acp-client.js";
import { toAcpNotifications, ToolUseCache } from "../../sdk-to-acp.js";
import { ClientCapabilities as ToolCallCapabilities } from "../../tool-calls/client-capabilities.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../../tool-calls/background.js";
import { ChangedMetaFilter } from "../../v1/tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
import { AcpToolCallRenderer, mcpToolName } from "../../tool-calls/renderer.js";
import { changedTaskPlanEntries, createTaskHook, type TaskState } from "../../tools.js";
const logger = { log: () => {}, error: () => {} };

View File

@@ -5,9 +5,10 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import type { TurnEvents } from "../turn-events.js";
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
import type { ClaudeAcpAgent } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import type { TurnEvents } from "../../v1/turn-events.js";
import { DEFAULT_CONTEXT_USAGE } from "../helpers.js";
import { randomUUID } from "node:crypto";
const mockQuery = vi.hoisted(() => vi.fn());
@@ -26,8 +27,8 @@ vi.mock("node:child_process", async () => ({
(args[args.length - 1] as (error: Error) => void)(new Error("no CLI in tests")),
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
vi.mock("../../tools.js", async () => ({
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
registerHookCallback: vi.fn(),
}));
@@ -198,7 +199,7 @@ describe("turn events", () => {
beforeEach(async () => {
vi.resetModules();
onSessionUpdate = () => {};
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),

View File

@@ -4,7 +4,8 @@ import * as os from "node:os";
import * as path from "node:path";
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../../v1/acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import {
createUltracodeConfigOption,
modelSupportsUltracode,
@@ -15,8 +16,8 @@ import {
ULTRACODE_ON,
ultracodeSupported,
ultracodeWorkflowsEnabled,
} from "../session-ultracode.js";
import { makeMockQuery } from "./helpers.js";
} from "../../session-ultracode.js";
import { makeMockQuery } from "../helpers.js";
const { querySpy } = vi.hoisted(() => ({ querySpy: vi.fn() }));
@@ -168,7 +169,7 @@ describe("ultracode session config option", () => {
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
}),
);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),

View File

@@ -11,29 +11,29 @@
* - Tool calls: the first report of each tool call has a title. v2 has no
* `tool_call`, so a report of a tool call that the client has not seen
* creates it, where v1 drops it.
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
* - Golden files: `../acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
* passing scenario, every message of the run. Run
* `npx vitest run src/tests/acp-scenarios-v2.test.ts -u` to update them.
* `npx vitest run src/tests/v2/acp-scenarios-v2.test.ts -u` to update them.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentHandle } from "../v2/agent.js";
import type { AgentHandle } from "../../v2/agent.js";
import {
resetIds,
runScenarioV2,
type ScenarioRunV2,
type WireRecorded,
} from "./acp-scenarios/harness.js";
import { SCENARIOS } from "./acp-scenarios/scenarios.js";
import { canonical } from "./acp-scenarios/compare.js";
import { validateV2Message } from "./acp-scenarios/schema.js";
} from "../acp-scenarios/harness.js";
import { SCENARIOS } from "../acp-scenarios/scenarios.js";
import { canonical } from "../acp-scenarios/compare.js";
import { validateV2Message } from "../acp-scenarios/schema.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
const harness = await import("./acp-scenarios/harness.js");
const harness = await import("../acp-scenarios/harness.js");
return {
...actual,
query: harness.mockedQuery,
@@ -46,7 +46,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
// learns each id that `randomUUID` returns.
vi.mock("node:crypto", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:crypto")>();
const harness = await import("./acp-scenarios/harness.js");
const harness = await import("../acp-scenarios/harness.js");
return {
...actual,
randomUUID: (...args: Parameters<typeof actual.randomUUID>) => {
@@ -84,7 +84,7 @@ beforeAll(async () => {
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
const { acpProtocolRouter } = await import("../serve.js");
const { acpProtocolRouter } = await import("../../serve.js");
for (const scenario of SCENARIOS) {
resetIds();
const agents: { dispose(): Promise<void> }[] = [];
@@ -168,7 +168,7 @@ describe("ACP v2 scenarios", () => {
(scenario) => statusOf(scenario) === PASSES,
);
const golden = (scenario: string) =>
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`);
path.join(here, "..", "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`);
it.each(passing)("%s", async (scenario) => {
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(golden(scenario));
});

View File

@@ -18,25 +18,25 @@ import {
type Options,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { AuthStatusUpdateNotification } from "../auth-status.js";
import { clearContextWindowCache } from "../context-window.js";
import { v2DiffContent } from "../diff.js";
import { type AcpProtocols, acpProtocolRouter, acpProtocolsFromEnv } from "../serve.js";
import { v1PermissionResponse, v2PermissionRequest } from "../v2/permissions.js";
import { v1PromptRequest } from "../v2/prompt.js";
import { V2Terminals } from "../v2/terminal.js";
import { V2ToolCallMeta } from "../v2/tool-call-meta.js";
import { v2RestartedToolCall } from "../v2/tool-call.js";
import { v2SessionUpdate } from "../v2/session-update.js";
import type { AgentHandle } from "../v2/agent.js";
import type { AuthStatusUpdateNotification } from "../../auth-status.js";
import { clearContextWindowCache } from "../../context-window.js";
import { v2DiffContent } from "../../diff.js";
import { type AcpProtocols, acpProtocolRouter, acpProtocolsFromEnv } from "../../serve.js";
import { v1PermissionResponse, v2PermissionRequest } from "../../v2/permissions.js";
import { v1PromptRequest } from "../../v2/prompt.js";
import { V2Terminals } from "../../v2/terminal.js";
import { V2ToolCallMeta } from "../../v2/tool-call-meta.js";
import { v2RestartedToolCall } from "../../v2/tool-call.js";
import { v2SessionUpdate } from "../../v2/session-update.js";
import type { AgentHandle } from "../../v2/agent.js";
import {
v1ForkSessionRequest,
v1NewSessionRequest,
v1SetSessionConfigOptionRequest,
v2ConfigOptions,
} from "../v2/session-requests.js";
import { startupFailure } from "./helpers.js";
import packageJson from "../../package.json" with { type: "json" };
} from "../../v2/session-requests.js";
import { startupFailure } from "../helpers.js";
import packageJson from "../../../package.json" with { type: "json" };
const execFileSpy = vi.hoisted(() => vi.fn());
@@ -118,7 +118,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
const { makeMockQuery } = await import("../helpers.js");
return {
...actual,
query: ({ prompt, options }: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => {
@@ -161,8 +161,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
vi.mock("../../tools.js", async () => ({
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
registerHookCallback: vi.fn(),
}));

View File

@@ -4,7 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import { nodeToWebReadable, nodeToWebWritable } from "../utils.js";
import { nodeToWebReadable, nodeToWebWritable } from "../../utils.js";
/**
* The ACP v2 core (`src/v2/`) against the bundled Claude Code

View File

@@ -20,10 +20,10 @@ import {
type Options,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { acpProtocolRouter } from "../serve.js";
import { SettingsManager } from "../settings.js";
import type { AgentHandle } from "../v2/agent.js";
import { StoppedWork } from "../v2/stopped-work.js";
import { acpProtocolRouter } from "../../serve.js";
import { SettingsManager } from "../../settings.js";
import type { AgentHandle } from "../../v2/agent.js";
import { StoppedWork } from "../../v2/stopped-work.js";
/** One Claude Code process: the prompts it reads and the messages it sends. */
type FakeClaudeCode = {
@@ -160,8 +160,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
};
});
vi.mock("../diff.js", async () => {
const actual = await vi.importActual<typeof import("../diff.js")>("../diff.js");
vi.mock("../../diff.js", async () => {
const actual = await vi.importActual<typeof import("../../diff.js")>("../../diff.js");
return {
...actual,
previewPatchContent: async (...args: Parameters<typeof actual.previewPatchContent>) => {
@@ -172,14 +172,16 @@ vi.mock("../diff.js", async () => {
});
// The subagents that a replayed transcript launched, as their metadata files name them.
vi.mock("../subagent-history.js", async () => ({
...(await vi.importActual<typeof import("../subagent-history.js")>("../subagent-history.js")),
vi.mock("../../subagent-history.js", async () => ({
...(await vi.importActual<typeof import("../../subagent-history.js")>(
"../../subagent-history.js",
)),
subagentHistory: vi.fn(async () => ({ agents: sdk.subagents })),
readSubagentMessages: vi.fn(async () => []),
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
vi.mock("../../tools.js", async () => ({
...(await vi.importActual<typeof import("../../tools.js")>("../../tools.js")),
registerHookCallback: vi.fn(),
}));

View File

@@ -1,9 +1,4 @@
import type {
PlanEntry,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk/unstable";
import type { PlanEntry } from "@agentclientprotocol/sdk/unstable";
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import type {
TaskCreateInput,
@@ -12,56 +7,9 @@ import type {
TaskUpdateInput,
TaskUpdateOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import { ClientCapabilities } from "./tool-calls/client-capabilities.js";
import type { ToolResultBlock } from "./tool-calls/content.js";
import { AcpToolCallRenderer, type RenderedResult } from "./tool-calls/renderer.js";
export { markdownEscape, toDisplayPath } from "./tool-calls/content.js";
/**
* The title, kind, content, and locations of a tool use, for a client with the
* given terminal and patch capabilities. The {@link AcpToolCallRenderer} builds
* them from the facts of the tool reporter.
*/
export function toolInfoFromToolUse(
toolUse: any,
supportsTerminalOutput: boolean = false,
cwd?: string,
supportsDiffPatch: boolean = false,
): {
title: string;
kind: ToolKind;
content: ToolCallContent[];
locations?: ToolCallLocation[];
} {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, false, supportsDiffPatch),
);
return renderer.toolInfo({ id: toolUse?.id, name: toolUse?.name, input: toolUse?.input }, cwd);
}
/**
* The result fields of a tool result, for a client with the given terminal
* capabilities. The {@link AcpToolCallRenderer} builds them from the facts of
* the tool reporter.
*/
export function toolUpdateFromToolResult(
toolResult: ToolResultBlock,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
toolUseResult?: unknown,
preferTerminalOutputDelta: boolean = false,
): RenderedResult {
const renderer = new AcpToolCallRenderer(
new ClientCapabilities(supportsTerminalOutput, preferTerminalOutputDelta),
);
return renderer.resultFields(
{ id: toolUse?.id, name: toolUse?.name ?? "", input: toolUse?.input },
toolResult as Parameters<AcpToolCallRenderer["resultFields"]>[1],
toolUseResult,
);
}
export type ClaudePlanEntry = {
content: string;
status: "pending" | "in_progress" | "completed";

View File

@@ -2,7 +2,7 @@
import { Readable, Writable } from "node:stream";
import { WritableStream, ReadableStream } from "node:stream/web";
import { Logger } from "./acp-agent.js";
import { Logger } from "./logger.js";
// Useful for bridging push-based and async-iterator-based code.
export class Pushable<T> implements AsyncIterable<T> {

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
* command through its async tasks): the display terminal of each command
* runs from its Bash tool result until its task ends.
*/
import type { AcpSessionNotification } from "./acp-subagents.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
/** The display terminal of a command that went to the background. */
export interface BackgroundTerminal {

View File

@@ -40,7 +40,7 @@
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { TASK_NOTIFICATION_TAG, taskNotificationsOf } from "../task-notifications.js";
import { TASK_NOTIFICATION_TAG, taskNotificationsOf } from "../../task-notifications.js";
export type BackgroundTasks = {
/** The ids of the tasks that started and did not end, in start order. */

View File

@@ -17,7 +17,9 @@ import {
type ListSessionsResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, Logger, Session } from "../acp-agent.js";
import type { Session } from "../acp-agent.js";
import type { AcpClient } from "../../acp-client.js";
import type { Logger } from "../../logger.js";
import {
AIR_SESSION_ARCHIVE_CAPABILITY,
AIR_SESSION_BATCH_GET_CAPABILITY,
@@ -27,7 +29,7 @@ import {
AIR_SESSION_WORKTREES_CAPABILITY,
clientSupportsAirCapability,
withAirMeta,
} from "../air-extension.js";
} from "../../air-extension.js";
import type { OwnSessionState } from "./activity.js";
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
import {

View File

@@ -23,7 +23,7 @@ import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { claudeConfigDir } from "../paths.js";
import { claudeConfigDir } from "../../paths.js";
import { errorCode } from "./project-dirs.js";
const SNAPSHOT_TTL_MS = 5_000;

View File

@@ -14,7 +14,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { claudeConfigDir } from "../paths.js";
import { claudeConfigDir } from "../../paths.js";
/** The SDK cuts an encoded path at this length and appends a hash. */
const MAX_SANITIZED_LENGTH = 200;

View File

@@ -18,8 +18,8 @@ import {
type SessionInfo,
} from "@agentclientprotocol/sdk/unstable";
import { deleteSession as sdkDeleteSession } from "@anthropic-ai/claude-agent-sdk";
import { airExtensionMeta } from "../air-extension.js";
import { sanitizeTitle } from "../session-titles.js";
import { airExtensionMeta } from "../../air-extension.js";
import { sanitizeTitle } from "../../session-titles.js";
import type { OwnSessionState } from "./activity.js";
import {
effectiveTitle,

View File

@@ -21,7 +21,7 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { sanitizeTitle } from "../session-titles.js";
import { sanitizeTitle } from "../../session-titles.js";
import { effectiveTitle, visibleTitle } from "./archive-title.js";
import {
canonicalPath,

View File

@@ -5,7 +5,7 @@
*/
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
import { withAirMeta } from "../air-extension.js";
import { withAirMeta } from "../../air-extension.js";
import { deriveActivity, selectCost, type OwnSessionState } from "./activity.js";
import type { LiveRecord } from "./live-registry.js";
import type { IndexRow } from "./session-index.js";

View File

@@ -29,7 +29,8 @@ import {
type SessionMessage,
} from "@anthropic-ai/claude-agent-sdk";
import packageJson from "../../package.json" with { type: "json" };
import { messageIdForGrouping, type Logger } from "../acp-agent.js";
import { messageIdForGrouping } from "../sdk-to-acp.js";
import type { Logger } from "../logger.js";
import { customAgents } from "../session-config-options.js";
import { claudeCliPath } from "../claude-cli.js";
import { clientSystemPrompt, sessionSystemPrompt } from "../client-system-prompt.js";

View File

@@ -18,7 +18,7 @@ import { RequestError } from "@agentclientprotocol/sdk/unstable";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { AccountInfo, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk";
import type { AuthStatusKind } from "../auth-status.js";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import { terminalAuthMethods } from "../auth-methods.js";
import {
AUTH_STATUS_UPDATE_METHOD,

View File

@@ -16,7 +16,7 @@
import type { ContentBlock } from "@agentclientprotocol/sdk/unstable";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { Query, SDKSystemMessage, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
import { getAvailableSlashCommands } from "../acp-agent.js";
import { getAvailableSlashCommands } from "../slash-commands.js";
import { parseMcpCommand, runMcpCommand, type McpCommand } from "../mcp-command.js";
import { v2AvailableCommands } from "./session-requests.js";

View File

@@ -43,7 +43,7 @@ import type {
Query,
Settings,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import type { SubagentModels } from "../native-subagents.js";
import { DEFAULT_AGENT_ID } from "../session-config-ids.js";
import {

View File

@@ -23,7 +23,7 @@ import type {
CreateElicitationResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { CanUseTool, Options, Query } from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import {
answerAskUserQuestion,
answerMcpElicitation,

View File

@@ -10,7 +10,7 @@
* makes no permission decision, and a failure only leaves the preview out.
*/
import type { HookCallbackMatcher } from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import { previewPatchContent } from "../diff.js";
import type { PermissionHost } from "./permissions.js";

View File

@@ -14,7 +14,8 @@ import type {
SDKAssistantMessageError,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { AUTONOMOUS_RESULT_ORIGINS, isSyntheticLoginMessage } from "../acp-agent.js";
import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js";
import { isSyntheticLoginMessage } from "../message-filters.js";
import { resultEnd } from "../result-end.js";
import { providerFailureCategory } from "../session-failure-extension.js";
import {

View File

@@ -13,7 +13,7 @@
*/
import { randomUUID } from "node:crypto";
import type { Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import {
adapterRemoteControlCommand,
isRemoteHumanMessage,

View File

@@ -22,7 +22,7 @@ import type {
SDKPartialAssistantMessage,
SDKResultMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Logger } from "../acp-agent.js";
import type { Logger } from "../logger.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import {
ContextCompactionLifecycle,

Some files were not shown because too many files have changed in this diff Show More