mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 20:01:11 +00:00
Compare commits
17 Commits
v0.104.2-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c331e178a5 | ||
|
|
830f05a0ac | ||
|
|
4df69996ca | ||
|
|
ecd066c870 | ||
|
|
a22a797db2 | ||
|
|
4f690e129c | ||
|
|
51f514d73c | ||
|
|
b1763c750a | ||
|
|
f6d6429187 | ||
|
|
9fca22a03b | ||
|
|
4a3ff6a1eb | ||
|
|
b73ee491c8 | ||
|
|
626015bf81 | ||
|
|
1ffbb6b577 | ||
|
|
36cb674589 | ||
|
|
f5d79167a6 | ||
|
|
a4a8534c23 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.104.1"
|
||||
".": "0.106.0"
|
||||
}
|
||||
|
||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [0.106.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.105.0...v0.106.0) (2026-11-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* report the auth detail as the account's description ([#1591](https://github.com/agentclientprotocol/claude-agent-acp/issues/1591)) ([f6d6429](https://github.com/agentclientprotocol/claude-agent-acp/commit/f6d64291876177f9f593415abfc7e50eb7e7ff56))
|
||||
* serve session/options and initial config options ([#1592](https://github.com/agentclientprotocol/claude-agent-acp/issues/1592)) ([b1763c7](https://github.com/agentclientprotocol/claude-agent-acp/commit/b1763c750aef83fd05ce16901ff1a0d9b4b90234))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* honour the client's settings option in idle compaction ([#1555](https://github.com/agentclientprotocol/claude-agent-acp/issues/1555)) ([b73ee49](https://github.com/agentclientprotocol/claude-agent-acp/commit/b73ee491c8f60a1584a95a7f03557f356e1a991c))
|
||||
* replay the messages before each compaction on ACP v1 and v2 ([#1430](https://github.com/agentclientprotocol/claude-agent-acp/issues/1430)) ([626015b](https://github.com/agentclientprotocol/claude-agent-acp/commit/626015bf81fd34f952406ae77bf60feb9ca39a16))
|
||||
* stop the release executable from loading the project's .env and bunfig.toml ([#1589](https://github.com/agentclientprotocol/claude-agent-acp/issues/1589)) ([4a3ff6a](https://github.com/agentclientprotocol/claude-agent-acp/commit/4a3ff6a1eb16075b96676616ab2bbb98694a4d24))
|
||||
* update to @agentclientprotocol/sdk 2.6.0 ([#1590](https://github.com/agentclientprotocol/claude-agent-acp/issues/1590)) ([9fca22a](https://github.com/agentclientprotocol/claude-agent-acp/commit/9fca22a03bdec5dc265bfdc42df316fb286a8142))
|
||||
|
||||
## [0.105.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.104.1...v0.105.0) (2026-11-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* serve session/set_title on ACP v1 and v2 ([#1585](https://github.com/agentclientprotocol/claude-agent-acp/issues/1585)) ([a4a8534](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4a8534c23b08cdfd7f656d25f9ab16068fd8ffd))
|
||||
* serve session/status on v1 and v2 ([#1584](https://github.com/agentclientprotocol/claude-agent-acp/issues/1584)) ([6bc2e05](https://github.com/agentclientprotocol/claude-agent-acp/commit/6bc2e058cb27624b4307462ac46167535af400d1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* include inline model settings in the model picker ([#1488](https://github.com/agentclientprotocol/claude-agent-acp/issues/1488)) ([36cb674](https://github.com/agentclientprotocol/claude-agent-acp/commit/36cb6745897bf9dabcb967b54233c42b860436b3))
|
||||
* keep a v2 session idle when the model changes between turns ([#1586](https://github.com/agentclientprotocol/claude-agent-acp/issues/1586)) ([f5d7916](https://github.com/agentclientprotocol/claude-agent-acp/commit/f5d79167a649737bdeb1ce3b55ad449a34bb9d47))
|
||||
* report Claude Code's API error message as a notice in v2 ([#1582](https://github.com/agentclientprotocol/claude-agent-acp/issues/1582)) ([99cc1f3](https://github.com/agentclientprotocol/claude-agent-acp/commit/99cc1f324586fad07f1da14bb9764cd16c64e920))
|
||||
|
||||
## [0.104.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.104.0...v0.104.1) (2026-11-10)
|
||||
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ for v2 is negotiated down to v1. With `v2`, the adapter rejects the `initialize`
|
||||
|
||||
On v2 the adapter serves the session baseline (`session/new`, `session/list`, `session/resume`
|
||||
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
|
||||
`session/fork`, `session/status`, additional directories, config options (the mode is one), plans, notices,
|
||||
`session/fork`, `session/status`, `session/options` (with initial `configOptions` on `session/new` and
|
||||
`session/resume`), `session/set_title`, additional directories, config options (the mode is one), plans, notices,
|
||||
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
|
||||
[subagents](#subagent-sessions). Steering is v1 only for now.
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ The adapter keeps them where they are.
|
||||
|
||||
## Auth source display
|
||||
|
||||
The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. The adapter also reports the same identity through the ACP `auth/status` method and `auth/status_update` notification (advertised as `auth.status`), with `kind`, `detail` and `plan` under the account's `_meta.claudeCode`. The account's `label` also carries a `detail` in parentheses, e.g. `Anthropic API key (ANTHROPIC_API_KEY)`. Each notification is sent when its own payload changed. `auth/status` answers with the identity last reported and starts a new read, whose change arrives as a notification. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides.
|
||||
The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. The adapter also reports the same identity through the ACP `auth/status` method and `auth/status_update` notification (advertised as `auth.status`), with `kind`, `detail` and `plan` under the account's `_meta.claudeCode`. The account's `description` carries the `detail` too, e.g. `label: "Anthropic API key"` with `description: "ANTHROPIC_API_KEY"`. Each notification is sent when its own payload changed. `auth/status` answers with the identity last reported and starts a new read, whose change arrives as a notification. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides.
|
||||
|
||||
A command containing the whole word `central` and the word sequence `proxy start` is displayed as:
|
||||
|
||||
@@ -1408,6 +1408,8 @@ Each row carries `_meta.jetbrains.air` with flat row fields of the RFDs. `archiv
|
||||
|
||||
`_session/rename { "sessionId": "…", "title": "…" }` returns `{}`.
|
||||
|
||||
ACP's `session/set_title` renames the same way for every client, on v1 and v2, without `sessionIndex`, and returns `{ "title": "…" }`: the title as `session/list` shows it to the client. On v1 an AIR client gets it without the archive prefix, and every other client, and every v2 client, with it.
|
||||
|
||||
- A blank title is `-32602`. The adapter collapses whitespace and keeps the first 200 characters, trimmed, as the CLI does. An `[archived] ` prefix of the requested title is dropped: a rename never changes the archive state.
|
||||
- The title is stored as the title records of [Archive](#archive): a `custom-title` and an `agent-name` record. An archived session keeps its `[archived] ` prefix (and the title is cut to 200 characters with it), so a rename does not unarchive it.
|
||||
- A session that runs here on a CLI without the `rename_session` control request is refused with `-32600`: its CLI holds the title and would write it back.
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.104.1",
|
||||
"version": "0.106.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.104.1",
|
||||
"version": "0.106.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@agentclientprotocol/sdk": "2.6.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.322",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
@@ -38,9 +38,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.5.0.tgz",
|
||||
"integrity": "sha512-MFlXeIuwN6nZSK45SAsSnVjbLgQ2r4fnthoF1lQoGJP3PdaPRpOZeCF2Dds8S4cN8yZP5QbxB1bkJccGskmcYw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-2.6.0.tgz",
|
||||
"integrity": "sha512-3FtL6MkAV5F9qxg0FQV8wmBszIm2xuVPoDWOILIFSDQ+Vq3Twdh7Cw6Hk2GOPUbevgrTUpUAqZXQy7Geaj79mw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/server": "^2.4.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.104.1",
|
||||
"version": "0.106.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -68,7 +68,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
"@agentclientprotocol/sdk": "2.6.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.322",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.7"
|
||||
|
||||
@@ -74,9 +74,24 @@ try {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// By default a compiled executable loads `.env`, `.env.local` and `bunfig.toml`
|
||||
// from its working directory at startup. That directory is the user's project:
|
||||
// its dotenv values would reach Claude and every Bash command as real
|
||||
// environment variables (overriding the project's own `.env.test`), and a
|
||||
// `bunfig.toml` that fails to parse would stop the agent from starting.
|
||||
const bun = spawnSync(
|
||||
"bun",
|
||||
["build", "src/binary-entry.ts", "--compile", "--target", target, "--outfile", outfile],
|
||||
[
|
||||
"build",
|
||||
"src/binary-entry.ts",
|
||||
"--compile",
|
||||
"--no-compile-autoload-dotenv",
|
||||
"--no-compile-autoload-bunfig",
|
||||
"--target",
|
||||
target,
|
||||
"--outfile",
|
||||
outfile,
|
||||
],
|
||||
{ cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" },
|
||||
);
|
||||
if (bun.error) {
|
||||
|
||||
@@ -58,8 +58,9 @@ function check(ok, label, detail) {
|
||||
|
||||
// ---------------------------------------------------------------- basic
|
||||
|
||||
function runSync(args, [cmd, ...cmdArgs] = [command, ...commandArgs]) {
|
||||
function runSync(args, [cmd, ...cmdArgs] = [command, ...commandArgs], cwd) {
|
||||
const result = spawnSync(cmd, [...cmdArgs, ...args], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
@@ -101,6 +102,26 @@ if (positionals.length === 0) {
|
||||
"without claude next to it, says the CLI is missing",
|
||||
missing.stdout || missing.error,
|
||||
);
|
||||
|
||||
// Started in a project directory, the executable must not read the
|
||||
// project's dotenv files or bunfig.toml. Node stands in for `claude`, to
|
||||
// print the environment the CLI is spawned with.
|
||||
copyFileSync(process.execPath, join(scratch, `claude${exe}`));
|
||||
const project = join(scratch, "project");
|
||||
mkdirSync(project);
|
||||
writeFileSync(join(project, ".env"), "ACP_SMOKE_DOTENV=leaked\n");
|
||||
writeFileSync(join(project, ".env.local"), "ACP_SMOKE_DOTENV_LOCAL=leaked\n");
|
||||
writeFileSync(join(project, "bunfig.toml"), "= not toml\n");
|
||||
const probe = runSync(
|
||||
["--cli", "-p", "[process.env.ACP_SMOKE_DOTENV, process.env.ACP_SMOKE_DOTENV_LOCAL].join()"],
|
||||
[alone],
|
||||
project,
|
||||
);
|
||||
check(
|
||||
probe.stdout === ",",
|
||||
"ignores .env, .env.local and bunfig.toml in the working directory",
|
||||
probe.stdout || probe.error,
|
||||
);
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -104,10 +104,10 @@ export type AcpAuthStatus = Pick<AuthStatusResponse, "state" | "accounts">;
|
||||
* - No identity read yet (`undefined`): `unknown`, no accounts.
|
||||
* - `kind: "none"`: `unauthenticated`, no accounts.
|
||||
* - Any other kind: `authenticated`, with the identity as the one account,
|
||||
* marked `active`. The account has no field for `detail`, so its label
|
||||
* carries it in parentheses, e.g. "Anthropic API key (ANTHROPIC_API_KEY)",
|
||||
* for clients that show only the standard fields. `_meta.claudeCode`
|
||||
* carries the `kind`, `detail` and `plan` on their own.
|
||||
* marked `active`. `detail` is the account's `description`, e.g. label
|
||||
* "Anthropic API key" with description "ANTHROPIC_API_KEY".
|
||||
* `_meta.claudeCode` carries the `kind` and `plan`, and the `detail` again
|
||||
* for clients that read it there.
|
||||
*
|
||||
* `--hide-claude-auth` does not change the payload: it reports the login the
|
||||
* CLI holds, also when the subscription guard refuses to use it.
|
||||
@@ -119,8 +119,8 @@ export function toAcpAuthStatus(status: AuthStatus | undefined): AcpAuthStatus {
|
||||
if (status.kind === "none") {
|
||||
return { state: "unauthenticated" };
|
||||
}
|
||||
const label = status.detail ? `${status.label} (${status.detail})` : status.label;
|
||||
const account: AuthAccount = { label, active: true };
|
||||
const account: AuthAccount = { label: status.label, active: true };
|
||||
if (status.detail) account.description = status.detail;
|
||||
if (status.account?.email) account.email = status.account.email;
|
||||
if (status.account?.organization) account.organization = status.account.organization;
|
||||
account._meta = {
|
||||
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
import {
|
||||
forkSession as forkClaudeSession,
|
||||
getSessionMessages,
|
||||
importSessionToStore,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createHash } from "node:crypto";
|
||||
import { loadFullSessionHistory } from "./resumed-session.js";
|
||||
import { assistantMessageText } from "./session-failure-extension.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
@@ -69,19 +68,6 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
|
||||
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
|
||||
}
|
||||
|
||||
async function loadFullSessionHistory(sessionId: string): Promise<SessionStoreEntry[]> {
|
||||
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
const store: SessionStore = {
|
||||
append: async (_key, batch) => {
|
||||
entries.push(...batch);
|
||||
},
|
||||
load: async () => null,
|
||||
};
|
||||
await importSessionToStore(sessionId, store, { includeSubagents: false });
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assistantGroups(
|
||||
entries: SessionStoreEntry[],
|
||||
messageIdForGrouping: ForkSessionDependencies["messageIdForGrouping"],
|
||||
@@ -235,6 +221,7 @@ async function forkTranscript(
|
||||
.find((message) => dependencies.messageIdForGrouping(message) === candidateId)?.uuid,
|
||||
)
|
||||
.find(Boolean);
|
||||
// getSessionMessages returns only the active parentUuid chain. The full history keeps inactive branches that AIR can still reference.
|
||||
const fullHistoryUuid = messageUuid
|
||||
? undefined
|
||||
: resolveFromFullHistory(
|
||||
|
||||
@@ -5,7 +5,8 @@ export type IdleCompactionHost = {
|
||||
isBusy(): boolean;
|
||||
/** Context size after the last main-thread request, or undefined if unknown. */
|
||||
contextTokens(): number | undefined;
|
||||
/** Current merged Claude Code settings. */
|
||||
/** Current Claude Code settings, the client's `settings` option included
|
||||
* (`SettingsManager.getCompactionSettings`). */
|
||||
settings(): {
|
||||
idleCompaction?: boolean;
|
||||
autoCompactEnabled?: boolean;
|
||||
|
||||
63
src/initial-config-options.ts
Normal file
63
src/initial-config-options.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The unstable "Session options" RFD, as both cores serve it: `session/options`
|
||||
* answers with what a session would start with, and a setup request's
|
||||
* `configOptions` set the initial values of the session's config options.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Logger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* What a core advertises as `options` in its session capabilities:
|
||||
* `session/options`, and `configOptions` on its setup requests.
|
||||
*/
|
||||
export const SESSION_OPTIONS_CAPABILITY = {};
|
||||
|
||||
const INVALID_PARAMS = RequestError.invalidParams().code;
|
||||
|
||||
/**
|
||||
* The entries of a setup request's `configOptions`. Omitted and `null` are no
|
||||
* entries. Two entries with the same `configId` fail with `-32602`. Call it
|
||||
* before the session starts, so a refused request starts none.
|
||||
*/
|
||||
export function initialConfigOptions<E extends { configId: string }>(
|
||||
entries: readonly E[] | null | undefined,
|
||||
): readonly E[] {
|
||||
const seen = new Set<string>();
|
||||
for (const { configId } of entries ?? []) {
|
||||
if (seen.has(configId)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ configId },
|
||||
`\`configOptions\` sets ${configId} more than once. Send one entry for each config option.`,
|
||||
);
|
||||
}
|
||||
seen.add(configId);
|
||||
}
|
||||
return entries ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets each entry with `set`, in list order. `set` checks an entry against the
|
||||
* options as the entries before it left them, so a model entry decides which
|
||||
* effort levels a later entry can pick.
|
||||
*
|
||||
* An entry that `set` refuses is skipped, and the entries after it still
|
||||
* apply. The option keeps its value, which the setup response reports:
|
||||
*
|
||||
* - `-32602`: an unknown `configId`, a value of another type than the
|
||||
* option's, or a value that the option does not offer.
|
||||
* - Any other failure, such as a model that Claude Code refuses. It is logged.
|
||||
*/
|
||||
export async function applyInitialConfigOptions<E extends { configId: string }>(
|
||||
entries: readonly E[],
|
||||
set: (entry: E) => Promise<unknown>,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await set(entry);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestError && error.code === INVALID_PARAMS) continue;
|
||||
logger.error(`Could not set the initial value of config option ${entry.configId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,22 @@ function hasMarkerTag(text: string, tag: string): boolean {
|
||||
return markerTagText(text, tag) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the content of a user message is only the output of a local
|
||||
* command, such as the `<local-command-stdout>Set model to …</local-command-stdout>`
|
||||
* that Claude Code echoes when the model changes.
|
||||
*/
|
||||
export function isLocalCommandOutput(content: unknown): boolean {
|
||||
if (typeof content !== "string") return false;
|
||||
if (
|
||||
!hasMarkerTag(content, "local-command-stdout") &&
|
||||
!hasMarkerTag(content, "local-command-stderr")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return stripMarkerTags(content).trim() === "";
|
||||
}
|
||||
|
||||
function commandInvocationFromMarkerOnlyText(text: string): string | null {
|
||||
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
getSessionMessages,
|
||||
importSessionToStore,
|
||||
type SessionMessage,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { access, open, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isCompactSummaryMessage } from "./context-compaction.js";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
import { SessionTiming } from "./session-timing.js";
|
||||
|
||||
@@ -68,6 +75,97 @@ export async function readResumedSession(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the messages of a session from its first prompt on, given the
|
||||
* `getSessionMessages` chain of the session.
|
||||
*
|
||||
* `getSessionMessages` follows `parentUuid` links. Claude Code writes each
|
||||
* `compact_boundary` with `parentUuid: null`, so after a compaction the chain
|
||||
* starts at its summary. The boundary keeps the last message before it in
|
||||
* `logicalParentUuid`. For a chain that starts at a summary, this links each
|
||||
* boundary to that message and reads the transcript through
|
||||
* `getSessionMessages` again. For a session compacted twice, the result has
|
||||
* both summaries and the messages before, between and after them. The result
|
||||
* ends at the last message of `messages`. Any other chain is returned as it
|
||||
* is, and so is the chain when the read fails or when its result does not
|
||||
* reach that last message (e.g. the transcript changed between the reads).
|
||||
*
|
||||
* `dir` is the project directory of the first read: both reads then resolve
|
||||
* the same transcript file. Without it, all project directories are searched.
|
||||
*
|
||||
* The preserved-message metadata of each boundary is dropped. With it,
|
||||
* `getSessionMessages` moves the messages that a compaction kept to after the
|
||||
* summary, and the relinked boundary then leads from them back to the summary:
|
||||
* the chain loops and ends there. Without it, the kept messages stay where
|
||||
* they were said, before the boundary.
|
||||
*/
|
||||
export async function readHistoryAcrossCompactions(
|
||||
sessionId: string,
|
||||
messages: SessionMessage[],
|
||||
logger?: ResumeLogger,
|
||||
dir?: string,
|
||||
): Promise<SessionMessage[]> {
|
||||
if (!isCompactSummaryMessage(messages[0])) return messages;
|
||||
try {
|
||||
const entries = (await loadFullSessionHistory(sessionId, dir)).map(linkCompactBoundary);
|
||||
const history = await getSessionMessages(sessionId, {
|
||||
dir,
|
||||
sessionStore: { append: async () => {}, load: async () => entries },
|
||||
});
|
||||
const leaf = messages.at(-1)?.uuid;
|
||||
const end = history.findIndex((message) => message.uuid === leaf);
|
||||
if (end < 0) {
|
||||
logger?.error(
|
||||
`The history before the last compaction of session ${sessionId} does not reach message ${leaf}: replaying the messages after it`,
|
||||
);
|
||||
return messages;
|
||||
}
|
||||
return history.slice(0, end + 1);
|
||||
} catch (error) {
|
||||
logger?.error(
|
||||
`Failed to read the history before the last compaction of session ${sessionId}:`,
|
||||
error,
|
||||
);
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every record of a session's transcript, including the inactive branches
|
||||
* and the history before a compaction that `getSessionMessages` leaves out.
|
||||
* Without `dir`, all project directories are searched. */
|
||||
export async function loadFullSessionHistory(
|
||||
sessionId: string,
|
||||
dir?: string,
|
||||
): Promise<SessionStoreEntry[]> {
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
const store: SessionStore = {
|
||||
append: async (_key, batch) => {
|
||||
entries.push(...batch);
|
||||
},
|
||||
load: async () => null,
|
||||
};
|
||||
await importSessionToStore(sessionId, store, { dir, includeSubagents: false });
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** The record unchanged, or a compaction boundary linked to the message before it. */
|
||||
function linkCompactBoundary(entry: SessionStoreEntry): SessionStoreEntry {
|
||||
if (
|
||||
entry.type !== "system" ||
|
||||
entry.subtype !== "compact_boundary" ||
|
||||
typeof entry.logicalParentUuid !== "string"
|
||||
) {
|
||||
return entry;
|
||||
}
|
||||
const compactMetadata: Record<string, unknown> =
|
||||
typeof entry.compactMetadata === "object" && entry.compactMetadata !== null
|
||||
? { ...entry.compactMetadata }
|
||||
: {};
|
||||
delete compactMetadata.preservedSegment;
|
||||
delete compactMetadata.preservedMessages;
|
||||
return { ...entry, parentUuid: entry.logicalParentUuid, compactMetadata };
|
||||
}
|
||||
|
||||
/** What a resume restores from the end of the local transcript. */
|
||||
export type ResumedTail = {
|
||||
model?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
getSessionMessages,
|
||||
type SDKAPIRetryMessage,
|
||||
type SDKAssistantMessageError,
|
||||
USAGE_LIMIT_ERROR_PREFIXES,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
@@ -446,6 +447,84 @@ export class SessionFailureController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats a wait in whole seconds, at least 1s: `8000` → `"8s"`. */
|
||||
function retrySeconds(ms: number): string {
|
||||
return `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
}
|
||||
|
||||
/** The wait before the next attempt: `0` → `"now"`, `8000` → `"in 8s"`, and
|
||||
* from 120 s whole minutes rounded up: `1_800_000` → `"in 30m"`. The CLI sends
|
||||
* `0` for a `no_response` retry and minutes for a background rate-limit wait. */
|
||||
function nextAttemptDelay(ms: number): string {
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds <= 0) return "now";
|
||||
if (seconds < 120) return `in ${seconds}s`;
|
||||
return `in ${Math.ceil(ms / 60_000)}m`;
|
||||
}
|
||||
|
||||
/** Plain-English names of the SDK error kinds. `unknown` has none: it adds
|
||||
* nothing to the HTTP status or the connection error. */
|
||||
const RETRY_ERROR_KIND_LABELS: Record<Exclude<SDKAssistantMessageError, "unknown">, string> = {
|
||||
authentication_failed: "authentication failed",
|
||||
oauth_org_not_allowed: "organization not allowed",
|
||||
account_on_hold: "account on hold",
|
||||
verification_required: "verification required",
|
||||
billing_error: "billing error",
|
||||
rate_limit: "rate limited",
|
||||
overloaded: "overloaded",
|
||||
invalid_request: "invalid request",
|
||||
model_not_found: "model not found",
|
||||
server_error: "server error",
|
||||
max_output_tokens: "output token limit",
|
||||
cloud_credential_error: "cloud credential error",
|
||||
};
|
||||
|
||||
/** The `api_retry` message fields the warning reads. `error` is optional
|
||||
* because an older CLI can omit it; the warning then treats it as `unknown`. */
|
||||
type ApiRetryWarningInput = Pick<
|
||||
SDKAPIRetryMessage,
|
||||
"attempt" | "max_retries" | "error_status" | "retry_delay_ms" | "no_response"
|
||||
> & { error?: SDKAssistantMessageError };
|
||||
|
||||
/** The cause of a retry. A null `error_status` with a kind is not always a
|
||||
* lost connection: the CLI maps an SSE `overloaded_error` event after HTTP 200
|
||||
* to `overloaded` with no status. */
|
||||
function apiRetryCause(message: ApiRetryWarningInput): string {
|
||||
const kind = message.error ?? "unknown";
|
||||
const label =
|
||||
kind === "unknown"
|
||||
? undefined
|
||||
: (RETRY_ERROR_KIND_LABELS[kind] ?? String(kind).replaceAll("_", " "));
|
||||
if (message.error_status !== null) {
|
||||
return `HTTP ${message.error_status}${label ? ` (${label})` : ""}.`;
|
||||
}
|
||||
return label ? `No HTTP status (${label}).` : "Connection error: no HTTP response.";
|
||||
}
|
||||
|
||||
/** The title and the `details` of an `api_retry` warning. The title says the
|
||||
* attempt and, for a `no_response` retry, how long the CLI waited and will
|
||||
* wait. The details say the cause and the wait before the next attempt, e.g.
|
||||
* "HTTP 529 (overloaded). Next attempt in 8s." The details do not repeat the
|
||||
* `no_response` wait. */
|
||||
export function apiRetryWarning(message: ApiRetryWarningInput): {
|
||||
title: string;
|
||||
details: string;
|
||||
} {
|
||||
// `no_response` (SDK 0.3.261+): the API sent no response headers
|
||||
// within the first-byte window, so this retry waits longer for
|
||||
// them. Say so — "attempt 1 of 1" alone reads like a final
|
||||
// failure, and the wait is what the user is about to sit through.
|
||||
const noResponse = message.no_response
|
||||
? ` No response after ${retrySeconds(message.no_response.waited_ms)}; waiting up to ${retrySeconds(message.no_response.retry_wait_ms)}.`
|
||||
: "";
|
||||
const title =
|
||||
message.error_status === null
|
||||
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`
|
||||
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`;
|
||||
const delay = nextAttemptDelay(message.retry_delay_ms);
|
||||
return { title, details: `${apiRetryCause(message)} Next attempt ${delay}.` };
|
||||
}
|
||||
|
||||
export function providerFailureCategory(
|
||||
errorKind?: SDKAssistantMessageError,
|
||||
isUsageLimit = false,
|
||||
|
||||
@@ -28,16 +28,9 @@ export function fastModeStateEnabled(state: FastModeState): boolean {
|
||||
return state !== "off";
|
||||
}
|
||||
|
||||
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
|
||||
* that a user can act on (or at least wants to know about). Deliberately
|
||||
* partial — the omitted reasons are not worth surfacing:
|
||||
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
||||
* opt-in), so it describes the default, not a problem.
|
||||
* - `preference`: the user turned Fast mode off themselves.
|
||||
* - `pending`: eligibility is still resolving; the next report supersedes it.
|
||||
* - `unknown`: nothing meaningful to say.
|
||||
* Unknown future reasons fall through the same way (open set — the SDK's docs
|
||||
* say to ignore values you don't handle). */
|
||||
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values.
|
||||
* Every reason is explained except the two in {@link FAST_MODE_UNEXPLAINED_REASONS};
|
||||
* a code missing here gets the generic text from {@link explainFastModeDisabledReason}. */
|
||||
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
|
||||
free: "not available on the free plan",
|
||||
extra_usage_disabled: "requires extra usage to be enabled for this account",
|
||||
@@ -45,17 +38,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason,
|
||||
not_first_party: "not available on this API provider",
|
||||
disabled_by_env: "disabled by environment configuration",
|
||||
network_error: "eligibility could not be verified (network error)",
|
||||
preference: "not enabled for your organization or API gateway",
|
||||
unknown: "currently unavailable",
|
||||
};
|
||||
|
||||
/** Reasons that are never explained:
|
||||
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
||||
* opt-in), so it describes the default, not a problem.
|
||||
* - `pending`: eligibility is still resolving; the next report supersedes it. */
|
||||
const FAST_MODE_UNEXPLAINED_REASONS: ReadonlySet<FastModeDisabledReason> = new Set([
|
||||
"sdk_opt_in_required",
|
||||
"pending",
|
||||
]);
|
||||
|
||||
/** The user-facing explanation of a `fast_mode_disabled_reason`: `undefined`
|
||||
* for no reason, `sdk_opt_in_required` or `pending`; the table text when there
|
||||
* is one; else a generic text naming the code (a reason a newer SDK added). */
|
||||
export function explainFastModeDisabledReason(
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): string | undefined {
|
||||
if (reason === undefined || FAST_MODE_UNEXPLAINED_REASONS.has(reason)) return undefined;
|
||||
return FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ?? `not available (${reason})`;
|
||||
}
|
||||
|
||||
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
|
||||
* a reason we have an explanation for, else `undefined`. Keeping only
|
||||
* explainable reasons means state comparisons (see `fastModeStateChange`) track
|
||||
* exactly what the user can see, so routine reports like
|
||||
* `sdk_opt_in_required` never churn the config option. */
|
||||
* a reason {@link explainFastModeDisabledReason} explains (including codes a
|
||||
* newer SDK adds), else `undefined`. State comparisons (see
|
||||
* `fastModeStateChange`) then track exactly what the user can see, so
|
||||
* `sdk_opt_in_required` and `pending` never churn the config option. */
|
||||
export function normalizeFastModeDisabledReason(
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): FastModeDisabledReason | undefined {
|
||||
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
||||
return explainFastModeDisabledReason(reason) !== undefined ? reason : undefined;
|
||||
}
|
||||
|
||||
/** Build the Fast mode config option. When the Client supports boolean config
|
||||
@@ -73,9 +87,7 @@ export function createFastModeConfigOption(
|
||||
useBooleanOption: boolean,
|
||||
disabledReason?: FastModeDisabledReason,
|
||||
): SessionConfigOption {
|
||||
const explanation = enabled
|
||||
? undefined
|
||||
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
||||
const explanation = enabled ? undefined : explainFastModeDisabledReason(disabledReason);
|
||||
const base = {
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
name: "Fast mode",
|
||||
@@ -141,15 +153,17 @@ export type FastModeOptionState = {
|
||||
* off), or the same state with the same explainable reason.
|
||||
*
|
||||
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
|
||||
* state. Only explainable reasons are retained (see
|
||||
* {@link normalizeFastModeDisabledReason}), so a routine
|
||||
* `sdk_opt_in_required` report on every turn's result can't churn the
|
||||
* option, while a real blocker updates the description even when the
|
||||
* state. Every reason except `sdk_opt_in_required` and `pending` is retained
|
||||
* (see {@link normalizeFastModeDisabledReason}; unknown future codes get the
|
||||
* generic text), so `sdk_opt_in_required` on every turn's result can't churn
|
||||
* the option, while a real blocker updates the description even when the
|
||||
* toggle's own value is unchanged.
|
||||
*
|
||||
* `notice` is set when the user had Fast mode on and the SDK turned it off
|
||||
* for an explainable reason: the description carries the same explanation,
|
||||
* but a toggle silently snapping back is worth saying out loud once. */
|
||||
* with a retained reason: the description carries the same explanation, but a
|
||||
* toggle silently snapping back is worth saying out loud once. Off with no
|
||||
* reason, `sdk_opt_in_required` or `pending` stays quiet: a `result` can carry
|
||||
* the turn-start snapshot, so a notice there would falsely say "turned off". */
|
||||
export function fastModeStateChange(
|
||||
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
|
||||
state: FastModeState | undefined,
|
||||
@@ -163,8 +177,7 @@ export function fastModeStateChange(
|
||||
// so it can't decorate the option the next time fast mode goes off.
|
||||
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
|
||||
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
|
||||
const explanation =
|
||||
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
|
||||
const explanation = explainFastModeDisabledReason(disabledReason);
|
||||
if (!current.enabled || enabled || explanation === undefined) {
|
||||
return { enabled, disabledReason };
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
type LiveSessionRegistry,
|
||||
type LiveSnapshot,
|
||||
} from "./live-registry.js";
|
||||
import { isSessionId, projectsRoot } from "../../paths.js";
|
||||
import { isSessionId, projectsRoot } from "../paths.js";
|
||||
import { isExactProjectDir, projectDirMatches } from "./project-dirs.js";
|
||||
import { type IndexRow, type SessionIndex, type TranscriptCandidate } from "./session-index.js";
|
||||
import { changeSignature, sessionInfoOf } from "./session-info.js";
|
||||
@@ -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;
|
||||
115
src/session-index/rename.ts
Normal file
115
src/session-index/rename.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Client renames of a session: `session/set_title` (v1 and v2) and AIR's
|
||||
* `_session/rename` (v1). Both cores call {@link renameSession}, which stores
|
||||
* the title in every transcript of the session (see
|
||||
* {@link SessionIndexService.retitle}) and, for a session loaded on the
|
||||
* connection, makes it the client title that no generated title replaces.
|
||||
*/
|
||||
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionTitles, TitledSession } from "../session-titles.js";
|
||||
import { storedTitle } from "./archive-title.js";
|
||||
import {
|
||||
renameTo,
|
||||
type RenameSessionRequest,
|
||||
type RetitleOptions,
|
||||
type SessionIndexService,
|
||||
type TitleChange,
|
||||
} from "./service.js";
|
||||
|
||||
/** A session loaded on the connection, and its title state. */
|
||||
export interface RetitledSession {
|
||||
readonly session: TitledSession;
|
||||
readonly titles: Pick<
|
||||
SessionTitles,
|
||||
"setExplicitTitle" | "storedTitle" | "rememberStoredTitle" | "shownTitle"
|
||||
>;
|
||||
}
|
||||
|
||||
type RenamingQuery = Query & {
|
||||
renameSession?: (title: string, sessionId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Names a session; no generated title replaces it. An archived session stays
|
||||
* archived. `loaded` is the session loaded on this connection, if any;
|
||||
* `clientTitle` turns a stored title into the one the client shows (an AIR
|
||||
* client's drops the archive prefix). Returns that title, which the loaded
|
||||
* session also publishes as a `session_info_update`.
|
||||
*
|
||||
* A session that is not loaded and has no transcript is unknown (`-32002`).
|
||||
*/
|
||||
export async function renameSession(
|
||||
index: SessionIndexService,
|
||||
request: RenameSessionRequest,
|
||||
loaded: RetitledSession | undefined,
|
||||
clientTitle: (stored: string) => string = (stored) => stored,
|
||||
): Promise<string> {
|
||||
const { sessionId } = request;
|
||||
// As the CLI keeps it, and never with the archive prefix: a rename does
|
||||
// not change the archive state.
|
||||
const title = storedTitle(request.title, false, sessionId);
|
||||
// The title as stored: an archived one is cut with its prefix.
|
||||
const shown = (stored: string | undefined) => clientTitle(stored ?? title);
|
||||
const stored = await retitleSession(index, sessionId, renameTo(title), shown, loaded, {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "always",
|
||||
});
|
||||
return shown(stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles a session (see {@link SessionIndexService.retitle}) and returns
|
||||
* the title stored. A session that runs here is titled through its CLI
|
||||
* (`rename_session`). A loaded session's title state takes the change as a
|
||||
* client title, so no generated title replaces it; `publish` is the title it
|
||||
* then publishes.
|
||||
*/
|
||||
export async function retitleSession(
|
||||
index: SessionIndexService,
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
publish: Parameters<SessionTitles["setExplicitTitle"]>[0],
|
||||
loaded: RetitledSession | undefined,
|
||||
options: Pick<RetitleOptions, "mayBeUnwritten" | "sidecar">,
|
||||
): Promise<string | undefined> {
|
||||
let stored: string | undefined;
|
||||
// Decided once a title generation in flight has ended: the query may
|
||||
// have closed meanwhile.
|
||||
const persist = async () => {
|
||||
const session = loaded?.session;
|
||||
const query = session?.query as RenamingQuery | undefined;
|
||||
// A closed session's CLI is gone here: only a running query titles it.
|
||||
const running = session !== undefined && !session.queryClosed;
|
||||
if (running && typeof query?.renameSession !== "function") {
|
||||
// Its CLI holds the title and would write it back over ours.
|
||||
throw RequestError.invalidRequest(
|
||||
{ sessionId },
|
||||
"The Claude Code CLI of this session cannot change its title while it runs.",
|
||||
);
|
||||
}
|
||||
const live = running
|
||||
? {
|
||||
cwd: session.cwd,
|
||||
rename: (title: string) => query!.renameSession!(title, sessionId),
|
||||
stored: () => loaded!.titles.storedTitle,
|
||||
remember: (title: string) => loaded!.titles.rememberStoredTitle(title),
|
||||
shown: loaded!.titles.shownTitle,
|
||||
}
|
||||
: undefined;
|
||||
stored = await index.retitle(sessionId, change, {
|
||||
...options,
|
||||
// Without its CLI, a session needs its transcript.
|
||||
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
|
||||
live,
|
||||
});
|
||||
return stored;
|
||||
};
|
||||
if (loaded) {
|
||||
await loaded.titles.setExplicitTitle(publish, persist);
|
||||
} else {
|
||||
await persist();
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type ListSubscriptionDeps,
|
||||
} from "./list-subscriptions.js";
|
||||
import { LiveSessionRegistry } from "./live-registry.js";
|
||||
import { isSessionId } from "../../paths.js";
|
||||
import { isSessionId } from "../paths.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
exists,
|
||||
@@ -466,6 +466,18 @@ export type SessionIndexDeps = {
|
||||
logError: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
/** The transcript reads and writes of the session index, without its list
|
||||
* or watcher: what a client without `sessionIndex` uses for
|
||||
* `session/set_title`, AIR's archive on delete, and delete's ordering
|
||||
* after them (see {@link SessionIndexService.exclusive}). */
|
||||
export function transcriptWriter(logger: {
|
||||
error: (message: string, error: unknown) => void;
|
||||
}): SessionIndexService {
|
||||
return new SessionIndexService({
|
||||
logError: (message, error) => logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
}
|
||||
|
||||
export class SessionIndexService {
|
||||
readonly index: SessionIndex;
|
||||
readonly registry: LiveSessionRegistry;
|
||||
@@ -541,8 +553,10 @@ export class SessionIndexService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `mutation` after the previous mutation of the session ended. */
|
||||
private exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
/** Runs `mutation` after the previous mutation of the session ended, such
|
||||
* as a delete that does not go through {@link delete}, so that a retitle
|
||||
* cannot append to a transcript the delete is removing. */
|
||||
exclusive<T>(sessionId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutations.get(sessionId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => undefined).then(mutation);
|
||||
const settled = next.catch(() => undefined);
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
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 { isSessionId, projectsRoot } from "../../paths.js";
|
||||
import { isSessionId, projectsRoot } from "../paths.js";
|
||||
import {
|
||||
canonicalPath,
|
||||
encodeProjectPath,
|
||||
@@ -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";
|
||||
@@ -285,7 +285,16 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async selectMode(sessionId: string, modeId: string): Promise<PermissionMode> {
|
||||
/**
|
||||
* Sets the mode on the query. `auto` on a model without Auto runs as its
|
||||
* fallback, with the warning. With `deferWarning`, the warning waits for
|
||||
* the next prompt (see {@link deferFallbackWarning}).
|
||||
*/
|
||||
async selectMode(
|
||||
sessionId: string,
|
||||
modeId: string,
|
||||
deferWarning = false,
|
||||
): Promise<PermissionMode> {
|
||||
const session = this.requireOpenSession(sessionId);
|
||||
const requestedMode = this.parseMode(modeId);
|
||||
if (!this.isAvailable(session.modes.availableModes, requestedMode)) {
|
||||
@@ -308,7 +317,8 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
|
||||
if (effectiveMode !== requestedMode) {
|
||||
await this.publishFallbackWarning(sessionId, session);
|
||||
if (deferWarning) this.deferFallbackWarning(session);
|
||||
else await this.publishFallbackWarning(sessionId, session);
|
||||
}
|
||||
return effectiveMode;
|
||||
}
|
||||
@@ -352,6 +362,16 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the Auto fallback's warning for the next prompt
|
||||
* (`autoModeFallbackWarningPending`), as a session's start does. For a
|
||||
* fallback during session setup: the client cannot place an update of a
|
||||
* session before the setup response names it.
|
||||
*/
|
||||
deferFallbackWarning(session: SessionMode): void {
|
||||
if (!session.autoModeFallbackWarningShown) session.autoModeFallbackWarningPending = true;
|
||||
}
|
||||
|
||||
async publishFallbackState(sessionId: string, session: SessionMode): Promise<void> {
|
||||
await this.publishCurrent(sessionId, AUTO_MODE_FALLBACK);
|
||||
await this.publishFallbackWarning(sessionId, session);
|
||||
|
||||
@@ -37,6 +37,15 @@ export interface SettingsManagerOptions {
|
||||
logger?: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
}
|
||||
|
||||
type ModelSelectionSettings = Pick<Settings, "availableModels" | "modelOverrides">;
|
||||
|
||||
/** The settings that decide whether Claude Code compacts (see `idle-compaction.ts`). */
|
||||
export type CompactionSettings = {
|
||||
idleCompaction?: boolean;
|
||||
autoCompactEnabled?: boolean;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
||||
* so the values we see match what `query()` would observe.
|
||||
@@ -50,7 +59,9 @@ export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private managedModelSettings: ModelSelectionSettings = {};
|
||||
private managedApiKeyHelper?: string;
|
||||
private managedCompactionSettings: CompactionSettings = {};
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -112,15 +123,36 @@ export class SettingsManager {
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
this.managedModelSettings = Object.assign(
|
||||
{},
|
||||
...resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.map((entry) => ({
|
||||
availableModels: entry.settings.availableModels,
|
||||
modelOverrides: entry.settings.modelOverrides,
|
||||
})),
|
||||
);
|
||||
this.managedApiKeyHelper =
|
||||
resolved.provenance.apiKeyHelper?.source === "managed"
|
||||
? resolved.effective.apiKeyHelper
|
||||
: undefined;
|
||||
const managed = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.map((entry) => entry.settings);
|
||||
this.managedCompactionSettings = {
|
||||
idleCompaction: managed.findLast((tier) => tier.idleCompaction !== undefined)
|
||||
?.idleCompaction,
|
||||
autoCompactEnabled: managed.findLast((tier) => tier.autoCompactEnabled !== undefined)
|
||||
?.autoCompactEnabled,
|
||||
env: Object.assign({}, ...managed.map((tier) => tier.env)),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
this.managedModelSettings = {};
|
||||
this.managedApiKeyHelper = undefined;
|
||||
this.managedCompactionSettings = {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +225,51 @@ export class SettingsManager {
|
||||
);
|
||||
}
|
||||
|
||||
/** Model selection settings including the inline SDK settings tier. */
|
||||
getModelSelectionSettings(override?: Settings): ModelSelectionSettings {
|
||||
// The SDK unions non-managed allowlists, but a managed list owns the
|
||||
// whole allowlist. Keep that policy when adding
|
||||
// models the SDK did not include in its initialization result.
|
||||
const lists = [this.effective.availableModels, override?.availableModels].filter(
|
||||
(list): list is string[] => Array.isArray(list),
|
||||
);
|
||||
const managed = this.managedModelSettings;
|
||||
return {
|
||||
availableModels:
|
||||
managed.availableModels ?? (lists.length > 0 ? [...new Set(lists.flat())] : undefined),
|
||||
modelOverrides: {
|
||||
...this.effective.modelOverrides,
|
||||
...override?.modelOverrides,
|
||||
...managed.modelOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `idleCompaction`, `autoCompactEnabled` and `env` as the CLI resolves them:
|
||||
* managed settings outrank the `--settings` override, which outranks the
|
||||
* user/project/local files. `env` merges entry by entry in the same order. A
|
||||
* key whose value is `undefined` in the override does not hide the files' value.
|
||||
*/
|
||||
getCompactionSettings(settingsOverride?: Settings): CompactionSettings {
|
||||
const managed = this.managedCompactionSettings;
|
||||
const env: Record<string, string> = {};
|
||||
for (const tier of [this.effective.env, settingsOverride?.env, managed.env]) {
|
||||
for (const [name, value] of Object.entries(tier ?? {})) {
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
idleCompaction:
|
||||
managed.idleCompaction ?? settingsOverride?.idleCompaction ?? this.effective.idleCompaction,
|
||||
autoCompactEnabled:
|
||||
managed.autoCompactEnabled ??
|
||||
settingsOverride?.autoCompactEnabled ??
|
||||
this.effective.autoCompactEnabled,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
|
||||
@@ -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 (`v1/acp-agent.ts`) and
|
||||
* the session index (`v1/session-index/background-tasks.ts`) read them here, so
|
||||
* the session index (`session-index/background-tasks.ts`) read them here, so
|
||||
* both read the same fields.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{},"status":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"status":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{},"status":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"options":{},"resume":{},"status":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Check the file.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_thought_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"I will edit it.","type":"text"},"messageId":"msg_r1","sessionUpdate":"agent_message_chunk"}}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{},"status":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"status":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","customInstructions","recommendedValue","diffPatch","planFile","backgroundSubagents"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{},"status":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"options":{},"resume":{},"status":{},"subagents":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"authMethods":[],"protocolVersion":1}}
|
||||
{"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"auto","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"auto"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"/goal Ship the feature","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":{"text":"Fix the bug","type":"text"},"messageId":"00000000-0000-4000-8000-00000000c001","sessionUpdate":"user_message_chunk"}}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[],"messageId":"msg_r1","sessionUpdate":"agent_thought"}}}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"initialize","payload":{"authMethods":[],"capabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"status":{}},"providers":{},"session":{"additionalDirectories":{},"delete":{},"fork":{},"mcp":{"http":{},"stdio":{}},"options":{},"prompt":{"embeddedContext":{},"image":{}},"status":{},"systemPrompt":{"mode":{"override":{}}},"title":{}}},"info":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":"<version>"},"protocolVersion":2}}
|
||||
{"kind":"response","method":"session/new","payload":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]","type":"text"},"name":"mcp","type":"command"}],"configOptions":[{"category":"mode","configId":"mode","currentValue":"auto","description":"Session permission mode","name":"Mode","options":[{"description":"Always ask before making changes","name":"Manual","value":"default"},{"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionId":"<session>"}}
|
||||
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"prompt 1","type":"text"}],"messageId":"<id-1>","sessionUpdate":"user_message"}}}
|
||||
{"kind":"response","method":"session/prompt","payload":{"messageId":"<id-1>"}}
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
* - The `initialize` response also advertises
|
||||
* `agentCapabilities.sessionCapabilities.status`, which origin/main did
|
||||
* not: the agent now serves `session/status`.
|
||||
* - The `initialize` response also advertises
|
||||
* `agentCapabilities.sessionCapabilities.title`, which origin/main did not:
|
||||
* the agent now serves `session/set_title`.
|
||||
* - The `initialize` response also advertises
|
||||
* `agentCapabilities.sessionCapabilities.options`, which origin/main did
|
||||
* not: the agent now serves `session/options` and takes `configOptions` on
|
||||
* session setup requests.
|
||||
* - A SandboxNetworkAccess tool call is settled by a `tool_call_update` with
|
||||
* only its final `status` once its permission request is answered. The
|
||||
* callback runs nothing, so origin/main never settled the call (see
|
||||
@@ -232,19 +239,17 @@ function isMeta(key: string): boolean {
|
||||
return key.startsWith("_meta.");
|
||||
}
|
||||
|
||||
/** `record` without the session capability `key`, when it is an `initialize` response. */
|
||||
function withoutSessionCapability(record: Recorded, key: string): Recorded {
|
||||
/** `record` without the session capabilities `keys`, when it is an `initialize` response. */
|
||||
function withoutSessionCapabilities(record: Recorded, keys: readonly string[]): Recorded {
|
||||
if (record.kind !== "initialize") return record;
|
||||
const payload = record.payload as Json;
|
||||
const agent = payload.agentCapabilities as Json | undefined;
|
||||
const session = agent?.sessionCapabilities as Json | undefined;
|
||||
if (!session || !(key in session)) return record;
|
||||
if (!session || !keys.some((key) => key in session)) return record;
|
||||
const remaining = keys.reduce((rest, key) => without(rest, key), session);
|
||||
return {
|
||||
...record,
|
||||
payload: {
|
||||
...payload,
|
||||
agentCapabilities: { ...agent, sessionCapabilities: without(session, key) },
|
||||
},
|
||||
payload: { ...payload, agentCapabilities: { ...agent, sessionCapabilities: remaining } },
|
||||
} as Recorded;
|
||||
}
|
||||
|
||||
@@ -267,7 +272,9 @@ function withoutAuthStatusCapability(record: Recorded): Recorded {
|
||||
*/
|
||||
export function compareWithBaseline(baseline: Recorded[], traffic: Recorded[]): string[] {
|
||||
const expected = baseline
|
||||
.map((record) => withoutSessionCapability(withoutAirOnlyKeys(record) as Recorded, "subagents"))
|
||||
.map((record) =>
|
||||
withoutSessionCapabilities(withoutAirOnlyKeys(record) as Recorded, ["subagents"]),
|
||||
)
|
||||
.filter((record) => {
|
||||
const update = updateOf(record);
|
||||
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);
|
||||
@@ -275,7 +282,7 @@ export function compareWithBaseline(baseline: Recorded[], traffic: Recorded[]):
|
||||
const current = withoutSandboxNetworkSettlements(
|
||||
traffic.map((record) =>
|
||||
withoutAuthStatusCapability(
|
||||
withoutSessionCapability(withoutSessionCapability(record, "systemPrompt"), "status"),
|
||||
withoutSessionCapabilities(record, ["systemPrompt", "status", "title", "options"]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type ClientCapabilities,
|
||||
type InitializeRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/** The context-usage report the base mock query returns. `rawMaxTokens`
|
||||
* matches the agent's DEFAULT_CONTEXT_WINDOW so window-related assertions
|
||||
@@ -82,3 +84,73 @@ export function heldRequest<T>(_params?: unknown, signal?: AbortSignal): Promise
|
||||
else signal?.addEventListener("abort", cancel, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** A transcript record of a user message. */
|
||||
export function transcriptUser(uuid: string, content: string, extra: object = {}) {
|
||||
return { type: "user", uuid, message: { role: "user", content }, ...extra };
|
||||
}
|
||||
|
||||
/** A transcript record of an assistant text response. */
|
||||
export function transcriptAssistant(uuid: string, text: string) {
|
||||
return {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
message: {
|
||||
id: `msg_${uuid}`,
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A `compact_boundary` as Claude Code writes it: no parent, and the last
|
||||
* message before it in `logicalParentUuid`. */
|
||||
export function transcriptCompactBoundary(
|
||||
uuid: string,
|
||||
logicalParentUuid: string,
|
||||
compactMetadata: object = {},
|
||||
) {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "compact_boundary",
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
logicalParentUuid,
|
||||
compactMetadata: { trigger: "manual", preTokens: 1000, ...compactMetadata },
|
||||
};
|
||||
}
|
||||
|
||||
/** The summary message that follows a `compact_boundary`. */
|
||||
export function transcriptCompactSummary(
|
||||
uuid: string,
|
||||
content = "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n" +
|
||||
"Summary:\n1. Primary Request and Intent:\n Say hi.\n\n" +
|
||||
"Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary.",
|
||||
) {
|
||||
return transcriptUser(uuid, content, { isCompactSummary: true });
|
||||
}
|
||||
|
||||
/** Writes `<directory>/<sessionId>.jsonl` as Claude Code does: each record
|
||||
* links to the record before it, except a record that sets its own
|
||||
* `parentUuid`. */
|
||||
export async function writeTranscript(
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
records: Record<string, unknown>[],
|
||||
): Promise<void> {
|
||||
await mkdir(directory, { recursive: true });
|
||||
let parentUuid: unknown = null;
|
||||
const lines = records.map((record) => {
|
||||
const line = {
|
||||
parentUuid,
|
||||
sessionId,
|
||||
isSidechain: false,
|
||||
timestamp: "2026-10-11T00:00:00.000Z",
|
||||
...record,
|
||||
};
|
||||
parentUuid = record.uuid;
|
||||
return JSON.stringify(line);
|
||||
});
|
||||
await writeFile(path.join(directory, `${sessionId}.jsonl`), lines.join("\n") + "\n");
|
||||
}
|
||||
|
||||
92
src/tests/model-selection-settings.test.ts
Normal file
92
src/tests/model-selection-settings.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>()),
|
||||
resolveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("model selection settings policy", () => {
|
||||
let manager: SettingsManager;
|
||||
afterEach(() => manager?.dispose());
|
||||
|
||||
it.each([{ availableModels: ["claude-haiku-4-5"] }, { availableModels: [] }])(
|
||||
"keeps the managed allowlist %j and mappings above inline settings",
|
||||
async ({ availableModels }) => {
|
||||
const managed = {
|
||||
availableModels,
|
||||
modelOverrides: { "claude-haiku-4-5": "managed-haiku" },
|
||||
deniedModels: ["claude-opus-5"],
|
||||
};
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: managed,
|
||||
provenance: {},
|
||||
sources: [{ source: "managed", settings: managed }],
|
||||
});
|
||||
manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
expect(
|
||||
manager.getModelSelectionSettings({
|
||||
availableModels: ["claude-opus-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
|
||||
}),
|
||||
).toEqual({
|
||||
availableModels: managed.availableModels,
|
||||
modelOverrides: managed.modelOverrides,
|
||||
});
|
||||
expect(manager.getManagedDeniedModels()).toEqual(managed.deniedModels);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps managed override keys above inline keys without discarding other inline mappings", async () => {
|
||||
const managed = { modelOverrides: { "claude-haiku-4-5": "managed-haiku" } };
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: managed,
|
||||
provenance: {},
|
||||
sources: [{ source: "managed", settings: managed }],
|
||||
});
|
||||
manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
expect(
|
||||
manager.getModelSelectionSettings({
|
||||
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
|
||||
modelOverrides: {
|
||||
"claude-haiku-4-5": "inline-haiku",
|
||||
"claude-sonnet-4-6": "inline-sonnet",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
|
||||
modelOverrides: { "claude-haiku-4-5": "managed-haiku", "claude-sonnet-4-6": "inline-sonnet" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves file and inline mappings when the managed allowlist supplies no mappings", async () => {
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: {
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
|
||||
},
|
||||
provenance: {},
|
||||
sources: [
|
||||
{ source: "user", settings: { modelOverrides: { "claude-haiku-4-5": "file-haiku" } } },
|
||||
{ source: "managed", settings: { availableModels: ["claude-haiku-4-5"] } },
|
||||
],
|
||||
});
|
||||
manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
expect(manager.getModelSelectionSettings()).toEqual({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
|
||||
});
|
||||
expect(
|
||||
manager.getModelSelectionSettings({
|
||||
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
|
||||
}),
|
||||
).toEqual({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSessionMessages, type SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
transcriptAssistant,
|
||||
transcriptCompactBoundary,
|
||||
transcriptCompactSummary,
|
||||
transcriptUser,
|
||||
writeTranscript,
|
||||
} from "./helpers.js";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readResumedTail, resumedModelFromTranscript } from "../resumed-session.js";
|
||||
import {
|
||||
readHistoryAcrossCompactions,
|
||||
readResumedTail,
|
||||
resumedModelFromTranscript,
|
||||
} from "../resumed-session.js";
|
||||
|
||||
function assistant(
|
||||
model: unknown,
|
||||
@@ -203,3 +214,187 @@ describe("readResumedTail", () => {
|
||||
expect(await readResumedTail("no-mode")).toEqual({ model: "claude-opus-5" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("readHistoryAcrossCompactions", () => {
|
||||
const sessionId = "33333333-3333-4333-8333-333333333333";
|
||||
let configDir: string;
|
||||
let originalConfigDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-compacted-"));
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const transcript = (records: Record<string, unknown>[], project = "-workspace") =>
|
||||
writeTranscript(path.join(configDir, "projects", project), sessionId, records);
|
||||
const user = transcriptUser;
|
||||
const assistant = transcriptAssistant;
|
||||
const boundary = transcriptCompactBoundary;
|
||||
const summary = (uuid: string) => transcriptCompactSummary(uuid, `Summary ${uuid}`);
|
||||
|
||||
const texts = (messages: SessionMessage[]) =>
|
||||
messages.map((message) => {
|
||||
const content = (message.message as { content: unknown }).content;
|
||||
return typeof content === "string" ? content : (content as { text: string }[])[0]!.text;
|
||||
});
|
||||
|
||||
it("reads the messages before each compaction", async () => {
|
||||
await transcript([
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u2", "next"),
|
||||
assistant("a2", "Next"),
|
||||
boundary("b2", "a2"),
|
||||
summary("s2"),
|
||||
user("u3", "identify yourself"),
|
||||
assistant("a3", "Claude"),
|
||||
]);
|
||||
|
||||
// getSessionMessages stops at the last boundary.
|
||||
const chain = await getSessionMessages(sessionId);
|
||||
expect(texts(chain)).toEqual(["Summary s2", "identify yourself", "Claude"]);
|
||||
expect(texts(await readHistoryAcrossCompactions(sessionId, chain))).toEqual([
|
||||
"hi",
|
||||
"Hello",
|
||||
"Summary s1",
|
||||
"next",
|
||||
"Next",
|
||||
"Summary s2",
|
||||
"identify yourself",
|
||||
"Claude",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the messages that a compaction preserved where they were said", async () => {
|
||||
// Claude Code 2.1.293 keeps u2 and a2 in the context after the summary,
|
||||
// and records them in both forms of preserved-message metadata.
|
||||
await transcript([
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
user("u2", "keep this"),
|
||||
assistant("a2", "Kept"),
|
||||
boundary("b1", "a2", {
|
||||
preservedSegment: { headUuid: "u2", anchorUuid: "s1", tailUuid: "a2" },
|
||||
preservedMessages: { anchorUuid: "s1", uuids: ["u2", "a2"] },
|
||||
}),
|
||||
summary("s1"),
|
||||
user("u3", "after"),
|
||||
assistant("a3", "After"),
|
||||
]);
|
||||
|
||||
// getSessionMessages moves the preserved messages after the summary.
|
||||
const chain = await getSessionMessages(sessionId);
|
||||
expect(texts(chain)).toEqual(["Summary s1", "keep this", "Kept", "after", "After"]);
|
||||
expect(texts(await readHistoryAcrossCompactions(sessionId, chain))).toEqual([
|
||||
"hi",
|
||||
"Hello",
|
||||
"keep this",
|
||||
"Kept",
|
||||
"Summary s1",
|
||||
"after",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the chain of a session without a compaction as it is", async () => {
|
||||
await transcript([user("u1", "hi"), assistant("a1", "Hello")]);
|
||||
|
||||
const chain = await getSessionMessages(sessionId);
|
||||
expect(await readHistoryAcrossCompactions(sessionId, chain)).toBe(chain);
|
||||
});
|
||||
|
||||
it("ends at the last message of the chain it was given", async () => {
|
||||
await transcript([
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u2", "identify yourself"),
|
||||
]);
|
||||
const chain = await getSessionMessages(sessionId);
|
||||
// Claude Code appends a response after the first read.
|
||||
await transcript([
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u2", "identify yourself"),
|
||||
assistant("a2", "Claude"),
|
||||
]);
|
||||
|
||||
expect(texts(await readHistoryAcrossCompactions(sessionId, chain))).toEqual([
|
||||
"hi",
|
||||
"Hello",
|
||||
"Summary s1",
|
||||
"identify yourself",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the transcript is gone", async () => rm(configDir, { recursive: true, force: true })],
|
||||
[
|
||||
"the transcript no longer has the last message",
|
||||
() =>
|
||||
transcript([
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u9", "other"),
|
||||
]),
|
||||
],
|
||||
])("returns the chain and logs when %s", async (_case, change) => {
|
||||
await transcript([
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u2", "identify yourself"),
|
||||
]);
|
||||
const chain = await getSessionMessages(sessionId);
|
||||
await change();
|
||||
const error = vi.fn();
|
||||
|
||||
expect(await readHistoryAcrossCompactions(sessionId, chain, { log: () => {}, error })).toBe(
|
||||
chain,
|
||||
);
|
||||
expect(error).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reads the transcript in the project directory it is given", async () => {
|
||||
await transcript(
|
||||
[
|
||||
user("u1", "elsewhere"),
|
||||
assistant("a1", "Elsewhere"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u2", "identify yourself"),
|
||||
],
|
||||
"-a-other",
|
||||
);
|
||||
await transcript(
|
||||
[
|
||||
user("u1", "hi"),
|
||||
assistant("a1", "Hello"),
|
||||
boundary("b1", "a1"),
|
||||
summary("s1"),
|
||||
user("u2", "identify yourself"),
|
||||
],
|
||||
"-z-workspace",
|
||||
);
|
||||
const chain = await getSessionMessages(sessionId, { dir: "/z/workspace" });
|
||||
|
||||
expect(
|
||||
texts(await readHistoryAcrossCompactions(sessionId, chain, undefined, "/z/workspace")),
|
||||
).toEqual(["hi", "Hello", "Summary s1", "identify yourself"]);
|
||||
});
|
||||
});
|
||||
|
||||
50
src/tests/session-failure-extension.test.ts
Normal file
50
src/tests/session-failure-extension.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { apiRetryWarning } from "../session-failure-extension.js";
|
||||
|
||||
describe("apiRetryWarning", () => {
|
||||
// Each input is one the bundled CLI sends: the CLI derives `error` from the
|
||||
// status (`IIt`), and an error with no HTTP response has a null status.
|
||||
it.each([
|
||||
{
|
||||
name: "a no_response retry, which the CLI sends with a 0 ms delay",
|
||||
message: {
|
||||
error_status: null,
|
||||
error: "unknown" as const,
|
||||
retry_delay_ms: 0,
|
||||
no_response: { waited_ms: 180_000, retry_wait_ms: 600_000 },
|
||||
},
|
||||
details: "Connection error: no HTTP response. Next attempt now.",
|
||||
},
|
||||
{
|
||||
name: "a background rate-limit wait in minutes, rounded up",
|
||||
message: { error_status: 429, error: "rate_limit" as const, retry_delay_ms: 1_740_500 },
|
||||
details: "HTTP 429 (rate limited). Next attempt in 30m.",
|
||||
},
|
||||
{
|
||||
name: "a wait just under 120 s in seconds",
|
||||
message: { error_status: 529, error: "overloaded" as const, retry_delay_ms: 119_000 },
|
||||
details: "HTTP 529 (overloaded). Next attempt in 119s.",
|
||||
},
|
||||
{
|
||||
name: "a wait of 120 s in minutes",
|
||||
message: { error_status: 529, error: "overloaded" as const, retry_delay_ms: 120_000 },
|
||||
details: "HTTP 529 (overloaded). Next attempt in 2m.",
|
||||
},
|
||||
{
|
||||
name: "a credential error, which has no HTTP status",
|
||||
message: {
|
||||
error_status: null,
|
||||
error: "cloud_credential_error" as const,
|
||||
retry_delay_ms: 1_100,
|
||||
},
|
||||
details: "No HTTP status (cloud credential error). Next attempt in 1s.",
|
||||
},
|
||||
{
|
||||
name: "a message with no error kind as unknown",
|
||||
message: { error_status: 503, error: undefined, retry_delay_ms: 600 },
|
||||
details: "HTTP 503. Next attempt in 1s.",
|
||||
},
|
||||
])("describes $name", ({ message, details }) => {
|
||||
expect(apiRetryWarning({ attempt: 1, max_retries: 10, ...message }).details).toBe(details);
|
||||
});
|
||||
});
|
||||
@@ -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 "../../v1/session-index/activity.js";
|
||||
import { LiveSessionRegistry, type LiveRecord } from "../../v1/session-index/live-registry.js";
|
||||
import { isSessionId } from "../../paths.js";
|
||||
import { deriveActivity, selectCost, type ActivityState } from "../session-index/activity.js";
|
||||
import { LiveSessionRegistry, type LiveRecord } from "../session-index/live-registry.js";
|
||||
import { isSessionId } from "../paths.js";
|
||||
import {
|
||||
encodeProjectPath,
|
||||
pathAndAncestors,
|
||||
projectDirMatches,
|
||||
} from "../../v1/session-index/project-dirs.js";
|
||||
} from "../session-index/project-dirs.js";
|
||||
import {
|
||||
decodeListCursor,
|
||||
encodeListCursor,
|
||||
parseListOptions,
|
||||
parseRenameSessionRequest,
|
||||
type ListScope,
|
||||
} from "../../v1/session-index/service.js";
|
||||
} from "../session-index/service.js";
|
||||
import {
|
||||
effectiveTitle,
|
||||
isArchivedTitle,
|
||||
storedTitle,
|
||||
titleRecords,
|
||||
visibleTitle,
|
||||
} from "../../v1/session-index/archive-title.js";
|
||||
} from "../session-index/archive-title.js";
|
||||
import {
|
||||
agentWorktreeOf,
|
||||
continuedInSessionId,
|
||||
@@ -33,11 +33,11 @@ import {
|
||||
sdkTitles,
|
||||
transcriptAgentName,
|
||||
type TranscriptFacts,
|
||||
} 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";
|
||||
} 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";
|
||||
|
||||
const SESSION = "11111111-1111-4111-8111-111111111111";
|
||||
const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||
@@ -38,3 +38,62 @@ describe("SettingsManager.getApiKeyHelper", () => {
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsManager.getCompactionSettings", () => {
|
||||
it("ranks managed settings over the --settings override over the files, env entry by entry", async () => {
|
||||
const manager = await managerResolving({
|
||||
effective: {
|
||||
idleCompaction: true,
|
||||
autoCompactEnabled: false,
|
||||
env: {
|
||||
DISABLE_COMPACT: "1",
|
||||
DISABLE_AUTO_COMPACT: "1",
|
||||
CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: "1",
|
||||
},
|
||||
},
|
||||
provenance: {},
|
||||
sources: [
|
||||
{ source: "userSettings", settings: { autoCompactEnabled: false } },
|
||||
{
|
||||
source: "managed",
|
||||
settings: { idleCompaction: true, env: { DISABLE_AUTO_COMPACT: "1" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
manager.getCompactionSettings({
|
||||
idleCompaction: false,
|
||||
autoCompactEnabled: true,
|
||||
env: { DISABLE_COMPACT: "0", DISABLE_AUTO_COMPACT: "0" },
|
||||
}),
|
||||
).toEqual({
|
||||
idleCompaction: true,
|
||||
autoCompactEnabled: true,
|
||||
env: {
|
||||
DISABLE_COMPACT: "0",
|
||||
DISABLE_AUTO_COMPACT: "1",
|
||||
CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: "1",
|
||||
},
|
||||
});
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it("keeps the files' value for a key the override sets to undefined", async () => {
|
||||
const manager = await managerResolving({
|
||||
effective: { idleCompaction: false, env: { DISABLE_COMPACT: "1" } },
|
||||
provenance: {},
|
||||
sources: [],
|
||||
});
|
||||
expect(
|
||||
manager.getCompactionSettings({
|
||||
idleCompaction: undefined,
|
||||
env: { DISABLE_COMPACT: undefined as unknown as string },
|
||||
}),
|
||||
).toEqual({
|
||||
idleCompaction: false,
|
||||
autoCompactEnabled: undefined,
|
||||
env: { DISABLE_COMPACT: "1" },
|
||||
});
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -921,6 +921,136 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
return { setModelSpy };
|
||||
}
|
||||
|
||||
it.each(["environment", "caller object", "caller file"])(
|
||||
"includes configured models omitted by the SDK from %s settings",
|
||||
async (source) => {
|
||||
const configured = {
|
||||
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
|
||||
modelOverrides: {
|
||||
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"claude-haiku-5-5": "global.anthropic.claude-haiku-5-5",
|
||||
},
|
||||
};
|
||||
let settings: object | string | undefined;
|
||||
if (source === "environment") {
|
||||
vi.stubEnv("CLAUDE_MODEL_CONFIG", JSON.stringify(configured));
|
||||
} else if (source === "caller file") {
|
||||
settings = "caller-settings.json";
|
||||
await fs.promises.writeFile(path.join(tempDir, settings), JSON.stringify(configured));
|
||||
} else {
|
||||
settings = configured;
|
||||
}
|
||||
const { setModelSpy } = mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
]);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
try {
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings } } },
|
||||
});
|
||||
expect(querySpy.mock.calls[0][0].options.settings).toEqual(configured);
|
||||
const ids = ["default", ...Object.values(configured.modelOverrides)];
|
||||
expect(
|
||||
agent.sessions[response.sessionId].models.availableModels.map(
|
||||
(model: any) => model.modelId,
|
||||
),
|
||||
).toEqual(ids);
|
||||
const modelOption = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(modelOption.options.map((option: any) => option.value)).toEqual(ids);
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "model",
|
||||
value: ids[2],
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenLastCalledWith(ids[2]);
|
||||
expect(agent.sessions[response.sessionId].modelInfos.map((model) => model.value)).toEqual(
|
||||
ids,
|
||||
);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("merges file settings with caller settings after caller keys replace the env config", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-sonnet-4-6", "claude-haiku-4-5"],
|
||||
modelOverrides: {
|
||||
"claude-sonnet-4-6": "file-sonnet",
|
||||
"claude-haiku-4-5": "file-haiku",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.stubEnv(
|
||||
"CLAUDE_MODEL_CONFIG",
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-opus-4-6"],
|
||||
modelOverrides: { "claude-sonnet-4-6": "env-sonnet", "claude-haiku-4-5": "env-haiku" },
|
||||
}),
|
||||
);
|
||||
mockQueryWithModels([{ value: "default", displayName: "Default", description: "" }]);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
const settings = {
|
||||
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
|
||||
modelOverrides: { "claude-haiku-4-5": "caller-haiku" },
|
||||
};
|
||||
try {
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings } } },
|
||||
});
|
||||
expect(querySpy.mock.calls[0][0].options.settings).toEqual(settings);
|
||||
const modelOption = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(modelOption.options.map((option: any) => option.value)).toEqual([
|
||||
"default",
|
||||
"file-sonnet",
|
||||
"caller-haiku",
|
||||
"claude-haiku-5-5",
|
||||
]);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([[], undefined])(
|
||||
"preserves empty and absent configured allowlists: %j",
|
||||
async (availableModels) => {
|
||||
vi.stubEnv(
|
||||
"CLAUDE_MODEL_CONFIG",
|
||||
JSON.stringify({
|
||||
availableModels,
|
||||
modelOverrides: { "claude-haiku-4-5": "provider-haiku" },
|
||||
}),
|
||||
);
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
]);
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
try {
|
||||
const response = await (agent as any).createSession({ cwd: tempDir, mcpServers: [] });
|
||||
const modelOption = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(modelOption.options.map((option: any) => option.value)).toEqual(
|
||||
availableModels ? ["default"] : ["default", "sonnet"],
|
||||
);
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("restricts configOptions to the user's allowlist using their exact IDs", async () => {
|
||||
// Reproduces the scenario from
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/620:
|
||||
|
||||
@@ -28,12 +28,9 @@ 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 {
|
||||
ClaudeAcpAgent,
|
||||
computeSessionFingerprint,
|
||||
runPromptWithCancellation,
|
||||
type SteerRequest,
|
||||
} from "../../v1/acp-agent.js";
|
||||
import { ClaudeAcpAgent, runPromptWithCancellation } from "../../v1/acp-agent.js";
|
||||
import { computeSessionFingerprint } from "../../v1/session-fingerprint.js";
|
||||
import type { SteerRequest } from "../../v1/steering.js";
|
||||
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
|
||||
import { createFastModeConfigOption } from "../../session-fast-mode.js";
|
||||
import {
|
||||
@@ -58,7 +55,16 @@ import {
|
||||
parseUsageResponse,
|
||||
} from "../../usage-markdown.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { heldRequest, initializeClient, makeMockQuery } from "../helpers.js";
|
||||
import {
|
||||
heldRequest,
|
||||
initializeClient,
|
||||
makeMockQuery,
|
||||
transcriptAssistant,
|
||||
transcriptCompactBoundary,
|
||||
transcriptCompactSummary,
|
||||
transcriptUser,
|
||||
writeTranscript,
|
||||
} from "../helpers.js";
|
||||
import { acpProtocolRouter } from "../../serve.js";
|
||||
import * as agentSdk from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
@@ -73,6 +79,7 @@ import {
|
||||
PermissionUpdate,
|
||||
query,
|
||||
SDKAssistantMessage,
|
||||
type SDKAssistantMessageError,
|
||||
type Options,
|
||||
type SDKControlGetUsageResponse,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
@@ -3629,6 +3636,63 @@ describe("usage-limit failure replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("compacted session replay", () => {
|
||||
it("replays the messages before the compaction, then the compaction", async () => {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-compacted-replay-"));
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
try {
|
||||
const sessionId = "44444444-4444-4444-8444-444444444444";
|
||||
// The records of `/compact` between two prompts, as Claude Code writes
|
||||
// them: the boundary has no parent and links "Hello" logically.
|
||||
await writeTranscript(path.join(configDir, "projects", "-tmp-proj"), sessionId, [
|
||||
transcriptUser("u1", "hi"),
|
||||
transcriptAssistant("a1", "Hello"),
|
||||
transcriptCompactBoundary("b1", "a1"),
|
||||
transcriptCompactSummary("s1"),
|
||||
transcriptUser("u2", "identify yourself"),
|
||||
transcriptAssistant("a2", "Claude"),
|
||||
]);
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (update: SessionNotification) => {
|
||||
updates.push(update);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
await initializeClient(agent, { session: { compaction: {} } } as ClientCapabilities);
|
||||
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory(sessionId);
|
||||
|
||||
expect(
|
||||
updates.flatMap(({ update }) => {
|
||||
if (update.sessionUpdate === "compaction_update") return [["compaction", update.status]];
|
||||
if (
|
||||
(update.sessionUpdate === "user_message_chunk" ||
|
||||
update.sessionUpdate === "agent_message_chunk") &&
|
||||
update.content.type === "text"
|
||||
) {
|
||||
return [[update.sessionUpdate, update.content.text]];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
).toEqual([
|
||||
["user_message_chunk", "hi"],
|
||||
["agent_message_chunk", "Hello"],
|
||||
["compaction", "completed"],
|
||||
["user_message_chunk", "identify yourself"],
|
||||
["agent_message_chunk", "Claude"],
|
||||
]);
|
||||
} finally {
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/list", () => {
|
||||
const sdkSession = (index: number) => ({
|
||||
sessionId: `session-${index}`,
|
||||
@@ -11834,7 +11898,7 @@ describe("stop reason propagation", () => {
|
||||
subtype: "api_retry",
|
||||
attempt: 1,
|
||||
max_retries: 5,
|
||||
retry_delay_ms: 100,
|
||||
retry_delay_ms: 600,
|
||||
error_status: 429,
|
||||
error: "rate_limit",
|
||||
uuid: randomUUID(),
|
||||
@@ -11864,6 +11928,7 @@ describe("stop reason propagation", () => {
|
||||
severity: "warning",
|
||||
revision: 1,
|
||||
title: "Retrying Claude, attempt 1 of 5.",
|
||||
details: "HTTP 429 (rate limited). Next attempt in 1s.",
|
||||
actions: [],
|
||||
}),
|
||||
);
|
||||
@@ -11877,6 +11942,144 @@ describe("stop reason propagation", () => {
|
||||
actions: ["retry"],
|
||||
}),
|
||||
);
|
||||
expect(terminal).not.toHaveProperty("details");
|
||||
});
|
||||
|
||||
it("keeps the api retries of one turn and its failure in one row before the echo", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) => updates.push(update),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, airSessionFailureCapabilities);
|
||||
const retry = (attempt: number) => ({
|
||||
type: "system" as const,
|
||||
subtype: "api_retry" as const,
|
||||
attempt,
|
||||
max_retries: 10,
|
||||
retry_delay_ms: 1_000,
|
||||
error_status: 503,
|
||||
error: "server_error" as const,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
const input = new Pushable<any>();
|
||||
async function* cli() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
for (const turn of [1, 2]) {
|
||||
const { value: userMessage } = await iter.next();
|
||||
const echo = {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
// The CLI holds the echo back until the first assistant frame or
|
||||
// stream event of the turn. A request that gets a 503 has neither, so
|
||||
// its retries come before the echo.
|
||||
if (turn === 1) {
|
||||
yield retry(1);
|
||||
yield retry(2);
|
||||
yield echo;
|
||||
yield createAssistantError("server_error", "API Error: 503");
|
||||
yield createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "API Error: 503",
|
||||
});
|
||||
} else {
|
||||
yield retry(1);
|
||||
yield echo;
|
||||
yield createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "ok",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(cli()), input });
|
||||
|
||||
const first = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
});
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "second" }] });
|
||||
const warnings = updates
|
||||
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
|
||||
.filter((failure) => failure?.severity === "warning");
|
||||
|
||||
expect(warnings.map((warning) => warning.revision)).toEqual([1, 2, 1]);
|
||||
expect(warnings[1].id).toBe(warnings[0].id);
|
||||
expect(warnings[0].id).toMatch(/:error$/);
|
||||
expect(sessionFailureFromResponse(first)).toEqual(
|
||||
expect.objectContaining({ id: warnings[0].id, revision: 3, severity: "error" }),
|
||||
);
|
||||
expect(warnings[2].id).not.toBe(warnings[0].id);
|
||||
});
|
||||
|
||||
it("replaces the cause of an api retry warning on the next retry", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: SessionNotification) => updates.push(update),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, airSessionFailureCapabilities);
|
||||
const retry = (
|
||||
attempt: number,
|
||||
error_status: number | null,
|
||||
error: SDKAssistantMessageError,
|
||||
retry_delay_ms: number,
|
||||
) => ({
|
||||
type: "system" as const,
|
||||
subtype: "api_retry" as const,
|
||||
attempt,
|
||||
max_retries: 10,
|
||||
retry_delay_ms,
|
||||
error_status,
|
||||
error,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
injectSession(agent, [
|
||||
retry(1, 529, "overloaded", 8_000),
|
||||
// An SSE `overloaded_error` event after HTTP 200: the CLI reports
|
||||
// `overloaded` with no status.
|
||||
retry(2, null, "overloaded", 1_100),
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "ok",
|
||||
}),
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
const warnings = updates
|
||||
.map((update) => (update.update._meta as any)?.jetbrains?.air?.sessionFailure)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.objectContaining({
|
||||
revision: 1,
|
||||
title: "Retrying Claude, attempt 1 of 10.",
|
||||
details: "HTTP 529 (overloaded). Next attempt in 8s.",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: warnings[0].id,
|
||||
revision: 2,
|
||||
title: "Reconnecting to Claude, attempt 2 of 10.",
|
||||
details: "No HTTP status (overloaded). Next attempt in 1s.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("says how long a no-response retry waited and will wait", async () => {
|
||||
@@ -11940,7 +12143,7 @@ describe("stop reason propagation", () => {
|
||||
subtype: "api_retry",
|
||||
attempt: 2,
|
||||
max_retries: 5,
|
||||
retry_delay_ms: 100,
|
||||
retry_delay_ms: 1_100,
|
||||
error_status: null,
|
||||
error: "unknown",
|
||||
uuid: randomUUID(),
|
||||
@@ -12522,7 +12725,7 @@ describe("stop reason propagation", () => {
|
||||
subtype: "api_retry",
|
||||
attempt: 1,
|
||||
max_retries: 10,
|
||||
retry_delay_ms: 100,
|
||||
retry_delay_ms: 600,
|
||||
error_status: 401,
|
||||
error: "authentication_failed",
|
||||
uuid: randomUUID(),
|
||||
|
||||
@@ -220,7 +220,8 @@ describe("auth status mappers", () => {
|
||||
state: "authenticated",
|
||||
accounts: [
|
||||
{
|
||||
label: "Anthropic API key (env)",
|
||||
label: "Anthropic API key",
|
||||
description: "env",
|
||||
active: true,
|
||||
_meta: { claudeCode: { kind: "api_key", detail: "env" } },
|
||||
},
|
||||
@@ -842,7 +843,8 @@ describe("auth status over ACP", () => {
|
||||
state: "authenticated",
|
||||
accounts: [
|
||||
{
|
||||
label: "Custom model gateway (gw.example.com)",
|
||||
label: "Custom model gateway",
|
||||
description: "gw.example.com",
|
||||
active: true,
|
||||
_meta: { claudeCode: { kind: "gateway", detail: "gw.example.com" } },
|
||||
},
|
||||
@@ -860,7 +862,7 @@ describe("auth status over ACP", () => {
|
||||
agent.setAuthStatus({ kind: "api_key", label: "Anthropic API key", detail: "env" });
|
||||
expect(await agent.authStatusResponse()).toMatchObject({
|
||||
state: "authenticated",
|
||||
accounts: [{ label: "Anthropic API key (env)" }],
|
||||
accounts: [{ label: "Anthropic API key", description: "env" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 "../../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";
|
||||
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";
|
||||
|
||||
let configDir: string;
|
||||
let workspace: string;
|
||||
|
||||
@@ -1365,6 +1365,229 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session options", () => {
|
||||
const cwd = process.cwd();
|
||||
const TWO_MODELS = [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Fast",
|
||||
supportsAutoMode: true,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Faster",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium"],
|
||||
},
|
||||
];
|
||||
let updates: SessionNotification[];
|
||||
|
||||
const values = (response: { configOptions?: { id: string; currentValue: unknown }[] | null }) =>
|
||||
Object.fromEntries((response.configOptions ?? []).map((o) => [o.id, o.currentValue]));
|
||||
|
||||
beforeEach(() => {
|
||||
initModels = TWO_MODELS;
|
||||
updates = [];
|
||||
vi.spyOn(agent.client, "sessionUpdate").mockImplementation(async (notification) => {
|
||||
updates.push(notification as SessionNotification);
|
||||
});
|
||||
});
|
||||
|
||||
it("advertises session/options", async () => {
|
||||
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
expect(response.agentCapabilities?.sessionCapabilities?.options).toEqual({});
|
||||
});
|
||||
|
||||
it("answers session/options with what session/new returns, from a query that it closes", async () => {
|
||||
const options = await agent.sessionOptions({ cwd, additionalDirectories: ["/extra"] });
|
||||
|
||||
expect(capturedOptions).toMatchObject({
|
||||
cwd,
|
||||
additionalDirectories: ["/extra"],
|
||||
mcpServers: {},
|
||||
persistSession: false,
|
||||
});
|
||||
expect(capturedOptions!.resume).toBeUndefined();
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agent.sessions).toEqual({});
|
||||
expect(updates).toEqual([]);
|
||||
|
||||
const { sessionId, ...created } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(capturedOptions!.persistSession).toBeUndefined();
|
||||
expect(options).toEqual(created);
|
||||
});
|
||||
|
||||
it("answers session/options of a held session with its current options, without a query", async () => {
|
||||
const { sessionId } = await agent.newSession({ cwd, mcpServers: [] });
|
||||
const { configOptions } = await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
capturedOptions = undefined;
|
||||
|
||||
const options = await agent.sessionOptions({ cwd, sessionId });
|
||||
|
||||
expect(capturedOptions).toBeUndefined();
|
||||
expect(options).toEqual({ modes: agent.sessions[sessionId]!.modes, configOptions });
|
||||
expect(values(options).model).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("answers session/options of a stored session from a query that resumes it", async () => {
|
||||
const sessionId = "7f1c5b0e-3c53-4c0c-9d0a-0d6c1f0a2b11";
|
||||
await agent.sessionOptions({ cwd, sessionId });
|
||||
|
||||
expect(capturedOptions).toMatchObject({ resume: sessionId, persistSession: false });
|
||||
expect(capturedOptions!.sessionId).toBeUndefined();
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agent.sessions).toEqual({});
|
||||
});
|
||||
|
||||
it("fails session/options of an unknown session with -32002", async () => {
|
||||
const sessionId = "7f1c5b0e-3c53-4c0c-9d0a-0d6c1f0a2b11";
|
||||
initError = new Error(`No conversation found with session ID: ${sessionId}`);
|
||||
|
||||
await expect(agent.sessionOptions({ cwd, sessionId })).rejects.toMatchObject({
|
||||
code: RequestError.sessionNotFound(sessionId).code,
|
||||
data: { sessionId },
|
||||
});
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts a new session with the request's configOptions, in list order, without an update", async () => {
|
||||
const setModels: string[] = [];
|
||||
setModelImpl = async (model) => void setModels.push(model);
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [
|
||||
{ configId: "model", value: "claude-haiku-4-5" },
|
||||
// Haiku offers no "high": the entry is checked against the options the model left.
|
||||
{ configId: "effort", value: "high" },
|
||||
{ configId: "mode", value: "plan" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(setModels).toEqual(["claude-haiku-4-5"]);
|
||||
expect(values(response)).toMatchObject({ model: "claude-haiku-4-5", mode: "plan" });
|
||||
expect(values(response).effort).not.toBe("high");
|
||||
expect(response.modes?.currentModeId).toBe("plan");
|
||||
expect(response.configOptions).toEqual(agent.sessions[response.sessionId]!.configOptions);
|
||||
expect(updates.filter((u) => u.sessionId === response.sessionId)).toEqual([]);
|
||||
// A restart of the query restores the session's choices, not the request's.
|
||||
expect(agent.sessions[response.sessionId]!.creationParams).not.toHaveProperty(
|
||||
"configOptions",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown configId", { configId: "nope", value: "x" }],
|
||||
["a boolean for a select", { configId: "model", type: "boolean" as const, value: true }],
|
||||
["a value the option does not offer", { configId: "model", value: "gpt-4" }],
|
||||
])("skips an entry with %s and sets the entries after it", async (_name, entry) => {
|
||||
const plain = await agent.newSession({ cwd, mcpServers: [] });
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [entry, { configId: "mode", value: "plan" }],
|
||||
});
|
||||
|
||||
expect(values(response)).toEqual({ ...values(plain), mode: "plan" });
|
||||
});
|
||||
|
||||
it("skips an entry that Claude Code refuses", async () => {
|
||||
setModelImpl = async () => {
|
||||
throw new Error("model refused");
|
||||
};
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [
|
||||
{ configId: "model", value: "claude-haiku-4-5" },
|
||||
{ configId: "mode", value: "plan" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(values(response)).toMatchObject({ model: "claude-sonnet-4-6", mode: "plan" });
|
||||
expect(errors).toHaveBeenCalledWith(
|
||||
"Could not set the initial value of config option model:",
|
||||
expect.any(Error),
|
||||
);
|
||||
errors.mockRestore();
|
||||
});
|
||||
|
||||
it.each(["newSession", "loadSession", "resumeSession"] as const)(
|
||||
"%s rejects two entries of one configId before it starts a query",
|
||||
async (method) => {
|
||||
await expect(
|
||||
agent[method]({
|
||||
sessionId: "7f1c5b0e-3c53-4c0c-9d0a-0d6c1f0a2b11",
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [
|
||||
{ configId: "mode", value: "plan" },
|
||||
{ configId: "mode", value: "default" },
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: RequestError.invalidParams().code,
|
||||
data: { configId: "mode" },
|
||||
});
|
||||
expect(capturedOptions).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["loadSession", "resumeSession"] as const)(
|
||||
"%s sets the request's configOptions on the session, a held one too",
|
||||
async (method) => {
|
||||
const sessionId = "7f1c5b0e-3c53-4c0c-9d0a-0d6c1f0a2b11";
|
||||
const started = await agent[method]({
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [{ configId: "mode", value: "plan" }],
|
||||
});
|
||||
expect(values(started).mode).toBe("plan");
|
||||
expect(started.modes?.currentModeId).toBe("plan");
|
||||
|
||||
const held = await agent[method]({
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [{ configId: "model", value: "claude-haiku-4-5" }],
|
||||
});
|
||||
expect(values(held)).toMatchObject({ mode: "plan", model: "claude-haiku-4-5" });
|
||||
expect(updates.map((u) => u.update.sessionUpdate)).not.toContain("current_mode_update");
|
||||
expect(updates.map((u) => u.update.sessionUpdate)).not.toContain("config_option_update");
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves the warning of an Auto mode fallback for the first prompt", async () => {
|
||||
const response = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
configOptions: [
|
||||
{ configId: "mode", value: "auto" },
|
||||
// Haiku cannot serve Auto.
|
||||
{ configId: "model", value: "claude-haiku-4-5" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(values(response)).toMatchObject({ mode: "acceptEdits", model: "claude-haiku-4-5" });
|
||||
expect(updates.filter((u) => u.sessionId === response.sessionId)).toEqual([]);
|
||||
expect(agent.sessions[response.sessionId]!.autoModeFallbackWarningPending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cwd validation", () => {
|
||||
it("rejects a relative cwd with invalidParams", async () => {
|
||||
await expect(
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
FAST_MODE_OFF,
|
||||
} from "../../session-fast-mode.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { Pushable } from "../../utils.js";
|
||||
import { initializeClient } from "../helpers.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
@@ -33,6 +41,8 @@ const MODEL_INFOS: ModelInfo[] = [
|
||||
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
|
||||
];
|
||||
|
||||
const FUTURE_REASON = "some_future_reason" as FastModeDisabledReason;
|
||||
|
||||
describe("createFastModeConfigOption", () => {
|
||||
it("produces a native boolean toggle when the client opted in", () => {
|
||||
expect(createFastModeConfigOption(true, true)).toEqual({
|
||||
@@ -45,20 +55,40 @@ describe("createFastModeConfigOption", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("explains a blocking disabled reason while the toggle reads off", () => {
|
||||
const option = createFastModeConfigOption(false, true, "free");
|
||||
expect(option.description).toBe(
|
||||
"Faster responses on supported models — not available on the free plan",
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Build the option with the toggle off and a disabled reason.
|
||||
* Expected:
|
||||
* - The description is the base text plus the reason's explanation.
|
||||
* - A code the table lacks gets the generic text naming the code.
|
||||
*/
|
||||
it.each([
|
||||
["free", "not available on the free plan"],
|
||||
["preference", "not enabled for your organization or API gateway"],
|
||||
["unknown", "currently unavailable"],
|
||||
[FUTURE_REASON, "not available (some_future_reason)"],
|
||||
] as [FastModeDisabledReason, string][])(
|
||||
"explains the disabled reason %s while the toggle reads off",
|
||||
(reason, explanation) => {
|
||||
const option = createFastModeConfigOption(false, true, reason);
|
||||
expect(option.description).toBe(`Faster responses on supported models — ${explanation}`);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores routine and on-state reasons", () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Build the option, toggle off, with `sdk_opt_in_required` or `pending`.
|
||||
* 2. Build the option, toggle on, with `free`.
|
||||
* Expected:
|
||||
* - The description stays the base text in all three cases.
|
||||
*/
|
||||
it("ignores unexplained and on-state reasons", () => {
|
||||
// Every SDK session starts at `sdk_opt_in_required` — the toggle IS the
|
||||
// opt-in, so it must not read as a blocker.
|
||||
expect(createFastModeConfigOption(false, true, "sdk_opt_in_required").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
expect(createFastModeConfigOption(false, true, "preference").description).toBe(
|
||||
expect(createFastModeConfigOption(false, true, "pending").description).toBe(
|
||||
"Faster responses on supported models",
|
||||
);
|
||||
// A reason riding an `on` state isn't blocking anything right now.
|
||||
@@ -133,25 +163,38 @@ describe("fastModeStateEnabled", () => {
|
||||
});
|
||||
|
||||
describe("normalizeFastModeDisabledReason", () => {
|
||||
it("retains only reasons we have an explanation for", () => {
|
||||
expect(normalizeFastModeDisabledReason("free")).toBe("free");
|
||||
expect(normalizeFastModeDisabledReason("extra_usage_disabled")).toBe("extra_usage_disabled");
|
||||
expect(normalizeFastModeDisabledReason("model_not_allowed")).toBe("model_not_allowed");
|
||||
expect(normalizeFastModeDisabledReason("not_first_party")).toBe("not_first_party");
|
||||
expect(normalizeFastModeDisabledReason("disabled_by_env")).toBe("disabled_by_env");
|
||||
expect(normalizeFastModeDisabledReason("network_error")).toBe("network_error");
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Normalize each explained reason, including `preference`, `unknown` and a future code.
|
||||
* Expected:
|
||||
* - Each reason is returned unchanged.
|
||||
*/
|
||||
it.each([
|
||||
"free",
|
||||
"extra_usage_disabled",
|
||||
"model_not_allowed",
|
||||
"not_first_party",
|
||||
"disabled_by_env",
|
||||
"network_error",
|
||||
"preference",
|
||||
"unknown",
|
||||
FUTURE_REASON,
|
||||
] as FastModeDisabledReason[])("retains the explained reason %s", (reason) => {
|
||||
expect(normalizeFastModeDisabledReason(reason)).toBe(reason);
|
||||
});
|
||||
|
||||
it("drops routine states and unknown values", () => {
|
||||
expect(normalizeFastModeDisabledReason("sdk_opt_in_required")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("preference")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("pending")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason("unknown")).toBeUndefined();
|
||||
expect(normalizeFastModeDisabledReason(undefined)).toBeUndefined();
|
||||
expect(
|
||||
normalizeFastModeDisabledReason("some_future_reason" as FastModeDisabledReason),
|
||||
).toBeUndefined();
|
||||
});
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Normalize `sdk_opt_in_required`, `pending` and `undefined`.
|
||||
* Expected:
|
||||
* - Each normalizes to `undefined`.
|
||||
*/
|
||||
it.each(["sdk_opt_in_required", "pending", undefined] as (FastModeDisabledReason | undefined)[])(
|
||||
"drops the unexplained reason %s",
|
||||
(reason) => {
|
||||
expect(normalizeFastModeDisabledReason(reason)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("buildConfigOptions Fast mode", () => {
|
||||
@@ -403,29 +446,61 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("explains a blocking reason once when the SDK refuses a toggle the user turned on", async () => {
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The toggle is on and the SDK reports `off` with a reason.
|
||||
* 2. The SDK reports the same `off` and reason again.
|
||||
* Expected:
|
||||
* - The toggle is cleared and the reason retained.
|
||||
* - One transcript line carries the explanation, then one `config_option_update`.
|
||||
* - The repeat report sends nothing.
|
||||
*/
|
||||
it.each([
|
||||
["extra_usage_disabled", "requires extra usage to be enabled for this account"],
|
||||
["preference", "not enabled for your organization or API gateway"],
|
||||
["unknown", "currently unavailable"],
|
||||
[FUTURE_REASON, "not available (some_future_reason)"],
|
||||
] as [FastModeDisabledReason, string][])(
|
||||
"explains the blocking reason %s once when the SDK refuses a toggle the user turned on",
|
||||
async (reason, explanation) => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", reason);
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.fastModeDisabledReason).toBe(reason);
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(sessionUpdates[0].update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `\n\n> **Fast mode turned off:** ${explanation}.\n\n`,
|
||||
},
|
||||
});
|
||||
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true, reason));
|
||||
|
||||
// The same report again changes nothing the user can see: no repeat notice.
|
||||
await sync(SESSION_ID, session, "off", reason);
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The toggle is on and the SDK reports `off` with `sdk_opt_in_required`.
|
||||
* Expected:
|
||||
* - The toggle is cleared and no reason is retained.
|
||||
* - Only a `config_option_update` is sent: no notice or transcript line.
|
||||
*/
|
||||
it("clears the toggle quietly when the SDK reports off with sdk_opt_in_required", async () => {
|
||||
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
|
||||
|
||||
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
|
||||
await sync(SESSION_ID, session, "off", "sdk_opt_in_required");
|
||||
|
||||
expect(session.fastModeEnabled).toBe(false);
|
||||
expect(session.fastModeDisabledReason).toBe("extra_usage_disabled");
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(sessionUpdates[0].update).toEqual({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
|
||||
},
|
||||
});
|
||||
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
|
||||
expect(session.configOptions).toContainEqual(
|
||||
createFastModeConfigOption(false, true, "extra_usage_disabled"),
|
||||
);
|
||||
|
||||
// The same report again changes nothing the user can see: no repeat notice.
|
||||
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
|
||||
expect(sessionUpdates).toHaveLength(2);
|
||||
expect(session.fastModeDisabledReason).toBeUndefined();
|
||||
expect(sessionUpdates.map((n) => n.update.sessionUpdate)).toEqual(["config_option_update"]);
|
||||
});
|
||||
|
||||
it("explains the flip as a live notice to a client on the notice contract", async () => {
|
||||
@@ -493,3 +568,89 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
|
||||
expect(disabledCase.sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fast mode that Claude Code refuses during a turn", () => {
|
||||
const SESSION_ID = "test-session";
|
||||
|
||||
/** A session whose Claude Code answers the first prompt with a `system`/init
|
||||
* that reports Fast mode off with `reason`, as it does on every turn. */
|
||||
async function setupTurn(reason: FastModeDisabledReason) {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
updates.push(n);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
await initializeClient(agent, { session: { configOptions: { boolean: {} } } });
|
||||
|
||||
const input = new Pushable<any>();
|
||||
async function* messages() {
|
||||
const { value: prompt } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(prompt);
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
fast_mode_state: "off",
|
||||
fast_mode_disabled_reason: reason,
|
||||
session_id: SESSION_ID,
|
||||
};
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
const query = Object.assign(wrapQuery(messages()), { applyFlagSettings: async () => {} });
|
||||
agent.sessions[SESSION_ID] = mockSessionState({
|
||||
query,
|
||||
input,
|
||||
fastModeEnabled: false,
|
||||
configOptions: [createFastModeConfigOption(false, true)],
|
||||
});
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The client, without the notices capability, turns Fast mode on.
|
||||
* 2. It sends a prompt, and Claude Code's init reports Fast mode off, reason `preference`.
|
||||
* Expected:
|
||||
* - The transcript gets one line that explains the organization or gateway has not enabled it.
|
||||
* - Then one `config_option_update` where `fast` reads off, with the explanation in its description.
|
||||
*/
|
||||
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
|
||||
const { agent, updates } = await setupTurn("preference");
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "fast",
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
updates.length = 0;
|
||||
await agent.prompt({ sessionId: SESSION_ID, prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const explained = updates
|
||||
.map((n) => n.update)
|
||||
.filter((u) => u.sessionUpdate !== "usage_update");
|
||||
expect(explained).toEqual([
|
||||
{
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: "\n\n> **Fast mode turned off:** not enabled for your organization or API gateway.\n\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
expect.objectContaining({
|
||||
id: "fast",
|
||||
currentValue: false,
|
||||
description: expect.stringMatching(
|
||||
/ — not enabled for your organization or API gateway$/,
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { messageIdForGrouping } from "../../sdk-to-acp.js";
|
||||
import { forkSession } from "../../fork-session.js";
|
||||
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
|
||||
import { encodeProjectPath } from "../../session-index/project-dirs.js";
|
||||
|
||||
// Use the real SDK against temporary transcripts, without starting Claude Code.
|
||||
let root: string;
|
||||
|
||||
@@ -226,7 +226,10 @@ function scriptClaudeCode({
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(clientCapabilities: Record<string, unknown> = {}) {
|
||||
async function connect(
|
||||
clientCapabilities: Record<string, unknown> = {},
|
||||
sessionMeta?: Record<string, unknown>,
|
||||
) {
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../../v1/acp-agent.js");
|
||||
const updates: Array<{ update: Record<string, any> }> = [];
|
||||
@@ -241,7 +244,7 @@ async function connect(clientCapabilities: Record<string, unknown> = {}) {
|
||||
error: () => {},
|
||||
});
|
||||
await initializeClient(agent, clientCapabilities);
|
||||
const session = await agent.newSession({ cwd, mcpServers: [] });
|
||||
const session = await agent.newSession({ cwd, mcpServers: [], _meta: sessionMeta });
|
||||
return { agent, sessionId: session.sessionId, updates, logs };
|
||||
}
|
||||
|
||||
@@ -322,6 +325,22 @@ describe("idle compaction in the agent", () => {
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
/** The client's `settings` option reaches the CLI as `--settings`, and idle compaction reads it too. */
|
||||
it("does not compact a session whose client turned idle compaction off in the settings option", async () => {
|
||||
const claude = scriptClaudeCode();
|
||||
const { agent, sessionId } = await connect(
|
||||
{},
|
||||
{ claudeCode: { options: { settings: { idleCompaction: false } } } },
|
||||
);
|
||||
|
||||
await promptHi(agent, sessionId);
|
||||
await claude.untilIdle();
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * MINUTE_MS);
|
||||
|
||||
expect(claude.received).toEqual(["hi"]);
|
||||
await agent.dispose();
|
||||
});
|
||||
|
||||
/** Closing the session during the wait drops the timer. */
|
||||
it("does not compact once the session is closed", async () => {
|
||||
const claude = scriptClaudeCode();
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type InitializeResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { encodeProjectPath } from "../../v1/session-index/project-dirs.js";
|
||||
import { encodeProjectPath } from "../../session-index/project-dirs.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
archiveTo,
|
||||
@@ -31,16 +31,16 @@ import {
|
||||
parseBatchGetSessionsRequest,
|
||||
SessionIndexService,
|
||||
writeCustomTitleSidecar,
|
||||
} from "../../v1/session-index/service.js";
|
||||
import { scanTranscriptFile } from "../../v1/session-index/transcript-scan.js";
|
||||
import { repositoryWorktrees } from "../../v1/session-index/worktrees.js";
|
||||
} from "../../session-index/service.js";
|
||||
import { scanTranscriptFile } from "../../session-index/transcript-scan.js";
|
||||
import { repositoryWorktrees } from "../../session-index/worktrees.js";
|
||||
import {
|
||||
ListSubscriptions,
|
||||
MAX_SUBSCRIPTIONS,
|
||||
parseListSubscribeRequest,
|
||||
parseListUnsubscribeRequest,
|
||||
type ListChanges,
|
||||
} from "../../v1/session-index/list-subscriptions.js";
|
||||
} from "../../session-index/list-subscriptions.js";
|
||||
import { SessionTitles } from "../../session-titles.js";
|
||||
import { initializeClient } from "../helpers.js";
|
||||
import {
|
||||
@@ -1350,6 +1350,48 @@ describe("_session/rename", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/set_title", () => {
|
||||
it("titles a stored session for a client without the session index", async () => {
|
||||
const session = await writeTranscript({});
|
||||
const { agent } = createAgent();
|
||||
await initializeClient(agent, {});
|
||||
expect(
|
||||
await agent.setSessionTitle({ sessionId: session.id, title: " Investigate\n OAuth " }),
|
||||
).toEqual({ title: "Investigate OAuth" });
|
||||
expect(await lastRecords(session.file)).toEqual(titleRecords(session.id, "Investigate OAuth"));
|
||||
const page = await agent.listSessions({ cwd: workspace });
|
||||
expect(page.sessions[0]!.title).toBe("Investigate OAuth");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an AIR client with sessionIndex", air("sessionIndex"), "Renamed"],
|
||||
["an AIR client without sessionIndex", air(), "Renamed"],
|
||||
["a client that is not AIR", {}, "[archived] Renamed"],
|
||||
])(
|
||||
"keeps an archived session archived and answers the title %s shows",
|
||||
async (_client, capabilities, shown) => {
|
||||
const session = await writeTranscript({ trailer: titleRecords("", "[archived] Fix it") });
|
||||
const { agent } = createAgent();
|
||||
await initializeClient(agent, capabilities);
|
||||
expect(await agent.setSessionTitle({ sessionId: session.id, title: "Renamed" })).toEqual({
|
||||
title: shown,
|
||||
});
|
||||
expect(await lastRecords(session.file)).toEqual(
|
||||
titleRecords(session.id, "[archived] Renamed"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["an unknown session", () => ({ sessionId: randomUUID(), title: "x" }), -32002],
|
||||
["an empty title", () => ({ sessionId: randomUUID(), title: " \n " }), -32602],
|
||||
])("refuses %s", async (_case, params, code) => {
|
||||
const { agent } = createAgent();
|
||||
await initializeClient(agent, {});
|
||||
await expect(agent.setSessionTitle(params())).rejects.toMatchObject({ code });
|
||||
});
|
||||
});
|
||||
|
||||
describe("_session/archive and _session/unarchive", () => {
|
||||
it("append AIR's archive title records, are idempotent and keep updatedAt", async () => {
|
||||
const session = await writeTranscript({ prompt: "Fix the\nparser" });
|
||||
@@ -1854,7 +1896,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("../../v1/session-index/connection.js");
|
||||
const { noteSessionState } = await import("../../v1/session-index.js");
|
||||
const session = { lastTurnFailed: true } as any;
|
||||
noteSessionState(session, "idle", "running");
|
||||
expect(session.lastTurnFailed).toBe(false);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* schema, so a malformed v2 message fails the test.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
v1SetSessionConfigOptionRequest,
|
||||
v2ConfigOptions,
|
||||
} from "../../v2/session-requests.js";
|
||||
import { encodeProjectPath } from "../../session-index/project-dirs.js";
|
||||
import { startupFailure } from "../helpers.js";
|
||||
import packageJson from "../../../package.json" with { type: "json" };
|
||||
|
||||
@@ -108,6 +109,8 @@ const sdk = vi.hoisted(() => ({
|
||||
accountInfo: async (): Promise<Record<string, unknown>> => ({}),
|
||||
/** The query switches its model (`setModel`). */
|
||||
setModel: async (_model?: string): Promise<void> => {},
|
||||
/** Claude Code stores a session title (`rename_session`). */
|
||||
renameSession: async (_title: string, _sessionId?: string): Promise<void> => {},
|
||||
/** Set to serve Remote Control, as a query of a CLI that supports it does. */
|
||||
enableRemoteControl: undefined as
|
||||
((enabled: boolean, name?: string) => Promise<{ session_url?: string }>) | undefined,
|
||||
@@ -142,6 +145,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
accountInfo: () => sdk.accountInfo(),
|
||||
setMcpServers: (servers: Record<string, unknown>) => sdk.setMcpServers(servers),
|
||||
setModel: (model?: string) => sdk.setModel(model),
|
||||
renameSession: (title: string, sessionId?: string) => sdk.renameSession(title, sessionId),
|
||||
...(sdk.enableRemoteControl ? { enableRemoteControl: sdk.enableRemoteControl } : {}),
|
||||
});
|
||||
return Object.assign(messages, controls);
|
||||
@@ -395,6 +399,8 @@ describe("ACP protocol routing", () => {
|
||||
additionalDirectories: {},
|
||||
systemPrompt: { mode: { override: {} } },
|
||||
status: {},
|
||||
options: {},
|
||||
title: {},
|
||||
});
|
||||
expect(response.capabilities?.providers).toEqual({});
|
||||
// Not `_session/steering`, which v1 advertises in the top-level `_meta`.
|
||||
@@ -883,6 +889,101 @@ describe("ACP v2 sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/set_title", () => {
|
||||
it("titles an active session through Claude Code and publishes the stored title", async () => {
|
||||
const renameSession = vi.fn(sdkDefaults.renameSession);
|
||||
sdk.renameSession = renameSession;
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
client.sessionUpdates.length = 0;
|
||||
const response = await agent.request(v2.methods.agent.session.setTitle, {
|
||||
sessionId,
|
||||
title: " Investigate\n OAuth timeout ",
|
||||
});
|
||||
expect(response).toEqual({ title: "Investigate OAuth timeout" });
|
||||
expect(renameSession).toHaveBeenCalledWith("Investigate OAuth timeout", sessionId);
|
||||
expect(client.sessionUpdates).toEqual([
|
||||
{
|
||||
sessionId,
|
||||
update: { sessionUpdate: "session_info_update", title: "Investigate OAuth timeout" },
|
||||
},
|
||||
]);
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("titles the query that a resume started while the rename ran", async () => {
|
||||
let release!: () => void;
|
||||
const renameSession = vi.fn(async (_title: string, _sessionId?: string) => {
|
||||
if (renameSession.mock.calls.length === 1) {
|
||||
await new Promise<void>((resolve) => (release = resolve));
|
||||
}
|
||||
});
|
||||
sdk.renameSession = renameSession;
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
const renamed = agent.request(v2.methods.agent.session.setTitle, {
|
||||
sessionId,
|
||||
title: "Mine",
|
||||
});
|
||||
await vi.waitFor(() => expect(renameSession).toHaveBeenCalledTimes(1));
|
||||
await agent.request(v2.methods.agent.session.close, { sessionId });
|
||||
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
|
||||
release();
|
||||
expect(await renamed).toEqual({ title: "Mine" });
|
||||
expect(renameSession.mock.calls).toEqual([
|
||||
["Mine", sessionId],
|
||||
["Mine", sessionId],
|
||||
]);
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("titles a stored session that is not active in its transcript", async () => {
|
||||
const sessionId = randomUUID();
|
||||
const dir = path.join(tempDir, "claude", "projects", encodeProjectPath(cwd));
|
||||
await mkdir(dir, { recursive: true });
|
||||
const file = path.join(dir, `${sessionId}.jsonl`);
|
||||
const prompt = { type: "user", sessionId, cwd, message: { role: "user", content: "hi" } };
|
||||
await writeFile(file, JSON.stringify(prompt) + "\n");
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
expect(
|
||||
await agent.request(v2.methods.agent.session.setTitle, { sessionId, title: "Stored" }),
|
||||
).toEqual({ title: "Stored" });
|
||||
await authUpdate(1);
|
||||
});
|
||||
const records = (await readFile(file, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.slice(-2)
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(records).toEqual([
|
||||
{ type: "custom-title", customTitle: "Stored", sessionId },
|
||||
{ type: "agent-name", agentName: "Stored", sessionId },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unknown session", { sessionId: randomUUID(), title: "Title" }, -32002],
|
||||
["a title that is empty once normalized", { sessionId: randomUUID(), title: " \n " }, -32602],
|
||||
])("refuses %s", async (_case, params, code) => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.session.setTitle, params),
|
||||
).rejects.toMatchObject({ code });
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a resumed Fable 5.2 session's model as `fable`, as a new session lists it", async () => {
|
||||
// On resume, the CLI lists the transcript's Fable row under its id, with no `fable` row.
|
||||
const row = (value: string, resolvedModel: string, displayName: string) => ({
|
||||
@@ -1344,6 +1445,51 @@ describe("ACP v2 sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. The client turns Fast mode on and sends a prompt.
|
||||
* 2. Claude Code's `system`/init of that turn reports Fast mode off, reason `preference`.
|
||||
* Expected:
|
||||
* - One warning notice explains that the organization or gateway has not enabled it.
|
||||
* - The `fast` option reads off, and its description ends with the explanation.
|
||||
*/
|
||||
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield fastModeInit(options, "off", "preference");
|
||||
yield assistantText(options, "Done.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "fast",
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
|
||||
expect(client.updates("notice")).toEqual([
|
||||
{
|
||||
sessionUpdate: "notice",
|
||||
severity: "warning",
|
||||
title: "Fast mode turned off",
|
||||
description: "Not enabled for your organization or API gateway.",
|
||||
},
|
||||
]);
|
||||
expect(fastOption(client.updates("config_option_update").at(-1))).toMatchObject({
|
||||
currentValue: false,
|
||||
description:
|
||||
"Faster responses on supported models — not enabled for your organization or API gateway",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a refusal fallback with a notice and follows the model it swapped to", async () => {
|
||||
sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] });
|
||||
scriptTurns(async function* (options) {
|
||||
@@ -1590,6 +1736,23 @@ function stream(options: Options, event: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Claude Code's per-turn `system`/init, reporting the Fast mode state. */
|
||||
function fastModeInit(options: Options, state: string, reason: string) {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
fast_mode_state: state,
|
||||
fast_mode_disabled_reason: reason,
|
||||
session_id: options.sessionId,
|
||||
uuid: randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The `fast` option of a `config_option_update`. */
|
||||
function fastOption(update: { configOptions: { configId: string }[] } | undefined) {
|
||||
return update?.configOptions.find((option) => option.configId === "fast");
|
||||
}
|
||||
|
||||
function idleState(options: Options) {
|
||||
return {
|
||||
type: "system",
|
||||
@@ -4111,47 +4274,60 @@ describe("ACP v2 idle compaction", () => {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Prompts "hi" in a session created with `meta`, answers it with a large
|
||||
* one-hour request, and waits 54 minutes. Returns the prompts Claude Code
|
||||
* received and the client.
|
||||
*/
|
||||
async function promptAndIdle(meta?: Record<string, unknown>) {
|
||||
const received: string[] = [];
|
||||
sdk.run = async function* (prompts, options) {
|
||||
const input = prompts[Symbol.asyncIterator]();
|
||||
for (;;) {
|
||||
const { value, done } = await input.next();
|
||||
if (done) return;
|
||||
received.push((value.message.content as Array<{ text: string }>)[0].text);
|
||||
if (received.length === 1) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value.uuid,
|
||||
session_id: options.sessionId,
|
||||
isReplay: true,
|
||||
};
|
||||
yield largeOneHourRequest(options);
|
||||
} else {
|
||||
yield { type: "system", subtype: "status", status: "compacting", uuid: randomUUID() };
|
||||
}
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
}
|
||||
};
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
_meta: meta,
|
||||
});
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(54 * 60_000);
|
||||
for (let i = 0; i < 20 && received.length < 2; i++) await vi.advanceTimersByTimeAsync(0);
|
||||
for (let i = 0; i < 20 && turnTrace(client.sessionUpdates).length < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
return { received, client };
|
||||
}
|
||||
|
||||
/** The session is idle for 54 minutes after a large one-hour request, and the adapter's `/compact` runs. */
|
||||
it("reports the compaction that the adapter starts as running, then idle, with no user_message", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
||||
try {
|
||||
const received: string[] = [];
|
||||
sdk.run = async function* (prompts, options) {
|
||||
const input = prompts[Symbol.asyncIterator]();
|
||||
for (;;) {
|
||||
const { value, done } = await input.next();
|
||||
if (done) return;
|
||||
received.push((value.message.content as Array<{ text: string }>)[0].text);
|
||||
if (received.length === 1) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: value.uuid,
|
||||
session_id: options.sessionId,
|
||||
isReplay: true,
|
||||
};
|
||||
yield largeOneHourRequest(options);
|
||||
} else {
|
||||
yield { type: "system", subtype: "status", status: "compacting", uuid: randomUUID() };
|
||||
}
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
}
|
||||
};
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(54 * 60_000);
|
||||
for (let i = 0; i < 20 && received.length < 2; i++) await vi.advanceTimersByTimeAsync(0);
|
||||
for (let i = 0; i < 20 && turnTrace(client.sessionUpdates).length < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
const { received, client } = await promptAndIdle();
|
||||
|
||||
expect(received).toEqual(["hi", "/compact"]);
|
||||
expect(turnTrace(client.sessionUpdates)).toEqual([
|
||||
@@ -4166,6 +4342,20 @@ describe("ACP v2 idle compaction", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
/** The client's `settings` option reaches the CLI as `--settings`, and idle compaction reads it too. */
|
||||
it("does not compact a session whose client turned idle compaction off in the settings option", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
||||
try {
|
||||
const { received } = await promptAndIdle({
|
||||
claudeCode: { options: { settings: { idleCompaction: false } } },
|
||||
});
|
||||
|
||||
expect(received).toEqual(["hi"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Claude Code runs model cycles that no prompt started, for example its
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* `session_state_changed`.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, realpath, rm } from "node:fs/promises";
|
||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -24,6 +24,13 @@ 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 {
|
||||
transcriptAssistant,
|
||||
transcriptCompactBoundary,
|
||||
transcriptCompactSummary,
|
||||
transcriptUser,
|
||||
writeTranscript,
|
||||
} from "../helpers.js";
|
||||
|
||||
/** One Claude Code process: the prompts it reads and the messages it sends. */
|
||||
type FakeClaudeCode = {
|
||||
@@ -489,12 +496,58 @@ describe("ACP v2 core", () => {
|
||||
|
||||
expect(response.protocolVersion).toBe(2);
|
||||
expect(response.capabilities?.session?.fork).toEqual({});
|
||||
expect(response.capabilities?.session?.options).toEqual({});
|
||||
expect(response.capabilities?.session?.prompt).toEqual({ image: {}, embeddedContext: {} });
|
||||
expect((response.authMethods ?? []).map((method) => method.methodId)).toContain(
|
||||
"console-login",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["environment", "caller object", "caller file"])(
|
||||
"includes configured models omitted by the SDK from %s settings",
|
||||
async (source) => {
|
||||
const configured = {
|
||||
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
|
||||
modelOverrides: {
|
||||
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"claude-haiku-5-5": "global.anthropic.claude-haiku-5-5",
|
||||
},
|
||||
};
|
||||
let settings: typeof configured | string | undefined;
|
||||
if (source === "environment") {
|
||||
vi.stubEnv("CLAUDE_MODEL_CONFIG", JSON.stringify(configured));
|
||||
} else if (source === "caller file") {
|
||||
settings = "caller-settings.json";
|
||||
await writeFile(path.join(cwd, settings), JSON.stringify(configured));
|
||||
} else {
|
||||
settings = configured;
|
||||
}
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "v2-test-client", version: "1" },
|
||||
});
|
||||
const response = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
_meta: { claudeCode: { options: { settings } } },
|
||||
});
|
||||
const ids = ["default", ...Object.values(configured.modelOverrides)];
|
||||
const model = response.configOptions?.find((option) => option.configId === "model");
|
||||
expect(model).toMatchObject({
|
||||
options: ids.map((value) => expect.objectContaining({ value })),
|
||||
});
|
||||
await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId: response.sessionId,
|
||||
configId: "model",
|
||||
type: "id",
|
||||
value: ids[2],
|
||||
} as v2.SetSessionConfigOptionRequest);
|
||||
expect(sdk.processes[0].setModel).toHaveBeenLastCalledWith(ids[2]);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("routes the sessions started after a gateway login through the gateway", async () => {
|
||||
const client = v2Client();
|
||||
const baseUrl = "https://gateway.example.com";
|
||||
@@ -1012,6 +1065,30 @@ describe("ACP v2 core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stays idle at the echo of a model change's local command output", async () => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
const { claude } = await openSession(agent);
|
||||
// As the bundled CLI sends it after `setModel`, with no turn: no
|
||||
// `command_lifecycle` frame, result or `session_state_changed` follows.
|
||||
claude.send({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
"<local-command-stdout>Set model to `haiku (claude-haiku-5-5)`</local-command-stdout>",
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "session",
|
||||
timestamp: new Date().toISOString(),
|
||||
isReplay: true,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(client.states()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards the result of a local command, which streamed no text", async () => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
@@ -1736,6 +1813,234 @@ describe("ACP v2 core", () => {
|
||||
expect(error).toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
describe("session options", () => {
|
||||
const STORED = "7f1c5b0e-3c53-4c0c-9d0a-0d6c1f0a2b11";
|
||||
const values = (options: v2.SessionConfigOption[] | undefined) =>
|
||||
Object.fromEntries((options ?? []).map((o) => [o.configId, o.currentValue]));
|
||||
const initialize = (agent: v2.ClientContext) =>
|
||||
agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "v2-test-client", version: "1.0.0" },
|
||||
capabilities: {},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sdk.models = [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "Recommended",
|
||||
supportsAutoMode: true,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
displayName: "Haiku",
|
||||
description: "Fast",
|
||||
supportsAutoMode: false,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium"],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it("answers session/options with what session/new returns, from a query that it closes", async () => {
|
||||
const client = v2Client();
|
||||
const { options, created } = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
const options = await agent.request(v2.methods.agent.session.options, {
|
||||
cwd,
|
||||
additionalDirectories: ["/extra"],
|
||||
});
|
||||
const created = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
return { options, created };
|
||||
});
|
||||
|
||||
const [probe, session] = sdk.processes;
|
||||
expect(probe.options).toMatchObject({
|
||||
cwd,
|
||||
additionalDirectories: ["/extra"],
|
||||
mcpServers: {},
|
||||
persistSession: false,
|
||||
});
|
||||
expect(probe.close).toHaveBeenCalledTimes(1);
|
||||
expect(session.options.persistSession).toBeUndefined();
|
||||
const { sessionId, ...fields } = created;
|
||||
expect(sessionId).not.toBe(probe.options.sessionId);
|
||||
expect(options).toEqual(fields);
|
||||
expect(options.availableCommands?.map((command) => command.name)).toContain("review");
|
||||
expect(client.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it("answers session/options of an active session with its current options, without a query", async () => {
|
||||
const client = v2Client();
|
||||
const { options, configOptions, wrongCwd } = await client.connect(async (agent) => {
|
||||
const { response } = await openSession(agent);
|
||||
const { sessionId } = response;
|
||||
const { configOptions } = await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "model",
|
||||
type: "id",
|
||||
value: "haiku",
|
||||
});
|
||||
const options = await agent.request(v2.methods.agent.session.options, { cwd, sessionId });
|
||||
const wrongCwd = await agent
|
||||
.request(v2.methods.agent.session.options, { cwd: tempDir, sessionId })
|
||||
.catch((error: unknown) => error);
|
||||
return { options, configOptions, wrongCwd };
|
||||
});
|
||||
|
||||
expect(sdk.processes).toHaveLength(1);
|
||||
expect(options.configOptions).toEqual(configOptions);
|
||||
expect(values(options.configOptions).model).toBe("haiku");
|
||||
expect(wrongCwd).toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("answers session/options of a stored session from a query that resumes it", async () => {
|
||||
const client = v2Client();
|
||||
const status = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
await agent.request(v2.methods.agent.session.options, { cwd, sessionId: STORED });
|
||||
return agent
|
||||
.request(v2.methods.agent.session.status, { sessionId: STORED })
|
||||
.catch((error: unknown) => error);
|
||||
});
|
||||
|
||||
const [probe] = sdk.processes;
|
||||
expect(probe.options).toMatchObject({ resume: STORED, persistSession: false });
|
||||
expect(probe.options.sessionId).toBeUndefined();
|
||||
expect(probe.close).toHaveBeenCalledTimes(1);
|
||||
// The session is not active: it has no transcript in this test.
|
||||
expect(status).toMatchObject({ code: -32002 });
|
||||
});
|
||||
|
||||
it("fails session/options of an unknown session with -32002", async () => {
|
||||
sdk.startFailure = new Error(`No conversation found with session ID: ${STORED}`);
|
||||
const client = v2Client();
|
||||
const error = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
return agent
|
||||
.request(v2.methods.agent.session.options, { cwd, sessionId: STORED })
|
||||
.catch((error: unknown) => error);
|
||||
});
|
||||
|
||||
expect(error).toMatchObject({ code: -32002, data: { sessionId: STORED } });
|
||||
expect(sdk.processes[0].close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a new session with the request's configOptions, in list order, without an update", async () => {
|
||||
const client = v2Client();
|
||||
const response = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
return agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
configOptions: [
|
||||
// Skipped: an unknown option, a boolean for a select, and a value not offered.
|
||||
{ configId: "nope", type: "id", value: "x" },
|
||||
{ configId: "ultracode", type: "boolean", value: true },
|
||||
{ configId: "model", type: "id", value: "haiku" },
|
||||
// Haiku offers no "high": the entry is checked against the options the model left.
|
||||
{ configId: "effort", type: "id", value: "high" },
|
||||
{ configId: "mode", type: "id", value: "plan" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const [claude] = sdk.processes;
|
||||
expect(claude.setModel).toHaveBeenCalledWith("haiku");
|
||||
expect(claude.setPermissionMode).toHaveBeenCalledWith("plan");
|
||||
expect(values(response.configOptions)).toMatchObject({ model: "haiku", mode: "plan" });
|
||||
expect(values(response.configOptions).effort).not.toBe("high");
|
||||
expect(client.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it.each<[string, v2.InitialSessionConfigOption[]]>([
|
||||
[
|
||||
"two entries of one configId",
|
||||
[
|
||||
{ configId: "mode", type: "id", value: "plan" },
|
||||
{ configId: "mode", type: "id", value: "default" },
|
||||
],
|
||||
],
|
||||
["a value type it does not take", [{ configId: "mode", type: "_custom", value: 1 }]],
|
||||
])("rejects %s before it starts a query", async (_name, configOptions) => {
|
||||
const client = v2Client();
|
||||
const errors = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
return Promise.all(
|
||||
[
|
||||
agent.request(v2.methods.agent.session.new, { cwd, configOptions }),
|
||||
agent.request(v2.methods.agent.session.resume, {
|
||||
sessionId: STORED,
|
||||
cwd,
|
||||
configOptions,
|
||||
}),
|
||||
].map((request) => request.catch((error: unknown) => error)),
|
||||
);
|
||||
});
|
||||
|
||||
expect(errors).toEqual([
|
||||
expect.objectContaining({ code: -32602 }),
|
||||
expect.objectContaining({ code: -32602 }),
|
||||
]);
|
||||
expect(sdk.processes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sets the configOptions of a session/resume on the session, an active one too", async () => {
|
||||
const client = v2Client();
|
||||
const { started, active } = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
const resume = (configOptions: v2.InitialSessionConfigOption[]) =>
|
||||
agent.request(v2.methods.agent.session.resume, { sessionId: STORED, cwd, configOptions });
|
||||
const started = await resume([{ configId: "mode", type: "id", value: "plan" }]);
|
||||
const active = await resume([{ configId: "model", type: "id", value: "haiku" }]);
|
||||
return { started, active };
|
||||
});
|
||||
|
||||
expect(sdk.processes).toHaveLength(1);
|
||||
expect(values(started.configOptions).mode).toBe("plan");
|
||||
expect(values(active.configOptions)).toMatchObject({ mode: "plan", model: "haiku" });
|
||||
expect(client.sessionUpdates.map((update) => update.sessionUpdate)).not.toContain(
|
||||
"config_option_update",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the notice of an Auto mode fallback for the first prompt", async () => {
|
||||
const client = v2Client();
|
||||
const { response, before } = await client.connect(async (agent) => {
|
||||
await initialize(agent);
|
||||
const response = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
configOptions: [
|
||||
{ configId: "mode", type: "id", value: "auto" },
|
||||
// Haiku cannot serve Auto.
|
||||
{ configId: "model", type: "id", value: "haiku" },
|
||||
],
|
||||
});
|
||||
const before = [...client.sessionUpdates];
|
||||
const claude = sdk.processes[0];
|
||||
const prompted = agent.request(v2.methods.agent.session.prompt, {
|
||||
sessionId: response.sessionId,
|
||||
prompt: PROMPT,
|
||||
});
|
||||
const prompt = await claude.prompt(0);
|
||||
claude.send(lifecycle(prompt, "started"));
|
||||
claude.send(echo(prompt));
|
||||
claude.send(result());
|
||||
claude.send(sessionState("idle"));
|
||||
await prompted;
|
||||
return { response, before };
|
||||
});
|
||||
|
||||
expect(values(response.configOptions)).toMatchObject({ mode: "acceptEdits", model: "haiku" });
|
||||
expect(before).toEqual([]);
|
||||
expect(client.sessionUpdates.filter((update) => update.sessionUpdate === "notice")).toEqual([
|
||||
expect.objectContaining({ title: "Auto mode unavailable" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects additional directories that are not absolute", async () => {
|
||||
const client = v2Client();
|
||||
const error = await client.connect(async (agent) => {
|
||||
@@ -1895,6 +2200,49 @@ describe("ACP v2 core", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("replays the messages before a compaction from the transcript", async () => {
|
||||
const sessionId = randomUUID();
|
||||
// The records of `/compact` between two prompts, as Claude Code writes
|
||||
// them: the boundary has no parent and links "Hello" logically. The SDK
|
||||
// looks the project up by the real path of `cwd` (`/private/var` on macOS).
|
||||
const project = (await realpath(cwd)).replace(/[^a-zA-Z0-9]/g, "-");
|
||||
await writeTranscript(path.join(tempDir, "claude", "projects", project), sessionId, [
|
||||
transcriptUser("u1", "hi"),
|
||||
transcriptAssistant("a1", "Hello"),
|
||||
transcriptCompactBoundary("b1", "a1"),
|
||||
transcriptCompactSummary("s1"),
|
||||
transcriptUser("u2", "identify yourself"),
|
||||
]);
|
||||
// The transcript read and the read across the compaction both go to disk.
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
vi.mocked(getSessionMessages)
|
||||
.mockImplementationOnce(actual.getSessionMessages)
|
||||
.mockImplementationOnce(actual.getSessionMessages);
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "v2-test-client", version: "1.0.0" },
|
||||
capabilities: {},
|
||||
});
|
||||
await agent.request(v2.methods.agent.session.resume, {
|
||||
sessionId,
|
||||
cwd,
|
||||
replayFrom: { type: "start" },
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
client.sessionUpdates.flatMap((update) =>
|
||||
update.sessionUpdate === "user_message" || update.sessionUpdate === "compaction_update"
|
||||
? [update.sessionUpdate]
|
||||
: [],
|
||||
),
|
||||
).toEqual(["user_message", "compaction_update", "user_message"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a prompt that Claude Code started after the cancel answered it",
|
||||
@@ -2085,7 +2433,7 @@ describe("ACP v2 core", () => {
|
||||
expect(client.sessionUpdates.at(-1)).toMatchObject({
|
||||
state: "idle",
|
||||
stopReason: "error",
|
||||
error: { code: -32000 },
|
||||
error: { code: -32000, message: "Authentication required" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2094,7 +2442,7 @@ describe("ACP v2 core", () => {
|
||||
"an authentication failure as authRequired",
|
||||
"authentication_failed",
|
||||
"Failed to authenticate. API Error: 401",
|
||||
{ code: -32000 },
|
||||
{ code: -32000, message: "Authentication required: Failed to authenticate. API Error: 401" },
|
||||
],
|
||||
[
|
||||
"another failure with its error kind",
|
||||
@@ -2157,6 +2505,53 @@ describe("ACP v2 core", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a failed API request that the turn goes on past where it failed", async () => {
|
||||
const text = "API Error: Connection lost mid-response. The response above may be incomplete.";
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
const { claude } = await openSession(agent);
|
||||
claude.send(cycleStart());
|
||||
// Claude Code keeps the tool call of the response that broke off, and runs it.
|
||||
claude.send(
|
||||
assistant("msg_partial", [
|
||||
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: { command: "ls" } },
|
||||
]),
|
||||
);
|
||||
claude.send({
|
||||
...assistant("msg_api_error", [{ type: "text", text }]),
|
||||
message: {
|
||||
id: "msg_api_error",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "<synthetic>",
|
||||
content: [{ type: "text", text }],
|
||||
usage: {},
|
||||
},
|
||||
error: "server_error",
|
||||
});
|
||||
claude.send({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "src" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
});
|
||||
claude.send(assistant("msg_after", [{ type: "text", text: "Done." }]));
|
||||
claude.send(result({ result: "Done." }));
|
||||
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
|
||||
});
|
||||
|
||||
const notice = { sessionUpdate: "notice", severity: "error", title: text };
|
||||
const shown = client.sessionUpdates.filter(
|
||||
(update) =>
|
||||
update.sessionUpdate === "notice" || update.sessionUpdate === "agent_message_chunk",
|
||||
);
|
||||
expect(shown).toMatchObject([notice, { sessionUpdate: "agent_message_chunk" }]);
|
||||
expect(shown[0]).toEqual(notice);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an error at the turn limit",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ForkSessionResponse,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
InitialSessionConfigOption,
|
||||
ListProvidersRequest,
|
||||
ListProvidersResponse,
|
||||
McpServer,
|
||||
@@ -43,11 +44,15 @@ import {
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
SessionOptionsRequest,
|
||||
SessionOptionsResponse,
|
||||
SessionStatusRequest,
|
||||
SessionStatusResponse,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
DeleteSessionRequest,
|
||||
SetSessionTitleRequest,
|
||||
SetSessionTitleResponse,
|
||||
DeleteSessionResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
@@ -94,6 +99,11 @@ import {
|
||||
import { checklistPlanUpdate, resetPlanUpdates, type SubagentChecklists } from "../plans.js";
|
||||
import { PromptSuggestions } from "../prompt-suggestions.js";
|
||||
import { savedSessionStatus, SESSION_STATUS_CAPABILITY } from "../session-status.js";
|
||||
import {
|
||||
applyInitialConfigOptions,
|
||||
initialConfigOptions,
|
||||
SESSION_OPTIONS_CAPABILITY,
|
||||
} from "../initial-config-options.js";
|
||||
import { sanitizeTitle, SessionTitles } from "../session-titles.js";
|
||||
import {
|
||||
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
|
||||
@@ -133,16 +143,16 @@ import {
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "../air-extension.js";
|
||||
import * as sessionIndex from "./session-index/connection.js";
|
||||
import * as sessionIndex from "./session-index.js";
|
||||
import type {
|
||||
BatchGetSessionsRequest,
|
||||
RenameSessionRequest,
|
||||
SessionIdRequest,
|
||||
} from "./session-index/service.js";
|
||||
} from "../session-index/service.js";
|
||||
import type {
|
||||
ListSubscribeRequest,
|
||||
ListUnsubscribeRequest,
|
||||
} from "./session-index/list-subscriptions.js";
|
||||
} from "../session-index/list-subscriptions.js";
|
||||
import {
|
||||
AsyncTaskRuntime,
|
||||
backgroundBashTaskFromToolResult,
|
||||
@@ -150,6 +160,20 @@ import {
|
||||
clientSupportsAsyncTasks,
|
||||
} from "../async-tasks.js";
|
||||
import { SessionBackgroundWork } from "./background-work.js";
|
||||
import {
|
||||
ASYNC_TASK_STOP_METHOD,
|
||||
parseAsyncTaskStopRequest,
|
||||
type AsyncTaskStopRequest,
|
||||
type AsyncTaskStopResponse,
|
||||
} from "./async-task-stop.js";
|
||||
import {
|
||||
STEER_METHOD,
|
||||
STEER_PRIORITY_LATER,
|
||||
STEER_PRIORITY_NOW,
|
||||
parseSteerRequest,
|
||||
type SteerRequest,
|
||||
type SteerResponse,
|
||||
} from "./steering.js";
|
||||
import {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
type AuthStatus,
|
||||
@@ -201,6 +225,7 @@ import {
|
||||
replaySubagentTerminalState,
|
||||
} from "../subagent-replay.js";
|
||||
import {
|
||||
readHistoryAcrossCompactions,
|
||||
readResumedSession,
|
||||
readResumedTail,
|
||||
type ResumedSessionSnapshot,
|
||||
@@ -218,6 +243,7 @@ import { decodeClaudePermissionResponse } from "../permissions/response.js";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
import {
|
||||
activeUsageLimitMessage,
|
||||
apiRetryWarning,
|
||||
airSessionFailureCapabilityMeta,
|
||||
assistantMessageText,
|
||||
type ClaudeFailureKind,
|
||||
@@ -409,6 +435,7 @@ import {
|
||||
stripLocalCommandMetadata,
|
||||
} from "../message-filters.js";
|
||||
import type { NewSessionMeta, SDKMessageFilter } from "../new-session-meta.js";
|
||||
import { computeSessionFingerprint } from "./session-fingerprint.js";
|
||||
import { promptToClaude, SUPPORTED_PROMPT_CONTENT_TYPES } from "../prompt-to-claude.js";
|
||||
import { AUTONOMOUS_RESULT_ORIGINS, isAbortedResult } from "../result-end.js";
|
||||
import {
|
||||
@@ -565,112 +592,6 @@ const TURN_NO_RESULT_MESSAGE =
|
||||
"The turn ended without a result: the agent went idle while this prompt was still in flight " +
|
||||
"(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
|
||||
|
||||
/** Custom (extension) request method a client uses to steer the turn that is
|
||||
* currently running: the message is injected into the in-flight turn rather
|
||||
* than queued as a separate `session/prompt`. Named `_session/steering` per the
|
||||
* agreed ACP steering wire protocol; advertised to clients via the top-level
|
||||
* `InitializeResponse._meta.steering.supported`. */
|
||||
const STEER_METHOD = "_session/steering";
|
||||
|
||||
/** Stops one Claude background task without cancelling the parent prompt turn. */
|
||||
const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
|
||||
|
||||
type AsyncTaskStopRequest = {
|
||||
sessionId: string;
|
||||
asyncTaskId: string;
|
||||
};
|
||||
|
||||
type AsyncTaskStopResponse = {
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
function parseAsyncTaskStopRequest(value: unknown): AsyncTaskStopRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw RequestError.invalidParams(undefined, "async task stop params must be an object");
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
|
||||
const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
|
||||
if (!sessionId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty sessionId",
|
||||
);
|
||||
}
|
||||
if (!asyncTaskId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty asyncTaskId",
|
||||
);
|
||||
}
|
||||
return { sessionId, asyncTaskId };
|
||||
}
|
||||
|
||||
/** How urgently the SDK delivers a steered message relative to the running
|
||||
* turn — an internal Claude implementation detail, not part of the wire
|
||||
* contract. `now` pre-empts the current generation, while `later` waits for a
|
||||
* pending permission/elicitation callback to settle instead of cancelling its
|
||||
* ACP request and hiding the client's user-input card (IJAI-1191). */
|
||||
const STEER_PRIORITY_NOW = "now" as const;
|
||||
const STEER_PRIORITY_LATER = "later" as const;
|
||||
|
||||
/** Request-level steering options. `promptRequired` is opt-in so existing Hosts
|
||||
* keep the established idle fallback behavior. */
|
||||
type SteerMeta = {
|
||||
[key: string]: unknown;
|
||||
steering?: {
|
||||
idleBehavior?: "promptRequired";
|
||||
};
|
||||
};
|
||||
|
||||
/** Params of a {@link STEER_METHOD} request. Shaped like the relevant subset of
|
||||
* a `PromptRequest` so the same `promptToClaude` conversion applies. Delivery
|
||||
* priority is deliberately NOT exposed here — it's an internal detail the agent
|
||||
* chooses (see {@link STEER_PRIORITY}). */
|
||||
export type SteerRequest = {
|
||||
sessionId: string;
|
||||
prompt: PromptRequest["prompt"];
|
||||
_meta?: SteerMeta | null;
|
||||
};
|
||||
|
||||
/** Result of a {@link STEER_METHOD} request. The legacy `startedNewTurn` result
|
||||
* remains the default idle behavior; `promptRequired` is returned only when the
|
||||
* Host explicitly opts into the host-owned fallback in request `_meta`. */
|
||||
export type SteerResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
|
||||
* content blocks are handed to `promptToClaude`, which tolerates unknown block
|
||||
* types — but `sessionId` and a non-empty `prompt` array are required. */
|
||||
function parseSteerRequest(params: unknown): SteerRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "steer params must be an object");
|
||||
}
|
||||
const { sessionId, prompt, _meta } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
|
||||
}
|
||||
if (!Array.isArray(prompt) || prompt.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
|
||||
}
|
||||
const steering =
|
||||
_meta && typeof _meta === "object" ? (_meta as Record<string, unknown>).steering : undefined;
|
||||
const idleBehavior =
|
||||
steering && typeof steering === "object"
|
||||
? (steering as Record<string, unknown>).idleBehavior
|
||||
: undefined;
|
||||
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
|
||||
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
prompt: prompt as PromptRequest["prompt"],
|
||||
_meta: _meta as SteerMeta | null | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Who started a turn. `idleCompaction` is the adapter's own `/compact` (see
|
||||
* `IdleCompaction`): it is not a user prompt, so it neither disarms idle
|
||||
* compaction, asks for a title from its prompt, nor clears the published prompt suggestion. */
|
||||
@@ -1145,12 +1066,13 @@ export type Session = {
|
||||
* independent of user ownership: opted-in clients also apply automatic
|
||||
* recommendations and settings-derived values. */
|
||||
appliedEffortLevel?: string;
|
||||
/** Why the SDK currently can't serve Fast mode, when the reason is one worth
|
||||
* telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} —
|
||||
* routine states like the SDK's own opt-in requirement normalize to
|
||||
* `undefined`). Refreshed from every `fast_mode_disabled_reason` the SDK
|
||||
* reports on `system`/init and user-turn `result`s; surfaced in the Fast mode
|
||||
* option's description so a toggle that snaps back off explains itself. */
|
||||
/** Why the SDK currently can't serve Fast mode: the last
|
||||
* `fast_mode_disabled_reason` that `explainFastModeDisabledReason` explains.
|
||||
* {@link normalizeFastModeDisabledReason} drops the two it doesn't,
|
||||
* `sdk_opt_in_required` and `pending`, to `undefined`. Refreshed from every
|
||||
* `fast_mode_disabled_reason` the SDK reports on `system`/init and user-turn
|
||||
* `result`s; surfaced in the Fast mode option's description so a toggle that
|
||||
* snaps back off explains itself. */
|
||||
fastModeDisabledReason?: FastModeDisabledReason;
|
||||
abortController: AbortController;
|
||||
/** Signal the consumer races `query.next()` against. Aborted by cancel()
|
||||
@@ -1542,149 +1464,6 @@ function disarmForceCancel(session: Session): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize skills without changing their SDK semantics. Array order and
|
||||
* duplicates do not affect the selected skill set, so neither should rebuild
|
||||
* the underlying Query process. */
|
||||
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
|
||||
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
|
||||
}
|
||||
|
||||
/** Whether a changed `_meta.claudeCode.options` key on session/load or
|
||||
* session/resume must rebuild the Query process. Almost every option is read
|
||||
* only when the process starts, so the default is `true`. Every key of the
|
||||
* SDK's `Options` must be classified: an SDK bump that adds or removes an
|
||||
* option fails to compile until it is decided here. */
|
||||
const OPTION_REBUILDS_SESSION = {
|
||||
additionalDirectories: true,
|
||||
agentProgressSummaries: true,
|
||||
agents: true,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
allowedTools: true,
|
||||
betas: true,
|
||||
debug: true,
|
||||
debugFile: true,
|
||||
disallowedTools: true,
|
||||
effort: true,
|
||||
enableFileCheckpointing: true,
|
||||
env: true,
|
||||
executableArgs: true,
|
||||
extraArgs: true,
|
||||
fallbackModel: true,
|
||||
forwardSubagentText: true,
|
||||
includeHookEvents: true,
|
||||
managedSettings: true,
|
||||
maxBudgetUsd: true,
|
||||
maxThinkingTokens: true,
|
||||
maxTurns: true,
|
||||
mcpServers: true,
|
||||
model: true,
|
||||
outputFormat: true,
|
||||
pathToClaudeCodeExecutable: true,
|
||||
permissionPrompts: true,
|
||||
permissionPromptToolName: true,
|
||||
persistSession: true,
|
||||
perTaskStopAffordance: true,
|
||||
planModeInstructions: true,
|
||||
pluginDelivery: true,
|
||||
plugins: true,
|
||||
projectConfigRoot: true,
|
||||
promptSuggestions: true,
|
||||
sandbox: true,
|
||||
sessionStoreFlush: true,
|
||||
settings: true,
|
||||
settingSources: true,
|
||||
skills: true,
|
||||
strictMcpConfig: true,
|
||||
supportedDialogKinds: true,
|
||||
systemPrompt: true,
|
||||
taskBudget: true,
|
||||
thinking: true,
|
||||
toolAliases: true,
|
||||
toolConfig: true,
|
||||
tools: true,
|
||||
verbatimPrompts: true,
|
||||
// Managed or ignored by the adapter.
|
||||
agent: false,
|
||||
cwd: false,
|
||||
executable: false,
|
||||
includePartialMessages: false,
|
||||
permissionMode: false,
|
||||
// Per-call controls for attaching to the stored conversation.
|
||||
continue: false,
|
||||
forkSession: false,
|
||||
resume: false,
|
||||
resumeDropsTurn: false,
|
||||
resumeSessionAt: false,
|
||||
sessionId: false,
|
||||
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
|
||||
abortController: false,
|
||||
canUseTool: false,
|
||||
hooks: false,
|
||||
onElicitation: false,
|
||||
onUserDialog: false,
|
||||
sessionStore: false,
|
||||
spawnClaudeCodeProcess: false,
|
||||
stderr: false,
|
||||
// No effect on an already-created session.
|
||||
loadTimeoutMs: false,
|
||||
title: false,
|
||||
} as const satisfies Record<keyof Options, boolean>;
|
||||
|
||||
/** A JSON value with object keys sorted, so key order never changes a
|
||||
* fingerprint. Functions and class instances (hook callbacks, abort
|
||||
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
|
||||
* stable serialized form, so they are left out. */
|
||||
function canonicalJson(value: unknown): unknown {
|
||||
if (typeof value === "function") return undefined;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
|
||||
.filter(([, entry]) => entry !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. Every option classified in
|
||||
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
|
||||
* keeps stale values. MCP servers are sorted by name, and skills are
|
||||
* normalized as a set, so ordering differences don't trigger unnecessary
|
||||
* recreations. */
|
||||
export function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
additionalDirectories?: NewSessionRequest["additionalDirectories"];
|
||||
systemPrompt?: NewSessionRequest["systemPrompt"];
|
||||
_meta?: NewSessionRequest["_meta"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
|
||||
const options: Record<string, unknown> = Object.fromEntries(
|
||||
Object.entries(meta?.claudeCode?.options ?? {}).filter(
|
||||
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
|
||||
),
|
||||
);
|
||||
options.skills = normalizeSkills(options.skills as Options["skills"]);
|
||||
return JSON.stringify(
|
||||
canonicalJson({
|
||||
cwd: params.cwd,
|
||||
mcpServers: servers,
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
additionalRoots: meta?.additionalRoots,
|
||||
systemPrompt: meta?.systemPrompt,
|
||||
clientSystemPrompt: params.systemPrompt ?? undefined,
|
||||
disableBuiltInTools: meta?.disableBuiltInTools,
|
||||
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
|
||||
options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it
|
||||
* cannot drift from the errors this agent throws. */
|
||||
const AUTH_REQUIRED_CODE = RequestError.authRequired().code;
|
||||
@@ -2022,9 +1801,13 @@ export class ClaudeAcpAgent {
|
||||
delete: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
// `session/options`, and `configOptions` on `session/new`, `load` and `resume`.
|
||||
options: SESSION_OPTIONS_CAPABILITY,
|
||||
resume: {},
|
||||
status: SESSION_STATUS_CAPABILITY,
|
||||
systemPrompt: SYSTEM_PROMPT_CAPABILITY,
|
||||
// `session/set_title`.
|
||||
title: {},
|
||||
...(this.toolCallCapabilities.air.client ? { subagents: {} } : {}),
|
||||
};
|
||||
|
||||
@@ -2105,15 +1888,77 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
// Without `configOptions`: a restart of the query restores the session's choices itself.
|
||||
const { configOptions, ...request } = params;
|
||||
const initial = initialConfigOptions(configOptions);
|
||||
while (this.providerUpdate) await this.providerUpdate;
|
||||
const response = await this.createSession(params, {
|
||||
const created = await this.createSession(request, {
|
||||
// Revisit these meta values once we support resume
|
||||
resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
|
||||
});
|
||||
const response = await this.withInitialConfigOptions(created, initial);
|
||||
this.afterSetupResponse(response.sessionId, params.mcpServers);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/options`: the modes and config options that `session/new` with
|
||||
* the same `cwd`, additional directories and `_meta` would answer with, or
|
||||
* `session/load` and `session/resume` of `sessionId`.
|
||||
*
|
||||
* A session that this connection holds answers with what it has now.
|
||||
* Otherwise a query starts as the session's would, without MCP servers, and
|
||||
* closes once its options are read (`optionsOnly` in `createSession`). The
|
||||
* query does not persist: it stores no session, and it does not write to
|
||||
* the transcript of `sessionId`. Claude Code runs the user's `SessionStart`
|
||||
* hooks in it, as in every query.
|
||||
*
|
||||
* It sends no update, and it creates, loads and changes no session. A
|
||||
* `sessionId` without a stored conversation fails with `-32002`.
|
||||
*/
|
||||
async sessionOptions(params: SessionOptionsRequest): Promise<SessionOptionsResponse> {
|
||||
while (this.providerUpdate) await this.providerUpdate;
|
||||
const held = params.sessionId != null ? this.sessions[params.sessionId] : undefined;
|
||||
if (held) {
|
||||
await this.validateCwd(params.cwd, params.additionalDirectories);
|
||||
return { modes: held.modes, configOptions: held.configOptions };
|
||||
}
|
||||
const { modes, configOptions } = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: [],
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
_meta: params._meta,
|
||||
},
|
||||
{ resume: params.sessionId ?? undefined, optionsOnly: true },
|
||||
);
|
||||
return { modes, configOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* The setup response once the request's `configOptions` are set on the
|
||||
* session, in list order, as `session/set_config_option` sets each one. An
|
||||
* entry that the session refuses is skipped (`applyInitialConfigOptions`).
|
||||
* No update goes out for them: the response carries the modes and options.
|
||||
*/
|
||||
private async withInitialConfigOptions<R extends NewSessionResponse>(
|
||||
response: R,
|
||||
entries: readonly InitialSessionConfigOption[],
|
||||
): Promise<R> {
|
||||
if (entries.length === 0) return response;
|
||||
const { sessionId } = response;
|
||||
await this.serializeConfigurationChange(sessionId, () =>
|
||||
applyInitialConfigOptions(
|
||||
entries,
|
||||
(entry) => this.selectSessionConfigOption({ ...entry, sessionId }, true),
|
||||
this.logger,
|
||||
),
|
||||
);
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return response;
|
||||
return { ...response, modes: session.modes, configOptions: session.configOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the CLI of a loaded session, and replay the history while the CLI
|
||||
* starts. The CLI start takes most of the load time, and the replay needs
|
||||
@@ -2219,8 +2064,12 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const initial = initialConfigOptions(params.configOptions);
|
||||
while (this.providerUpdate) await this.providerUpdate;
|
||||
const result = await this.getOrCreateSession(params);
|
||||
const result = await this.withInitialConfigOptions(
|
||||
await this.getOrCreateSession(params),
|
||||
initial,
|
||||
);
|
||||
|
||||
this.afterSetupResponse(params.sessionId, params.mcpServers ?? []);
|
||||
return result;
|
||||
@@ -2228,6 +2077,7 @@ export class ClaudeAcpAgent {
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const timing = new SessionTiming(this.logger, "load", params.sessionId);
|
||||
const initial = initialConfigOptions(params.configOptions);
|
||||
while (this.providerUpdate) await this.providerUpdate;
|
||||
let result: NewSessionResponse;
|
||||
if (this.sessions[params.sessionId]) {
|
||||
@@ -2239,6 +2089,7 @@ export class ClaudeAcpAgent {
|
||||
result = await this.createSessionWhileReplaying(params, timing);
|
||||
}
|
||||
timing.phase("replay");
|
||||
result = await this.withInitialConfigOptions(result, initial);
|
||||
|
||||
// After the replay, so the commands do not interleave with the history.
|
||||
this.afterSetupResponse(params.sessionId, params.mcpServers ?? []);
|
||||
@@ -3522,12 +3373,17 @@ export class ClaudeAcpAgent {
|
||||
kind: ClaudeFailureKind,
|
||||
options: {
|
||||
turnScoped?: boolean;
|
||||
/** The turn the failure belongs to, when it is not `activeTurn`. */
|
||||
turnId?: string;
|
||||
title?: string;
|
||||
details?: string;
|
||||
severity?: "warning" | "error";
|
||||
} = {},
|
||||
) => {
|
||||
const turnId = options.turnScoped === false ? undefined : session.activeTurn?.promptUuid;
|
||||
const turnId =
|
||||
options.turnScoped === false
|
||||
? undefined
|
||||
: (options.turnId ?? session.activeTurn?.promptUuid);
|
||||
await sessionFailures.publish(kind, {
|
||||
turnId,
|
||||
sessionScoped: options.turnScoped === false,
|
||||
@@ -5506,19 +5362,20 @@ export class ClaudeAcpAgent {
|
||||
// here would outlive the `auth_required` rejection with no
|
||||
// action to clear it (issue #1072).
|
||||
if (kind === "auth_required") break;
|
||||
// `no_response` (SDK 0.3.261+): the API sent no response headers
|
||||
// within the first-byte window, so this retry waits longer for
|
||||
// them. Say so — "attempt 1 of 1" alone reads like a final
|
||||
// failure, and the wait is what the user is about to sit through.
|
||||
const seconds = (ms: number) => `${Math.max(1, Math.round(ms / 1000))}s`;
|
||||
const noResponse = message.no_response
|
||||
? ` No response after ${seconds(message.no_response.waited_ms)}; waiting up to ${seconds(message.no_response.retry_wait_ms)}.`
|
||||
: "";
|
||||
const title =
|
||||
message.error_status === null
|
||||
? `Reconnecting to Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`
|
||||
: `Retrying Claude, attempt ${message.attempt} of ${message.max_retries}.${noResponse}`;
|
||||
await publishSessionFailure(kind, { title, severity: "warning" });
|
||||
// The CLI holds the echo of a prompt back until the first
|
||||
// assistant frame or stream event of the turn, so the retries
|
||||
// of a request that gets no response come before the echo
|
||||
// activates the turn. Give them the queue head, the prompt
|
||||
// being sent: all retries and the terminal failure of one turn
|
||||
// then share its `${turnId}:error` row.
|
||||
const retryTurn =
|
||||
session.activeTurn ??
|
||||
(asyncTasks.autonomousTurnOpen ? undefined : firstUnsettledQueuedTurn());
|
||||
await publishSessionFailure(kind, {
|
||||
turnId: retryTurn?.promptUuid,
|
||||
...apiRetryWarning(message),
|
||||
severity: "warning",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "model_refusal_fallback": {
|
||||
@@ -7876,6 +7733,11 @@ export class ClaudeAcpAgent {
|
||||
return savedSessionStatus(sessionId);
|
||||
}
|
||||
|
||||
/** See {@link sessionIndex.SessionIndexConnection.setTitle}. */
|
||||
setSessionTitle(params: SetSessionTitleRequest): Promise<SetSessionTitleResponse> {
|
||||
return this.sessionIndex.setTitle(params);
|
||||
}
|
||||
|
||||
/** Per client: see {@link sessionIndex.SessionIndexConnection.deleteSession}. */
|
||||
async deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||
return this.sessionIndex.deleteSession(params);
|
||||
@@ -7895,8 +7757,14 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `duringSetup`: the value is one of a setup request's `configOptions`. No
|
||||
* mode update goes out, and the warning of an Auto mode fallback waits for
|
||||
* the next prompt.
|
||||
*/
|
||||
private async selectSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
duringSetup = false,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
@@ -7927,8 +7795,12 @@ export class ClaudeAcpAgent {
|
||||
|
||||
let effectiveValue = resolvedValue;
|
||||
if (params.configId === MODE_CONFIG_ID) {
|
||||
effectiveValue = await this.sessionModes.selectMode(params.sessionId, resolvedValue);
|
||||
await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
|
||||
effectiveValue = await this.sessionModes.selectMode(
|
||||
params.sessionId,
|
||||
resolvedValue,
|
||||
duringSetup,
|
||||
);
|
||||
if (!duringSetup) await this.sessionModes.publishCurrent(params.sessionId, effectiveValue);
|
||||
} else if (params.configId === MODEL_CONFIG_ID) {
|
||||
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
||||
}
|
||||
@@ -7936,7 +7808,13 @@ export class ClaudeAcpAgent {
|
||||
// effort changes and effort changes induced by a model switch go through
|
||||
// the same path.
|
||||
|
||||
await this.applyConfigOptionValue(params.sessionId, session, params.configId, effectiveValue);
|
||||
await this.applyConfigOptionValue(
|
||||
params.sessionId,
|
||||
session,
|
||||
params.configId,
|
||||
effectiveValue,
|
||||
duringSetup,
|
||||
);
|
||||
|
||||
return { configOptions: session.configOptions };
|
||||
}
|
||||
@@ -7948,7 +7826,12 @@ export class ClaudeAcpAgent {
|
||||
): Promise<void> {
|
||||
const replayStartedAt = performance.now();
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const messages = resumedMessages ?? (await getSessionMessages(sessionId));
|
||||
// A load replays the messages before each compaction too, unless reading them fails.
|
||||
const messages = await readHistoryAcrossCompactions(
|
||||
sessionId,
|
||||
resumedMessages ?? (await getSessionMessages(sessionId)),
|
||||
this.logger,
|
||||
);
|
||||
const historyLoadedAt = performance.now();
|
||||
this.logger.log(
|
||||
`[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`,
|
||||
@@ -9447,6 +9330,8 @@ export class ClaudeAcpAgent {
|
||||
session: Session,
|
||||
configId: string,
|
||||
value: string,
|
||||
/** See {@link selectSessionConfigOption}. */
|
||||
duringSetup = false,
|
||||
): Promise<void> {
|
||||
if (configId === MODE_CONFIG_ID) {
|
||||
this.sessionModes.syncConfig(session, value);
|
||||
@@ -9556,7 +9441,8 @@ export class ClaudeAcpAgent {
|
||||
// still precedes the caller's config_option_update so order-sensitive
|
||||
// clients update currentModeId before re-rendering the option list.
|
||||
if (modeDowngraded) {
|
||||
await this.sessionModes.publishFallbackState(sessionId, session);
|
||||
if (duringSetup) this.sessionModes.deferFallbackWarning(session);
|
||||
else await this.sessionModes.publishFallbackState(sessionId, session);
|
||||
}
|
||||
// Last, so the switch's own control requests don't queue behind it.
|
||||
if (modelChanged) this.refreshContextWindowInBackground(sessionId, session);
|
||||
@@ -9855,6 +9741,12 @@ export class ClaudeAcpAgent {
|
||||
replayState?: ReplayState;
|
||||
/** A close of this session during startup cancels registration of its replacement. */
|
||||
replacingSession?: Session;
|
||||
/**
|
||||
* `session/options`: the query answers with the modes and config options
|
||||
* and closes. No session is registered, and Claude Code stores nothing
|
||||
* of the query (`persistSession: false`).
|
||||
*/
|
||||
optionsOnly?: boolean;
|
||||
} = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
const createStartedAt = performance.now();
|
||||
@@ -10247,6 +10139,7 @@ export class ClaudeAcpAgent {
|
||||
},
|
||||
...(creationOpts.resume !== undefined && { resume: creationOpts.resume }),
|
||||
...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }),
|
||||
...(creationOpts.optionsOnly ? { persistSession: false } : {}),
|
||||
abortController,
|
||||
};
|
||||
|
||||
@@ -10330,15 +10223,15 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply user's `availableModels` allowlist from settings.json before any
|
||||
// Apply the effective `availableModels` allowlist before any
|
||||
// downstream model handling. `initializationResult.models` is already
|
||||
// policy-filtered by the CLI; we rebuild the picker from the allowlist
|
||||
// so the user's exact spellings (and `modelOverrides` targets) are the
|
||||
// values passed to `setModel`, keeping configOptions, the current-model
|
||||
// resolver, and the stored modelInfos consistent with what the user
|
||||
// configured. Managed `deniedModels` drops entries the CLI would refuse.
|
||||
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
||||
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
|
||||
const { availableModels: settingsAvailableModels, modelOverrides: settingsModelOverrides } =
|
||||
settingsManager.getModelSelectionSettings(configuredSettingsObject);
|
||||
const resumedModel = await resumedModelHint;
|
||||
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new
|
||||
// session does (see `aliasResumedModelRow`).
|
||||
@@ -10418,7 +10311,8 @@ export class ClaudeAcpAgent {
|
||||
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
|
||||
// 0.3.219 (the initialize response used to answer from the spawn-time
|
||||
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
|
||||
// the opt-in — which normalizes away, so only real blockers are retained.
|
||||
// the opt-in — which normalizes to `undefined`, as does `pending`; every
|
||||
// other reason is retained.
|
||||
const fastModeDisabledReason = fastModeEnabled
|
||||
? undefined
|
||||
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);
|
||||
@@ -10466,6 +10360,10 @@ export class ClaudeAcpAgent {
|
||||
},
|
||||
ultracode,
|
||||
);
|
||||
if (creationOpts.optionsOnly) {
|
||||
this.discardUnregisteredQuery(q, input, settingsManager);
|
||||
return { sessionId, modes, configOptions };
|
||||
}
|
||||
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
|
||||
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, ultracodeEnabled));
|
||||
@@ -10584,7 +10482,8 @@ export class ClaudeAcpAgent {
|
||||
this.claudeIsWorking(sessionId, registered) ||
|
||||
(registered.openUserInputs?.size ?? 0) > 0,
|
||||
contextTokens: () => registered.contextUsedTokens,
|
||||
settings: () => registered.settingsManager.getSettings(),
|
||||
settings: () =>
|
||||
registered.settingsManager.getCompactionSettings(configuredSettingsObject),
|
||||
compact: () =>
|
||||
this.prompt(
|
||||
{ sessionId, prompt: [{ type: "text", text: "/compact" }] },
|
||||
@@ -11149,9 +11048,11 @@ export function v1AgentApp(
|
||||
)
|
||||
.onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
|
||||
.onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
|
||||
.onRequest(methods.agent.session.setTitle, (ctx) => agent.setSessionTitle(ctx.params))
|
||||
.onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params))
|
||||
.onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
|
||||
.onRequest(methods.agent.session.status, (ctx) => agent.sessionStatus(ctx.params))
|
||||
.onRequest(methods.agent.session.options, (ctx) => agent.sessionOptions(ctx.params))
|
||||
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
|
||||
.onRequest(methods.agent.session.setConfigOption, (ctx) =>
|
||||
agent.setSessionConfigOption(ctx.params),
|
||||
|
||||
35
src/v1/async-task-stop.ts
Normal file
35
src/v1/async-task-stop.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
/** Stops one Claude background task without cancelling the parent prompt turn. */
|
||||
export const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
|
||||
|
||||
export type AsyncTaskStopRequest = {
|
||||
sessionId: string;
|
||||
asyncTaskId: string;
|
||||
};
|
||||
|
||||
export type AsyncTaskStopResponse = {
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export function parseAsyncTaskStopRequest(value: unknown): AsyncTaskStopRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw RequestError.invalidParams(undefined, "async task stop params must be an object");
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
|
||||
const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
|
||||
if (!sessionId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty sessionId",
|
||||
);
|
||||
}
|
||||
if (!asyncTaskId) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"async task stop params require a non-empty asyncTaskId",
|
||||
);
|
||||
}
|
||||
return { sessionId, asyncTaskId };
|
||||
}
|
||||
147
src/v1/session-fingerprint.ts
Normal file
147
src/v1/session-fingerprint.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
import type { NewSessionMeta } from "../new-session-meta.js";
|
||||
|
||||
/** Normalize skills without changing their SDK semantics. Array order and
|
||||
* duplicates do not affect the selected skill set, so neither should rebuild
|
||||
* the underlying Query process. */
|
||||
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
|
||||
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
|
||||
}
|
||||
|
||||
/** Whether a changed `_meta.claudeCode.options` key on session/load or
|
||||
* session/resume must rebuild the Query process. Almost every option is read
|
||||
* only when the process starts, so the default is `true`. Every key of the
|
||||
* SDK's `Options` must be classified: an SDK bump that adds or removes an
|
||||
* option fails to compile until it is decided here. */
|
||||
const OPTION_REBUILDS_SESSION = {
|
||||
additionalDirectories: true,
|
||||
agentProgressSummaries: true,
|
||||
agents: true,
|
||||
allowDangerouslySkipPermissions: true,
|
||||
allowedTools: true,
|
||||
betas: true,
|
||||
debug: true,
|
||||
debugFile: true,
|
||||
disallowedTools: true,
|
||||
effort: true,
|
||||
enableFileCheckpointing: true,
|
||||
env: true,
|
||||
executableArgs: true,
|
||||
extraArgs: true,
|
||||
fallbackModel: true,
|
||||
forwardSubagentText: true,
|
||||
includeHookEvents: true,
|
||||
managedSettings: true,
|
||||
maxBudgetUsd: true,
|
||||
maxThinkingTokens: true,
|
||||
maxTurns: true,
|
||||
mcpServers: true,
|
||||
model: true,
|
||||
outputFormat: true,
|
||||
pathToClaudeCodeExecutable: true,
|
||||
permissionPrompts: true,
|
||||
permissionPromptToolName: true,
|
||||
persistSession: true,
|
||||
perTaskStopAffordance: true,
|
||||
planModeInstructions: true,
|
||||
pluginDelivery: true,
|
||||
plugins: true,
|
||||
projectConfigRoot: true,
|
||||
promptSuggestions: true,
|
||||
sandbox: true,
|
||||
sessionStoreFlush: true,
|
||||
settings: true,
|
||||
settingSources: true,
|
||||
skills: true,
|
||||
strictMcpConfig: true,
|
||||
supportedDialogKinds: true,
|
||||
systemPrompt: true,
|
||||
taskBudget: true,
|
||||
thinking: true,
|
||||
toolAliases: true,
|
||||
toolConfig: true,
|
||||
tools: true,
|
||||
verbatimPrompts: true,
|
||||
// Managed or ignored by the adapter.
|
||||
agent: false,
|
||||
cwd: false,
|
||||
executable: false,
|
||||
includePartialMessages: false,
|
||||
permissionMode: false,
|
||||
// Per-call controls for attaching to the stored conversation.
|
||||
continue: false,
|
||||
forkSession: false,
|
||||
resume: false,
|
||||
resumeDropsTurn: false,
|
||||
resumeSessionAt: false,
|
||||
sessionId: false,
|
||||
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
|
||||
abortController: false,
|
||||
canUseTool: false,
|
||||
hooks: false,
|
||||
onElicitation: false,
|
||||
onUserDialog: false,
|
||||
sessionStore: false,
|
||||
spawnClaudeCodeProcess: false,
|
||||
stderr: false,
|
||||
// No effect on an already-created session.
|
||||
loadTimeoutMs: false,
|
||||
title: false,
|
||||
} as const satisfies Record<keyof Options, boolean>;
|
||||
|
||||
/** A JSON value with object keys sorted, so key order never changes a
|
||||
* fingerprint. Functions and class instances (hook callbacks, abort
|
||||
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
|
||||
* stable serialized form, so they are left out. */
|
||||
function canonicalJson(value: unknown): unknown {
|
||||
if (typeof value === "function") return undefined;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(canonicalJson);
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) return undefined;
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
|
||||
.filter(([, entry]) => entry !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
* detect when a loadSession/resumeSession call requires tearing down and
|
||||
* recreating the underlying Query process. Every option classified in
|
||||
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
|
||||
* keeps stale values. MCP servers are sorted by name, and skills are
|
||||
* normalized as a set, so ordering differences don't trigger unnecessary
|
||||
* recreations. */
|
||||
export function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
additionalDirectories?: NewSessionRequest["additionalDirectories"];
|
||||
systemPrompt?: NewSessionRequest["systemPrompt"];
|
||||
_meta?: NewSessionRequest["_meta"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
|
||||
const options: Record<string, unknown> = Object.fromEntries(
|
||||
Object.entries(meta?.claudeCode?.options ?? {}).filter(
|
||||
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
|
||||
),
|
||||
);
|
||||
options.skills = normalizeSkills(options.skills as Options["skills"]);
|
||||
return JSON.stringify(
|
||||
canonicalJson({
|
||||
cwd: params.cwd,
|
||||
mcpServers: servers,
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
additionalRoots: meta?.additionalRoots,
|
||||
systemPrompt: meta?.systemPrompt,
|
||||
clientSystemPrompt: params.systemPrompt ?? undefined,
|
||||
disableBuiltInTools: meta?.disableBuiltInTools,
|
||||
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
|
||||
options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
type ListSessionsResponse,
|
||||
type SetSessionTitleRequest,
|
||||
type SetSessionTitleResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Session } from "../acp-agent.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import type { Logger } from "../../logger.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,
|
||||
@@ -29,9 +30,10 @@ import {
|
||||
AIR_SESSION_WORKTREES_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "../../air-extension.js";
|
||||
import type { OwnSessionState } from "./activity.js";
|
||||
import { isArchivedTitle, storedTitle, visibleTitle } from "./archive-title.js";
|
||||
} from "../air-extension.js";
|
||||
import type { OwnSessionState } from "../session-index/activity.js";
|
||||
import { isArchivedTitle, visibleTitle } from "../session-index/archive-title.js";
|
||||
import { renameSession, retitleSession, type RetitledSession } from "../session-index/rename.js";
|
||||
import {
|
||||
LIST_CHANGES_METHOD,
|
||||
LIST_SUBSCRIBE_METHOD,
|
||||
@@ -41,12 +43,11 @@ import {
|
||||
type ListSubscribeRequest,
|
||||
type ListSubscribeResponse,
|
||||
type ListUnsubscribeRequest,
|
||||
} from "./list-subscriptions.js";
|
||||
} from "../session-index/list-subscriptions.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
deleteSessionIfStored,
|
||||
archiveTo,
|
||||
renameTo,
|
||||
parseRenameSessionRequest,
|
||||
parseSessionIdRequest,
|
||||
parseBatchGetSessionsRequest,
|
||||
@@ -55,13 +56,12 @@ import {
|
||||
SESSION_RENAME_METHOD,
|
||||
SESSION_UNARCHIVE_METHOD,
|
||||
SessionIndexService,
|
||||
transcriptWriter,
|
||||
type RenameSessionRequest,
|
||||
type RetitleOptions,
|
||||
type SessionIdRequest,
|
||||
type TitleChange,
|
||||
type BatchGetSessionsRequest,
|
||||
type BatchGetSessionsResponse,
|
||||
} from "./service.js";
|
||||
} from "../session-index/service.js";
|
||||
|
||||
/** What the session index keeps on each `Session`. */
|
||||
export type SessionIndexFields = {
|
||||
@@ -115,9 +115,9 @@ export class SessionIndexConnection {
|
||||
* exactly as before.
|
||||
*/
|
||||
service?: SessionIndexService;
|
||||
/** The archive behind `session/delete` of an AIR client without
|
||||
* `sessionIndex`: the same writes, without a list or a watcher. */
|
||||
private archiver?: SessionIndexService;
|
||||
/** The transcript writes of a client without `sessionIndex` (see
|
||||
* {@link transcriptWriter}). */
|
||||
private transcripts?: SessionIndexService;
|
||||
/** When the query of a session failed here, by session id. */
|
||||
private readonly failedQueries = new Map<string, number>();
|
||||
|
||||
@@ -250,24 +250,34 @@ export class SessionIndexConnection {
|
||||
return sessions.filter((session) => !isArchivedTitle(session.customTitle));
|
||||
}
|
||||
|
||||
/** `_session/rename`: names a session; no generated title replaces it.
|
||||
* An archived session stays archived. */
|
||||
/** `_session/rename` of a `sessionIndex` client (see {@link renameSession}). */
|
||||
async rename(request: RenameSessionRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_RENAME_METHOD);
|
||||
const { sessionId } = request;
|
||||
// As the CLI keeps it, and never with the archive prefix: a rename does
|
||||
// not change the archive state.
|
||||
const title = storedTitle(request.title, false, sessionId);
|
||||
// The title as stored: an archived one is cut with its prefix.
|
||||
const shown = (stored: string | undefined) =>
|
||||
stored === undefined ? title : visibleTitle(stored);
|
||||
await this.retitle(index, sessionId, renameTo(title), shown, {
|
||||
mayBeUnwritten: true,
|
||||
sidecar: "always",
|
||||
});
|
||||
await this.renameWith(this.requireService(SESSION_RENAME_METHOD), request);
|
||||
return {};
|
||||
}
|
||||
|
||||
/** `session/set_title`, for every client: the rename of `_session/rename`,
|
||||
* without the session index of a client that did not ask for it. */
|
||||
async setTitle(request: SetSessionTitleRequest): Promise<SetSessionTitleResponse> {
|
||||
const title = await this.renameWith(
|
||||
this.service ?? this.writer(),
|
||||
parseRenameSessionRequest(request),
|
||||
);
|
||||
return { title };
|
||||
}
|
||||
|
||||
private renameWith(index: SessionIndexService, request: RenameSessionRequest): Promise<string> {
|
||||
return renameSession(index, request, this.loaded(request.sessionId), (stored) =>
|
||||
this.clientTitle(stored),
|
||||
);
|
||||
}
|
||||
|
||||
/** The session loaded here, as a rename reaches it. */
|
||||
private loaded(sessionId: string): RetitledSession | undefined {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
return session && { session, titles: session.titles };
|
||||
}
|
||||
|
||||
/** `_session/archive`: hides a session from the default list and stops
|
||||
* it. Idempotent; the session need not be loaded. A session loaded here
|
||||
* is interrupted, retitled through its CLI while that still runs, then
|
||||
@@ -279,7 +289,7 @@ export class SessionIndexConnection {
|
||||
// No turn goes on after the archive: the interrupt goes to the CLI ahead
|
||||
// of the rename.
|
||||
if (loaded) await this.host.interruptSession(sessionId);
|
||||
await this.retitle(index, sessionId, archiveTo(true), undefined, {
|
||||
await retitleSession(index, sessionId, archiveTo(true), undefined, this.loaded(sessionId), {
|
||||
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
||||
sidecar: "existing",
|
||||
});
|
||||
@@ -308,7 +318,7 @@ export class SessionIndexConnection {
|
||||
async unarchive(params: SessionIdRequest): Promise<EmptyResponse> {
|
||||
const index = this.requireService(SESSION_UNARCHIVE_METHOD);
|
||||
const { sessionId } = params;
|
||||
await this.retitle(index, sessionId, archiveTo(false), undefined, {
|
||||
await retitleSession(index, sessionId, archiveTo(false), undefined, this.loaded(sessionId), {
|
||||
mayBeUnwritten: this.isUnwrittenSession(sessionId),
|
||||
sidecar: "existing",
|
||||
});
|
||||
@@ -316,58 +326,6 @@ export class SessionIndexConnection {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitles a session (see {@link SessionIndexService.retitle}). A session
|
||||
* this connection runs is titled through its CLI (`rename_session`). A
|
||||
* loaded session's title state takes the change as a client title, so no
|
||||
* generated title replaces it; `publish` is the title it then publishes.
|
||||
*/
|
||||
private async retitle(
|
||||
index: SessionIndexService,
|
||||
sessionId: string,
|
||||
change: TitleChange,
|
||||
publish: Parameters<Session["titles"]["setExplicitTitle"]>[0],
|
||||
options: Pick<RetitleOptions, "mayBeUnwritten" | "sidecar">,
|
||||
): Promise<void> {
|
||||
const session = this.host.agent.sessions[sessionId];
|
||||
// Decided once a title generation in flight has ended: the query may
|
||||
// have closed meanwhile.
|
||||
const persist = () => {
|
||||
const query = session?.query as
|
||||
| (Query & { renameSession?: (title: string, sessionId?: string) => Promise<void> })
|
||||
| undefined;
|
||||
// A closed session's CLI is gone here: only a running query titles it.
|
||||
const running = session !== undefined && !session.queryClosed;
|
||||
if (running && typeof query?.renameSession !== "function") {
|
||||
// Its CLI holds the title and would write it back over ours.
|
||||
throw RequestError.invalidRequest(
|
||||
{ sessionId },
|
||||
"The Claude Code CLI of this session cannot change its title while it runs.",
|
||||
);
|
||||
}
|
||||
const live = running
|
||||
? {
|
||||
cwd: session.cwd,
|
||||
rename: (title: string) => query!.renameSession!(title, sessionId),
|
||||
stored: () => session.titles.storedTitle,
|
||||
remember: (title: string) => session.titles.rememberStoredTitle(title),
|
||||
shown: session.titles.shownTitle,
|
||||
}
|
||||
: undefined;
|
||||
return index.retitle(sessionId, change, {
|
||||
...options,
|
||||
// Without its CLI, a session needs its transcript.
|
||||
mayBeUnwritten: live !== undefined && options.mayBeUnwritten,
|
||||
live,
|
||||
});
|
||||
};
|
||||
if (session) {
|
||||
await session.titles.setExplicitTitle(publish, persist);
|
||||
} else {
|
||||
await persist();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/delete`:
|
||||
* - A `sessionIndex` client deletes for real: every transcript, whoever
|
||||
@@ -388,19 +346,19 @@ export class SessionIndexConnection {
|
||||
if (this.service) {
|
||||
await this.service.delete(params.sessionId, loaded);
|
||||
} else if (this.host.isAirClient()) {
|
||||
await archiveInsteadOfDelete(params.sessionId, this.airArchiver());
|
||||
await archiveInsteadOfDelete(params.sessionId, this.writer());
|
||||
} else {
|
||||
await deleteSessionIfStored(params.sessionId);
|
||||
// After a `session/set_title` of the session in flight.
|
||||
await this.writer().exclusive(params.sessionId, () =>
|
||||
deleteSessionIfStored(params.sessionId),
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private airArchiver(): SessionIndexService {
|
||||
this.archiver ??= new SessionIndexService({
|
||||
logError: (message, error) =>
|
||||
this.host.agent.logger.error(`[session-index] ${message}:`, error),
|
||||
});
|
||||
return this.archiver;
|
||||
private writer(): SessionIndexService {
|
||||
this.transcripts ??= transcriptWriter(this.host.agent.logger);
|
||||
return this.transcripts;
|
||||
}
|
||||
|
||||
private requireService(method: string): SessionIndexService {
|
||||
73
src/v1/steering.ts
Normal file
73
src/v1/steering.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { PromptRequest, RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
|
||||
/** Custom (extension) request method a client uses to steer the turn that is
|
||||
* currently running: the message is injected into the in-flight turn rather
|
||||
* than queued as a separate `session/prompt`. Named `_session/steering` per the
|
||||
* agreed ACP steering wire protocol; advertised to clients via the top-level
|
||||
* `InitializeResponse._meta.steering.supported`. */
|
||||
export const STEER_METHOD = "_session/steering";
|
||||
|
||||
/** How urgently the SDK delivers a steered message relative to the running
|
||||
* turn — an internal Claude implementation detail, not part of the wire
|
||||
* contract. `now` pre-empts the current generation, while `later` waits for a
|
||||
* pending permission/elicitation callback to settle instead of cancelling its
|
||||
* ACP request and hiding the client's user-input card (IJAI-1191). */
|
||||
export const STEER_PRIORITY_NOW = "now" as const;
|
||||
export const STEER_PRIORITY_LATER = "later" as const;
|
||||
|
||||
/** Request-level steering options. `promptRequired` is opt-in so existing Hosts
|
||||
* keep the established idle fallback behavior. */
|
||||
type SteerMeta = {
|
||||
[key: string]: unknown;
|
||||
steering?: {
|
||||
idleBehavior?: "promptRequired";
|
||||
};
|
||||
};
|
||||
|
||||
/** Params of a {@link STEER_METHOD} request. Shaped like the relevant subset of
|
||||
* a `PromptRequest` so the same `promptToClaude` conversion applies. Delivery
|
||||
* priority is deliberately NOT exposed here — it's an internal detail the agent
|
||||
* chooses (see {@link STEER_PRIORITY}). */
|
||||
export type SteerRequest = {
|
||||
sessionId: string;
|
||||
prompt: PromptRequest["prompt"];
|
||||
_meta?: SteerMeta | null;
|
||||
};
|
||||
|
||||
/** Result of a {@link STEER_METHOD} request. The legacy `startedNewTurn` result
|
||||
* remains the default idle behavior; `promptRequired` is returned only when the
|
||||
* Host explicitly opts into the host-owned fallback in request `_meta`. */
|
||||
export type SteerResponse =
|
||||
| { outcome: "injected" }
|
||||
| { outcome: "startedNewTurn" }
|
||||
| { outcome: "promptRequired"; reason: "noRunningTurn" };
|
||||
|
||||
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
|
||||
* content blocks are handed to `promptToClaude`, which tolerates unknown block
|
||||
* types — but `sessionId` and a non-empty `prompt` array are required. */
|
||||
export function parseSteerRequest(params: unknown): SteerRequest {
|
||||
if (!params || typeof params !== "object") {
|
||||
throw RequestError.invalidParams(undefined, "steer params must be an object");
|
||||
}
|
||||
const { sessionId, prompt, _meta } = params as Record<string, unknown>;
|
||||
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
|
||||
}
|
||||
if (!Array.isArray(prompt) || prompt.length === 0) {
|
||||
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
|
||||
}
|
||||
const steering =
|
||||
_meta && typeof _meta === "object" ? (_meta as Record<string, unknown>).steering : undefined;
|
||||
const idleBehavior =
|
||||
steering && typeof steering === "object"
|
||||
? (steering as Record<string, unknown>).idleBehavior
|
||||
: undefined;
|
||||
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
|
||||
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
prompt: prompt as PromptRequest["prompt"],
|
||||
_meta: _meta as SteerMeta | null | undefined,
|
||||
};
|
||||
}
|
||||
229
src/v2/agent.ts
229
src/v2/agent.ts
@@ -39,17 +39,32 @@ import { forkSession } from "../fork-session.js";
|
||||
import { supportsMcpOAuth } from "../mcp-oauth.js";
|
||||
import { sdkMcpServers } from "../mcp-servers.js";
|
||||
import { ALLOW_BYPASS, autoModeDisabled } from "../permissions/modes.js";
|
||||
import { readResumedTail, resumedModelFromTranscript } from "../resumed-session.js";
|
||||
import {
|
||||
readHistoryAcrossCompactions,
|
||||
readResumedTail,
|
||||
resumedModelFromTranscript,
|
||||
} from "../resumed-session.js";
|
||||
import { mergeEffortSettings } from "../session-effort.js";
|
||||
import { configuredSettings, resolveThinkingConfig } from "../session-env.js";
|
||||
import { resolveInitialPermissionMode } from "../session-mode.js";
|
||||
import { sanitizeTitle } from "../session-titles.js";
|
||||
import { renameSession } from "../session-index/rename.js";
|
||||
import {
|
||||
parseRenameSessionRequest,
|
||||
transcriptWriter,
|
||||
type SessionIndexService,
|
||||
} from "../session-index/service.js";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js";
|
||||
import { createPostToolUseHook } from "../tools.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { V2_SESSION_CAPABILITIES } from "./initialize.js";
|
||||
import { v1ForkSessionRequest, v1NewSessionRequest } from "./session-requests.js";
|
||||
import {
|
||||
initialConfigRequests,
|
||||
v1ForkSessionRequest,
|
||||
v1NewSessionRequest,
|
||||
} from "./session-requests.js";
|
||||
import { SessionCommands } from "./commands.js";
|
||||
import { V2UpdateTranslator } from "./update-translator.js";
|
||||
import { airInitializeMeta, airSupport, NO_AIR, type AirSupport } from "./air.js";
|
||||
import { V2Auth } from "./auth.js";
|
||||
@@ -108,7 +123,9 @@ export function v2AgentApp(
|
||||
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
|
||||
.onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params))
|
||||
.onRequest(v2.methods.agent.session.status, ({ params }) => agent.sessionStatus(params))
|
||||
.onRequest(v2.methods.agent.session.options, ({ params }) => agent.sessionOptions(params))
|
||||
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
|
||||
.onRequest(v2.methods.agent.session.setTitle, ({ params }) => agent.setTitle(params))
|
||||
.onRequest(v2.methods.agent.session.setConfigOption, ({ params }) =>
|
||||
agent.setConfigOption(params),
|
||||
)
|
||||
@@ -154,6 +171,8 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
private readonly guard: SubscriptionGuard;
|
||||
/** The restarts of the sessions' queries, for a new route or account (see `restarts.ts`). */
|
||||
private readonly restarts: SessionRestarts;
|
||||
/** The transcript writes of `session/set_title`, made at its first call (see `transcriptWriter`). */
|
||||
private transcripts: SessionIndexService | undefined;
|
||||
/** What the client declared in `initialize`. */
|
||||
private clientCapabilities: v2.ClientCapabilities | null | undefined;
|
||||
/** The elicitation modes that the client advertised in `initialize`. */
|
||||
@@ -308,7 +327,9 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
|
||||
async newSession(params: v2.NewSessionRequest): Promise<v2.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const initial = initialConfigRequests(sessionId, params.configOptions);
|
||||
const session = await this.startSession(sessionId, params, undefined);
|
||||
await session.setInitialConfigOptions(initial);
|
||||
const response = { sessionId, ...(await this.setupFields(session)) };
|
||||
this.afterSetup(session, params);
|
||||
return response;
|
||||
@@ -335,7 +356,9 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
/**
|
||||
* Resumes a stored session, or attaches to a live one. With `replayFrom:
|
||||
* { type: "start" }` the stored history replays first. Either way, the
|
||||
* state snapshot goes out after any replay and before the response.
|
||||
* state snapshot goes out after any replay and before the response. The
|
||||
* request's `configOptions` are set before the replay, on an active session
|
||||
* too.
|
||||
*/
|
||||
resumeSession(params: v2.ResumeSessionRequest): Promise<v2.ResumeSessionResponse> {
|
||||
const { sessionId } = params;
|
||||
@@ -361,23 +384,21 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
);
|
||||
}
|
||||
if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "resume");
|
||||
const initial = initialConfigRequests(sessionId, params.configOptions);
|
||||
await this.restarts.settled(sessionId);
|
||||
// A concurrent resume of this session started its query: this one then
|
||||
// attaches to the session that it started.
|
||||
await this.startSettled(sessionId);
|
||||
let session = this.sessions.get(sessionId);
|
||||
let history: Promise<SessionMessage[]> | undefined;
|
||||
// The replay: the messages before each compaction too, unless reading them fails.
|
||||
let replayed: Promise<SessionMessage[]> | undefined;
|
||||
if (session) {
|
||||
// Resuming the active session synchronizes it again. Its work goes on,
|
||||
// and the request's MCP servers and additional directories replace the
|
||||
// session's.
|
||||
const request = await validatedSetup(params);
|
||||
if (request.cwd !== session.cwd) {
|
||||
throw RequestError.invalidParams(
|
||||
{ cwd: request.cwd },
|
||||
`Session ${sessionId} works in ${session.cwd}: resume it with that \`cwd\``,
|
||||
);
|
||||
}
|
||||
assertCwdOf(session, request.cwd, "resume it");
|
||||
await session.setMcpServers({
|
||||
...clientOptions(params._meta)?.mcpServers,
|
||||
...sdkMcpServers(request.mcpServers),
|
||||
@@ -393,13 +414,23 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
} else {
|
||||
// One read of the transcript serves the replay and the resumed model.
|
||||
history = replayFrom != null ? getSessionMessages(sessionId, { dir: params.cwd }) : undefined;
|
||||
// A failed start leaves it unawaited.
|
||||
// The read across compactions overlaps the start of the query.
|
||||
replayed = history?.then((messages) =>
|
||||
readHistoryAcrossCompactions(sessionId, messages, this.logger, params.cwd),
|
||||
);
|
||||
// A failed start leaves them unawaited.
|
||||
history?.catch(() => {});
|
||||
replayed?.catch(() => {});
|
||||
session = await this.startSession(sessionId, params, sessionId, history);
|
||||
}
|
||||
await session.setInitialConfigOptions(initial);
|
||||
let subagents: SubagentReplay | undefined;
|
||||
if (replayFrom != null) {
|
||||
const messages = await (history ?? getSessionMessages(sessionId, { dir: session.cwd }));
|
||||
const cwd = session.cwd;
|
||||
const messages = await (replayed ??
|
||||
getSessionMessages(sessionId, { dir: cwd }).then((chain) =>
|
||||
readHistoryAcrossCompactions(sessionId, chain, this.logger, cwd),
|
||||
));
|
||||
subagents = await session.tasks.loadReplay(messages);
|
||||
session.output.replay(
|
||||
messages,
|
||||
@@ -473,6 +504,66 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
return savedSessionStatus(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/options`: the config options and commands that `session/new`
|
||||
* with the same `cwd`, additional directories and `_meta` would answer
|
||||
* with, or `session/resume` of `sessionId`.
|
||||
*
|
||||
* An active session answers with what it has now. Otherwise a query starts
|
||||
* as the session's would, without MCP servers, and closes once its options
|
||||
* are read. So the commands lack the MCP prompts, and a failed read of the
|
||||
* commands leaves them out. The query does not persist
|
||||
* (`persistSession: false`): it stores no session, and it does not write to
|
||||
* the transcript of `sessionId`. Claude Code runs the user's `SessionStart`
|
||||
* hooks in it, as in every query.
|
||||
*
|
||||
* It sends no update, and it creates, resumes and changes no session. A
|
||||
* `sessionId` without a stored conversation fails with `-32002`.
|
||||
*/
|
||||
async sessionOptions(params: v2.SessionOptionsRequest): Promise<v2.SessionOptionsResponse> {
|
||||
const { sessionId } = params;
|
||||
const setup: v2.NewSessionRequest = {
|
||||
cwd: params.cwd,
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
_meta: params._meta,
|
||||
};
|
||||
let session: V2Session | undefined;
|
||||
if (sessionId != null) {
|
||||
if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "read the options of");
|
||||
// A restart or a resume that runs makes the session active.
|
||||
await this.restarts.settled(sessionId);
|
||||
await this.startSettled(sessionId);
|
||||
session = this.sessions.get(sessionId);
|
||||
}
|
||||
const fields = (config: SessionConfig, commands: v2.AvailableCommand[]) => ({
|
||||
configOptions: config.options(),
|
||||
// The draft leaves out an empty list.
|
||||
...(commands.length > 0 ? { availableCommands: commands } : {}),
|
||||
});
|
||||
const listed = (commands: SessionCommands) =>
|
||||
commands.list().catch((error: unknown) => {
|
||||
this.logger.error("session/options: failed to read the commands", error);
|
||||
return [];
|
||||
});
|
||||
if (session) {
|
||||
assertCwdOf(session, (await validatedSetup(setup)).cwd, "ask for its options");
|
||||
return fields(session.config, await listed(session.commands));
|
||||
}
|
||||
// The probe's own id: its records never meet those of the session `sessionId`.
|
||||
const probeId = randomUUID();
|
||||
const probe = await this.openQuery(probeId, setup, sessionId ?? undefined, undefined, {
|
||||
persist: false,
|
||||
});
|
||||
try {
|
||||
return fields(probe.config, await listed(new SessionCommands(probe.query)));
|
||||
} finally {
|
||||
probe.query.close();
|
||||
probe.settings.dispose();
|
||||
this.auth.sessionEnded(probeId);
|
||||
this.guard.sessionEnded(probeId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Closing is idempotent: a session that is not active closes at once. */
|
||||
async closeSession({ sessionId }: v2.CloseSessionRequest): Promise<v2.CloseSessionResponse> {
|
||||
if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "close");
|
||||
@@ -502,7 +593,9 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
const live = this.sessions.get(sessionId);
|
||||
if (live || this.stopped.has(sessionId)) await this.closeSession({ sessionId });
|
||||
try {
|
||||
await deleteSession(sessionId, live ? { dir: live.cwd } : undefined);
|
||||
// After a `session/set_title` of the session in flight.
|
||||
const remove = () => deleteSession(sessionId, live ? { dir: live.cwd } : undefined);
|
||||
await (this.transcripts ? this.transcripts.exclusive(sessionId, remove) : remove());
|
||||
} catch (error) {
|
||||
// A session without a transcript, such as one never prompted, has nothing to delete.
|
||||
if (error instanceof Error && /not found/i.test(error.message)) return {};
|
||||
@@ -511,6 +604,30 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/set_title`, as the v1 core does it (see `session-index/rename.ts`).
|
||||
* The session need not be active. An active one publishes the title, and no
|
||||
* generated title replaces it.
|
||||
*/
|
||||
async setTitle(params: v2.SetSessionTitleRequest): Promise<v2.SetSessionTitleResponse> {
|
||||
const request = parseRenameSessionRequest(params);
|
||||
const { sessionId } = request;
|
||||
if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "retitle");
|
||||
// Renamed through the query that runs once a restart or resume settled.
|
||||
await this.restarts.settled(sessionId);
|
||||
await this.setupSettled(sessionId);
|
||||
const writer = (this.transcripts ??= transcriptWriter(this.logger));
|
||||
for (;;) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
const title = await renameSession(writer, request, session?.reports.retitled());
|
||||
// A restart or resume started another query of the session while the
|
||||
// rename ran: its CLI may hold the old title and write it back, so it
|
||||
// is renamed too. A session that only closed took the title already.
|
||||
const now = this.sessions.get(sessionId);
|
||||
if (now === undefined || now === session) return { title };
|
||||
}
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
params: v2.SetSessionConfigOptionRequest,
|
||||
): Promise<v2.SetSessionConfigOptionResponse> {
|
||||
@@ -683,10 +800,7 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
return start;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query start of {@link startSession}. Its options are the client's
|
||||
* `_meta.claudeCode.options` under the ones this core controls.
|
||||
*/
|
||||
/** The query start of {@link startSession}: {@link openQuery}, then the session is active. */
|
||||
private async launchSession(
|
||||
sessionId: string,
|
||||
params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest,
|
||||
@@ -694,6 +808,50 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
/** The resumed transcript, when the caller reads it anyway (a replay). */
|
||||
history?: Promise<SessionMessage[]>,
|
||||
): Promise<V2Session> {
|
||||
const {
|
||||
session,
|
||||
query: q,
|
||||
config,
|
||||
models,
|
||||
...opened
|
||||
} = await this.openQuery(sessionId, params, resume, history);
|
||||
session.reports.configure({
|
||||
settings: opened.settings,
|
||||
configuredSettings: opened.configured,
|
||||
env: opened.env,
|
||||
mcpServers: opened.request.mcpServers.map((server) => server.name),
|
||||
mcpOAuth: this.elicitation.url && supportsMcpOAuth(q),
|
||||
query: q,
|
||||
model: { id: models.models.currentModelId, info: models.allowlistedModelInfo },
|
||||
});
|
||||
session.start(q, config, opened.mcpServers, resume !== undefined);
|
||||
this.sessions.set(sessionId, session);
|
||||
this.stopped.delete(sessionId);
|
||||
// A route change during the start did not find this session live.
|
||||
this.restarts.track(sessionId, params, {
|
||||
staleRoute: this.auth.routeGeneration !== opened.routeGeneration,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the query of a session and builds its config. Its options are the
|
||||
* client's `_meta.claudeCode.options` under the ones this core controls.
|
||||
* The session is not active yet, and nothing of it goes out. The caller
|
||||
* closes the query and disposes the settings when it does not keep them.
|
||||
*
|
||||
* `resume`: the stored session that the query resumes, `sessionId` itself
|
||||
* but for a `session/options` probe. Without `persist`, Claude Code stores
|
||||
* nothing of the query (see {@link sessionOptions}).
|
||||
*/
|
||||
private async openQuery(
|
||||
sessionId: string,
|
||||
params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest,
|
||||
resume: string | undefined,
|
||||
/** The resumed transcript, when the caller reads it anyway (a replay). */
|
||||
history?: Promise<SessionMessage[]>,
|
||||
{ persist = true } = {},
|
||||
) {
|
||||
const request = await validatedSetup(params);
|
||||
const client = clientOptions(params._meta);
|
||||
// After a provider change that is still listing its models.
|
||||
@@ -842,6 +1000,7 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
...STARTUP_FAILURE_RESULTS_ENV,
|
||||
},
|
||||
...(resume !== undefined ? { resume } : { sessionId }),
|
||||
...(persist ? {} : { persistSession: false }),
|
||||
};
|
||||
const q = query({ prompt: input, options });
|
||||
let initialization;
|
||||
@@ -855,7 +1014,7 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
error instanceof Error &&
|
||||
error.message.includes("No conversation found with session ID")
|
||||
) {
|
||||
throw RequestError.sessionNotFound(sessionId);
|
||||
throw RequestError.sessionNotFound(resume);
|
||||
}
|
||||
throw startupFailureError(error) ?? error;
|
||||
}
|
||||
@@ -880,6 +1039,7 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
resumed,
|
||||
sessionId,
|
||||
this.logger,
|
||||
configured,
|
||||
);
|
||||
} catch (error) {
|
||||
// Claude Code refused a fresh session's model pin.
|
||||
@@ -916,22 +1076,18 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
settings.dispose();
|
||||
throw error;
|
||||
}
|
||||
session.reports.configure({
|
||||
settings,
|
||||
env: options.env ?? {},
|
||||
mcpServers: request.mcpServers.map((server) => server.name),
|
||||
mcpOAuth: this.elicitation.url && supportsMcpOAuth(q),
|
||||
return {
|
||||
session,
|
||||
query: q,
|
||||
model: { id: models.models.currentModelId, info: models.allowlistedModelInfo },
|
||||
});
|
||||
session.start(q, config, mcpServers, resume !== undefined);
|
||||
this.sessions.set(sessionId, session);
|
||||
this.stopped.delete(sessionId);
|
||||
// A route change during the start did not find this session live.
|
||||
this.restarts.track(sessionId, params, {
|
||||
staleRoute: this.auth.routeGeneration !== routeGeneration,
|
||||
});
|
||||
return session;
|
||||
config,
|
||||
models,
|
||||
settings,
|
||||
configured,
|
||||
env: options.env ?? {},
|
||||
request,
|
||||
mcpServers,
|
||||
routeGeneration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +1122,15 @@ async function validatedSetup(
|
||||
return request;
|
||||
}
|
||||
|
||||
/** A request of the active `session` names its `cwd`, as `session/resume` must. */
|
||||
function assertCwdOf(session: V2Session, cwd: string, action: string): void {
|
||||
if (cwd === session.cwd) return;
|
||||
throw RequestError.invalidParams(
|
||||
{ cwd },
|
||||
`Session ${session.sessionId} works in ${session.cwd}: ${action} with that \`cwd\``,
|
||||
);
|
||||
}
|
||||
|
||||
function sameDirectories(left: readonly string[], right: readonly string[]): boolean {
|
||||
const normalize = (directories: readonly string[]) =>
|
||||
[...new Set(directories.map((directory) => path.resolve(directory)))].sort().join("\n");
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
Query,
|
||||
Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { applyInitialConfigOptions } from "../initial-config-options.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { SubagentModels } from "../native-subagents.js";
|
||||
import { DEFAULT_AGENT_ID } from "../session-config-ids.js";
|
||||
@@ -346,6 +347,19 @@ export class SessionConfig {
|
||||
return this.serialized(() => this.apply(params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the initial values of a setup request's `configOptions`, in list
|
||||
* order, as {@link set} sets each one. An entry that the session refuses is
|
||||
* skipped (`applyInitialConfigOptions`). Nothing goes out: the setup
|
||||
* response carries the options, and the notice of an Auto mode fallback
|
||||
* waits for the next prompt.
|
||||
*/
|
||||
setInitial(entries: readonly v2.SetSessionConfigOptionRequest[]): Promise<void> {
|
||||
return this.serialized(() =>
|
||||
applyInitialConfigOptions(entries, (entry) => this.apply(entry, true), this.logger),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the choices of `previous`, the config of the query that this one
|
||||
* replaced (see `restarts.ts`), as the v1 core's `restoreSessionChoices`
|
||||
@@ -411,7 +425,11 @@ export class SessionConfig {
|
||||
return result;
|
||||
}
|
||||
|
||||
private async apply(params: v2.SetSessionConfigOptionRequest): Promise<void> {
|
||||
/** `duringSetup`: see {@link setInitial}. */
|
||||
private async apply(
|
||||
params: v2.SetSessionConfigOptionRequest,
|
||||
duringSetup = false,
|
||||
): Promise<void> {
|
||||
const request = v1SetSessionConfigOptionRequest(params);
|
||||
const option = this.session.configOptions.find((entry) => entry.id === request.configId);
|
||||
if (!option) {
|
||||
@@ -429,11 +447,11 @@ export class SessionConfig {
|
||||
const value = selected.value;
|
||||
switch (request.configId) {
|
||||
case MODE_CONFIG_ID:
|
||||
await this.applyMode(value as PermissionMode);
|
||||
await this.applyMode(value as PermissionMode, duringSetup);
|
||||
return;
|
||||
case MODEL_CONFIG_ID:
|
||||
await this.session.query.setModel(value);
|
||||
await this.recordModel(value);
|
||||
await this.recordModel(value, duringSetup);
|
||||
return;
|
||||
case EFFORT_CONFIG_ID:
|
||||
// Applied first, so a rejected control request leaves the option as it was.
|
||||
@@ -472,11 +490,17 @@ export class SessionConfig {
|
||||
return this.rules.effectiveMode(this.session, requested);
|
||||
}
|
||||
|
||||
private async applyMode(requested: PermissionMode): Promise<void> {
|
||||
private async applyMode(requested: PermissionMode, duringSetup = false): Promise<void> {
|
||||
const mode = this.rules.effectiveMode(this.session, requested);
|
||||
await this.session.query.setPermissionMode(mode);
|
||||
this.rules.syncConfig(this.session, mode);
|
||||
if (mode !== requested) await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
if (mode !== requested) await this.warnOfFallback(duringSetup);
|
||||
}
|
||||
|
||||
/** The notice of an Auto mode fallback: now, or with the next prompt during setup. */
|
||||
private async warnOfFallback(duringSetup: boolean): Promise<void> {
|
||||
if (duringSetup) this.rules.deferFallbackWarning(this.session);
|
||||
else await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
}
|
||||
|
||||
/** Claude Code switched the mode itself. Returns whether the option changed. */
|
||||
@@ -606,7 +630,7 @@ export class SessionConfig {
|
||||
* show only on a model that supports them, with the user's retained
|
||||
* choice.
|
||||
*/
|
||||
private async recordModel(value: string): Promise<void> {
|
||||
private async recordModel(value: string, duringSetup = false): Promise<void> {
|
||||
const info = this.modelInfos.find((model) => model.value === value);
|
||||
this.session.models = { ...this.session.models, currentModelId: value };
|
||||
const modeDowngraded = await this.rules.reconcileForModel(this.session, info);
|
||||
@@ -664,7 +688,7 @@ export class SessionConfig {
|
||||
effort.error,
|
||||
);
|
||||
}
|
||||
if (modeDowngraded) await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
if (modeDowngraded) await this.warnOfFallback(duringSetup);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user