mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-06 01:12:17 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52c7c64d8 | ||
|
|
de96cef382 | ||
|
|
a4957d17c1 | ||
|
|
bed795d261 | ||
|
|
434be13a33 | ||
|
|
2a1525e57c | ||
|
|
54f1e21bc4 | ||
|
|
01b686713d | ||
|
|
6a33dedcbb | ||
|
|
e369479f93 | ||
|
|
c26e640915 |
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-please-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Generate token scoped to the registry repo
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510
|
||||
id: registry-token
|
||||
with:
|
||||
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.96.0"
|
||||
".": "0.97.0"
|
||||
}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [0.97.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.96.0...v0.97.0) (2026-10-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add ultracode session config option ([#1438](https://github.com/agentclientprotocol/claude-agent-acp/issues/1438)) ([de96cef](https://github.com/agentclientprotocol/claude-agent-acp/commit/de96cef382d8daf27301f1f90ae4b5e7f665f73a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump body-parser from 2.3.0 to 2.4.0 ([#1436](https://github.com/agentclientprotocol/claude-agent-acp/issues/1436)) ([c26e640](https://github.com/agentclientprotocol/claude-agent-acp/commit/c26e640915739dc7a3cd0664f8f5879098e0fd83))
|
||||
* distinguish model options with duplicate labels ([#1286](https://github.com/agentclientprotocol/claude-agent-acp/issues/1286)) ([434be13](https://github.com/agentclientprotocol/claude-agent-acp/commit/434be13a33141af8655c6f7c30f574841bf2279c))
|
||||
* keep the usage-limit error when it arrives after compaction ([#1377](https://github.com/agentclientprotocol/claude-agent-acp/issues/1377)) ([2a1525e](https://github.com/agentclientprotocol/claude-agent-acp/commit/2a1525e57cd89246d1a50993fd6ba335c4e634ca))
|
||||
* pass --settings before the auth status subcommand ([#1392](https://github.com/agentclientprotocol/claude-agent-acp/issues/1392)) ([6a33ded](https://github.com/agentclientprotocol/claude-agent-acp/commit/6a33dedcbb90e7bf9db91207f0edbf19ca6d705e))
|
||||
* preserve foreground fork reports until launch completes ([#1431](https://github.com/agentclientprotocol/claude-agent-acp/issues/1431)) ([e369479](https://github.com/agentclientprotocol/claude-agent-acp/commit/e369479f93b34f84a11103323d097c44df42e02e))
|
||||
* settle advisor tool results before completing turns ([#1247](https://github.com/agentclientprotocol/claude-agent-acp/issues/1247)) ([bed795d](https://github.com/agentclientprotocol/claude-agent-acp/commit/bed795d261620e6d5113e427a7196d94e66e7034))
|
||||
* settle live steers folded into autonomous cycles ([#1185](https://github.com/agentclientprotocol/claude-agent-acp/issues/1185)) ([a4957d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/a4957d17c1e1a925ba3b72b29c5dcde4faabbe17))
|
||||
|
||||
## [0.96.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.95.0...v0.96.0) (2026-10-26)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
- An Ultracode session config option, which turns on Claude Code's dynamic workflows for every task. It appears when dynamic workflows are enabled and the model supports ultracode (any model with `xhigh` effort)
|
||||
- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation
|
||||
- Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal`
|
||||
- Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure)
|
||||
|
||||
@@ -707,7 +707,7 @@ A non-empty plan also gets one clear-context choice with the same priority.
|
||||
After that choice, the adapter stops the old private Claude query and hides that internal rejection.
|
||||
It starts a new private query under the same ACP session.
|
||||
It continues the turn with `Implement the following plan:` and the complete plan.
|
||||
The mode, model, agent, effort, Fast mode, and turn usage stay the same.
|
||||
The mode, model, agent, effort, Fast mode, ultracode, and turn usage stay the same.
|
||||
The SDK cannot apply `allowedPrompts` again, so those hints do not survive the handoff.
|
||||
|
||||
The adapter does not allow a callback only because the session mode is `bypassPermissions`.
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.0.2",
|
||||
@@ -1250,19 +1250,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.96.0",
|
||||
"version": "0.97.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
169
src/acp-agent.ts
169
src/acp-agent.ts
@@ -365,6 +365,15 @@ import {
|
||||
mergeEffortSettings,
|
||||
settingsEffortForModel,
|
||||
} from "./session-effort.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
readUltracodeSettings,
|
||||
resolveUltracodeEnabled,
|
||||
ULTRACODE_CONFIG_ID,
|
||||
ultracodeSupported,
|
||||
ultracodeWorkflowsEnabled,
|
||||
type UltracodeOptionState,
|
||||
} from "./session-ultracode.js";
|
||||
|
||||
export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js";
|
||||
export {
|
||||
@@ -1191,6 +1200,15 @@ export type Session = {
|
||||
* user's intent so it persists across model switches; the Fast mode config
|
||||
* option is only surfaced while the selected model supports it. */
|
||||
fastModeEnabled: boolean;
|
||||
/** Whether ultracode is turned on for this session. Tracked as the user's
|
||||
* intent, like `fastModeEnabled`: the CLI keeps the request across model
|
||||
* switches and applies it whenever the model supports ultracode, while the
|
||||
* config option is only surfaced on supporting models. */
|
||||
ultracodeEnabled: boolean;
|
||||
/** Whether dynamic workflows are enabled, learned from the CLI's
|
||||
* `get_settings` at session start; undefined when unknown. Combined with
|
||||
* the model's support to decide whether to show the ultracode option. */
|
||||
ultracodeWorkflowsEnabled?: boolean;
|
||||
/** The non-default effort the user picked through the ACP picker this
|
||||
* session. A pin lives at the SDK's flag layer, which overrides the CLI's
|
||||
* persisted effort (including the per-model `modelSettings` entries), so it
|
||||
@@ -2801,6 +2819,7 @@ export class ClaudeAcpAgent {
|
||||
return session;
|
||||
},
|
||||
applyFastMode: (session, enabled) => this.applyFastMode(session, enabled),
|
||||
applyUltracode: (session, enabled) => this.applyUltracode(session, enabled),
|
||||
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
|
||||
supportsPlanOperations: () => this.clientCapabilities?.plan != null,
|
||||
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
|
||||
@@ -3430,7 +3449,9 @@ export class ClaudeAcpAgent {
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
const args = ["auth", "status", "--json"];
|
||||
if (context?.override) args.push("--settings", JSON.stringify(context.override));
|
||||
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
|
||||
// it with "unknown option '--settings'", so it has to come first.
|
||||
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
|
||||
({ stdout } = await execFileAsync(cliPath, args, {
|
||||
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
cwd: context?.manager.getCwd() ?? process.cwd(),
|
||||
@@ -5087,6 +5108,8 @@ export class ClaudeAcpAgent {
|
||||
* marked as a background result, but it lists the uuids of the prompts it
|
||||
* answered.
|
||||
*
|
||||
* A steer belongs to the live active turn even though it has no queued
|
||||
* Turn of its own. Its stamped result can answer that turn too.
|
||||
* A turn held open for its background subagents already has its result,
|
||||
* so a result that names it does not answer it. */
|
||||
const answersPendingPrompt = (message: {
|
||||
@@ -5102,8 +5125,15 @@ export class ClaudeAcpAgent {
|
||||
answeredPromptUuids = [message.user_message_uuid];
|
||||
}
|
||||
|
||||
const activeTurn = session.activeTurn;
|
||||
const liveActiveTurn =
|
||||
activeTurn && !activeTurn.settled && !isHeldOpen(activeTurn) ? activeTurn : undefined;
|
||||
for (const promptUuid of answeredPromptUuids) {
|
||||
const turn = findUnsettledTurn(promptUuid);
|
||||
// Keep queued prompt matching, including folded sends with no echo.
|
||||
// Steers qualify only through their live active owner.
|
||||
const turn =
|
||||
findUnsettledTurn(promptUuid) ??
|
||||
(liveActiveTurn?.steeredUuids?.has(promptUuid) ? liveActiveTurn : undefined);
|
||||
if (turn === undefined) {
|
||||
// Not a prompt of this session, or it was answered already.
|
||||
continue;
|
||||
@@ -8045,6 +8075,13 @@ export class ClaudeAcpAgent {
|
||||
message.message.model === "<synthetic>" &&
|
||||
compaction.hasDeliveredOutput
|
||||
) {
|
||||
if (isSyntheticUsageLimitMessage(message.message)) {
|
||||
lastAssistantWasUsageLimit = true;
|
||||
lastAssistantFailureTitle = assistantMessageText(message.message);
|
||||
if (message.error) {
|
||||
lastAssistantError = message.error;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -8200,6 +8237,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
let content: typeof message.message.content;
|
||||
let toolUseResult = message.type === "user" ? message.tool_use_result : undefined;
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
(message.parent_tool_use_id === null || forwardsSubagentText())
|
||||
@@ -8220,6 +8258,20 @@ export class ClaudeAcpAgent {
|
||||
(item) => item.type !== "text" && item.type !== "thinking",
|
||||
);
|
||||
streamedBlocksOf(message.parent_tool_use_id).length = 0;
|
||||
} else if (
|
||||
message.parent_tool_use_id !== null &&
|
||||
Array.isArray(message.message.content)
|
||||
) {
|
||||
// A foreground fork inherits a placeholder result for its own
|
||||
// launch. Only the launching side can complete that Agent call
|
||||
// with the fork's actual report.
|
||||
const launchId = message.parent_tool_use_id;
|
||||
content = message.message.content.filter(
|
||||
(block) => block.type !== "tool_result" || block.tool_use_id !== launchId,
|
||||
);
|
||||
// The message-level sidecar cannot describe a remaining result
|
||||
// after its launch result was removed.
|
||||
if (content.length !== message.message.content.length) toolUseResult = undefined;
|
||||
} else {
|
||||
content = message.message.content;
|
||||
}
|
||||
@@ -8237,7 +8289,7 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const backgroundBashTask = backgroundBashTaskFromToolResult(
|
||||
content,
|
||||
message.tool_use_result,
|
||||
toolUseResult,
|
||||
session.toolUseCache,
|
||||
);
|
||||
if (backgroundBashTask) await asyncTasks.taskBackgrounded(backgroundBashTask);
|
||||
@@ -8266,7 +8318,7 @@ export class ClaudeAcpAgent {
|
||||
asyncTasks,
|
||||
backgroundBashTask,
|
||||
);
|
||||
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
|
||||
const resumedAgentId = resumedNativeSubagentId(toolUseResult);
|
||||
if (resumedAgentId) {
|
||||
const resume = sendMessageResume(
|
||||
session.toolUseCache,
|
||||
@@ -8307,7 +8359,7 @@ export class ClaudeAcpAgent {
|
||||
emittedToolCalls: session.emittedToolCalls,
|
||||
toolCallFields: toolCallFieldsOf(session),
|
||||
messageId: messageIdForGrouping(message),
|
||||
toolUseResult: message.type === "user" ? message.tool_use_result : undefined,
|
||||
toolUseResult,
|
||||
// On the wire since CLI 2.1.216 but not in SDKUserMessage's
|
||||
// type, hence the cast. Validated by parseToolResultMeta.
|
||||
toolResultMeta:
|
||||
@@ -9170,13 +9222,18 @@ export class ClaudeAcpAgent {
|
||||
throw new Error(`Unknown config option: ${params.configId}`);
|
||||
}
|
||||
|
||||
// Fast mode carries a boolean value (for Clients that opted into boolean
|
||||
// config options) or the "on"/"off" select fallback, so it bypasses the
|
||||
// Fast mode and ultracode carry a boolean value (for Clients that opted
|
||||
// into boolean config options) or the "on"/"off" select fallback, so they
|
||||
// bypass the
|
||||
// string-only validation the select-style options below rely on.
|
||||
if (params.configId === FAST_MODE_CONFIG_ID) {
|
||||
await this.applyFastMode(session, resolveFastModeEnabled(params));
|
||||
return { configOptions: session.configOptions };
|
||||
}
|
||||
if (params.configId === ULTRACODE_CONFIG_ID) {
|
||||
await this.applyUltracode(session, resolveUltracodeEnabled(params));
|
||||
return { configOptions: session.configOptions };
|
||||
}
|
||||
|
||||
if (typeof params.value !== "string") {
|
||||
throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`);
|
||||
@@ -11005,6 +11062,13 @@ export class ClaudeAcpAgent {
|
||||
{
|
||||
useRecommendedValue,
|
||||
},
|
||||
{
|
||||
// Like Fast mode, the toggle hides on models without ultracode
|
||||
// support and comes back with the retained intent on one that has it.
|
||||
supported: ultracodeSupported(session.ultracodeWorkflowsEnabled, newModelInfo),
|
||||
enabled: session.ultracodeEnabled,
|
||||
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
||||
},
|
||||
);
|
||||
|
||||
// Opted-in clients apply the concrete displayed effort on every switch,
|
||||
@@ -11031,10 +11095,7 @@ export class ClaudeAcpAgent {
|
||||
// deliberately apply their concrete displayed effort.
|
||||
effortFlagSettings(
|
||||
useRecommendedValue ? newEffort : undefined,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
session.ultracodeEnabled,
|
||||
),
|
||||
);
|
||||
session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined;
|
||||
@@ -11103,15 +11164,7 @@ export class ClaudeAcpAgent {
|
||||
} 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(
|
||||
effortFlagSettings(
|
||||
value,
|
||||
mergeEffortSettings(
|
||||
session.settingsManager.getSettings(),
|
||||
session.effortSettingsOverride,
|
||||
),
|
||||
),
|
||||
);
|
||||
await session.query.applyFlagSettings(effortFlagSettings(value, session.ultracodeEnabled));
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
@@ -11193,6 +11246,22 @@ export class ClaudeAcpAgent {
|
||||
this.refreshFastModeOption(session, enabled);
|
||||
}
|
||||
|
||||
/** Turn ultracode on or off for a session. Sends an explicit boolean, which
|
||||
* leaves the effort level alone (`null` would reset the flag layer instead).
|
||||
* The SDK flag is applied first so a rejected request leaves the session
|
||||
* state and option untouched. */
|
||||
private async applyUltracode(session: Session, enabled: boolean): Promise<void> {
|
||||
await session.query.applyFlagSettings({ ultracode: enabled });
|
||||
session.ultracodeEnabled = enabled;
|
||||
const refreshed = createUltracodeConfigOption(
|
||||
enabled,
|
||||
clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
||||
);
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === ULTRACODE_CONFIG_ID ? refreshed : o,
|
||||
);
|
||||
}
|
||||
|
||||
/** Reconcile the session's Fast mode toggle with an SDK-reported
|
||||
* `fast_mode_state` (delivered on `system`/init and on user-turn `result`s).
|
||||
* The SDK can flip fast mode independently of the user — e.g. back to `on`
|
||||
@@ -12031,6 +12100,21 @@ export class ClaudeAcpAgent {
|
||||
settingsManager.getSettings(),
|
||||
configuredSettingsObject,
|
||||
);
|
||||
|
||||
// The CLI reports whether ultracode can run (workflows enabled and a
|
||||
// supporting model) and whether the session already requests it (an
|
||||
// `ultracode: true` setting). Without a report, fall back to settings.
|
||||
const ultracodeReport = await readUltracodeSettings(q);
|
||||
timing.phase("ultracode");
|
||||
const ultracodeEnabled = ultracodeReport?.requested ?? effortSettings.ultracode === true;
|
||||
const workflowsEnabled = ultracodeWorkflowsEnabled(ultracodeReport, currentModelInfo);
|
||||
const ultracode: UltracodeOptionState = {
|
||||
supported:
|
||||
ultracodeReport?.available ?? ultracodeSupported(workflowsEnabled, currentModelInfo),
|
||||
enabled: ultracodeEnabled,
|
||||
useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities),
|
||||
};
|
||||
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
@@ -12042,10 +12126,11 @@ export class ClaudeAcpAgent {
|
||||
{
|
||||
useRecommendedValue,
|
||||
},
|
||||
ultracode,
|
||||
);
|
||||
const initialEffort = configOptions.find((option) => option.id === EFFORT_CONFIG_ID);
|
||||
if (useRecommendedValue && typeof initialEffort?.currentValue === "string") {
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, effortSettings));
|
||||
await q.applyFlagSettings(effortFlagSettings(initialEffort.currentValue, ultracodeEnabled));
|
||||
}
|
||||
// Seed the context window without awaited IPC. The cached authoritative
|
||||
// window from a prior turn wins (`result.modelUsage`, cross-session),
|
||||
@@ -12113,6 +12198,8 @@ export class ClaudeAcpAgent {
|
||||
currentAgent,
|
||||
fastModeEnabled,
|
||||
fastModeDisabledReason,
|
||||
ultracodeEnabled,
|
||||
ultracodeWorkflowsEnabled: workflowsEnabled,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
forwardSubagentText,
|
||||
@@ -12942,6 +13029,7 @@ export function buildConfigOptions(
|
||||
currentAgent: string = DEFAULT_AGENT_ID,
|
||||
fastMode?: FastModeOptionState,
|
||||
presentation?: ConfigOptionPresentation,
|
||||
ultracode?: UltracodeOptionState,
|
||||
): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
|
||||
// A gateway that lists none of the SDK's models leaves no model to offer, so
|
||||
@@ -12972,6 +13060,12 @@ export function buildConfigOptions(
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the ultracode toggle only while the CLI can run it: dynamic
|
||||
// workflows are enabled and the current model supports it.
|
||||
if (ultracode?.supported) {
|
||||
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
|
||||
}
|
||||
|
||||
// Only surface the Agent picker when there's a real choice — i.e. the user
|
||||
// has configured at least one custom agent (built-ins are filtered out in
|
||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||
@@ -13614,6 +13708,38 @@ export function toAcpNotifications(
|
||||
break;
|
||||
}
|
||||
|
||||
case "advisor_tool_result": {
|
||||
// Advisor results arrive in assistant content, including encrypted advice.
|
||||
// Resolve the server tool through the normal result path without rendering
|
||||
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
|
||||
const result = chunk.content;
|
||||
unregisterHookCallback(chunk.tool_use_id);
|
||||
output.push(
|
||||
...toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: chunk.tool_use_id,
|
||||
is_error: result.type === "advisor_tool_result_error",
|
||||
content:
|
||||
result.type === "advisor_result"
|
||||
? result.text
|
||||
: result.type === "advisor_redacted_result"
|
||||
? "Advisor guidance applied server-side."
|
||||
: `Advisor error: ${result.error_code}`,
|
||||
},
|
||||
],
|
||||
role,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
options,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
case "tool_search_tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
@@ -13783,7 +13909,6 @@ export function toAcpNotifications(
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
case "fallback":
|
||||
case "mcp_tool_listing":
|
||||
break;
|
||||
|
||||
@@ -39,6 +39,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
configOptions: SessionConfigOption[];
|
||||
currentAgent: string;
|
||||
fastModeEnabled: boolean;
|
||||
ultracodeEnabled: boolean;
|
||||
effortPinnedLevel?: string;
|
||||
activeTurn?: Turn | null;
|
||||
turnQueue?: Turn[];
|
||||
@@ -59,6 +60,7 @@ export type ClearContextCoordinatorHost<
|
||||
options: { publicSessionId: string; permissionMode: PermissionMode },
|
||||
): Promise<Session>;
|
||||
applyFastMode(session: Session, enabled: boolean): Promise<void>;
|
||||
applyUltracode(session: Session, enabled: boolean): Promise<void>;
|
||||
publishSessionState(
|
||||
sessionId: string,
|
||||
mode: PermissionMode,
|
||||
@@ -146,6 +148,16 @@ export async function continuePlanInFreshContext<
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
// The fresh query seeds ultracode from settings only, so a toggle made
|
||||
// during the old session would otherwise be lost.
|
||||
if (oldSession.ultracodeEnabled !== freshSession.ultracodeEnabled) {
|
||||
try {
|
||||
await host.applyUltracode(freshSession, oldSession.ultracodeEnabled);
|
||||
} catch (error) {
|
||||
host.logError("Failed to restore ultracode after clearing context:", error);
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
}
|
||||
|
||||
oldSession.activeTurn = null;
|
||||
oldSession.turnQueue = [];
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
export const ULTRACODE_CONFIG_ID = "ultracode";
|
||||
|
||||
@@ -14,14 +14,15 @@ export function toSdkEffortLevel(value: string | undefined): EffortLevel | null
|
||||
|
||||
// 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.
|
||||
// ultracode on at any level, so send `ultracode: true` with every effort apply
|
||||
// while the session has ultracode on. When it is off, the key is omitted: an
|
||||
// effort change then leaves ultracode off, as it already was.
|
||||
export function effortFlagSettings(
|
||||
value: string | undefined,
|
||||
settings: Settings,
|
||||
ultracode: boolean,
|
||||
): { effortLevel: EffortLevel | null; ultracode?: true } {
|
||||
const effortLevel = toSdkEffortLevel(value);
|
||||
return settings.ultracode === true ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
|
||||
@@ -595,12 +595,20 @@ export async function getAvailableModels(
|
||||
for (const name of displayNames) {
|
||||
displayNameCounts.set(name, (displayNameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
const names = models.map((model, index) =>
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
);
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const name of names) {
|
||||
nameCounts.set(name, (nameCounts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
availableModels: models.map((model, index) => ({
|
||||
modelId: model.value,
|
||||
name:
|
||||
displayNameCounts.get(displayNames[index]) === 1 ? displayNames[index] : model.displayName,
|
||||
// Allowlisted aliases can inherit the same SDK label. Keep every ID
|
||||
// selectable, but distinguish the rows for clients that show only names.
|
||||
name: nameCounts.get(names[index]) === 1 ? names[index] : `${names[index]} (${model.value})`,
|
||||
description: model.description,
|
||||
})),
|
||||
currentModelId: currentModel.value,
|
||||
|
||||
136
src/session-ultracode.ts
Normal file
136
src/session-ultracode.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
export { ULTRACODE_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
/** Select-fallback values for clients without boolean config options. */
|
||||
export const ULTRACODE_ON = "on";
|
||||
export const ULTRACODE_OFF = "off";
|
||||
const ULTRACODE_DESCRIPTION = "Dynamic workflows on every task, at any effort level";
|
||||
|
||||
/** Ultracode state from the CLI's `get_settings` control response
|
||||
* (`applied.ultracodeAvailable` / `applied.ultracodeRequested`). Each field is
|
||||
* undefined when the CLI does not report it. */
|
||||
export type UltracodeSettingsReport = {
|
||||
/** Workflows are enabled and the current model supports ultracode. */
|
||||
available?: boolean;
|
||||
/** Ultracode is turned on for the session, whether or not it can take
|
||||
* effect on the current model. */
|
||||
requested?: boolean;
|
||||
};
|
||||
|
||||
// `Query.getSettings()` exists at runtime (sdk.mjs) but is missing from the
|
||||
// SDK's type declarations as of 0.3.310.
|
||||
type GetSettingsQuery = Query & {
|
||||
getSettings?: () => Promise<{
|
||||
applied?: { ultracodeAvailable?: unknown; ultracodeRequested?: unknown };
|
||||
}>;
|
||||
};
|
||||
|
||||
/** Ask the CLI whether ultracode is available and requested for the session.
|
||||
* Returns undefined when the SDK has no `getSettings` or the request fails;
|
||||
* callers then fall back to {@link modelSupportsUltracode}. */
|
||||
export async function readUltracodeSettings(
|
||||
query: Query,
|
||||
): Promise<UltracodeSettingsReport | undefined> {
|
||||
const getSettings = (query as GetSettingsQuery).getSettings;
|
||||
if (typeof getSettings !== "function") return undefined;
|
||||
try {
|
||||
const applied = (await getSettings.call(query))?.applied;
|
||||
return {
|
||||
available:
|
||||
typeof applied?.ultracodeAvailable === "boolean" ? applied.ultracodeAvailable : undefined,
|
||||
requested:
|
||||
typeof applied?.ultracodeRequested === "boolean" ? applied.ultracodeRequested : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The CLI supports ultracode on exactly the models that support `xhigh`
|
||||
* effort (both use the same capability check), and `supportedEffortLevels`
|
||||
* lists `xhigh` only for those models. E.g. Opus 4.7+, Sonnet 5+ and Haiku
|
||||
* 5.5 qualify; Opus 4.6 and Haiku 4.5 do not. */
|
||||
export function modelSupportsUltracode(modelInfo: ModelInfo | undefined): boolean {
|
||||
return (
|
||||
modelInfo?.supportsEffort === true &&
|
||||
modelInfo.supportedEffortLevels?.includes("xhigh") === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether dynamic workflows are enabled for the session, inferred from the
|
||||
* CLI's `ultracodeAvailable` for the model the session started on. This is
|
||||
* the part of availability that does not change with the model: the plan
|
||||
* default (off on Pro), `enableWorkflows`/`disableWorkflows` and org policy.
|
||||
* Undefined when the report can't tell, e.g. `ultracodeAvailable: false` on a
|
||||
* model that lacks ultracode support anyway. */
|
||||
export function ultracodeWorkflowsEnabled(
|
||||
report: UltracodeSettingsReport | undefined,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
): boolean | undefined {
|
||||
if (report?.available === true) return true;
|
||||
if (report?.available === false && modelSupportsUltracode(modelInfo)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Whether to offer the ultracode toggle for `modelInfo`. An unknown workflows
|
||||
* state shows the toggle: the CLI keeps an unavailable request without
|
||||
* error, so the toggle is merely inert there. */
|
||||
export function ultracodeSupported(
|
||||
workflowsEnabled: boolean | undefined,
|
||||
modelInfo: ModelInfo | undefined,
|
||||
): boolean {
|
||||
return workflowsEnabled !== false && modelSupportsUltracode(modelInfo);
|
||||
}
|
||||
|
||||
/** Ultracode state threaded into `buildConfigOptions`. The option is only
|
||||
* surfaced while `supported`. */
|
||||
export type UltracodeOptionState = {
|
||||
supported: boolean;
|
||||
enabled: boolean;
|
||||
/** Whether the client opted into boolean config options. */
|
||||
useBooleanOption: boolean;
|
||||
};
|
||||
|
||||
/** Build the ultracode toggle: a native boolean for clients that opted into
|
||||
* boolean config options, otherwise an "on"/"off" select. */
|
||||
export function createUltracodeConfigOption(
|
||||
enabled: boolean,
|
||||
useBooleanOption: boolean,
|
||||
): SessionConfigOption {
|
||||
const base = {
|
||||
id: ULTRACODE_CONFIG_ID,
|
||||
name: "Ultracode",
|
||||
description: ULTRACODE_DESCRIPTION,
|
||||
category: "model_config",
|
||||
} as const;
|
||||
|
||||
if (useBooleanOption) {
|
||||
return { ...base, type: "boolean", currentValue: enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
currentValue: enabled ? ULTRACODE_ON : ULTRACODE_OFF,
|
||||
options: [
|
||||
{ value: ULTRACODE_ON, name: "On" },
|
||||
{ value: ULTRACODE_OFF, name: "Off" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the requested ultracode value from a `session/set_config_option`
|
||||
* request: a boolean, or the "on"/"off" select-fallback strings. */
|
||||
export function resolveUltracodeEnabled(params: SetSessionConfigOptionRequest): boolean {
|
||||
const value = params.value;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === ULTRACODE_ON) return true;
|
||||
if (value === ULTRACODE_OFF) return false;
|
||||
throw new Error(`Invalid value for config option ${ULTRACODE_CONFIG_ID}: ${value}`);
|
||||
}
|
||||
@@ -12631,6 +12631,56 @@ describe("stop reason propagation", () => {
|
||||
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
|
||||
});
|
||||
|
||||
it.each(
|
||||
[false, true].flatMap((typedFailures) =>
|
||||
[undefined, "authentication_failed" as const].map((staleError) => ({
|
||||
typedFailures,
|
||||
staleError,
|
||||
})),
|
||||
),
|
||||
)(
|
||||
"preserves a usage-limit failure after compaction (typed: $typedFailures, stale error: $staleError)",
|
||||
async ({ typedFailures, staleError }) => {
|
||||
const agent = createMockAgent();
|
||||
if (typedFailures) await initializeClient(agent, airSessionFailureCapabilities);
|
||||
const staleFrame = createAssistantError(staleError, "stale compaction output");
|
||||
staleFrame.message.model = "<synthetic>";
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "status",
|
||||
status: null,
|
||||
compact_result: "success",
|
||||
uuid: "compact-completed",
|
||||
session_id: "test-session",
|
||||
},
|
||||
createUsageLimitAssistantError(),
|
||||
staleFrame,
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "private provider detail",
|
||||
}),
|
||||
]);
|
||||
|
||||
const prompt = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
if (typedFailures) {
|
||||
expect(sessionFailureFromResponse(await prompt)).toMatchObject({
|
||||
category: "limit",
|
||||
actions: [],
|
||||
title:
|
||||
"You've hit your individual spend limit · run /usage-credits to ask your admin for a higher limit",
|
||||
});
|
||||
} else {
|
||||
await expect(prompt).rejects.toMatchObject({ data: { errorKind: "rate_limit" } });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
@@ -13928,6 +13978,7 @@ describe("session/close", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -14049,6 +14100,7 @@ describe("session/delete", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -14208,6 +14260,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -15964,6 +16017,132 @@ describe("usage_update computation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("foreground fork tool results", () => {
|
||||
it.each([
|
||||
["plain", {}, null],
|
||||
["AIR", AIR_CLIENT_CAPABILITIES, null],
|
||||
["plain nested", {}, "toolu_outer"],
|
||||
["AIR nested", AIR_CLIENT_CAPABILITIES, "toolu_outer"],
|
||||
] as const)(
|
||||
"waits for the launching side's result for a %s client",
|
||||
async (_, capabilities, parent) => {
|
||||
const updates: SessionNotification[] = [];
|
||||
const logger = { log: vi.fn(), error: vi.fn() };
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
logger,
|
||||
);
|
||||
await initializeClient(agent, capabilities);
|
||||
const input = new Pushable<any>();
|
||||
const assistant = (content: unknown[], parentToolUseId: string | null) => ({
|
||||
type: "assistant",
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: randomUUID(),
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content,
|
||||
usage: successfulResultMessage().usage,
|
||||
},
|
||||
});
|
||||
const user = (content: unknown[], parentToolUseId: string | null) => ({
|
||||
type: "user",
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: { role: "user", content },
|
||||
});
|
||||
const toolResult = (id: string, text: string) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: id,
|
||||
content: [{ type: "text", text }],
|
||||
});
|
||||
async function* messages() {
|
||||
const { value } = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(value);
|
||||
yield assistant(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_fork",
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "fork",
|
||||
run_in_background: false,
|
||||
prompt: "Inspect the parser",
|
||||
},
|
||||
},
|
||||
],
|
||||
parent,
|
||||
);
|
||||
// The fork inherits a result for its own launch. It cannot complete
|
||||
// that call: the launching side sends the actual report later.
|
||||
yield user([toolResult("toolu_fork", "Fork started — that fork is you.")], "toolu_fork");
|
||||
yield assistant(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_child",
|
||||
name: "Agent",
|
||||
input: { subagent_type: "general-purpose", prompt: "Read the parser" },
|
||||
},
|
||||
],
|
||||
"toolu_fork",
|
||||
);
|
||||
// Keep results of tools the fork itself called, even in a message
|
||||
// that also carries the copied launch result.
|
||||
yield {
|
||||
...user(
|
||||
[
|
||||
toolResult("toolu_fork", "Fork started — processing in background"),
|
||||
toolResult("toolu_child", "child report"),
|
||||
],
|
||||
"toolu_fork",
|
||||
),
|
||||
// Filtering must not attribute the inherited launch sidecar to
|
||||
// the one result that remains in this message.
|
||||
tool_use_result: {
|
||||
status: "completed",
|
||||
content: [{ type: "text", text: "inherited launch report" }],
|
||||
},
|
||||
};
|
||||
yield user([toolResult("toolu_fork", "fork report")], parent);
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(messages()), input });
|
||||
|
||||
await expect(
|
||||
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "Inspect it" }] }),
|
||||
).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
|
||||
const completed = updates.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "tool_call_update" && update.status === "completed"
|
||||
? [{ id: update.toolCallId, content: update.content, rawOutput: update.rawOutput }]
|
||||
: [],
|
||||
);
|
||||
expect(completed.map(({ id }) => id)).toEqual(["toolu_child", "toolu_fork"]);
|
||||
expect(completed.map(({ content }) => content)).toEqual([
|
||||
[{ type: "content", content: { type: "text", text: "child report" } }],
|
||||
[{ type: "content", content: { type: "text", text: "fork report" } }],
|
||||
]);
|
||||
if (!("_meta" in capabilities)) {
|
||||
expect(completed.map(({ rawOutput }) => rawOutput)).toEqual([
|
||||
[{ type: "text", text: "child report" }],
|
||||
[{ type: "text", text: "fork report" }],
|
||||
]);
|
||||
}
|
||||
expect(logger.error.mock.calls.flat().join("\n")).not.toContain("wasn't tracked");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("assembled assistant text fallback", () => {
|
||||
const ZERO_USAGE = {
|
||||
input_tokens: 0,
|
||||
@@ -18536,6 +18715,7 @@ describe("post-error recovery", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -26892,6 +27072,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
agents: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
@@ -28799,6 +28980,7 @@ describe("agent selection config option", () => {
|
||||
agents,
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
|
||||
@@ -153,7 +153,7 @@ afterEach(async () => {
|
||||
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
|
||||
execFileSpy.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(tempDir, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
||||
|
||||
@@ -22,6 +22,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
/** Shared spy for both `claude auth status --json` and `claude auth logout`.
|
||||
* Tests set `execFileResults` per invoked subcommand. */
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
/** `auth status` probes, wherever global options such as `--settings` put the subcommand. */
|
||||
function isStatusProbe(args: string[]): boolean {
|
||||
const auth = args.indexOf("auth");
|
||||
return auth >= 0 && args[auth + 1] === "status";
|
||||
}
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
@@ -409,7 +414,7 @@ describe("auth status over ACP", () => {
|
||||
execFileSpy.mockImplementation((...invocation: unknown[]) => {
|
||||
const args = invocation[1] as string[];
|
||||
const cb = invocation[invocation.length - 1] as (...a: unknown[]) => void;
|
||||
if (args[1] === "status") {
|
||||
if (isStatusProbe(args)) {
|
||||
const stdout = statusStdout;
|
||||
const fails = statusFails;
|
||||
const answer = () => {
|
||||
@@ -451,7 +456,7 @@ describe("auth status over ACP", () => {
|
||||
});
|
||||
|
||||
function statusCalls() {
|
||||
return execFileSpy.mock.calls.filter((call) => (call[1] as string[])[1] === "status");
|
||||
return execFileSpy.mock.calls.filter((call) => isStatusProbe(call[1] as string[]));
|
||||
}
|
||||
|
||||
function updates() {
|
||||
@@ -542,11 +547,11 @@ describe("auth status over ACP", () => {
|
||||
env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" },
|
||||
});
|
||||
expect(statusCalls().at(-1)?.[1]).toEqual([
|
||||
"--settings",
|
||||
JSON.stringify({ apiKeyHelper: "central proxy start" }),
|
||||
"auth",
|
||||
"status",
|
||||
"--json",
|
||||
"--settings",
|
||||
JSON.stringify({ apiKeyHelper: "central proxy start" }),
|
||||
]);
|
||||
await promptOnce(sessionId);
|
||||
await flushStatus();
|
||||
|
||||
@@ -31,6 +31,7 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
|
||||
configOptions: [],
|
||||
currentAgent: "default",
|
||||
fastModeEnabled: false,
|
||||
ultracodeEnabled: false,
|
||||
input: { push: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
@@ -49,6 +50,7 @@ function testHost(
|
||||
return freshSession;
|
||||
}),
|
||||
applyFastMode: vi.fn(async () => {}),
|
||||
applyUltracode: vi.fn(async () => {}),
|
||||
publishSessionState: vi.fn(async () => {}),
|
||||
continuationMessage: vi.fn((sessionId, plan, promptUuid) => ({
|
||||
type: "user" as const,
|
||||
@@ -252,6 +254,25 @@ describe("continuePlanInFreshContext", () => {
|
||||
expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, false);
|
||||
});
|
||||
|
||||
it("restores ultracode turned on before clearing context", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
promptUuid: "00000000-0000-4000-8000-000000000000",
|
||||
};
|
||||
const oldSession = testSession({ activeTurn: turn, ultracodeEnabled: true });
|
||||
const freshSession = testSession({ ultracodeEnabled: false });
|
||||
const host = testHost(oldSession, freshSession);
|
||||
|
||||
await continuePlanInFreshContext(
|
||||
"public-session",
|
||||
oldSession,
|
||||
{ toolUseId: "tool-plan", plan: "Ship it", mode: "auto" },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(host.applyUltracode).toHaveBeenCalledWith(freshSession, true);
|
||||
});
|
||||
|
||||
it("does not publish or continue a restart cancelled while creating the fresh session", async () => {
|
||||
const turn: ClearContextTurn = {
|
||||
settled: false,
|
||||
|
||||
298
src/tests/folded-steer.test.ts
Normal file
298
src/tests/folded-steer.test.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
successfulResultMessage,
|
||||
userEcho,
|
||||
wrapQuery,
|
||||
} from "./session-doubles.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
const refused = () => {
|
||||
throw new Error("Unexpected real SDK call");
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
query: vi.fn(refused),
|
||||
getSessionMessages: vi.fn(async () => []),
|
||||
getSubagentMessages: vi.fn(async () => []),
|
||||
listSessions: vi.fn(async () => []),
|
||||
getSessionInfo: vi.fn(async () => undefined),
|
||||
deleteSession: vi.fn(refused),
|
||||
forkSession: vi.fn(refused),
|
||||
importSessionToStore: vi.fn(refused),
|
||||
};
|
||||
});
|
||||
|
||||
async function untilDone<T>(promise: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("Prompt did not settle")), 3000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
||||
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function usage(inputTokens: number, outputTokens: number) {
|
||||
return {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function modelUsage(inputTokens: number, outputTokens: number) {
|
||||
return {
|
||||
"claude-sonnet-4-6": {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0,
|
||||
contextWindow: 200000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stamp(uuid: string, fields: string) {
|
||||
return {
|
||||
...(fields !== "plural" ? { user_message_uuid: uuid } : {}),
|
||||
...(fields !== "singular" ? { user_message_uuids: [uuid] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
describe("a steer folded into an autonomous cycle", () => {
|
||||
it.each([
|
||||
["singular", true],
|
||||
["plural", true],
|
||||
["both", true],
|
||||
["singular", false],
|
||||
["plural", false],
|
||||
["both", false],
|
||||
] as const)("settles from its own %s UUID stamp (echo: %s)", async (fields, echo) => {
|
||||
const agent = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
const echoed = Promise.withResolvers<void>();
|
||||
const interrupted = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const processed = Promise.withResolvers<void>();
|
||||
const finish = Promise.withResolvers<void>();
|
||||
async function* messages() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: original } = await iter.next();
|
||||
yield userEcho(original);
|
||||
echoed.resolve();
|
||||
const { value: steered } = await iter.next();
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "human" },
|
||||
...stamp(original.uuid, "both"),
|
||||
queued_turn_count: 1,
|
||||
stop_reason: "tool_use",
|
||||
usage: usage(10, 5),
|
||||
modelUsage: modelUsage(10, 5),
|
||||
});
|
||||
interrupted.resolve();
|
||||
await resume.promise;
|
||||
if (echo) yield userEcho(steered);
|
||||
// Only the steer's UUID is named; it has no queued Turn of its own.
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "task-notification" },
|
||||
...stamp(steered.uuid, fields),
|
||||
queued_turn_count: 0,
|
||||
stop_reason: "max_tokens",
|
||||
usage: usage(20, 7),
|
||||
modelUsage: modelUsage(30, 12),
|
||||
});
|
||||
yield idle;
|
||||
processed.resolve();
|
||||
// Stream shutdown must not supply the prompt's response.
|
||||
await finish.promise;
|
||||
}
|
||||
const session = mockSessionState({ input, query: wrapQuery(messages()) });
|
||||
agent.sessions["test-session"] = session;
|
||||
let response: unknown;
|
||||
const prompt = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
|
||||
.then((value) => (response = value));
|
||||
try {
|
||||
await untilDone(echoed.promise);
|
||||
await expect(
|
||||
agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle this" }],
|
||||
}),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
await untilDone(interrupted.promise);
|
||||
expect(response).toBeUndefined();
|
||||
resume.resolve();
|
||||
await untilDone(processed.promise);
|
||||
await untilDone(prompt);
|
||||
const tokenCount = {
|
||||
totalTokens: 42,
|
||||
inputTokens: 30,
|
||||
cachedInputTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
outputTokens: 12,
|
||||
reasoningOutputTokens: 0,
|
||||
};
|
||||
expect(response).toEqual({
|
||||
stopReason: "max_tokens",
|
||||
usage: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 12,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 42,
|
||||
},
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: tokenCount,
|
||||
model_usage: [{ model: "claude-sonnet-4-6", token_count: tokenCount }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(session.activeTurn).toBeNull();
|
||||
expect(session.turnQueue).toHaveLength(0);
|
||||
expect(session.owedTrailingIdles).toBe(0);
|
||||
} finally {
|
||||
resume.resolve();
|
||||
finish.resolve();
|
||||
await untilDone(session.consumer);
|
||||
await untilDone(prompt);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["unrelated", "held", "settled", "plural-unrelated", "plural-empty"] as const)(
|
||||
"does not attribute a result to a steer in the %s state",
|
||||
async (state) => {
|
||||
const agent = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
const echoed = Promise.withResolvers<void>();
|
||||
const processed = Promise.withResolvers<void>();
|
||||
const resume = Promise.withResolvers<void>();
|
||||
const finish = Promise.withResolvers<void>();
|
||||
let response: unknown;
|
||||
let owner: any;
|
||||
let steerUuid = "";
|
||||
const heldOutcome = { stopReason: "end_turn" };
|
||||
async function* messages() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: original } = await iter.next();
|
||||
yield userEcho(original);
|
||||
echoed.resolve();
|
||||
const { value: steered } = await iter.next();
|
||||
steerUuid = steered.uuid;
|
||||
const session = agent.sessions["test-session"];
|
||||
owner = session.activeTurn;
|
||||
// Constructed defensive states: these do not model normal settlement.
|
||||
if (state === "held") {
|
||||
owner.deferredSettle = heldOutcome;
|
||||
owner.spawnedTaskIds = new Set(["still-running"]);
|
||||
session.liveBackgroundTasks.set("still-running", { isSubagent: true });
|
||||
} else if (state === "settled") {
|
||||
owner.settled = true;
|
||||
}
|
||||
const unrelated = randomUUID();
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "task-notification" },
|
||||
user_message_uuid: state === "unrelated" ? unrelated : steerUuid,
|
||||
user_message_uuids:
|
||||
state === "plural-empty" ? [] : [state.includes("unrelated") ? unrelated : steerUuid],
|
||||
stop_reason: "refusal",
|
||||
usage: usage(100, 50),
|
||||
modelUsage: modelUsage(100, 50),
|
||||
});
|
||||
processed.resolve();
|
||||
await resume.promise;
|
||||
owner.settled = false;
|
||||
owner.deferredSettle = undefined;
|
||||
owner.spawnedTaskIds = undefined;
|
||||
session.liveBackgroundTasks.clear();
|
||||
yield successfulResultMessage({
|
||||
origin: { kind: "human" },
|
||||
...stamp(steerUuid, "both"),
|
||||
usage: usage(10, 5),
|
||||
});
|
||||
yield idle; // the excluded autonomous result's trailer
|
||||
yield idle; // the human result's trailer
|
||||
await finish.promise;
|
||||
}
|
||||
const session = mockSessionState({ input, query: wrapQuery(messages()) });
|
||||
agent.sessions["test-session"] = session;
|
||||
const prompt = agent
|
||||
.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "original" }] })
|
||||
.then((value) => (response = value));
|
||||
try {
|
||||
await untilDone(echoed.promise);
|
||||
await expect(
|
||||
agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also handle this" }],
|
||||
}),
|
||||
).resolves.toEqual({ outcome: "injected" });
|
||||
await untilDone(processed.promise);
|
||||
expect(response).toBeUndefined();
|
||||
expect(session.activeTurn).toBe(owner);
|
||||
expect(session.accumulatedUsage).toEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
});
|
||||
expect(session.accumulatedModelUsage).toEqual({});
|
||||
expect(owner.steeredUuids).toEqual(new Set([steerUuid]));
|
||||
expect(owner.steeredEchoes).toEqual(new Set([steerUuid]));
|
||||
expect(owner.steeredSettle).toBeUndefined();
|
||||
if (state === "held") expect(owner.deferredSettle).toBe(heldOutcome);
|
||||
if (state === "settled") expect(owner.settled).toBe(true);
|
||||
resume.resolve();
|
||||
await untilDone(prompt);
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
totalTokens: 15,
|
||||
},
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
totalTokens: 15,
|
||||
inputTokens: 10,
|
||||
cachedInputTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
outputTokens: 5,
|
||||
reasoningOutputTokens: 0,
|
||||
},
|
||||
model_usage: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
resume.resolve();
|
||||
finish.resolve();
|
||||
await untilDone(session.consumer);
|
||||
await untilDone(prompt);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -31,6 +31,9 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
|
||||
supportedCommands: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
// No ultracode fields: session creation falls back to settings and the
|
||||
// model's effort levels, as with a CLI that does not report them.
|
||||
getSettings: async () => ({ applied: {} }),
|
||||
close: () => {},
|
||||
interrupt: async () => undefined,
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
|
||||
@@ -97,6 +97,76 @@ describe("incomplete foreground tools", () => {
|
||||
expect(agent.sessions[sessionId].toolCallParents?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
content: { type: "advisor_result", text: "Check the queue ordering." },
|
||||
status: "completed",
|
||||
text: "Check the queue ordering.",
|
||||
},
|
||||
{
|
||||
content: { type: "advisor_redacted_result", encrypted_content: "opaque-advice" },
|
||||
status: "completed",
|
||||
text: "Advisor guidance applied server-side.",
|
||||
},
|
||||
{
|
||||
content: { type: "advisor_tool_result_error", error_code: "overloaded" },
|
||||
status: "failed",
|
||||
text: "Advisor error: overloaded",
|
||||
},
|
||||
] as const)(
|
||||
"settles an advisor call with a $content.type result",
|
||||
async ({ content, status, text }) => {
|
||||
const advisorId = "srvtoolu_advisor";
|
||||
const { prompt, updates, logError, agent } = createTestSession(async function* (input) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield advisorStart(advisorId);
|
||||
yield advisorToolUse(advisorId);
|
||||
yield advisorResult(advisorId, content);
|
||||
expect(hasHookCallback(advisorId)).toBe(false);
|
||||
yield successfulResultMessage();
|
||||
});
|
||||
|
||||
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
const terminal = updates.filter(
|
||||
(update) =>
|
||||
update.toolCallId === advisorId &&
|
||||
(update.status === "completed" || update.status === "failed"),
|
||||
);
|
||||
expect(terminal).toHaveLength(1);
|
||||
expect(terminal[0]).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: advisorId,
|
||||
status,
|
||||
});
|
||||
expect(JSON.stringify(terminal[0])).toContain(text);
|
||||
expect(JSON.stringify(updates)).not.toContain("opaque-advice");
|
||||
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
|
||||
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("fails a dispatched advisor call without a result", async () => {
|
||||
const advisorId = "srvtoolu_advisor";
|
||||
const { prompt, updates, agent } = createTestSession(async function* (input) {
|
||||
yield* echoNextPrompt(input);
|
||||
yield advisorStart(advisorId);
|
||||
yield advisorToolUse(advisorId);
|
||||
yield successfulResultMessage();
|
||||
});
|
||||
|
||||
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
|
||||
const terminal = updates.filter(
|
||||
(update) =>
|
||||
update.toolCallId === advisorId &&
|
||||
(update.status === "completed" || update.status === "failed"),
|
||||
);
|
||||
expect(terminal).toHaveLength(1);
|
||||
expect(terminal[0]).toMatchObject({ status: "failed" });
|
||||
expect(agent.sessions[sessionId].emittedToolCalls.has(advisorId)).toBe(false);
|
||||
expect(agent.sessions[sessionId].dispatchedToolCalls?.has(advisorId)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a tool explicitly handed off to a background task", async () => {
|
||||
const { prompt, updates } = createTestSession(backgroundToolMessages);
|
||||
|
||||
@@ -1123,6 +1193,44 @@ function toolUse(
|
||||
};
|
||||
}
|
||||
|
||||
function advisorStart(id: string) {
|
||||
return {
|
||||
...toolStart(id),
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "server_tool_use", id, name: "advisor", input: {} },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The complete assistant message that dispatches the advisor server tool. */
|
||||
function advisorToolUse(id: string) {
|
||||
return {
|
||||
type: "assistant",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage: successfulResultMessage().usage,
|
||||
content: [{ type: "server_tool_use", id, name: "advisor", input: {} }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function advisorResult(id: string, content: unknown) {
|
||||
return {
|
||||
type: "assistant",
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
usage: successfulResultMessage().usage,
|
||||
content: [{ type: "advisor_tool_result", tool_use_id: id, content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assistantText(text: string) {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { query, type ModelInfo, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { buildModelConfigOption, getAvailableModels } from "../session-model.js";
|
||||
import {
|
||||
applyAvailableModelsAllowlist,
|
||||
buildModelConfigOption,
|
||||
getAvailableModels,
|
||||
} from "../session-model.js";
|
||||
|
||||
const OPUS: ModelInfo = {
|
||||
value: "opus[1m]",
|
||||
@@ -151,6 +155,68 @@ describe("versioned model display names", () => {
|
||||
);
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual(["Opus (1M context)", "Opus"]);
|
||||
});
|
||||
|
||||
it("distinguishes allowlisted aliases and pins that inherit the same SDK label", async () => {
|
||||
const sdkModels: ModelInfo[] = [
|
||||
{ value: "default", displayName: "Default (recommended)", description: "" },
|
||||
{
|
||||
value: "opus",
|
||||
resolvedModel: "claude-opus-5-5",
|
||||
displayName: "Opus 5.5",
|
||||
description: "For complex work and everyday tasks",
|
||||
},
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
];
|
||||
const ids = ["claude-opus-5-5[1m]", "claude-opus-5-5", "opus[1m]", "opus", "haiku"];
|
||||
const models = applyAvailableModelsAllowlist(sdkModels, ids);
|
||||
const setModel = vi.fn();
|
||||
const state = await getAvailableModels(
|
||||
{ setModel } as unknown as Query,
|
||||
models,
|
||||
sdkModels,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
|
||||
const expected = [
|
||||
"Default (recommended)",
|
||||
...ids.slice(0, 4).map((id) => `Opus 5.5 (${id})`),
|
||||
"Haiku",
|
||||
];
|
||||
expect(state.availableModels.map((model) => model.name)).toEqual(expected);
|
||||
expect(state.availableModels.map((model) => model.modelId)).toEqual(["default", ...ids]);
|
||||
expect(state.currentModelId).toBe("default");
|
||||
expect(state.availableModels.slice(1, 5).map((model) => model.description)).toEqual(
|
||||
Array(4).fill(sdkModels[1].description),
|
||||
);
|
||||
const option = buildModelConfigOption(state, models, false);
|
||||
expect(option).toMatchObject({ currentValue: "default" });
|
||||
expect(option.type === "select" && option.options.map((model) => model.name)).toEqual(expected);
|
||||
expect(sdkModels[1].displayName).toBe("Opus 5.5");
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes identical custom labels without changing their model IDs", async () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ value: "provider-a", displayName: "My model", description: "First endpoint" },
|
||||
{ value: "provider-b", displayName: "My model", description: "Second endpoint" },
|
||||
];
|
||||
const state = await getAvailableModels(
|
||||
{} as Query,
|
||||
models,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
{ log: vi.fn(), error: vi.fn() },
|
||||
false,
|
||||
"model-presentation-test",
|
||||
);
|
||||
expect(state.availableModels).toEqual([
|
||||
{ modelId: "provider-a", name: "My model (provider-a)", description: "First endpoint" },
|
||||
{ modelId: "provider-b", name: "My model (provider-b)", description: "Second endpoint" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Account-dependent model availability requires the same authenticated environment
|
||||
|
||||
@@ -381,17 +381,17 @@ describe("settingsEffortForModel", () => {
|
||||
});
|
||||
|
||||
describe("effortFlagSettings", () => {
|
||||
it("carries ultracode only when settings request it", () => {
|
||||
expect(effortFlagSettings("high", { ultracode: true })).toEqual({
|
||||
it("carries ultracode only while the session has it on", () => {
|
||||
expect(effortFlagSettings("high", true)).toEqual({
|
||||
effortLevel: "high",
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("default", { ultracode: true })).toEqual({
|
||||
expect(effortFlagSettings("default", true)).toEqual({
|
||||
effortLevel: null,
|
||||
ultracode: true,
|
||||
});
|
||||
expect(effortFlagSettings("high", { ultracode: false })).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, {})).toEqual({ effortLevel: null });
|
||||
expect(effortFlagSettings("high", false)).toEqual({ effortLevel: "high" });
|
||||
expect(effortFlagSettings(undefined, false)).toEqual({ effortLevel: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
292
src/tests/ultracode-config.test.ts
Normal file
292
src/tests/ultracode-config.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
modelSupportsUltracode,
|
||||
readUltracodeSettings,
|
||||
resolveUltracodeEnabled,
|
||||
ULTRACODE_CONFIG_ID,
|
||||
ULTRACODE_OFF,
|
||||
ULTRACODE_ON,
|
||||
ultracodeSupported,
|
||||
ultracodeWorkflowsEnabled,
|
||||
} from "../session-ultracode.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({ querySpy: vi.fn() }));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
|
||||
return { ...actual, query: querySpy };
|
||||
});
|
||||
|
||||
const OPUS: ModelInfo = {
|
||||
value: "opus",
|
||||
displayName: "Opus",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
|
||||
};
|
||||
const FABLE: ModelInfo = {
|
||||
value: "fable",
|
||||
displayName: "Fable",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],
|
||||
};
|
||||
// Opus 4.6 and Haiku 4.5 have no `xhigh` effort, so the CLI has no ultracode
|
||||
// for them either.
|
||||
const OLD_OPUS: ModelInfo = {
|
||||
value: "claude-opus-4-6",
|
||||
displayName: "Opus 4.6",
|
||||
description: "",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
};
|
||||
|
||||
function getSettingsQuery(getSettings: unknown): Query {
|
||||
return { getSettings } as unknown as Query;
|
||||
}
|
||||
|
||||
describe("ultracode helpers", () => {
|
||||
it("builds a boolean option or an on/off select", () => {
|
||||
expect(createUltracodeConfigOption(true, true)).toEqual({
|
||||
id: ULTRACODE_CONFIG_ID,
|
||||
name: "Ultracode",
|
||||
description: "Dynamic workflows on every task, at any effort level",
|
||||
category: "model_config",
|
||||
type: "boolean",
|
||||
currentValue: true,
|
||||
});
|
||||
expect(createUltracodeConfigOption(false, false)).toMatchObject({
|
||||
type: "select",
|
||||
currentValue: ULTRACODE_OFF,
|
||||
options: [
|
||||
{ value: ULTRACODE_ON, name: "On" },
|
||||
{ value: ULTRACODE_OFF, name: "Off" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts booleans and the select strings", () => {
|
||||
const request = (value: unknown) =>
|
||||
({ sessionId: "s", configId: ULTRACODE_CONFIG_ID, value }) as never;
|
||||
expect(resolveUltracodeEnabled(request(true))).toBe(true);
|
||||
expect(resolveUltracodeEnabled(request(ULTRACODE_OFF))).toBe(false);
|
||||
expect(() => resolveUltracodeEnabled(request("maybe"))).toThrow(
|
||||
"Invalid value for config option ultracode: maybe",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a model as supporting ultracode when it supports xhigh effort", () => {
|
||||
expect(modelSupportsUltracode(OPUS)).toBe(true);
|
||||
expect(modelSupportsUltracode(OLD_OPUS)).toBe(false);
|
||||
expect(modelSupportsUltracode({ ...OPUS, supportsEffort: false })).toBe(false);
|
||||
expect(modelSupportsUltracode(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("infers the workflows gate only when the start model can tell", () => {
|
||||
expect(ultracodeWorkflowsEnabled({ available: true }, OLD_OPUS)).toBe(true);
|
||||
expect(ultracodeWorkflowsEnabled({ available: false }, OPUS)).toBe(false);
|
||||
// Opus 4.6 is unavailable regardless of workflows.
|
||||
expect(ultracodeWorkflowsEnabled({ available: false }, OLD_OPUS)).toBeUndefined();
|
||||
expect(ultracodeWorkflowsEnabled(undefined, OPUS)).toBeUndefined();
|
||||
|
||||
expect(ultracodeSupported(undefined, OPUS)).toBe(true);
|
||||
expect(ultracodeSupported(false, OPUS)).toBe(false);
|
||||
expect(ultracodeSupported(true, OLD_OPUS)).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the CLI's ultracode report and tolerates CLIs without one", async () => {
|
||||
await expect(
|
||||
readUltracodeSettings(
|
||||
getSettingsQuery(async () => ({
|
||||
applied: { ultracodeAvailable: true, ultracodeRequested: false },
|
||||
})),
|
||||
),
|
||||
).resolves.toEqual({ available: true, requested: false });
|
||||
await expect(
|
||||
readUltracodeSettings(getSettingsQuery(async () => ({ applied: {} }))),
|
||||
).resolves.toEqual({ available: undefined, requested: undefined });
|
||||
await expect(readUltracodeSettings(getSettingsQuery(undefined))).resolves.toBeUndefined();
|
||||
await expect(
|
||||
readUltracodeSettings(
|
||||
getSettingsQuery(async () => {
|
||||
throw new Error("unsupported control request");
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ultracode session config option", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-ultracode-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
querySpy.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalClaudeConfigDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createSession(opts: {
|
||||
models?: ModelInfo[];
|
||||
getSettings?: () => Promise<unknown>;
|
||||
settings?: Record<string, unknown>;
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
}) {
|
||||
if (opts.settings) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify(opts.settings),
|
||||
);
|
||||
}
|
||||
const applyFlagSettings = vi.fn();
|
||||
querySpy.mockReturnValue(
|
||||
makeMockQuery({
|
||||
initializationResult: async () => ({ models: opts.models ?? [OPUS, FABLE, OLD_OPUS] }),
|
||||
applyFlagSettings,
|
||||
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient);
|
||||
(agent as any).clientCapabilities = opts.clientCapabilities ?? {
|
||||
session: { configOptions: { boolean: {} } },
|
||||
};
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: tempDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
const sessionId: string = response.sessionId;
|
||||
const ultracodeOption = () =>
|
||||
agent.sessions[sessionId].configOptions.find((o) => o.id === ULTRACODE_CONFIG_ID);
|
||||
return { agent, sessionId, applyFlagSettings, ultracodeOption };
|
||||
}
|
||||
|
||||
it("seeds the option from the CLI's report", async () => {
|
||||
const { ultracodeOption, agent, sessionId } = await createSession({
|
||||
getSettings: async () => ({
|
||||
applied: { ultracodeAvailable: true, ultracodeRequested: true },
|
||||
}),
|
||||
});
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, true));
|
||||
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the option while dynamic workflows are off, across model switches", async () => {
|
||||
const { agent, sessionId, ultracodeOption } = await createSession({
|
||||
getSettings: async () => ({
|
||||
applied: { ultracodeAvailable: false, ultracodeRequested: false },
|
||||
}),
|
||||
});
|
||||
expect(ultracodeOption()).toBeUndefined();
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
|
||||
expect(ultracodeOption()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("follows the model's support and keeps the toggle across switches", async () => {
|
||||
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({
|
||||
clientCapabilities: {},
|
||||
});
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, false));
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
value: ULTRACODE_ON,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: true });
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "claude-opus-4-6" });
|
||||
expect(ultracodeOption()).toBeUndefined();
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(true, false));
|
||||
});
|
||||
|
||||
it("keeps a toggled-on ultracode when effort changes, and not a toggled-off one", async () => {
|
||||
// An effortLevel apply without an ultracode key turns ultracode off, so
|
||||
// the effort picker must carry the session's toggle.
|
||||
const { agent, sessionId, applyFlagSettings } = await createSession({
|
||||
settings: { ultracode: true },
|
||||
});
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: false,
|
||||
});
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ ultracode: false });
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "high" });
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "high" });
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "low" });
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "low", ultracode: true });
|
||||
});
|
||||
|
||||
it("keeps ultracode on through the effort apply that follows a model switch", async () => {
|
||||
// recommendedValue clients apply the new model's effort on every switch.
|
||||
const { agent, sessionId, applyFlagSettings } = await createSession({
|
||||
clientCapabilities: {
|
||||
session: { configOptions: { boolean: {} } },
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
},
|
||||
});
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
});
|
||||
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "fable" });
|
||||
expect(applyFlagSettings).toHaveBeenLastCalledWith({ effortLevel: "medium", ultracode: true });
|
||||
});
|
||||
|
||||
it("does not change session state when the SDK rejects the flag", async () => {
|
||||
const { agent, sessionId, applyFlagSettings, ultracodeOption } = await createSession({});
|
||||
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
|
||||
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: ULTRACODE_CONFIG_ID,
|
||||
type: "boolean",
|
||||
value: true,
|
||||
}),
|
||||
).rejects.toThrow("nope");
|
||||
expect(agent.sessions[sessionId].ultracodeEnabled).toBe(false);
|
||||
expect(ultracodeOption()).toEqual(createUltracodeConfigOption(false, true));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user