mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-06 17:31:36 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84845272f | ||
|
|
af0b43de94 | ||
|
|
f53b0d7f40 | ||
|
|
4af807968c | ||
|
|
bdb50ad984 | ||
|
|
d5e6f33046 | ||
|
|
691328a919 | ||
|
|
49858b5273 | ||
|
|
77ceab0339 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.82.0"
|
||||
".": "0.85.0"
|
||||
}
|
||||
|
||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.286 ([#1211](https://github.com/agentclientprotocol/claude-agent-acp/issues/1211)) ([f53b0d7](https://github.com/agentclientprotocol/claude-agent-acp/commit/f53b0d7f40bcd0e9bddace85f123ff4851afcb1f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt to SDK 0.3.284 replay and ultracode changes ([#1208](https://github.com/agentclientprotocol/claude-agent-acp/issues/1208)) ([4af8079](https://github.com/agentclientprotocol/claude-agent-acp/commit/4af807968c7f0813bcb20fe2bcab2370320e9074))
|
||||
* send the legacy custom answer key to AIR ([#1210](https://github.com/agentclientprotocol/claude-agent-acp/issues/1210)) ([af0b43d](https://github.com/agentclientprotocol/claude-agent-acp/commit/af0b43de944e36f81343f40b7aa1a96cd48c3218))
|
||||
|
||||
## [0.84.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.83.0...v0.84.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude SDK ([#1189](https://github.com/agentclientprotocol/claude-agent-acp/issues/1189)) ([d5e6f33](https://github.com/agentclientprotocol/claude-agent-acp/commit/d5e6f33046614fca4798d49f0c77edba951a6771))
|
||||
|
||||
## [0.83.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.82.0...v0.83.0) (2026-09-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** Bump @anthropic-ai/claude-agent-sdk to 0.3.283 ([#1186](https://github.com/agentclientprotocol/claude-agent-acp/issues/1186)) ([49858b5](https://github.com/agentclientprotocol/claude-agent-acp/commit/49858b527397a08d343fe2c148a5fe9e3eb84547))
|
||||
|
||||
## [0.82.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.81.2...v0.82.0) (2026-09-28)
|
||||
|
||||
|
||||
|
||||
@@ -1204,6 +1204,10 @@ For AIR, the companion field carries `_meta.jetbrains.air.customAnswer`:
|
||||
`questionId` names the select field of the same question.
|
||||
A client can render the companion as the free-text choice of that question.
|
||||
|
||||
For AIR, the companion field also carries the same value under the root key `_meta._askUserQuestionCustomAnswer`.
|
||||
Released AIR versions read only that key.
|
||||
A client that is not AIR gets neither key.
|
||||
|
||||
## Session fork point
|
||||
|
||||
AIR can fork a session at one agent message.
|
||||
@@ -1255,12 +1259,11 @@ The adapter sends the key only to an AIR client.
|
||||
|
||||
These keys moved into the AIR namespace. The adapter sends the new key only to AIR, and the old key to no client.
|
||||
|
||||
| Old key | New key |
|
||||
| --------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
|
||||
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
|
||||
| elicitation property `_meta._askUserQuestionCustomAnswer` | `_meta.jetbrains.air.customAnswer`, same value |
|
||||
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
|
||||
| Old key | New key |
|
||||
| --------------------------------------------------------- | --------------------------------------- |
|
||||
| `initialize._meta.goal`, `session_info_update._meta.goal` | `_meta.jetbrains.air.goal`, same shape |
|
||||
| mode `_meta.kind`, config option value `_meta.kind` | `_meta.jetbrains.air.kind` |
|
||||
| tool call `_meta.contextCompaction` | `_meta.jetbrains.air.contextCompaction` |
|
||||
|
||||
The adapter does not send `_meta.claudeCode.title`, `claudeCode.subagent`, `claudeCode.skill`, or `claudeCode.skillPath` to any client.
|
||||
AIR reads `_meta.jetbrains.air.commandTitle`, `subagent`, and `skill` instead.
|
||||
|
||||
828
package-lock.json
generated
828
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.82.0",
|
||||
"version": "0.85.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -65,26 +65,26 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.280",
|
||||
"diff": "^8.0.3",
|
||||
"@agentclientprotocol/sdk": "1.5.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.127.0",
|
||||
"@anthropic-ai/sdk": "0.131.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@tsconfig/node22": "22.0.6",
|
||||
"@types/node": "26.6.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.70.1",
|
||||
"@typescript-eslint/parser": "8.70.1",
|
||||
"@types/node": "26.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.71.0",
|
||||
"@typescript-eslint/parser": "8.71.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.12.0",
|
||||
"prettier": "3.9.8",
|
||||
"globals": "17.13.0",
|
||||
"prettier": "3.9.9",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "5.0.1"
|
||||
"vitest": "5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
235
src/acp-agent.ts
235
src/acp-agent.ts
@@ -271,9 +271,9 @@ import {
|
||||
import {
|
||||
buildEffortConfigOption,
|
||||
EFFORT_CONFIG_ID,
|
||||
effortFlagSettings,
|
||||
mergeEffortSettings,
|
||||
settingsEffortForModel,
|
||||
toSdkEffortLevel,
|
||||
} from "./session-effort.js";
|
||||
|
||||
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
|
||||
@@ -706,10 +706,14 @@ type Turn = {
|
||||
deferredSettle?: PromptResponse;
|
||||
/** Uuids of `steer()`-injected messages the SDK has not replayed back yet.
|
||||
*
|
||||
* A steer is normally delivered at priority `now`, so the CLI ABORTS
|
||||
* the running cycle: it emits its own human-origin `result` —
|
||||
* indistinguishable from a turn's terminal one — and the steered message runs
|
||||
* as a SECOND cycle. Settling at that result would answer `session/prompt`
|
||||
* A steer is normally delivered at priority `now`. When it lands during
|
||||
* generation the CLI ABORTS the running cycle: it emits its own human-origin
|
||||
* `result` — indistinguishable from a turn's terminal one — and the steered
|
||||
* message runs as a SECOND cycle. When it lands during a foreground tool call
|
||||
* (CLI 2.1.286) the CLI moves the tool to the background instead and either
|
||||
* joins the steer into the running cycle (ONE result stamped with both
|
||||
* uuids) or still ends that cycle early and runs the steer as a second one.
|
||||
* Settling at an interrupted cycle's result would answer `session/prompt`
|
||||
* mid-work, so a steered turn's results only RECORD their outcome
|
||||
* (`steeredSettle`) until one provably answers the steer.
|
||||
*
|
||||
@@ -833,6 +837,9 @@ export type Session = {
|
||||
* terminal (e.g. /doctor, /color). ACP clients aren't that terminal, so
|
||||
* these are filtered out of `available_commands_update` payloads. */
|
||||
terminalSlashCommands?: string[];
|
||||
/** Serialized `system`/init `plugin_errors` last logged, so the per-turn
|
||||
* init re-emit logs a plugin load failure once, not every turn. */
|
||||
loggedPluginErrors?: string;
|
||||
/** The long-lived consumer task. Lazily started on the first `prompt()` and
|
||||
* kept alive for the session so between-turn/background messages are still
|
||||
* drained and forwarded. */
|
||||
@@ -928,16 +935,16 @@ export type Session = {
|
||||
* prompts so mid-stream usage_update notifications report a correct `size`
|
||||
* before the turn's first result message arrives. Seeded synchronously at
|
||||
* session creation and on model switches from the per-model cache or the
|
||||
* text heuristic (DEFAULT_CONTEXT_WINDOW when both miss), then confirmed —
|
||||
* and the cache populated — by each result's modelUsage. No extra
|
||||
* `getContextUsage` IPC is on these paths: before the first turn it can add
|
||||
* tens of seconds to both fresh and resumed sessions (see the seeding call
|
||||
* sites and `contextWindowCache`). */
|
||||
* text heuristic (DEFAULT_CONTEXT_WINDOW when both miss), refined by a
|
||||
* background `getContextUsage` when that seed was a guess (see
|
||||
* `refreshContextWindowInBackground`), then confirmed — and the cache
|
||||
* populated — by each result's modelUsage. No awaited IPC is on these
|
||||
* paths (see the seeding call sites and `contextWindowCache`). */
|
||||
contextWindowSize: number;
|
||||
contextUsedTokens?: number;
|
||||
/** Whether `contextWindowSize` came from an authoritative source (the
|
||||
* cross-session cache or a `result.modelUsage`) rather than the text
|
||||
* heuristic / default. Guards the
|
||||
* cross-session cache, a `result.modelUsage`, or the background
|
||||
* `getContextUsage`) rather than the text heuristic / default. Guards the
|
||||
* mid-stream `message_start` heuristic upgrade: an authoritative window that
|
||||
* happens to equal DEFAULT_CONTEXT_WINDOW must not be mistaken for "unseeded"
|
||||
* and clobbered by a "1m" text match. */
|
||||
@@ -1940,6 +1947,41 @@ export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Origin kinds of the meta user messages `getSessionMessages` returns since
|
||||
* SDK 0.3.284 (messages from other agents, sessions and channels). */
|
||||
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
|
||||
"peer",
|
||||
"channel",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
"slack-ping",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True for a transcript message delivered to the model from another agent,
|
||||
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
|
||||
* `is_meta` user messages whose content is the full harness framing (envelope
|
||||
* XML plus the "not typed by your user" preamble). The live prompt loop never
|
||||
* renders them, so replay skips them too rather than presenting them as text
|
||||
* the user typed. Other `is_meta` messages (compact summaries) are kept.
|
||||
*
|
||||
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
|
||||
*/
|
||||
export function isReplayHiddenMetaMessage(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const { type, is_meta, origin } = message as {
|
||||
type?: unknown;
|
||||
is_meta?: unknown;
|
||||
origin?: { kind?: unknown } | null;
|
||||
};
|
||||
return (
|
||||
type === "user" &&
|
||||
is_meta === true &&
|
||||
typeof origin?.kind === "string" &&
|
||||
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-facing surface the agent calls back into. This is the subset of ACP
|
||||
* client methods the agent actually uses, expressed as a narrow interface so
|
||||
@@ -3376,9 +3418,12 @@ export class ClaudeAcpAgent {
|
||||
* prompt (IJAI-1191). The steered message's own output streams via
|
||||
* `session/update`, not this response.
|
||||
*
|
||||
* Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
|
||||
* own and the steered message runs as a second one, so the turn is marked
|
||||
* (`Turn.steeredEchoes`) to settle only once a result answers the steer.
|
||||
* Pre-empting generation means ABORTING: the interrupted cycle emits a
|
||||
* `result` of its own and the steered message runs as a second one. A steer
|
||||
* during a foreground tool call moves that tool (Bash, Agent, MCP) to the
|
||||
* background instead, and may join the running cycle (CLI 2.1.286). Either
|
||||
* way the turn is marked (`Turn.steeredEchoes`) to settle only once a result
|
||||
* answers the steer.
|
||||
*
|
||||
* When the session is idle, the opt-in path returns `promptRequired` WITHOUT
|
||||
* calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
|
||||
@@ -4591,6 +4636,20 @@ export class ClaudeAcpAgent {
|
||||
this.logger.error(`Failed to re-advertise slash commands: ${error}`);
|
||||
}
|
||||
}
|
||||
// Plugin load failures (CLI 2.1.283+) have no ACP surface;
|
||||
// log them so a missing plugin isn't silent.
|
||||
if (message.plugin_errors?.length) {
|
||||
const pluginErrors = JSON.stringify(message.plugin_errors);
|
||||
if (pluginErrors !== session.loggedPluginErrors) {
|
||||
session.loggedPluginErrors = pluginErrors;
|
||||
for (const error of message.plugin_errors) {
|
||||
this.logger.error(
|
||||
`Plugin ${error.plugin} failed to load (${error.type})` +
|
||||
`${error.path ? ` from ${error.path}` : ""}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "status": {
|
||||
if (message.status === "compacting") {
|
||||
@@ -7275,6 +7334,9 @@ export class ClaudeAcpAgent {
|
||||
: { ids: new Map<string, string>() };
|
||||
const replayedSubagents = new Set<string>();
|
||||
const replayMessage = async (message: SessionMessage): Promise<void> => {
|
||||
if (isReplayHiddenMetaMessage(message)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "user" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
@@ -7983,6 +8045,41 @@ export class ClaudeAcpAgent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a heuristic context window with `getContextUsage().rawMaxTokens`
|
||||
* without blocking the caller. The text heuristic misses natively-1M models
|
||||
* whose picker rows carry no "1m" token (`sonnet`, and since CLI 2.1.283
|
||||
* `opus`/`default`), which would otherwise report 200k until the first
|
||||
* result's modelUsage. Never awaited: SDK control requests are serialized,
|
||||
* so an awaited call would delay session/new or a model switch (before the
|
||||
* first turn it took ~15s on older CLIs, issues #886/#880; ~0.5s on 2.1.283).
|
||||
* Not written to `contextWindowCache` — that stays keyed to the
|
||||
* `result.modelUsage` spellings — and a result still overwrites it.
|
||||
*/
|
||||
private refreshContextWindowInBackground(sessionId: string, session: Session): void {
|
||||
if (session.contextWindowAuthoritative) return;
|
||||
const { query } = session;
|
||||
const modelId = session.models.currentModelId;
|
||||
const stillCurrent = () =>
|
||||
this.sessions[sessionId] === session &&
|
||||
session.query === query &&
|
||||
session.models.currentModelId === modelId;
|
||||
// A synchronous throw must not fail the caller either.
|
||||
Promise.resolve()
|
||||
.then(() => query.getContextUsage())
|
||||
.then(
|
||||
(usage) => {
|
||||
if (!stillCurrent() || session.contextWindowAuthoritative) return;
|
||||
if (!(usage.rawMaxTokens > 0)) return;
|
||||
session.contextWindowSize = usage.rawMaxTokens;
|
||||
session.contextWindowAuthoritative = true;
|
||||
},
|
||||
(error) => {
|
||||
if (stillCurrent()) this.logger.error("Failed to read the context window:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async applyConfigOptionValue(
|
||||
sessionId: string,
|
||||
session: Session,
|
||||
@@ -7997,19 +8094,16 @@ export class ClaudeAcpAgent {
|
||||
// context window for semantic aliases (e.g. `default`) whose ID alone
|
||||
// carries no "1m" token.
|
||||
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
||||
if (session.models.currentModelId !== value) {
|
||||
// Seed the new model's context window WITHOUT any IPC on the switch
|
||||
// path: cached authoritative value if we've already learned it (from a
|
||||
// prior turn's `result.modelUsage`), else the text heuristic, else the
|
||||
// default. We deliberately do NOT call `getContextUsage` here — before
|
||||
// a fresh session's first prompt turn that control request is not
|
||||
// serviced (~15s stall, issues #886/#880), and (because SDK control
|
||||
// requests are serialized over one channel) it would drag the awaited
|
||||
// `setModel` down with it. The authoritative window arrives on the
|
||||
// first `result.modelUsage` for the model and is cached from there;
|
||||
// until then a switched-to alias that has never run a turn shows the
|
||||
// heuristic/default window, which self-corrects on its first response
|
||||
// (matches pre-0.59.0 behavior).
|
||||
const modelChanged = session.models.currentModelId !== value;
|
||||
if (modelChanged) {
|
||||
// Seed the new model's context window WITHOUT awaited IPC on the
|
||||
// switch path: cached authoritative value if we've already learned it
|
||||
// (from a prior turn's `result.modelUsage`), else the text heuristic,
|
||||
// else the default. SDK control requests are serialized over one
|
||||
// channel, so an awaited `getContextUsage` here would delay the rest
|
||||
// of the switch (issues #886/#880); a guessed seed is instead refined
|
||||
// in the background once the switch is done
|
||||
// (`refreshContextWindowInBackground`).
|
||||
const seeded = immediateContextWindow(session.providerCacheKey, value, newModelInfo);
|
||||
session.contextWindowSize = seeded.size;
|
||||
session.contextWindowAuthoritative = seeded.authoritative;
|
||||
@@ -8095,14 +8189,20 @@ export class ClaudeAcpAgent {
|
||||
const newEffort =
|
||||
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
||||
try {
|
||||
await session.query.applyFlagSettings({
|
||||
await session.query.applyFlagSettings(
|
||||
// A legacy client's unpinned effort is display-only: the CLI
|
||||
// resolves the persisted value for the new model. When an old
|
||||
// user pin is no longer supported, clear the flag layer instead
|
||||
// of replacing it with that displayed value. Opted-in clients
|
||||
// deliberately apply their concrete displayed effort.
|
||||
effortLevel: useRecommendedValue ? toSdkEffortLevel(newEffort) : null,
|
||||
});
|
||||
effortFlagSettings(
|
||||
useRecommendedValue ? newEffort : undefined,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
),
|
||||
);
|
||||
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
|
||||
session.appliedEffortLevel =
|
||||
useRecommendedValue && newEffort !== "default" ? newEffort : undefined;
|
||||
@@ -8151,12 +8251,20 @@ export class ClaudeAcpAgent {
|
||||
if (modeDowngraded) {
|
||||
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);
|
||||
} else if (configId === EFFORT_CONFIG_ID) {
|
||||
// Apply first so a rejected control request cannot leave the displayed
|
||||
// value ahead of the SDK flag layer.
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: toSdkEffortLevel(value),
|
||||
});
|
||||
await session.query.applyFlagSettings(
|
||||
effortFlagSettings(
|
||||
value,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
),
|
||||
);
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
@@ -8873,10 +8981,12 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
// Apply user's `availableModels` allowlist from settings.json before any
|
||||
// downstream model handling. The SDK only enforces this allowlist in its
|
||||
// own UI, not in `initializationResult.models`, so we filter here to keep
|
||||
// configOptions, the current-model resolver, and the stored modelInfos
|
||||
// consistent with what the user configured.
|
||||
// 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 allowedModels = Array.isArray(settingsAvailableModels)
|
||||
@@ -8884,6 +8994,7 @@ export class ClaudeAcpAgent {
|
||||
initializationResult.models,
|
||||
settingsAvailableModels,
|
||||
settingsModelOverrides,
|
||||
settingsManager.getManagedDeniedModels(),
|
||||
)
|
||||
: initializationResult.models;
|
||||
|
||||
@@ -8969,15 +9080,15 @@ export class ClaudeAcpAgent {
|
||||
// and persisted settings; automatic values re-seed on every model switch.
|
||||
// Legacy clients still leave effort resolution entirely to the CLI.
|
||||
const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities);
|
||||
const effortSettings = mergeEffortSettings(
|
||||
settingsManager.getSettings(),
|
||||
configuredSettingsObject,
|
||||
);
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
modelInfos,
|
||||
userProvidedOptions?.effort ??
|
||||
settingsEffortForModel(
|
||||
mergeEffortSettings(settingsManager.getSettings(), configuredSettingsObject),
|
||||
currentModelInfo,
|
||||
),
|
||||
userProvidedOptions?.effort ?? settingsEffortForModel(effortSettings, currentModelInfo),
|
||||
fastMode,
|
||||
{
|
||||
useRecommendedValue,
|
||||
@@ -8985,23 +9096,19 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
|
||||
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
|
||||
await q.applyFlagSettings({ effortLevel: toSdkEffortLevel(initialEffort.currentValue) });
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, effortSettings));
|
||||
}
|
||||
// Seed the context window without extra IPC. The cached authoritative
|
||||
// Seed the context window without awaited IPC. The cached authoritative
|
||||
// window from a prior turn wins (`result.modelUsage`, cross-session),
|
||||
// then the text heuristic, then the default. We deliberately do NOT issue
|
||||
// a getContextUsage call here: before the first prompt turn that control
|
||||
// request can take tens of seconds, on resumed as well as fresh sessions.
|
||||
// The authoritative window arrives on the first `result.modelUsage` and
|
||||
// is cached from there.
|
||||
// then the text heuristic, then the default. A guessed seed is refined
|
||||
// by a background getContextUsage once the session is registered
|
||||
// (`refreshContextWindowInBackground`); the authoritative window then
|
||||
// arrives on the first `result.modelUsage` and is cached from there.
|
||||
//
|
||||
// Text inference alone misses aliases that resolve to extended-context
|
||||
// models with no "1m" token anywhere in their id or description (e.g.
|
||||
// `sonnet` → claude-sonnet-5, natively ~1M): those stream
|
||||
// `usage_update.size: 200000` until the first result's modelUsage corrects
|
||||
// it — but the cache means only the FIRST session to ever run a turn on such
|
||||
// a model eats that window, not every fresh session after a process
|
||||
// restart (issue #596).
|
||||
// `sonnet` → claude-sonnet-5, and `opus`/`default` since CLI 2.1.283,
|
||||
// all natively ~1M) — the background refresh covers those (issue #596).
|
||||
//
|
||||
// The inference fallback is deliberately keyed to the allowlisted entry: a
|
||||
// fallback-resolved sibling's resolvedModel/displayName/description can
|
||||
@@ -9077,6 +9184,7 @@ export class ClaudeAcpAgent {
|
||||
fileChangeReporter,
|
||||
};
|
||||
timing.phase("register");
|
||||
this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
@@ -10310,6 +10418,7 @@ export function toAcpNotifications(
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
case "fallback":
|
||||
case "mcp_tool_listing":
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -10582,10 +10691,9 @@ function commonPrefixLength(a: string, b: string) {
|
||||
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
|
||||
* This text scan can't catch every model — some resolve to extended-context
|
||||
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
|
||||
* ~1M). Such a miss falls back to the default window and is corrected by
|
||||
* `result.modelUsage` (and cached) within one turn. We do NOT consult the
|
||||
* SDK's `getContextUsage` to close that gap: before the first prompt turn it
|
||||
* can take tens of seconds (issues #886/#880, see `contextWindowCache`). */
|
||||
* ~1M). Such a miss falls back to the default window, is refined by the
|
||||
* background `getContextUsage` (`refreshContextWindowInBackground`), and is
|
||||
* corrected by `result.modelUsage` (and cached) within one turn. */
|
||||
function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
|
||||
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
|
||||
return null;
|
||||
@@ -10606,11 +10714,10 @@ function inferContextWindowFromModel(...texts: Array<string | undefined>): numbe
|
||||
* verbatim live id (rows without `resolvedModel`) can hit too.
|
||||
*
|
||||
* Populated authoritatively by each `result.modelUsage` a turn confirms (see
|
||||
* the consumer's result handler). We deliberately never populate it from a
|
||||
* fresh session's `getContextUsage`: before that session's first prompt turn
|
||||
* has run the control request is not serviced (it stalls ~15s, and serializes
|
||||
* ahead of an awaited `setModel` — issues #886/#880, regressed in 0.59.0), so
|
||||
* it can neither beat the first `result` nor be issued cheaply before one.
|
||||
* the consumer's result handler). We deliberately never populate it from
|
||||
* `getContextUsage`: its `model` spelling and `rawMaxTokens` can differ from
|
||||
* the `result.modelUsage` key and window (e.g. 967000 vs 1000000 for sonnet),
|
||||
* so it only refines the live session (`refreshContextWindowInBackground`).
|
||||
* Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
|
||||
* windows learned under one login must not seed sessions under the next. */
|
||||
const contextWindowCache = new Map<string, number>();
|
||||
|
||||
@@ -20,6 +20,12 @@ export const AIR_GOAL_KEY = "goal";
|
||||
export const AIR_KIND_KEY = "kind";
|
||||
export const AIR_PERMISSION_KEY = "permission";
|
||||
export const AIR_CUSTOM_ANSWER_KEY = "customAnswer";
|
||||
/**
|
||||
* The root `_meta` spelling of {@link AIR_CUSTOM_ANSWER_KEY} that released
|
||||
* AIR versions read. Remove it when the ACP registry no longer serves AIR
|
||||
* versions that read only this spelling.
|
||||
*/
|
||||
export const LEGACY_AIR_CUSTOM_ANSWER_KEY = "_askUserQuestionCustomAnswer";
|
||||
|
||||
const JETBRAINS_META_KEY = "jetbrains";
|
||||
const AIR_META_KEY = "air";
|
||||
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { AIR_CUSTOM_ANSWER_KEY, withAirMeta } from "./air-extension.js";
|
||||
import {
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
withAirMeta,
|
||||
} from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Bridges between the Claude Agent SDK's elicitation/dialog callbacks and ACP's
|
||||
@@ -204,6 +208,7 @@ export function askUserQuestionsToCreateRequest(
|
||||
? { type: "array", title, description, items: { anyOf: options } }
|
||||
: { type: "string", title, description, oneOf: options };
|
||||
|
||||
const customAnswer = { questionId: questionFieldKey(index), isCustomAnswer: true };
|
||||
properties[questionCustomFieldKey(index)] = {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
@@ -211,13 +216,15 @@ export function askUserQuestionsToCreateRequest(
|
||||
? "Type your own answer to add to your selection above (optional)."
|
||||
: "Type your own answer, or add a note to the option you chose above (optional).",
|
||||
// Marks the field as the custom answer companion of a select question,
|
||||
// under `_meta.jetbrains.air.customAnswer`. Only AIR gets the marker.
|
||||
// under `_meta.jetbrains.air.customAnswer` and under the root key that
|
||||
// released AIR versions read. Only AIR gets the marker.
|
||||
...(airClient
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_CUSTOM_ANSWER_KEY, {
|
||||
questionId: questionFieldKey(index),
|
||||
isCustomAnswer: true,
|
||||
}),
|
||||
_meta: withAirMeta(
|
||||
{ [LEGACY_AIR_CUSTOM_ANSWER_KEY]: customAnswer },
|
||||
AIR_CUSTOM_ANSWER_KEY,
|
||||
customAnswer,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,18 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
return value === undefined || value === "default" ? null : (value as EffortLevel);
|
||||
}
|
||||
|
||||
// Since CLI 2.1.284 an `effortLevel` that changes the level, sent without an
|
||||
// `ultracode` key, turns ultracode off. The CLI's own effort control keeps
|
||||
// ultracode on at any level, so carry a settings-requested ultracode along
|
||||
// with every effort apply.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -260,6 +260,39 @@ function resolveSettingsModel(
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** A model id without the spellings `deniedModels` ignores: provider
|
||||
* prefixes, dates, version suffixes, `-fast` and context hints. */
|
||||
function normalizeDeniedModelId(value: string): string {
|
||||
let id = stripContextHints(value.trim().toLowerCase());
|
||||
const providerPrefix = id.lastIndexOf("anthropic.");
|
||||
if (providerPrefix !== -1) id = id.slice(providerPrefix + "anthropic.".length);
|
||||
return id
|
||||
.replace(/@.*$/, "")
|
||||
.replace(/-v\d+(?::\d+)?$/, "")
|
||||
.replace(/-fast$/, "")
|
||||
.replace(/-\d{8}$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
|
||||
* closely enough for the picker: a family alias blocks the whole family, and a
|
||||
* model id blocks that version in every spelling plus the later minor versions
|
||||
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). The CLI stays
|
||||
* the enforcer; this only keeps the picker from offering a refused row.
|
||||
*/
|
||||
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
|
||||
const id = normalizeDeniedModelId(modelId);
|
||||
if (!id) return false;
|
||||
return deniedModels.some((entry) => {
|
||||
const denied = normalizeDeniedModelId(entry);
|
||||
if (!denied) return false;
|
||||
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
|
||||
return id === denied || id.startsWith(`${denied}-`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
||||
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
||||
@@ -275,11 +308,14 @@ function resolveSettingsModel(
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
|
||||
* dropped even though the CLI would otherwise leave it unmatched.
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
deniedModels: readonly string[] = [],
|
||||
): ModelInfo[] {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
@@ -310,6 +346,13 @@ export function applyAvailableModelsAllowlist(
|
||||
if (seen.has(effective)) continue;
|
||||
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (
|
||||
[trimmed, effective, sdkMatch?.resolvedModel].some(
|
||||
(id) => id != null && isDeniedModel(id, deniedModels),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: effective });
|
||||
} else {
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface SettingsManagerOptions {
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -106,9 +107,14 @@ export class SettingsManager {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
// The CLI reads `deniedModels` from managed settings only.
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,14 @@ export class SettingsManager {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
*/
|
||||
getManagedDeniedModels(): string[] {
|
||||
return this.managedDeniedModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current working directory
|
||||
*/
|
||||
|
||||
@@ -154,6 +154,51 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a settings-requested ultracode on when applying effort", async () => {
|
||||
// Since CLI 2.1.284 an effortLevel apply without an ultracode key turns
|
||||
// ultracode off; the CLI's own effort control keeps it on.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ ultracode: true }),
|
||||
);
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
};
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: response.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high", ultracode: true });
|
||||
});
|
||||
|
||||
it("seeds effort from higher-priority programmatic model settings", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
ClaudeAcpAgent,
|
||||
@@ -2527,6 +2528,78 @@ describe("synthetic login message (issue #863)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("meta messages from other agents, sessions and channels", () => {
|
||||
// The shape SDK 0.3.284 getSessionMessages returns for a host-injected peer
|
||||
// message (live probe): is_meta plus origin, content is the full framing.
|
||||
const peerMessage = {
|
||||
type: "user",
|
||||
uuid: "peer-1",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
is_meta: true,
|
||||
origin: { kind: "peer", from: "uds:/tmp/fake.sock", hostInjected: true },
|
||||
message: {
|
||||
role: "user",
|
||||
content:
|
||||
'Another Claude session sent a message:\n<cross-session-message from="uds:/tmp/fake.sock">enveloped hi</cross-session-message>\n\nThis came from another Claude session — not typed by your user.',
|
||||
},
|
||||
};
|
||||
|
||||
it("isReplayHiddenMetaMessage matches only meta messages with an inter-agent origin", () => {
|
||||
expect(isReplayHiddenMetaMessage(peerMessage)).toBe(true);
|
||||
for (const kind of ["channel", "observer", "observer-activity", "slack-ping"]) {
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind } })).toBe(true);
|
||||
}
|
||||
// Not meta: an un-framed message persisted as a normal user turn.
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, is_meta: undefined })).toBe(false);
|
||||
// Meta without an inter-agent origin, such as a compact summary.
|
||||
expect(
|
||||
isReplayHiddenMetaMessage({ ...peerMessage, origin: undefined, isCompactSummary: true }),
|
||||
).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, origin: { kind: "human" } })).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage({ ...peerMessage, type: "assistant" })).toBe(false);
|
||||
expect(isReplayHiddenMetaMessage(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("loadSession replay skips them but keeps the user's own prompt", async () => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (u: SessionNotification) => {
|
||||
updates.push(u);
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||
{
|
||||
type: "user",
|
||||
uuid: "u1",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: null,
|
||||
parent_agent_id: null,
|
||||
message: { role: "user", content: [{ type: "text", text: "hi, say one word" }] },
|
||||
},
|
||||
peerMessage,
|
||||
] as Awaited<ReturnType<typeof getSessionMessages>>);
|
||||
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
|
||||
expect(
|
||||
updates.some(
|
||||
(u) =>
|
||||
u.update.sessionUpdate === "user_message_chunk" &&
|
||||
u.update.content.type === "text" &&
|
||||
u.update.content.text.includes("hi, say one word"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(updates)).not.toContain("cross-session-message");
|
||||
expect(JSON.stringify(updates)).not.toContain("Another Claude session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage-limit failure replay", () => {
|
||||
const usageLimitMessage = {
|
||||
type: "assistant" as const,
|
||||
@@ -19310,6 +19383,59 @@ describe("turn steering (_session/steering)", () => {
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
// CLI 2.1.286: a steer that lands during a foreground tool call moves the tool
|
||||
// to the background and joins the running cycle instead of aborting it, so
|
||||
// the turn ends in ONE result stamped with both the prompt and the steer.
|
||||
it.each([
|
||||
{ name: "without an echo", echo: false },
|
||||
{ name: "with a mid-cycle echo", echo: true },
|
||||
])("settles a steer that joined the running cycle $name", async ({ echo }) => {
|
||||
const timeline: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(timelineClient(timeline), {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const u1 = await iter.next();
|
||||
yield userEcho(u1.value);
|
||||
yield createAssistantText("working on it");
|
||||
const steered = await iter.next();
|
||||
if (echo) yield userEcho(steered.value);
|
||||
yield createAssistantText("STEERED-OK");
|
||||
yield {
|
||||
...createResultMessage(),
|
||||
user_message_uuid: u1.value.uuid,
|
||||
user_message_uuids: [u1.value.uuid, steered.value.uuid],
|
||||
};
|
||||
yield idleMessage();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "start" }] })
|
||||
.then((response) => {
|
||||
timeline.push(`prompt:${response.stopReason}`);
|
||||
return response;
|
||||
});
|
||||
await waitFor(() => !!agent.sessions["test-session"]?.activeTurn);
|
||||
await expect(
|
||||
agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also handle X" }] }),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
|
||||
const response = await turn;
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
|
||||
expect(timeline).toEqual(["working on it", "STEERED-OK", "prompt:end_turn"]);
|
||||
// One cycle ran, so usage is the single result's.
|
||||
expect(response.usage?.inputTokens).toBe(10);
|
||||
expect(agent.sessions["test-session"].turnQueue).toHaveLength(0);
|
||||
expect(agent.sessions["test-session"].owedTrailingIdles ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// Issue #1114's other hang, and #1063's: the steer aborts an autonomous
|
||||
// followup, whose result counts a trailing idle that the abort then folds into
|
||||
// the one idle spanning both cycles. Absorbing that idle as debt would starve
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
compareWithBaseline,
|
||||
} from "./acp-scenarios/compare.js";
|
||||
import { EXTENSION_SESSION_UPDATES, validateRecorded } from "./acp-scenarios/schema.js";
|
||||
import { LEGACY_AIR_CUSTOM_ANSWER_KEY } from "../air-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
@@ -661,6 +662,7 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
(record) => record.kind === "createElicitation",
|
||||
)!.payload as Record<string, any>;
|
||||
expect(elicitation.requestedSchema.properties.question_0_custom._meta).toEqual({
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { version: 1, customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
@@ -689,7 +691,13 @@ describe.skipIf(baselineDir)("ACP scenarios", () => {
|
||||
for (const scenario of SCENARIOS) {
|
||||
for (const { at, meta } of metaObjects(air(scenario.name))) {
|
||||
const duplicates = [
|
||||
...Object.keys(meta).filter((k) => AIR_ONLY_META_KEYS.has(k) && k !== "jetbrains"),
|
||||
// Released AIR versions read only the legacy custom answer key, so AIR gets both.
|
||||
...Object.keys(meta).filter(
|
||||
(k) =>
|
||||
AIR_ONLY_META_KEYS.has(k) &&
|
||||
k !== "jetbrains" &&
|
||||
k !== LEGACY_AIR_CUSTOM_ANSWER_KEY,
|
||||
),
|
||||
...Object.keys((meta.claudeCode as Record<string, unknown> | undefined) ?? {}).filter(
|
||||
(k) => AIR_ONLY_CLAUDE_CODE_KEYS.has(k),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawInput":{"questions":[{"header":"Database","multiSelect":false,"options":[{"description":"Relational","label":"Postgres","preview":"CREATE TABLE t ();"},{"description":"Key-value","label":"Redis"}],"question":"Which database?"}]},"sessionUpdate":"tool_call_update","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"createElicitation","payload":{"message":"Which database?","mode":"form","requestedSchema":{"properties":{"question_0":{"oneOf":[{"_meta":{"_claude/askUserQuestionOption":{"preview":"CREATE TABLE t ();"}},"const":"Postgres","description":"Relational","title":"Postgres"},{"const":"Redis","description":"Key-value","title":"Redis"}],"title":"Database","type":"string"},"question_0_custom":{"_meta":{"_askUserQuestionCustomAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"jetbrains":{"air":{"customAnswer":{"isCustomAnswer":true,"questionId":"question_0"},"version":1}}},"description":"Type your own answer, or add a note to the option you chose above (optional).","title":"Other","type":"string"}},"type":"object"},"sessionId":"<session>","toolCallId":"toolu_ask"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":[{"content":{"text":"User has answered your questions: \"Which database?\"=\"Postgres\".","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_ask"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
|
||||
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}
|
||||
|
||||
@@ -852,26 +852,40 @@ describe("createSession options merging", () => {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
|
||||
}
|
||||
|
||||
it("does not call getContextUsage during session creation", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn has run
|
||||
// (it is not serviced pre-turn), so session/new must never call it —
|
||||
it("does not wait for getContextUsage during session creation", async () => {
|
||||
// SDK control requests are serialized and getContextUsage used to stall
|
||||
// until the first prompt turn, so session/new only kicks it off —
|
||||
// awaiting it inline is what regressed session/new latency in 0.59.0.
|
||||
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
const ctxSpy = vi.fn(() => new Promise<never>(() => {}));
|
||||
contextUsageResult = ctxSpy;
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(ctxSpy).not.toHaveBeenCalled();
|
||||
expect(ctxSpy).toHaveBeenCalledOnce();
|
||||
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds contextWindowSize from text inference, falling back to the default when it misses", async () => {
|
||||
it("refines a guessed window from getContextUsage in the background", async () => {
|
||||
// The mock model ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") carries
|
||||
// no "1m" token anywhere, so inference misses and the window falls back to
|
||||
// the default; the authoritative value arrives later via result.modelUsage.
|
||||
// no "1m" token anywhere, so inference misses and the seed is the default
|
||||
// until the background getContextUsage answers.
|
||||
contextUsageResult = async () => ({ rawMaxTokens: 967000 });
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await vi.waitFor(() => expect(sessionFor(response.sessionId).contextWindowSize).toBe(967000));
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the guessed window when getContextUsage reports a non-positive size", async () => {
|
||||
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 0 }));
|
||||
contextUsageResult = ctxSpy;
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
await vi.waitFor(() => expect(ctxSpy).toHaveBeenCalled());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
|
||||
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
@@ -918,7 +932,7 @@ describe("createSession options merging", () => {
|
||||
expect(response.configOptions?.find((option) => option.id === "model")?.currentValue).toBe(
|
||||
"haiku",
|
||||
);
|
||||
expect(ctxSpy).not.toHaveBeenCalled();
|
||||
// Kicked off in the background, never awaited: it never answers here.
|
||||
expect(sessionFor("resumed-model-probe").contextWindowAuthoritative).toBe(false);
|
||||
expect(getSessionMessages).toHaveBeenCalledTimes(1);
|
||||
expect(getSessionMessages).toHaveBeenCalledWith("resumed-model-probe");
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { customAnswer: { questionId: "question_0", isCustomAnswer: true } },
|
||||
},
|
||||
@@ -274,6 +275,7 @@ describe("askUserQuestionsToCreateRequest", () => {
|
||||
type: "string",
|
||||
title: "Other",
|
||||
_meta: {
|
||||
_askUserQuestionCustomAnswer: { questionId: "question_1", isCustomAnswer: true },
|
||||
jetbrains: {
|
||||
air: { customAnswer: { questionId: "question_1", isCustomAnswer: true } },
|
||||
},
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("versioned model display names", () => {
|
||||
// Account-dependent model availability requires the same authenticated environment
|
||||
// as the adapter. No prompt is sent: inspect only the SDK initialization response.
|
||||
it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
"the real SDK exposes one named Opus with the expected context suffix",
|
||||
"the real SDK exposes one plain Opus row",
|
||||
async () => {
|
||||
let finishInput!: () => void;
|
||||
const inputGate = new Promise<void>((resolve) => {
|
||||
@@ -178,12 +178,14 @@ it.skipIf(!process.env.RUN_INTEGRATION_TESTS)(
|
||||
/\bopus\b/i.test(`${model.value} ${model.resolvedModel} ${model.displayName}`),
|
||||
);
|
||||
expect(opusModels).toHaveLength(1);
|
||||
// CLI 2.1.283 dropped the "[1m]" / "(1M context)" spelling from the Opus
|
||||
// row: Opus is natively 1M, so the row names the plain model.
|
||||
expect(opusModels[0]).toMatchObject({
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus (1M context)",
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-.*\[1m\]$/),
|
||||
description: expect.stringContaining("1M context"),
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
resolvedModel: expect.stringMatching(/^claude-opus-[\d-]+$/),
|
||||
});
|
||||
expect(opusModels[0].description).not.toMatch(/\b1m\b/i);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
finishInput();
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
matchResumedModel,
|
||||
} from "../session-model.js";
|
||||
import { settingsEffortForModel } from "../session-effort.js";
|
||||
import { effortFlagSettings, settingsEffortForModel } from "../session-effort.js";
|
||||
|
||||
// Mirrors a real `supportedModels()` response: alias rows carry
|
||||
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
|
||||
@@ -234,6 +235,49 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
|
||||
it("drops an allowlisted model that deniedModels blocks, keeping Default", () => {
|
||||
const result = applyAvailableModelsAllowlist(
|
||||
LIVE_SHAPED_MODELS,
|
||||
["claude-sonnet-5", "claude-opus-5-5"],
|
||||
undefined,
|
||||
["claude-opus-5"],
|
||||
);
|
||||
expect(result.map((m) => m.value)).toEqual(["default", "claude-sonnet-5"]);
|
||||
});
|
||||
|
||||
it("checks an alias entry against the row it resolves to", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
|
||||
"claude-sonnet-5",
|
||||
]);
|
||||
expect(result.map((m) => m.value)).toEqual(["default"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeniedModel", () => {
|
||||
it("blocks every spelling of a denied version and its later minors", () => {
|
||||
const denied = ["claude-opus-5"];
|
||||
expect(isDeniedModel("claude-opus-5", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-20260101", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5[1m]", denied)).toBe(true);
|
||||
expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks only that version when the entry names a minor", () => {
|
||||
expect(isDeniedModel("claude-opus-5", ["claude-opus-5-5"])).toBe(false);
|
||||
expect(isDeniedModel("claude-opus-5-5-fast", ["claude-opus-5-5"])).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a whole family for a family alias and ignores settings-dependent aliases", () => {
|
||||
expect(isDeniedModel("claude-haiku-4-5-20251001", ["haiku"])).toBe(true);
|
||||
expect(isDeniedModel("haiku", ["haiku"])).toBe(true);
|
||||
expect(isDeniedModel("claude-sonnet-5", ["haiku"])).toBe(false);
|
||||
expect(isDeniedModel("claude-opus-5", ["default", "best", "opusplan"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Runs against the real SDK (no mocks) so the fixture above is flagged the
|
||||
// moment the SDK's actual model list shape drifts from it. Requires a usable
|
||||
// ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.
|
||||
@@ -325,3 +369,18 @@ describe("settingsEffortForModel", () => {
|
||||
expect(settingsEffortForModel({ modelSettings: {} }, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effortFlagSettings", () => {
|
||||
it("carries ultracode only when settings request it", () => {
|
||||
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
|
||||
effortLevel: "high",
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
|
||||
effortLevel: null,
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1155,13 +1155,11 @@ describe("session config options", () => {
|
||||
return (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
}
|
||||
|
||||
it("sets the window from text inference on model switch, without any getContextUsage IPC", async () => {
|
||||
// getContextUsage stalls until the session's first prompt turn, so the
|
||||
// switch path must never call it; the window is seeded from the text
|
||||
// heuristic (here via the new model's resolvedModel) and later confirmed
|
||||
// by result.modelUsage.
|
||||
it("seeds the window from text inference on model switch", async () => {
|
||||
// The window is seeded synchronously from the text heuristic (here via
|
||||
// the new model's resolvedModel); the background refresh never answers.
|
||||
const session = getSession();
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.query.getContextUsage = vi.fn(() => new Promise<never>(() => {}));
|
||||
session.modelInfos = session.modelInfos.map((m: ModelInfo) =>
|
||||
m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m,
|
||||
);
|
||||
@@ -1172,15 +1170,17 @@ describe("session config options", () => {
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
expect(session.contextWindowSize).toBe(1_000_000);
|
||||
expect(session.contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the default window when inference misses, without any getContextUsage IPC", async () => {
|
||||
it("falls back to the default window when inference misses, then refines it in the background", async () => {
|
||||
const session = getSession();
|
||||
session.contextWindowSize = 1_000_000;
|
||||
// Present but must NOT be called; the switch never consults it.
|
||||
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
let answer!: (usage: { rawMaxTokens: number }) => void;
|
||||
session.query.getContextUsage = vi.fn(
|
||||
() => new Promise<{ rawMaxTokens: number }>((resolve) => (answer = resolve)),
|
||||
);
|
||||
// claude-sonnet-4-6 carries no "1m" token in its id, resolvedModel,
|
||||
// displayName, or description, so inference misses → default window.
|
||||
|
||||
@@ -1190,22 +1190,40 @@ describe("session config options", () => {
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(session.query.getContextUsage).not.toHaveBeenCalled();
|
||||
// The switch resolved without waiting for getContextUsage.
|
||||
expect(session.query.getContextUsage).toHaveBeenCalledOnce();
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
|
||||
answer({ rawMaxTokens: 967000 });
|
||||
await vi.waitFor(() => expect(session.contextWindowSize).toBe(967000));
|
||||
expect(session.contextWindowAuthoritative).toBe(true);
|
||||
});
|
||||
|
||||
it("does not call getContextUsage even when switching to a fresh model", async () => {
|
||||
it("drops a background answer that arrives after another switch", async () => {
|
||||
const session = getSession();
|
||||
const spy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
|
||||
session.query.getContextUsage = spy;
|
||||
let answer!: (usage: { rawMaxTokens: number }) => void;
|
||||
session.query.getContextUsage = vi.fn(
|
||||
() => new Promise<{ rawMaxTokens: number }>((resolve) => (answer = resolve)),
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
const staleAnswer = answer;
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
const windowAfterSecondSwitch = session.contextWindowSize;
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
staleAnswer({ rawMaxTokens: 967000 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(session.contextWindowSize).toBe(windowAfterSecondSwitch);
|
||||
expect(session.contextWindowAuthoritative).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the learned window when re-asserting the current model", async () => {
|
||||
|
||||
Reference in New Issue
Block a user