Add Claude Ultracode with setting-change notices (#1625)

* Add Claude Ultracode mode notices

* Share provider notice definitions

* Test Claude Ultracode through public turn API
This commit is contained in:
Mohamed Boudra
2026-06-20 12:15:04 +08:00
committed by GitHub
parent 7af92120fe
commit 2b0740ff84
20 changed files with 310 additions and 53 deletions

View File

@@ -343,7 +343,7 @@ interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
setMode(modeId: string): Promise<void | AgentProviderNotice>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(
requestId: string,
@@ -355,7 +355,7 @@ interface AgentSession {
// Optional:
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
@@ -363,6 +363,8 @@ interface AgentSession {
}
```
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces

View File

@@ -61,6 +61,7 @@ import {
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
interface AgentControlOption {
id: string;
@@ -1486,10 +1487,13 @@ export const AgentControls = memo(function AgentControls({
console.warn("[AgentControls] persist thinking preference failed", error);
});
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
},
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
);

View File

@@ -22,6 +22,7 @@ import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -272,10 +273,13 @@ export const AgentModeControl = memo(function AgentModeControl({
const handleSelectMode = useCallback(
(modeId: string) => {
if (!client) return;
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, client, toast],
);

View File

@@ -71,6 +71,7 @@ import {
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import {
applyLegacyDaemonWorkspaceOwnership,
@@ -1951,10 +1952,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentMode skipped: daemon unavailable");
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentMode(agentId, modeId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
},
[client, toast],
);
@@ -1979,10 +1983,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable");
return;
}
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
void client
.setAgentThinkingOption(agentId, thinkingOptionId)
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
},
[client, toast],
);

View File

@@ -0,0 +1,19 @@
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { ToastApi } from "@/components/toast-host";
export function showProviderNoticeToast(
toast: ToastApi,
notice: AgentProviderNotice | null | undefined,
): void {
if (!notice) {
return;
}
if (notice.type === "error") {
toast.error(notice.message);
return;
}
toast.show(notice.message, {
variant: "default",
durationMs: notice.type === "warning" ? 3200 : undefined,
});
}

View File

@@ -85,6 +85,7 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProviderNotice,
AgentProvider,
AgentSessionConfig,
} from "@getpaseo/protocol/agent-types";
@@ -2444,7 +2445,7 @@ export class DaemonClient {
});
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_mode_request",
@@ -2470,6 +2471,7 @@ export class DaemonClient {
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentMode rejected");
}
return payload.notice ?? null;
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
@@ -2529,7 +2531,10 @@ export class DaemonClient {
}
}
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_thinking_request",
@@ -2555,6 +2560,7 @@ export class DaemonClient {
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentThinkingOption rejected");
}
return payload.notice ?? null;
}
async restartServer(reason?: string, requestId?: string): Promise<RestartRequestedStatusPayload> {

View File

@@ -6,6 +6,11 @@ export interface AgentMetadata {
[key: string]: unknown;
}
export type AgentProviderNotice =
| { type: "info"; message: string }
| { type: "warning"; message: string }
| { type: "error"; message: string };
/**
* Stdio-based MCP server (spawns a subprocess).
*/

View File

@@ -178,6 +178,47 @@ describe("agent detach RPC", () => {
});
});
describe("agent setting action responses", () => {
test("parses optional provider notices on mode and thinking responses", () => {
const mode = SessionOutboundMessageSchema.parse({
type: "set_agent_mode_response",
payload: {
requestId: "req-mode",
agentId: "agent-1",
accepted: true,
error: null,
notice: {
type: "info",
message: "This change applies next turn.",
},
},
});
const thinking = SessionOutboundMessageSchema.parse({
type: "set_agent_thinking_response",
payload: {
requestId: "req-thinking",
agentId: "agent-1",
accepted: true,
error: null,
},
});
expect(mode.type).toBe("set_agent_mode_response");
if (mode.type !== "set_agent_mode_response") {
throw new Error("Expected set_agent_mode_response");
}
expect(mode.payload.notice).toEqual({
type: "info",
message: "This change applies next turn.",
});
expect(thinking.type).toBe("set_agent_thinking_response");
if (thinking.type !== "set_agent_thinking_response") {
throw new Error("Expected set_agent_thinking_response");
}
expect(thinking.payload.notice).toBeUndefined();
});
});
describe("file explorer request compatibility", () => {
test("acceptBinary is optional for old clients and accepted for new clients", () => {
expect(

View File

@@ -172,6 +172,7 @@ import type {
ProviderStatus,
AgentRuntimeInfo,
AgentTimelineItem,
AgentProviderNotice,
ToolCallDetail,
ToolCallTimelineItem,
AgentUsage,
@@ -202,6 +203,12 @@ const AgentSelectOptionSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(),
});
const AgentProviderNoticeSchema: z.ZodType<AgentProviderNotice> = z.discriminatedUnion("type", [
z.object({ type: z.literal("info"), message: z.string() }),
z.object({ type: z.literal("warning"), message: z.string() }),
z.object({ type: z.literal("error"), message: z.string() }),
]);
export const AgentFeatureToggleSchema = z.object({
type: z.literal("toggle"),
id: z.string(),
@@ -1264,6 +1271,7 @@ const AgentActionResponsePayloadSchema = z.object({
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
notice: AgentProviderNoticeSchema.nullable().optional(),
});
export const SetAgentModeResponseMessageSchema = z.object({

View File

@@ -27,6 +27,7 @@ import {
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPersistenceHandle,
type AgentProviderNotice,
type AgentPromptInput,
type AgentProvider,
type AgentRunOptions,
@@ -1276,9 +1277,9 @@ export class AgentManager {
});
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
await agent.session.setMode(modeId);
const notice = (await agent.session.setMode(modeId)) ?? null;
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
@@ -1288,6 +1289,7 @@ export class AgentManager {
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
@@ -1307,15 +1309,19 @@ export class AgentManager {
this.emitState(agent);
}
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
await agent.session.setThinkingOption(normalizedThinkingOptionId);
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
@@ -1327,6 +1333,7 @@ export class AgentManager {
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {

View File

@@ -1,6 +1,9 @@
import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
export type { AgentProviderNotice };
export type AgentProvider = string;
export interface AgentMetadata {
@@ -604,7 +607,7 @@ export interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
setMode(modeId: string): Promise<void | AgentProviderNotice>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(
requestId: string,
@@ -615,7 +618,7 @@ export interface AgentSession {
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>;

View File

@@ -134,8 +134,9 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
this.notifiedAgentIds.push(agentId);
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
async setAgentMode(agentId: string, modeId: string) {
this.modeUpdates.push({ agentId, modeId });
return null;
}
async updateAgentMetadata(
@@ -286,7 +287,7 @@ describe("agent lifecycle commands", () => {
await expect(
setAgentModeCommand({ agentManager: manager }, { agentId: "agent-1", modeId: "plan" }),
).resolves.toEqual({ modeId: "plan" });
).resolves.toEqual({ modeId: "plan", notice: null });
expect(manager.modeUpdates).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
});

View File

@@ -2,6 +2,7 @@ import type { Logger } from "pino";
import type { ManagedAgent } from "./agent-manager.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import type { AgentProviderNotice } from "./agent-sdk-types.js";
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">;
@@ -20,7 +21,7 @@ export interface LifecycleAgentManager {
previousParentAgentId: string | null;
}>;
notifyAgentState(agentId: string): void;
setAgentMode(agentId: string, modeId: string): Promise<void>;
setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
updateAgentMetadata(
agentId: string,
updates: {
@@ -190,9 +191,9 @@ export async function setAgentModeCommand(
agentId: string;
modeId: string;
},
): Promise<{ modeId: string }> {
await dependencies.agentManager.setAgentMode(input.agentId, input.modeId);
return { modeId: input.modeId };
): Promise<{ modeId: string; notice: AgentProviderNotice | null }> {
const notice = await dependencies.agentManager.setAgentMode(input.agentId, input.modeId);
return { modeId: input.modeId, notice };
}
async function archiveStoredAgent(

View File

@@ -0,0 +1,6 @@
import type { AgentProviderNotice } from "./agent-sdk-types.js";
export const SETTING_APPLIES_NEXT_TURN_NOTICE: AgentProviderNotice = {
type: "info",
message: "This change applies next turn.",
};

View File

@@ -432,6 +432,29 @@ describe("ClaudeAgentClient.listModels", () => {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
}
});
test("exposes Ultracode only on Claude models that support it", async () => {
const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-"));
try {
const client = new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
configDir: emptyConfigDir,
});
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
const getThinkingIds = (modelId: string) => {
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
};
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
}
});
});
describe("ClaudeAgentClient binary resolution", () => {
@@ -526,12 +549,26 @@ describe("ClaudeAgentSession features", () => {
const logger = createTestLogger();
function createQueryMock() {
const queryReturn = vi.fn(async () => undefined);
let endQuery: (() => void) | null = null;
const queryEnded = new Promise<void>((resolve) => {
endQuery = resolve;
});
const queryReturn = vi.fn(async () => {
endQuery?.();
});
const queryMock = {
close: vi.fn(),
return: queryReturn,
applyFlagSettings: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
[Symbol.asyncIterator](): AsyncIterator<SDKMessage, void> {
return {
next: async () => {
await queryEnded;
return { value: undefined, done: true };
},
};
},
};
const queryFactory = vi.fn(() => queryMock);
return { queryFactory, queryMock };
@@ -585,6 +622,58 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
thinkingOptionId: "ultracode",
});
await expect(session.startTurn("hello")).resolves.toEqual({
turnId: expect.stringMatching(/^foreground-turn-/),
});
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
effort: "xhigh",
thinking: { type: "adaptive" },
settings: { ultracode: true },
});
await session.close();
});
test("returns a next-turn notice when changing Claude thinking during an active turn", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
});
await expect(session.startTurn("hello")).resolves.toEqual({
turnId: expect.stringMatching(/^foreground-turn-/),
});
await expect(session.setThinkingOption?.("ultracode")).resolves.toEqual({
type: "info",
message: "This change applies next turn.",
});
await session.close();
});
test("toggles fast mode on the active query without restarting it", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({

View File

@@ -47,6 +47,7 @@ import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./quer
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
getAgentStreamEventTurnId,
@@ -64,6 +65,7 @@ import {
type AgentPermissionResponse,
type AgentPermissionUpdate,
type AgentPersistenceHandle,
type AgentProviderNotice,
type AgentPromptInput,
type AgentRunOptions,
type AgentRunResult,
@@ -362,6 +364,7 @@ interface ClaudeAgentSessionOptions {
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
type ClaudeThinkingOption = ClaudeThinkingEffort | "ultracode";
function resolvePathEnvKey(): "Path" | "PATH" | null {
if (process.env["Path"] !== undefined) return "Path";
@@ -408,6 +411,10 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
);
}
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
return value === "ultracode" || isClaudeThinkingEffort(value);
}
interface ClaudeOptionsLogSummary {
cwd: string | null;
permissionMode: string | null;
@@ -1966,7 +1973,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
@@ -1974,12 +1981,15 @@ class ClaudeAgentSession implements AgentSession {
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
this.config.thinkingOptionId = undefined;
} else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) {
} else if (isClaudeThinkingOption(normalizedThinkingOptionId)) {
this.config.thinkingOptionId = normalizedThinkingOptionId;
} else {
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
}
this.queryRestartNeeded = true;
if (this.activeForegroundTurnId || this.autonomousTurn) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
@@ -2607,15 +2617,19 @@ class ClaudeAgentSession implements AgentSession {
private resolveThinkingConfig(): {
thinking: ClaudeOptions["thinking"];
effort: ClaudeOptions["effort"];
ultracode: boolean;
} {
const thinkingOptionId =
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
: undefined;
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
return { thinking: { type: "adaptive" }, effort: thinkingOptionId };
if (thinkingOptionId === "ultracode") {
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
}
return { thinking: undefined, effort: undefined };
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false };
}
return { thinking: undefined, effort: undefined, ultracode: false };
}
private buildAppendedSystemPrompt(): string {
@@ -2641,10 +2655,10 @@ class ClaudeAgentSession implements AgentSession {
}
private async buildOptions(): Promise<ClaudeOptions> {
const { thinking, effort } = this.resolveThinkingConfig();
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
const extraClaudeOptions = this.config.extra?.claude;
const fastModeOptions = this.buildFastModeOptions(extraClaudeOptions);
const settingsOptions = this.buildSettingsOptions(extraClaudeOptions, { ultracode });
const sdkEnv = this.buildSdkEnv(extraClaudeOptions);
assertClaudeAutoModeEligible(this.currentMode, sdkEnv);
@@ -2697,7 +2711,7 @@ class ClaudeAgentSession implements AgentSession {
...(thinking ? { thinking } : {}),
...(effort ? { effort } : {}),
...extraClaudeOptions,
...fastModeOptions,
...settingsOptions,
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
env: sdkEnv,
};
@@ -2722,14 +2736,20 @@ class ClaudeAgentSession implements AgentSession {
return base;
}
private buildFastModeOptions(
private buildSettingsOptions(
extraClaudeOptions: Partial<ClaudeOptions> | undefined,
input: { ultracode: boolean },
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
const fastMode = this.resolveFastModeSetting();
if (fastMode === null) {
if (fastMode === null && !input.ultracode) {
return {};
}
return { settings: mergeClaudeSettings(extraClaudeOptions?.settings, { fastMode }) };
return {
settings: mergeClaudeSettings(extraClaudeOptions?.settings, {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
}),
};
}
private resolveFastModeSetting(): boolean | null {

View File

@@ -20,20 +20,25 @@ const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
{ id: "max", label: "Max" },
] as const;
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS,
{ id: "ultracode", label: "Ultracode" },
] as const;
const CLAUDE_MODELS: AgentModelDefinition[] = [
{
provider: "claude",
id: "claude-fable-5",
label: "Fable 5",
description: "Fable 5 · Most powerful model",
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-8[1m]",
label: "Opus 4.8 1M",
description: "Opus 4.8 with 1M context window",
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
@@ -41,7 +46,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
label: "Opus 4.8",
description: "Opus 4.8 · Latest release",
isDefault: true,
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS],
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",

View File

@@ -338,6 +338,24 @@ describe("Codex app-server provider", () => {
});
});
test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => {
const session = createSession({ modeId: "auto", thinkingOptionId: "medium" });
await expect(session.setMode("full-access")).resolves.toEqual({
type: "info",
message: "This change applies next turn.",
});
await expect(session.setThinkingOption?.("high")).resolves.toEqual({
type: "info",
message: "This change applies next turn.",
});
session.activeForegroundTurnId = null;
await expect(session.setMode("auto")).resolves.toBeUndefined();
await expect(session.setThinkingOption?.("low")).resolves.toBeUndefined();
});
test.each(["auto_review", "guardian_subagent"])(
"parses %s thread/start response as auto-review mode",
async (approvalsReviewer) => {

View File

@@ -13,6 +13,7 @@ import {
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentProviderNotice,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
@@ -92,6 +93,7 @@ import {
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
function assertChildWithPipes(
@@ -3654,10 +3656,13 @@ export class CodexAppServerAgentSession implements AgentSession {
return this.currentMode ?? null;
}
async setMode(modeId: string): Promise<void> {
async setMode(modeId: string): Promise<void | AgentProviderNotice> {
validateCodexMode(modeId);
this.currentMode = modeId;
this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
}
}
async setModel(modelId: string | null): Promise<void> {
@@ -3669,10 +3674,13 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {

View File

@@ -4672,14 +4672,17 @@ export class Session {
this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request");
try {
await setAgentModeCommand({ agentManager: this.agentManager }, { agentId, modeId });
const result = await setAgentModeCommand(
{ agentManager: this.agentManager },
{ agentId, modeId },
);
this.sessionLogger.info(
{ agentId, modeId, requestId },
"session: set_agent_mode_request success",
);
this.emit({
type: "set_agent_mode_response",
payload: { requestId, agentId, accepted: true, error: null },
payload: { requestId, agentId, accepted: true, error: null, notice: result.notice },
});
} catch (error) {
this.sessionLogger.error(
@@ -4808,14 +4811,14 @@ export class Session {
);
try {
await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId);
const notice = await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId);
this.sessionLogger.info(
{ agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request success",
);
this.emit({
type: "set_agent_thinking_response",
payload: { requestId, agentId, accepted: true, error: null },
payload: { requestId, agentId, accepted: true, error: null, notice },
});
} catch (error) {
this.sessionLogger.error(