Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@
"vitest": "^2.1.8"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.22.1",
"@anthropic-ai/claude-agent-sdk": "0.3.156",
"@agentclientprotocol/sdk": "0.25.0",
"@anthropic-ai/claude-agent-sdk": "0.3.165",
"@anthropic-ai/sdk": "0.100.1",
"@hono/node-server": "^1.19.9",
"@opentelemetry/api-logs": "^0.208.0",
Expand Down
164 changes: 164 additions & 0 deletions packages/agent/src/adapters/claude/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
---
name: upgrade-claude-adapter
description: >-
Sync this fork of @anthropic-ai/claude-agent-acp (packages/agent/src/adapters/claude)
with a newer upstream release: bump the claude-agent-sdk / @agentclientprotocol/sdk,
port upstream bug fixes and new SDK message handling, preserve the fork's divergences,
verify, and update UPSTREAM.md. Use when asked to "upgrade/sync the claude adapter",
"bump the agent SDK", or "port upstream claude-agent-acp changes".
---

# Upgrade the Claude ACP adapter (upstream sync)

This is a runbook for syncing our **fork** of `@anthropic-ai/claude-agent-acp` (the upstream
Zed/agentclientprotocol ACP agent) that lives in `packages/agent/src/adapters/claude/` with a newer
upstream release. The fork is heavily diverged. The job is to port the *valuable* upstream changes
(SDK bumps, bug fixes, new SDK-message handling) while preserving every intentional divergence — not
to make the fork identical to upstream.

`UPSTREAM.md` (this directory) is the source of truth for the **fork point**, **last-synced
version/commit**, the **file mapping**, the **PostHog-only code**, and the **intentional
divergences**. Read it first, update it last.

> This file is a runbook, not an auto-registered slash command. Invoke it by telling Claude to
> "follow the upgrade skill in the claude adapter dir." Move it to `.claude/skills/<name>/SKILL.md`
> if you ever want it runnable as `/<name>`.

## Inputs you need before starting

1. **Upstream source checkout** — a local git clone of `github.com/agentclientprotocol/claude-agent-acp`.
You need its history to diff. If the user hasn't given the path, **ask for it** (it's usually
somewhere like `~/Cloud/claude-agent-acp`). Do not guess.
2. **This repo** — the fork under `packages/agent/`.

## Process

### 0. Orient (read, don't write)

- Read `UPSTREAM.md`. Note **Last sync** (commit + version), the pinned **SDK** versions, the
**File Mapping**, **PostHog Code-Only Code (Do Not Sync)**, and **Intentional Divergences**.
- In the upstream checkout, list the change set since the last sync and skim the changelog:
- `git -C <upstream> log --oneline <last-sync-sha>..HEAD`
- `git -C <upstream> show <upstream>/CHANGELOG.md:CHANGELOG.md` (or just read `CHANGELOG.md`)
- Confirm the new target version + HEAD sha and the target SDK versions from the upstream
`package.json`.

### 1. Triage every commit

Bucket each commit since the last sync:

- **Port** — bug fixes and new feature / SDK-message handling that are *not* in the PostHog-only
list and don't fight a divergence.
- **Dep bump** — record the target SDK versions; the diff tells you if code changes ride along.
- **Skip** — `chore(main): release …`, `actions/* ` CI bumps, pure dependabot **dev**-dep bumps, and
anything matching the PostHog-only / divergence lists.

Read intent from source diffs (exclude tests + JSON first):

```
git -C <upstream> show <sha> -- src/ ':(exclude)src/tests/*' ':(exclude)*.json'
```

A dependabot SDK-bump commit often *also* carries real code (new message handling). Don't assume
"deps" == "no code".

### 2. Map upstream → fork

Upstream is one large `src/acp-agent.ts`; our fork is split. Use the File Mapping in `UPSTREAM.md`.
Rough guide:

| Upstream | Fork |
| --- | --- |
| `acp-agent.ts` prompt loop, lifecycle, cancel | `claude-agent.ts` |
| inline message/stream/result/system conversion | `conversion/sdk-to-acp.ts` |
| inline prompt→SDK conversion | `conversion/acp-to-sdk.ts` |
| `tools.ts` (tool_use→ACP, PostToolUse hook) | `conversion/tool-use-to-acp.ts`, `hooks.ts` |
| model alias resolution | `session/models.ts`, `session/model-config.ts` |
| options / system prompt | `session/options.ts` |
| permissions | `permissions/*` |

For each upstream change, `rg` the fork for the touched symbol first — the fork usually already has a
diverged version of it, so you're editing, not adding.

### 3. Bump dependencies

In `packages/agent/package.json`, set `@anthropic-ai/claude-agent-sdk`, `@agentclientprotocol/sdk`,
and `@anthropic-ai/sdk` to the upstream `package.json` versions, then `pnpm install` from the repo
root. (`packages/shared` pins its own older `@agentclientprotocol/sdk`; leave it unless a
cross-package type error forces a bump.)

### 4. Find the breaking-change surface

Run `pnpm --filter agent typecheck`. The errors are your ACP/SDK breaking-change list. Gotchas seen
in past syncs:

- **The ACP SDK ships name-mangled generated types.** `dist/schema/*.gen.d.ts` shows enum literals as
`n` (e.g. `StopReason = "…" | "n" | "cancelled"`). Don't trust grep there. Read the hand-written
`dist/acp.d.ts`, or download the exact target to inspect cleanly:
```
cd /tmp && npm pack @agentclientprotocol/sdk@<ver> && tar xzf *.tgz
rg -n "type StopReason|deleteSession|SessionModelState" package/dist/schema/types.gen.d.ts package/dist/acp.d.ts
```
- **`node -e "require('<pkg>/package.json')"` may fail** on the SDKs (exports map blocks the subpath).
Read `node_modules/<pkg>/package.json` directly for the installed version.
- **An ACP SDK bump can break code outside the claude adapter.** The whole `packages/agent` package
must typecheck — expect to also fix `adapters/codex/*` and `server/agent-server.ts`. Keep those
fixes minimal and behavior-preserving (e.g. when ACP removed the `models` response field, the codex
adapter derived the model id from `configOptions` instead).

### 5. Port in phases — bug fixes first, then features

For each ported change:

- **Preserve divergences** (see `UPSTREAM.md` → Intentional Divergences + PostHog-only). The big ones:
single-session `this.session` (not `this.sessions[id]`); `interruptReason` on cancel; gateway models
via `fetchGatewayModels` (not `initializationResult.models`); `_posthog/*` ext notifications;
the "Unsupported slash command" gate on `knownSlashCommands`; `SYSTEM_REMINDER` stripping; plan /
questions / MCP-metadata machinery.
- **New SDK `system` subtypes are safe by default.** `handleSystemMessage` ends in `default: break`,
and the prompt-loop top-level `switch (message.type)` only `unreachable()`s unknown top-level
*types*. So a new subtype won't crash the loop — port real handling only where there's user value
(e.g. `permission_denied` → failed tool_call, `tool_progress` → in_progress, `commands_changed` →
available_commands_update, `mirror_error` → log).
- When upstream reads new fields (`stop_details`, `getContextUsage`, `thinking`), confirm the
installed SDK `.d.ts` actually has them before porting. Skip ports the fork can't use (e.g. the
fork doesn't read `MAX_THINKING_TOKENS`, so upstream's `resolveThinkingConfig` was N/A).
- Typecheck after each logical group, not just at the end.

### 6. Verify (all of it)

```
pnpm --filter agent typecheck
pnpm --filter agent build
npx biome check --write <changed files> # biome is the formatter/linter, not prettier/eslint
pnpm typecheck # whole repo: confirms apps/code compiles vs the new ACP SDK
pnpm --filter agent test
pnpm --filter code test
```

- The `apps/code` renderer unit tests `analytics.test.ts` and `panelLayoutStore.test.ts` are **flaky**
— they sometimes throw in `getElectronTRPC` / electron-trpc `ipcLink` depending on test ordering. If
they fail, re-run; a clean rerun (or `git stash` + run on the clean tree) passing confirms it's the
known flake, not your change.

### 7. Update `UPSTREAM.md` (do this last)

- Bump **Last sync** (version + HEAD sha + date) and the pinned **SDK** versions.
- Add `## Changes Ported in v<X> Sync` (one bullet per change, with PR # and short sha) and
`## Skipped in v<X> Sync` (with the reason for each skip).
- If a port made a former divergence match upstream, move it out of the Intentional Divergences table.

## Fork facts worth remembering

- **Single session.** The agent owns one `this.session` (from `BaseAcpAgent`), not a `sessions` map.
Upstream's per-session refactors usually collapse to "just use `this.session`".
- **Renderer uses config options only.** Model/mode/effort selection is `SessionConfigOption` end to
end; the renderer never reads the legacy `models` response field or calls `unstable_setSessionModel`.
That's why upstream's ACP-0.24/0.25 model-state removals are safe to follow.
- **`toolUseCache` is never cleared** in the fork (created once in the constructor), so long sessions
accumulate — keep the prune-at-tool_result behavior, and make any PostToolUse hook close over the
data it needs rather than re-reading the cache.
- **Conversion is split out.** `claude-agent.ts` calls `handleSystemMessage` / `handleStreamEvent` /
`handleResultMessage` / `handleUserAssistantMessage` from `conversion/sdk-to-acp.ts`. Upstream
inlines all of this in `acp-agent.ts`.
- **Don't commit or push** unless the user explicitly asks. Leave the work on the current branch.
70 changes: 67 additions & 3 deletions packages/agent/src/adapters/claude/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
## Fork Point

- **Forked**: v0.10.9, commit `5411e0f4`, Dec 2 2025
- **Last sync**: v0.39.0, commit `51a370e`, May 29 2026
- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.156, `@agentclientprotocol/sdk` 0.22.1, `@anthropic-ai/sdk` 0.100.1
- **Last sync**: v0.42.0, commit `0dbccf5`, Jun 5 2026
- **SDK**: `@anthropic-ai/claude-agent-sdk` 0.3.165, `@agentclientprotocol/sdk` 0.25.0, `@anthropic-ai/sdk` 0.100.1

## File Mapping

Expand Down Expand Up @@ -55,6 +55,70 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth
| Shutdown on ACP close | Process exits | No standalone process | Agent is embedded in server |
| Unsupported slash commands | Loops silently on early idle | Emits "Unsupported slash command" chunk, gated on `initializationResult().commands` so plugin/skill commands (e.g. `/skills-store`) whose echoes use a fresh uuid are not false-flagged | The SDK consumes some slash commands without producing output (e.g. `/plugin` in non-interactive mode); without this we hang. The known-commands gate avoids racing plugin/skill loads where idle can arrive before the transformed user-message echo. |

## Changes Ported in v0.42.0 Sync

- **SDK bumps**: claude-agent-sdk 0.3.156 -> 0.3.165, ACP SDK 0.22.1 -> 0.25.0, anthropic SDK
unchanged at 0.100.1.
- **ACP SDK 0.25.0 model-state removal** (#737, 32175b8): 0.24.0 deleted `SessionModelState`,
`SetSessionModelRequest/Response`, `ModelInfo`, and the `models` field on every session lifecycle
response; model selection moved entirely into `SessionConfigOption` (category "model"). Our fork
already drove model selection through config options, so this just removed the vestigial legacy
path: dropped those imports, the `unstable_setSessionModel` method, and the `models` build/return
in `createSession` / `getExistingSessionState` / `loadSession`. The codex adapter's
`response.models?.currentModelId` read was replaced with a `modelIdFromConfigOptions()` helper
(codex `models.ts`). Verified the renderer reads only `configOptions`, never `.models`.
- **ACP SDK 0.25.0 `deleteSession` rename** (#753, 0dbccf5): No-op for us — our fork never
implemented `unstable_deleteSession`, and the method is optional on the `Agent` interface.
- **Refusal handling** (SDK 0.3.162, #740, add7e31): Capture the refused assistant message's
`stop_details.explanation`; the terminal `result` (stop_reason "refusal") emits it as an
`agent_message_chunk` and returns ACP's dedicated `refusal` stop reason instead of letting the
`is_error` path surface it as an internal error.
- **commands_changed** (SDK 0.3.162, #740, add7e31): New `system` subtype handled inline in the
prompt loop — pushes `available_commands_update` straight from `message.commands` (rather than
re-querying `supportedCommands()`, which only ever reflects the init list) and refreshes
`session.knownSlashCommands` so the unsupported-slash-command gate stays accurate.
- **Optimized marker stripping** (#738, 895422c): `stripMarkerTags` rewritten as a single-pass
scanner in `conversion/sdk-to-acp.ts`, removing the `[\s\S]*?` backtracking risk on pathological
input.
- **Force-cancel backstop** (#742, cffea4b): Added per-turn `cancelController` + `forceCancelTimer`
on `Session` and a mutable `forceCancelGraceMs` (30s) on the agent. The prompt loop races
`query.next()` against the cancel signal; `interrupt()` arms a grace-period timer that aborts it,
so a wedged SDK that never yields after interrupt (issue #680, e.g. a blocking `TaskOutput` poll)
returns "cancelled" instead of hanging. Adapted to our single-session model; preserves the
`interruptReason` meta on the forced return.
- **Cross-family model match fix** (#731, f4704c1): `scoreModelMatch` (session/models.ts) now
returns 0 when only the context-hint token matched, so `claude-opus-4-6[1m]` can't resolve to
`sonnet[1m]` purely on the shared "1m" token. Layers on top of our existing
`modelVersionsCompatible` filter.
- **compact_boundary getContextUsage** (#747, 398f763): compact_boundary now fetches the
authoritative post-compaction `used` via `query.getContextUsage()` (helper
`fetchContextUsedTokens`), falling back to 0 on failure. `size` still comes from the
gateway-learned window (getContextUsage under-reports 1M windows). Our fork-specific
`promptReplayed = true` side effect is preserved.
- **New SDK message handling** (#747, 398f763): `tool_progress` -> `tool_call_update` `in_progress`
with `elapsedTimeSeconds`; `rate_limit_event` -> `usage_update` carrying `_claude/rateLimit`;
`permission_denied` -> `tool_call_update` `failed` (in `handleSystemMessage`); `mirror_error` ->
logged (history-persistence failure / potential data loss on resume).
- **Prune tool cache** (#748, ec14211): `toolUseCache` was never cleared in our fork (set once in
the constructor, accumulated for the whole agent lifetime). Now pruned at `tool_result` time. The
PostToolUse hook closes over the tool name + bash command instead of re-reading the cache, so the
Edit/Write diff survives any hook/result reordering. We did NOT adopt upstream's per-session cache
move (we are single-session) or its `backgroundTerminals` deletion.
- **Test mock**: added `reloadSkills` to the SDK `MockQuery` (new method on the SDK `Query`
interface in 0.3.165).

## Skipped in v0.42.0 Sync

- **Message ids** (#750, 18516a3): Upstream records an ACP `messageId` -> SDK uuid map for a future
fork/rewind feature, explicitly "NOT READ YET". We don't consume it, it adds a `Session` field and
threads `messageId` through many `toAcpNotifications` call sites, so it is deferred until we wire
up rewind. (ACP 0.25.0 does expose the `messageId` field, so the port is unblocked when wanted.)
- **resolveThinkingConfig** (#747, 398f763): Upstream maps the legacy `MAX_THINKING_TOKENS` env var
to the SDK's new `thinking` option. Our fork never reads `MAX_THINKING_TOKENS` (model setup is
gateway-driven via `session/options.ts`), so there is nothing to migrate.
- **Pure dep-group / release / CI bumps** (#736, #741, #745, #728, #743): No fork-relevant code
beyond the SDK versions captured above.

## Changes Ported in v0.30.0 Sync

- **SDK bumps**: claude-agent-sdk 0.2.112 -> 0.2.114, ACP SDK 0.16.1 -> 0.19.0, anthropic SDK -> 0.89.0
Expand Down Expand Up @@ -165,7 +229,7 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth

## Next Sync

1. Check upstream changelog since v0.37.0
1. Check upstream changelog since v0.42.0
2. Diff upstream source against PostHog Code using the file mapping above
3. Port in phases: bug fixes first, then features
4. After each phase: `pnpm --filter agent typecheck && pnpm --filter agent build && pnpm lint`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,52 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
}
});
});

describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 'cancelled' when the SDK never yields after interrupt (issue #680)", async () => {
const { agent } = makeAgent();
const sessionId = "s-wedged";
const query = installFakeSession(agent, sessionId);
query.interrupt.mockImplementation(async () => {});
(agent as unknown as { forceCancelGraceMs: number }).forceCancelGraceMs = 5;

const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "do something slow" }],
});

await new Promise((resolve) => setImmediate(resolve));

await agent.cancel({ sessionId });

const result = await promptPromise;
expect(result.stopReason).toBe("cancelled");
});

it("clears the backstop timer on a healthy cancel (interrupt yields)", async () => {
const { agent } = makeAgent();
const sessionId = "s-healthy";
installFakeSession(agent, sessionId);
(agent as unknown as { forceCancelGraceMs: number }).forceCancelGraceMs =
50_000;

const promptPromise = agent.prompt({
sessionId,
prompt: [{ type: "text", text: "do something" }],
});
await new Promise((resolve) => setImmediate(resolve));

await agent.cancel({ sessionId });

const result = await promptPromise;
expect(result.stopReason).toBe("cancelled");
expect(
(agent as unknown as { session: { forceCancelTimer?: unknown } }).session
.forceCancelTimer,
).toBeUndefined();
});
});
Loading
Loading