diff --git a/README.md b/README.md index 0608ae9c..8373e29d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 3797c742..9574dc09 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -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`. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 446d6f43..d02b477b 100644 --- a/src/acp-agent.ts +++ b/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), @@ -9203,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}`); @@ -11038,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, @@ -11064,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; @@ -11136,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, ); @@ -11226,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 { + 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` @@ -12064,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, @@ -12075,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), @@ -12146,6 +12198,8 @@ export class ClaudeAcpAgent { currentAgent, fastModeEnabled, fastModeDisabledReason, + ultracodeEnabled, + ultracodeWorkflowsEnabled: workflowsEnabled, abortController, emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, forwardSubagentText, @@ -12975,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 @@ -13005,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 diff --git a/src/clear-context-coordinator.ts b/src/clear-context-coordinator.ts index 06bb40bf..8aa1fce5 100644 --- a/src/clear-context-coordinator.ts +++ b/src/clear-context-coordinator.ts @@ -39,6 +39,7 @@ export type ClearContextSession; applyFastMode(session: Session, enabled: boolean): Promise; + applyUltracode(session: Session, enabled: boolean): Promise; 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 = []; diff --git a/src/session-config-ids.ts b/src/session-config-ids.ts index fa24b42f..3a17a7f5 100644 --- a/src/session-config-ids.ts +++ b/src/session-config-ids.ts @@ -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"; diff --git a/src/session-effort.ts b/src/session-effort.ts index 5fb4cee7..68b69cfc 100644 --- a/src/session-effort.ts +++ b/src/session-effort.ts @@ -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 { diff --git a/src/session-ultracode.ts b/src/session-ultracode.ts new file mode 100644 index 00000000..0387bc17 --- /dev/null +++ b/src/session-ultracode.ts @@ -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 { + 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}`); +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index f64ca194..a3743c80 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -13978,6 +13978,7 @@ describe("session/close", () => { agents: [], currentAgent: "default", fastModeEnabled: false, + ultracodeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, forwardSubagentText: false, @@ -14099,6 +14100,7 @@ describe("session/delete", () => { agents: [], currentAgent: "default", fastModeEnabled: false, + ultracodeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, forwardSubagentText: false, @@ -14258,6 +14260,7 @@ describe("getOrCreateSession param change detection", () => { agents: [], currentAgent: "default", fastModeEnabled: false, + ultracodeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, forwardSubagentText: false, @@ -18712,6 +18715,7 @@ describe("post-error recovery", () => { agents: [], currentAgent: "default", fastModeEnabled: false, + ultracodeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, forwardSubagentText: false, @@ -27068,6 +27072,7 @@ describe("session/cancel wedge recovery (issue #680)", () => { agents: [], currentAgent: "default", fastModeEnabled: false, + ultracodeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, forwardSubagentText: false, @@ -28975,6 +28980,7 @@ describe("agent selection config option", () => { agents, currentAgent: "default", fastModeEnabled: false, + ultracodeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, forwardSubagentText: false, diff --git a/src/tests/clear-context-coordinator.test.ts b/src/tests/clear-context-coordinator.test.ts index 50106ef6..06316080 100644 --- a/src/tests/clear-context-coordinator.test.ts +++ b/src/tests/clear-context-coordinator.test.ts @@ -31,6 +31,7 @@ function testSession(overrides: Partial = {}): 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, diff --git a/src/tests/helpers.ts b/src/tests/helpers.ts index 768c083e..c7bc3571 100644 --- a/src/tests/helpers.ts +++ b/src/tests/helpers.ts @@ -31,6 +31,9 @@ export function makeMockQuery(overrides: Record = {}) { 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* () {}, diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index 3e203c23..95556df2 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -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 }); }); }); diff --git a/src/tests/ultracode-config.test.ts b/src/tests/ultracode-config.test.ts new file mode 100644 index 00000000..a818bfb0 --- /dev/null +++ b/src/tests/ultracode-config.test.ts @@ -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("@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; + settings?: Record; + 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)); + }); +});