mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a501434c0 | ||
|
|
c073131488 | ||
|
|
fcf5aaf06d | ||
|
|
cc73e37b41 | ||
|
|
41f04ec31c | ||
|
|
8acd0bd27f | ||
|
|
4b82236c1f | ||
|
|
0111786af2 | ||
|
|
aa82193330 | ||
|
|
26f3e8a521 | ||
|
|
769255c9a9 | ||
|
|
bea1a40e9b | ||
|
|
f9b15ba1c5 | ||
|
|
0990a8e520 | ||
|
|
15e91fb5c3 | ||
|
|
f422c7c954 | ||
|
|
063cd35380 | ||
|
|
a16145396f | ||
|
|
65a2230385 | ||
|
|
1d36b58cfb | ||
|
|
ee324b54dd | ||
|
|
d07799d7b3 | ||
|
|
7506223cff | ||
|
|
ae6c388314 |
10
.github/workflows/publish.yml
vendored
10
.github/workflows/publish.yml
vendored
@@ -38,7 +38,8 @@ jobs:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
# TODO: Re-enable once Bun SDK compatibility is fixed
|
||||
if: false
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
@@ -107,7 +108,7 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, release-please]
|
||||
needs: [release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
@@ -129,8 +130,9 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries, publish-npm, release-please]
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
needs: [publish-npm, release-please]
|
||||
# TODO: Re-enable once Bun SDK compatibility is fixed
|
||||
if: false
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.24.2"
|
||||
".": "0.26.0"
|
||||
}
|
||||
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [0.26.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.3...v0.26.0) (2026-04-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Update claude-agent-sdk to 0.2.96 ([#526](https://github.com/agentclientprotocol/claude-agent-acp/issues/526)) ([c073131](https://github.com/agentclientprotocol/claude-agent-acp/commit/c07313148808a55f27f385c10babbaf7511a7f12))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove bun builds from release ([#525](https://github.com/agentclientprotocol/claude-agent-acp/issues/525)) ([fcf5aaf](https://github.com/agentclientprotocol/claude-agent-acp/commit/fcf5aaf06dfe9f7d1b285b976eeb2d1e20ea8dec))
|
||||
* Use TUI login for remote environments ([#523](https://github.com/agentclientprotocol/claude-agent-acp/issues/523)) ([cc73e37](https://github.com/agentclientprotocol/claude-agent-acp/commit/cc73e37b41678aa67813c4fbef66ba33ca538743))
|
||||
|
||||
## [0.25.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.2...v0.25.3) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Drop claude-agent-sdk back to 0.2.91 to fix broken import ([#513](https://github.com/agentclientprotocol/claude-agent-acp/issues/513)) ([26f3e8a](https://github.com/agentclientprotocol/claude-agent-acp/commit/26f3e8a5216295985fadb80fb3b977045c0c1b2c))
|
||||
* Recreate resumed sessions when params change ([#515](https://github.com/agentclientprotocol/claude-agent-acp/issues/515)) ([aa82193](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa82193330026bae132fed8391c47dde777dcf5a))
|
||||
|
||||
## [0.25.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.1...v0.25.2) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prioritize ANTHROPIC_MODEL env var over settings.model in model … ([#505](https://github.com/agentclientprotocol/claude-agent-acp/issues/505)) ([bea1a40](https://github.com/agentclientprotocol/claude-agent-acp/commit/bea1a40e9bfe1e06672b118a727f9339def3be23))
|
||||
|
||||
## [0.25.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.25.0...v0.25.1) (2026-04-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add `auto` to valid modes in applySessionMode to fix mode cycling ([#507](https://github.com/agentclientprotocol/claude-agent-acp/issues/507)) ([15e91fb](https://github.com/agentclientprotocol/claude-agent-acp/commit/15e91fb5c3449de2600b583cb7a8d36b5b510443))
|
||||
|
||||
## [0.25.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.2...v0.25.0) (2026-04-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add auto permission mode support ([#501](https://github.com/agentclientprotocol/claude-agent-acp/issues/501)) ([a161453](https://github.com/agentclientprotocol/claude-agent-acp/commit/a16145396fe2e6ead8734478961b2de65707e335))
|
||||
* Add separate Claude and Console terminal logins ([#502](https://github.com/agentclientprotocol/claude-agent-acp/issues/502)) ([063cd35](https://github.com/agentclientprotocol/claude-agent-acp/commit/063cd353809df8f3dda9e57d8ea848c0a6d44257))
|
||||
* Update to claude-agent-sdk 0.2.91 ([#500](https://github.com/agentclientprotocol/claude-agent-acp/issues/500)) ([65a2230](https://github.com/agentclientprotocol/claude-agent-acp/commit/65a223038576d72b74e1483fed10e982a1f842bd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* log warnings for malformed settings files instead of silent fallback ([#486](https://github.com/agentclientprotocol/claude-agent-acp/issues/486)) ([ae6c388](https://github.com/agentclientprotocol/claude-agent-acp/commit/ae6c38831415f9fc1de2d3dd1d4a247becbbd32f))
|
||||
* prevent race conditions in SettingsManager setCwd and debounce ([#485](https://github.com/agentclientprotocol/claude-agent-acp/issues/485)) ([7506223](https://github.com/agentclientprotocol/claude-agent-acp/commit/7506223cffb1aba4b4560feda11f69a1395a8c9d))
|
||||
* use current model's context window for usage_update size ([#412](https://github.com/agentclientprotocol/claude-agent-acp/issues/412)) ([d07799d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d07799d7b3b4e438c8b158266c79723a0b592c07))
|
||||
|
||||
## [0.24.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.24.1...v0.24.2) (2026-03-27)
|
||||
|
||||
|
||||
|
||||
1602
package-lock.json
generated
1602
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.24.2",
|
||||
"version": "0.26.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,22 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.17.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.84",
|
||||
"@agentclientprotocol/sdk": "0.18.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.96",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.80.0",
|
||||
"@anthropic-ai/sdk": "0.85.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"eslint": "10.1.0",
|
||||
"@types/node": "25.5.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.4.0",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.1"
|
||||
"typescript": "6.0.2",
|
||||
"vitest": "4.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
237
src/acp-agent.ts
237
src/acp-agent.ts
@@ -48,6 +48,7 @@ import {
|
||||
listSessions,
|
||||
McpServerConfig,
|
||||
ModelInfo,
|
||||
ModelUsage,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
@@ -113,6 +114,9 @@ type Session = {
|
||||
input: Pushable<SDKUserMessage>;
|
||||
cancelled: boolean;
|
||||
cwd: string;
|
||||
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
|
||||
* detect when loadSession/resumeSession is called with changed values. */
|
||||
sessionFingerprint: string;
|
||||
settingsManager: SettingsManager;
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
@@ -124,6 +128,18 @@ type Session = {
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
/** 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. MCP servers are sorted by name
|
||||
* so that ordering differences don't trigger unnecessary recreations. */
|
||||
function computeSessionFingerprint(params: {
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
}): string {
|
||||
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return JSON.stringify({ cwd: params.cwd, mcpServers: servers });
|
||||
}
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
@@ -233,6 +249,7 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
acceptedits: "acceptEdits",
|
||||
dontask: "dontAsk",
|
||||
@@ -306,25 +323,77 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
};
|
||||
|
||||
const terminalAuthMethod: any = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
};
|
||||
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
||||
|
||||
// If client supports terminal-auth capability, use that instead.
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
if (supportsMetaTerminalAuth) {
|
||||
terminalAuthMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
const noBrowser = !!process.env.NO_BROWSER;
|
||||
|
||||
// When NO_BROWSER is set (e.g. remote environments), fall back to the single
|
||||
// terminal-only login that doesn't try to open a browser.
|
||||
const terminalAuthMethods: AuthMethod[] = [];
|
||||
|
||||
if (noBrowser) {
|
||||
const remoteLoginMethod: AuthMethod = {
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
};
|
||||
|
||||
if (supportsMetaTerminalAuth) {
|
||||
remoteLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...process.argv.slice(1), "--cli"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
||||
terminalAuthMethods.push(remoteLoginMethod);
|
||||
}
|
||||
} else {
|
||||
const claudeLoginMethod: AuthMethod = {
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
};
|
||||
|
||||
const consoleLoginMethod: AuthMethod = {
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
};
|
||||
|
||||
if (supportsMetaTerminalAuth) {
|
||||
const baseArgs = process.argv.slice(1);
|
||||
claudeLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--claudeai"],
|
||||
label: "Claude Login",
|
||||
},
|
||||
};
|
||||
consoleLoginMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: process.execPath,
|
||||
args: [...baseArgs, "--cli", "auth", "login", "--console"],
|
||||
label: "Anthropic Console Login",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) {
|
||||
terminalAuthMethods.push(claudeLoginMethod);
|
||||
}
|
||||
if (supportsTerminalAuth || supportsMetaTerminalAuth) {
|
||||
terminalAuthMethods.push(consoleLoginMethod);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -356,12 +425,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [
|
||||
...(!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)
|
||||
? [terminalAuthMethod]
|
||||
: []),
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod] : []),
|
||||
],
|
||||
authMethods: [...terminalAuthMethods, ...(supportsGatewayAuth ? [gatewayAuthMethod] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,6 +532,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
|
||||
let lastAssistantTotalUsage: number | null = null;
|
||||
let lastAssistantModel: string | null = null;
|
||||
let lastContextWindowSize: number = 200000;
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
@@ -527,9 +593,26 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "compact_boundary": {
|
||||
// We don't know the exact size, but since we compacted,
|
||||
// we set it to zero. The client gets the exact size on the next message.
|
||||
// Send used:0 immediately so the client doesn't keep showing
|
||||
// the stale pre-compaction context size until the next turn.
|
||||
//
|
||||
// This is a deliberate approximation: we don't know the exact
|
||||
// post-compaction token count (only the SDK's next API call
|
||||
// reveals that). But used:0 is directionally correct — context
|
||||
// just dropped dramatically — and the real value replaces it
|
||||
// within seconds when the next result message arrives.
|
||||
// The alternative (no update) leaves the client showing e.g.
|
||||
// "944k/1m" right after the user sees "Compacting completed",
|
||||
// which is confusing and wrong.
|
||||
lastAssistantTotalUsage = 0;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used: 0,
|
||||
size: lastContextWindowSize,
|
||||
},
|
||||
});
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
@@ -578,10 +661,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
|
||||
session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
|
||||
|
||||
// Calculate context window size from modelUsage (minimum across all models used)
|
||||
const contextWindows = Object.values(message.modelUsage).map((m) => m.contextWindow);
|
||||
const contextWindowSize =
|
||||
contextWindows.length > 0 ? Math.min(...contextWindows) : 200000;
|
||||
const matchingModelUsage = lastAssistantModel
|
||||
? getMatchingModelUsage(message.modelUsage, lastAssistantModel)
|
||||
: null;
|
||||
const contextWindowSize = matchingModelUsage?.contextWindow ?? 200000;
|
||||
lastContextWindowSize = contextWindowSize;
|
||||
|
||||
// Send usage_update notification
|
||||
if (lastAssistantTotalUsage !== null) {
|
||||
@@ -707,6 +791,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
|
||||
// Store latest assistant usage (excluding subagents)
|
||||
// Sum all token types as a proxy for post-turn context occupancy:
|
||||
// current turn's output will become next turn's input.
|
||||
// Note: per the Anthropic API, input_tokens excludes cache tokens —
|
||||
// cache_read and cache_creation are reported separately, so summing
|
||||
// all four fields is not double-counting.
|
||||
if ((message.message as any).usage && message.parent_tool_use_id === null) {
|
||||
const messageWithUsage = message.message as unknown as SDKResultMessage;
|
||||
lastAssistantTotalUsage =
|
||||
@@ -715,6 +804,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
messageWithUsage.usage.cache_read_input_tokens +
|
||||
messageWithUsage.usage.cache_creation_input_tokens;
|
||||
}
|
||||
// Track the current top-level model for context window size lookup
|
||||
// (exclude subagent messages to stay in sync with lastAssistantTotalUsage)
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
message.message.model &&
|
||||
message.message.model !== "<synthetic>"
|
||||
) {
|
||||
lastAssistantModel = message.message.model;
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
||||
@@ -846,17 +945,24 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await session.query.interrupt();
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
/** Cleanly tear down a session: cancel in-flight work, dispose resources,
|
||||
* and remove it from the session map. */
|
||||
private async teardownSession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
return;
|
||||
}
|
||||
await this.cancel({ sessionId: params.sessionId });
|
||||
|
||||
await this.cancel({ sessionId });
|
||||
session.settingsManager.dispose();
|
||||
session.abortController.abort();
|
||||
delete this.sessions[params.sessionId];
|
||||
delete this.sessions[sessionId];
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.teardownSession(params.sessionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -951,6 +1057,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
private async applySessionMode(sessionId: string, modeId: string): Promise<void> {
|
||||
switch (modeId) {
|
||||
case "auto":
|
||||
case "default":
|
||||
case "acceptEdits":
|
||||
case "bypassPermissions":
|
||||
@@ -1204,12 +1311,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions[params.sessionId];
|
||||
if (existingSession) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
const fingerprint = computeSessionFingerprint(params);
|
||||
if (fingerprint === existingSession.sessionFingerprint) {
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
modes: existingSession.modes,
|
||||
models: existingSession.models,
|
||||
configOptions: existingSession.configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
// Session-defining params changed (e.g. cwd pointed at a git worktree,
|
||||
// or MCP servers reconfigured). Tear down the existing session and
|
||||
// recreate it so the underlying Query process picks up the new values.
|
||||
await this.teardownSession(params.sessionId);
|
||||
}
|
||||
|
||||
const response = await this.createSession(
|
||||
@@ -1428,6 +1543,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
decription: "Use a model classifier to approve/deny permission prompts.",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
@@ -1470,6 +1590,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
input: input,
|
||||
cancelled: false,
|
||||
cwd: params.cwd,
|
||||
sessionFingerprint: computeSessionFingerprint(params),
|
||||
settingsManager,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1638,7 +1759,16 @@ async function getAvailableModels(
|
||||
|
||||
let currentModel = models[0];
|
||||
|
||||
if (settings.model) {
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
// 2. settings.model (user configuration)
|
||||
// 3. models[0] (default first model)
|
||||
if (process.env.ANTHROPIC_MODEL) {
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
}
|
||||
} else if (settings.model) {
|
||||
const match = resolveModelPreference(models, settings.model);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
@@ -2109,3 +2239,28 @@ export function runAcp() {
|
||||
const stream = ndJsonStream(input, output);
|
||||
new AgentSideConnection((client) => new ClaudeAcpAgent(client), stream);
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string) {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentModel: string) {
|
||||
let bestKey: string | null = null;
|
||||
let bestLen = 0;
|
||||
|
||||
for (const key of Object.keys(modelUsage)) {
|
||||
const len = commonPrefixLength(key, currentModel);
|
||||
if (len > bestLen) {
|
||||
bestLen = len;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestKey) {
|
||||
return modelUsage[bestKey];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,14 @@ export interface ClaudeCodeSettings {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid.
|
||||
* Silently ignores missing files (ENOENT) but logs warnings for other errors
|
||||
* (malformed JSON, permission errors, etc.) to aid debugging.
|
||||
*/
|
||||
async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSettings> {
|
||||
async function loadSettingsFile(
|
||||
filePath: string | null,
|
||||
logger?: { error: (...args: any[]) => void },
|
||||
): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
@@ -35,7 +40,11 @@ async function loadSettingsFile(filePath: string | null): Promise<ClaudeCodeSett
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
logger?.error(`Failed to load settings from ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -83,7 +92,9 @@ export class SettingsManager {
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
private initialized = false;
|
||||
private disposed = false;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(cwd: string, options?: SettingsManagerOptions) {
|
||||
this.cwd = cwd;
|
||||
@@ -98,10 +109,19 @@ export class SettingsManager {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
if (this.initPromise) {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
await this.loadAllSettings();
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
this.disposed = false;
|
||||
this.initPromise = this.loadAllSettings().then(() => {
|
||||
if (!this.disposed) {
|
||||
this.setupWatchers();
|
||||
this.initialized = true;
|
||||
}
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,10 +150,10 @@ export class SettingsManager {
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath()),
|
||||
loadSettingsFile(this.getProjectSettingsPath()),
|
||||
loadSettingsFile(this.getLocalSettingsPath()),
|
||||
loadSettingsFile(getManagedSettingsPath()),
|
||||
loadSettingsFile(this.getUserSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getProjectSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getLocalSettingsPath(), this.logger),
|
||||
loadSettingsFile(getManagedSettingsPath(), this.logger),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
@@ -226,9 +246,14 @@ export class SettingsManager {
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
this.debounceTimer = null;
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.loadAllSettings();
|
||||
this.onChange?.();
|
||||
if (!this.disposed) {
|
||||
this.onChange?.();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to reload settings:", error);
|
||||
}
|
||||
@@ -259,7 +284,6 @@ export class SettingsManager {
|
||||
|
||||
this.dispose();
|
||||
this.cwd = cwd;
|
||||
this.initialized = false;
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -267,6 +291,10 @@ export class SettingsManager {
|
||||
* Disposes of file watchers and cleans up resources
|
||||
*/
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.initialized = false;
|
||||
this.initPromise = null;
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
@@ -276,6 +304,5 @@ export class SettingsManager {
|
||||
watcher.close();
|
||||
}
|
||||
this.watchers = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1328,6 +1328,7 @@ describe("stop reason propagation", () => {
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1466,6 +1467,7 @@ describe("stop reason propagation", () => {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cwd: "/tmp/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/tmp/test", mcpServers: [] }),
|
||||
cancelled: false,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
@@ -1540,6 +1542,7 @@ describe("session/close", () => {
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
@@ -1608,3 +1611,582 @@ describe("session/close", () => {
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateSession param change detection", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function injectSession(
|
||||
agent: ClaudeAcpAgent,
|
||||
sessionId: string,
|
||||
opts: { cwd?: string; mcpServers?: { name: string }[] } = {},
|
||||
) {
|
||||
const cwd = opts.cwd ?? "/test";
|
||||
const mcpServers = (opts.mcpServers ?? []) as any[];
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), {
|
||||
interrupt: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd,
|
||||
sessionFingerprint: JSON.stringify({
|
||||
cwd,
|
||||
mcpServers: [...mcpServers].sort((a: any, b: any) => a.name.localeCompare(b.name)),
|
||||
}),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
|
||||
it("returns cached session when params are unchanged", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
// Session object should be the exact same reference (not recreated)
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down existing session when cwd changes", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/old" });
|
||||
|
||||
// Mock createSession to avoid spawning a real process.
|
||||
// It will throw, but we can catch that — we only need to verify
|
||||
// the old session was torn down before createSession was attempted.
|
||||
const createSessionSpy = vi
|
||||
.spyOn(agent as any, "createSession")
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
// Old session should have been fully torn down
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
expect(agent.sessions["s1"]).toBeUndefined();
|
||||
|
||||
// createSession should have been called with the new cwd
|
||||
expect(createSessionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: "/new" }),
|
||||
expect.objectContaining({ resume: "s1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("tears down existing session when mcpServers change", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
const createSessionSpy = vi
|
||||
.spyOn(agent as any, "createSession")
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }],
|
||||
}),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(agent.sessions["s1"]).toBeUndefined();
|
||||
expect(createSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats mcpServers in different order as unchanged", async () => {
|
||||
const agent = createMockAgent();
|
||||
const servers = [
|
||||
{ name: "b-server", command: "node", args: ["b.js"], env: [] },
|
||||
{ name: "a-server", command: "node", args: ["a.js"], env: [] },
|
||||
] as const;
|
||||
const session = injectSession(agent, "s1", {
|
||||
cwd: "/project",
|
||||
mcpServers: servers as any,
|
||||
});
|
||||
|
||||
// Same servers but reversed order — should NOT trigger teardown
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [...servers].reverse() as any,
|
||||
});
|
||||
|
||||
expect(agent.sessions["s1"]).toBe(session);
|
||||
expect(session.settingsManager.dispose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage_update computation", () => {
|
||||
function createAssistantMessage(overrides: {
|
||||
model: string;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
model: overrides.model,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
usage: overrides.usage ?? {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 20,
|
||||
cache_creation_input_tokens: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResultMessageWithModel(overrides: {
|
||||
modelUsage: Record<
|
||||
string,
|
||||
{
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
webSearchRequests: number;
|
||||
costUSD: number;
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
>;
|
||||
}) {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0.01,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: overrides.modelUsage,
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAgentWithCapture() {
|
||||
const updates: any[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
// Wait for the prompt to push its user message so we can replay it
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: {} as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
};
|
||||
}
|
||||
|
||||
it("used sums all token types as post-turn context occupancy proxy", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({
|
||||
model: "claude-opus-4-20250514",
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_input_tokens: 200,
|
||||
cache_creation_input_tokens: 100,
|
||||
},
|
||||
}),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cacheReadInputTokens: 200,
|
||||
cacheCreationInputTokens: 100,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// used = input(1000) + output(500) + cache_read(200) + cache_creation(100) = 1800
|
||||
expect(usageUpdate.update.used).toBe(1800);
|
||||
});
|
||||
|
||||
it("size reflects the current model's context window, not min across all", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 10,
|
||||
cacheCreationInputTokens: 5,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.005,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus), not 200000 (min of both)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("after model switch, size updates to the new model's window", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Simulate: assistant on Sonnet with both models in modelUsage
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-sonnet-4-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 10,
|
||||
cacheCreationInputTokens: 5,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.005,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 200000 (Sonnet - the current model)
|
||||
expect(usageUpdate.update.size).toBe(200000);
|
||||
});
|
||||
|
||||
it("after switching back to original model, size returns to original window", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Last assistant message is Opus again
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-sonnet-4-20250514" }),
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 200,
|
||||
outputTokens: 100,
|
||||
cacheReadInputTokens: 40,
|
||||
cacheCreationInputTokens: 20,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.02,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 10,
|
||||
cacheCreationInputTokens: 5,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.005,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus - switched back)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("subagent assistant messages do not affect size (top-level model is used)", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Top-level assistant on Opus, then subagent on Haiku (parent_tool_use_id set)
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
{
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: "tool_use_123",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
content: [{ type: "text", text: "subagent response" }],
|
||||
usage: {
|
||||
input_tokens: 50,
|
||||
output_tokens: 25,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
inputTokens: 50,
|
||||
outputTokens: 25,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.001,
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus - the top-level model), NOT 200000 (Haiku subagent)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("prefix-matches when assistant model has date suffix but modelUsage key does not", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// The API response has the full versioned model ID on assistant messages,
|
||||
// but the SDK's streaming path may key modelUsage by the shorter alias.
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-6-20250514" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-6": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// Should match via prefix: "claude-opus-4-6-20250514".startsWith("claude-opus-4-6")
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("prefix-matches when modelUsage key has date suffix but assistant model does not", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-6" }),
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-6-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
|
||||
it("synthetic assistant messages do not override lastAssistantModel", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Real assistant on Opus, then a synthetic message (e.g. from /compact)
|
||||
injectSession(agent, [
|
||||
createAssistantMessage({ model: "claude-opus-4-20250514" }),
|
||||
{
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
model: "<synthetic>",
|
||||
content: [{ type: "text", text: "compacted" }],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
createResultMessageWithModel({
|
||||
modelUsage: {
|
||||
"claude-opus-4-20250514": {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadInputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.01,
|
||||
contextWindow: 1000000,
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
const usageUpdate = updates.find((u: any) => u.update?.sessionUpdate === "usage_update");
|
||||
expect(usageUpdate).toBeDefined();
|
||||
// size should be 1000000 (Opus), not 200000 (the fallback if <synthetic> overrode the model)
|
||||
expect(usageUpdate.update.size).toBe(1000000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,14 +123,17 @@ describe("authorization", () => {
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide terminal auth even when terminal-auth is set", async () => {
|
||||
it("hide claude auth but still show console login when terminal-auth is set", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
@@ -140,7 +143,68 @@ describe("authorization", () => {
|
||||
_meta: { "terminal-auth": true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).toStrictEqual([]);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude auth but still show console login with terminal capability", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true },
|
||||
},
|
||||
});
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("NO_BROWSER falls back to single legacy login method", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, env: { ...process.env, NO_BROWSER: "1" } });
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("NO_BROWSER respects hide-claude-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", {
|
||||
...process,
|
||||
argv: ["--hide-claude-auth"],
|
||||
env: { ...process.env, NO_BROWSER: "1" },
|
||||
});
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
@@ -152,7 +216,10 @@ describe("authorization", () => {
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
expect.objectContaining({ id: "claude-ai-login" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "console-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user