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 04:15:04 +00:00
committed by GitHub
parent 7af92120fe
commit 2b0740ff84
20 changed files with 310 additions and 53 deletions
+4 -2
View File
@@ -343,7 +343,7 @@ interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>; getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>; getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>; getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>; setMode(modeId: string): Promise<void | AgentProviderNotice>;
getPendingPermissions(): AgentPermissionRequest[]; getPendingPermissions(): AgentPermissionRequest[];
respondToPermission( respondToPermission(
requestId: string, requestId: string,
@@ -355,7 +355,7 @@ interface AgentSession {
// Optional: // Optional:
listCommands?(): Promise<AgentSlashCommand[]>; listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>; 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>; setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): { tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>; 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 ### Steps
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces 1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
@@ -61,6 +61,7 @@ import {
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
interface AgentControlOption { interface AgentControlOption {
id: string; id: string;
@@ -1486,10 +1487,13 @@ export const AgentControls = memo(function AgentControls({
console.warn("[AgentControls] persist thinking preference failed", error); console.warn("[AgentControls] persist thinking preference failed", error);
}); });
} }
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { void client
console.warn("[AgentControls] setAgentThinkingOption failed", error); .setAgentThinkingOption(agentId, thinkingOptionId)
toast.error(toErrorMessage(error)); .then((notice) => showProviderNoticeToast(toast, notice))
}); .catch((error) => {
console.warn("[AgentControls] setAgentThinkingOption failed", error);
toast.error(toErrorMessage(error));
});
}, },
[activeModelId, agentId, agentProvider, client, toast, updatePreferences], [activeModelId, agentId, agentProvider, client, toast, updatePreferences],
); );
@@ -22,6 +22,7 @@ import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { formatAgentModeLabel } from "@/composer/agent-controls/utils"; import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentMode, AgentProvider } from "@getpaseo/protocol/agent-types";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
@@ -272,10 +273,13 @@ export const AgentModeControl = memo(function AgentModeControl({
const handleSelectMode = useCallback( const handleSelectMode = useCallback(
(modeId: string) => { (modeId: string) => {
if (!client) return; if (!client) return;
void client.setAgentMode(agentId, modeId).catch((error) => { void client
console.warn("[AgentModeControl] setAgentMode failed", error); .setAgentMode(agentId, modeId)
toast.error(toErrorMessage(error)); .then((notice) => showProviderNoticeToast(toast, notice))
}); .catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
}, },
[agentId, client, toast], [agentId, client, toast],
); );
+15 -8
View File
@@ -71,6 +71,7 @@ import {
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import { import {
applyLegacyDaemonWorkspaceOwnership, applyLegacyDaemonWorkspaceOwnership,
@@ -1951,10 +1952,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentMode skipped: daemon unavailable"); console.warn("[Session] setAgentMode skipped: daemon unavailable");
return; return;
} }
void client.setAgentMode(agentId, modeId).catch((error) => { void client
console.error("[Session] Failed to set agent mode:", error); .setAgentMode(agentId, modeId)
toast.error(toErrorMessage(error)); .then((notice) => showProviderNoticeToast(toast, notice))
}); .catch((error) => {
console.error("[Session] Failed to set agent mode:", error);
toast.error(toErrorMessage(error));
});
}, },
[client, toast], [client, toast],
); );
@@ -1979,10 +1983,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable"); console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable");
return; return;
} }
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { void client
console.error("[Session] Failed to set agent thinking option:", error); .setAgentThinkingOption(agentId, thinkingOptionId)
toast.error(toErrorMessage(error)); .then((notice) => showProviderNoticeToast(toast, notice))
}); .catch((error) => {
console.error("[Session] Failed to set agent thinking option:", error);
toast.error(toErrorMessage(error));
});
}, },
[client, toast], [client, toast],
); );
@@ -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,
});
}
+8 -2
View File
@@ -85,6 +85,7 @@ import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentProviderNotice,
AgentProvider, AgentProvider,
AgentSessionConfig, AgentSessionConfig,
} from "@getpaseo/protocol/agent-types"; } 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 requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "set_agent_mode_request", type: "set_agent_mode_request",
@@ -2470,6 +2471,7 @@ export class DaemonClient {
if (!payload.accepted) { if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentMode rejected"); throw new Error(payload.error ?? "setAgentMode rejected");
} }
return payload.notice ?? null;
} }
async setAgentModel(agentId: string, modelId: string | null): Promise<void> { 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 requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "set_agent_thinking_request", type: "set_agent_thinking_request",
@@ -2555,6 +2560,7 @@ export class DaemonClient {
if (!payload.accepted) { if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentThinkingOption rejected"); throw new Error(payload.error ?? "setAgentThinkingOption rejected");
} }
return payload.notice ?? null;
} }
async restartServer(reason?: string, requestId?: string): Promise<RestartRequestedStatusPayload> { async restartServer(reason?: string, requestId?: string): Promise<RestartRequestedStatusPayload> {
+5
View File
@@ -6,6 +6,11 @@ export interface AgentMetadata {
[key: string]: unknown; [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). * Stdio-based MCP server (spawns a subprocess).
*/ */
+41
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", () => { describe("file explorer request compatibility", () => {
test("acceptBinary is optional for old clients and accepted for new clients", () => { test("acceptBinary is optional for old clients and accepted for new clients", () => {
expect( expect(
+8
View File
@@ -172,6 +172,7 @@ import type {
ProviderStatus, ProviderStatus,
AgentRuntimeInfo, AgentRuntimeInfo,
AgentTimelineItem, AgentTimelineItem,
AgentProviderNotice,
ToolCallDetail, ToolCallDetail,
ToolCallTimelineItem, ToolCallTimelineItem,
AgentUsage, AgentUsage,
@@ -202,6 +203,12 @@ const AgentSelectOptionSchema = z.object({
metadata: z.record(z.string(), z.unknown()).optional(), 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({ export const AgentFeatureToggleSchema = z.object({
type: z.literal("toggle"), type: z.literal("toggle"),
id: z.string(), id: z.string(),
@@ -1264,6 +1271,7 @@ const AgentActionResponsePayloadSchema = z.object({
agentId: z.string(), agentId: z.string(),
accepted: z.boolean(), accepted: z.boolean(),
error: z.string().nullable(), error: z.string().nullable(),
notice: AgentProviderNoticeSchema.nullable().optional(),
}); });
export const SetAgentModeResponseMessageSchema = z.object({ export const SetAgentModeResponseMessageSchema = z.object({
@@ -27,6 +27,7 @@ import {
type AgentPermissionResponse, type AgentPermissionResponse,
type AgentPermissionResult, type AgentPermissionResult,
type AgentPersistenceHandle, type AgentPersistenceHandle,
type AgentProviderNotice,
type AgentPromptInput, type AgentPromptInput,
type AgentProvider, type AgentProvider,
type AgentRunOptions, 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); 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; const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined; agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode; agent.currentModeId = currentMode;
@@ -1288,6 +1289,7 @@ export class AgentManager {
} }
this.touchUpdatedAt(agent); this.touchUpdatedAt(agent);
this.emitState(agent); this.emitState(agent);
return notice;
} }
async setAgentModel(agentId: string, modelId: string | null): Promise<void> { async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
@@ -1307,15 +1309,19 @@ export class AgentManager {
this.emitState(agent); 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 agent = this.requireSessionAgent(agentId);
const normalizedThinkingOptionId = const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId ? thinkingOptionId
: null; : null;
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) { if (agent.session.setThinkingOption) {
await agent.session.setThinkingOption(normalizedThinkingOptionId); notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
} }
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
@@ -1327,6 +1333,7 @@ export class AgentManager {
} }
this.touchUpdatedAt(agent); this.touchUpdatedAt(agent);
this.emitState(agent); this.emitState(agent);
return notice;
} }
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> { async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
@@ -1,6 +1,9 @@
import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk"; 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"; import type { AgentAttachment } from "@getpaseo/protocol/messages";
export type { AgentProviderNotice };
export type AgentProvider = string; export type AgentProvider = string;
export interface AgentMetadata { export interface AgentMetadata {
@@ -604,7 +607,7 @@ export interface AgentSession {
getRuntimeInfo(): Promise<AgentRuntimeInfo>; getRuntimeInfo(): Promise<AgentRuntimeInfo>;
getAvailableModes(): Promise<AgentMode[]>; getAvailableModes(): Promise<AgentMode[]>;
getCurrentMode(): Promise<string | null>; getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>; setMode(modeId: string): Promise<void | AgentProviderNotice>;
getPendingPermissions(): AgentPermissionRequest[]; getPendingPermissions(): AgentPermissionRequest[];
respondToPermission( respondToPermission(
requestId: string, requestId: string,
@@ -615,7 +618,7 @@ export interface AgentSession {
close(): Promise<void>; close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>; listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>; 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>; setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>; revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>; revertFiles?(input: { messageId: string }): Promise<void>;
@@ -134,8 +134,9 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
this.notifiedAgentIds.push(agentId); this.notifiedAgentIds.push(agentId);
} }
async setAgentMode(agentId: string, modeId: string): Promise<void> { async setAgentMode(agentId: string, modeId: string) {
this.modeUpdates.push({ agentId, modeId }); this.modeUpdates.push({ agentId, modeId });
return null;
} }
async updateAgentMetadata( async updateAgentMetadata(
@@ -286,7 +287,7 @@ describe("agent lifecycle commands", () => {
await expect( await expect(
setAgentModeCommand({ agentManager: manager }, { agentId: "agent-1", modeId: "plan" }), 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" }]); expect(manager.modeUpdates).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
}); });
@@ -2,6 +2,7 @@ import type { Logger } from "pino";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
import type { StoredAgentRecord } from "./agent-storage.js"; import type { StoredAgentRecord } from "./agent-storage.js";
import type { AgentProviderNotice } from "./agent-sdk-types.js";
export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">; export type LifecycleAgentSnapshot = Pick<ManagedAgent, "id" | "cwd" | "lifecycle">;
@@ -20,7 +21,7 @@ export interface LifecycleAgentManager {
previousParentAgentId: string | null; previousParentAgentId: string | null;
}>; }>;
notifyAgentState(agentId: string): void; notifyAgentState(agentId: string): void;
setAgentMode(agentId: string, modeId: string): Promise<void>; setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
updateAgentMetadata( updateAgentMetadata(
agentId: string, agentId: string,
updates: { updates: {
@@ -190,9 +191,9 @@ export async function setAgentModeCommand(
agentId: string; agentId: string;
modeId: string; modeId: string;
}, },
): Promise<{ modeId: string }> { ): Promise<{ modeId: string; notice: AgentProviderNotice | null }> {
await dependencies.agentManager.setAgentMode(input.agentId, input.modeId); const notice = await dependencies.agentManager.setAgentMode(input.agentId, input.modeId);
return { modeId: input.modeId }; return { modeId: input.modeId, notice };
} }
async function archiveStoredAgent( async function archiveStoredAgent(
@@ -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.",
};
@@ -432,6 +432,29 @@ describe("ClaudeAgentClient.listModels", () => {
await fs.rm(emptyConfigDir, { recursive: true, force: true }); 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", () => { describe("ClaudeAgentClient binary resolution", () => {
@@ -526,12 +549,26 @@ describe("ClaudeAgentSession features", () => {
const logger = createTestLogger(); const logger = createTestLogger();
function createQueryMock() { 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 = { const queryMock = {
close: vi.fn(), close: vi.fn(),
return: queryReturn, return: queryReturn,
applyFlagSettings: vi.fn(async () => undefined), applyFlagSettings: vi.fn(async () => undefined),
setModel: 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); const queryFactory = vi.fn(() => queryMock);
return { queryFactory, queryMock }; return { queryFactory, queryMock };
@@ -585,6 +622,58 @@ describe("ClaudeAgentSession features", () => {
await session.close(); 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 () => { test("toggles fast mode on the active query without restarting it", async () => {
const { queryFactory, queryMock } = createQueryMock(); const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
@@ -47,6 +47,7 @@ import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./quer
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js"; import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeProjectDirSync } from "./project-dir.js"; import { claudeProjectDirSync } from "./project-dir.js";
import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import { import {
getAgentStreamEventTurnId, getAgentStreamEventTurnId,
@@ -64,6 +65,7 @@ import {
type AgentPermissionResponse, type AgentPermissionResponse,
type AgentPermissionUpdate, type AgentPermissionUpdate,
type AgentPersistenceHandle, type AgentPersistenceHandle,
type AgentProviderNotice,
type AgentPromptInput, type AgentPromptInput,
type AgentRunOptions, type AgentRunOptions,
type AgentRunResult, type AgentRunResult,
@@ -362,6 +364,7 @@ interface ClaudeAgentSessionOptions {
} }
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
type ClaudeThinkingOption = ClaudeThinkingEffort | "ultracode";
function resolvePathEnvKey(): "Path" | "PATH" | null { function resolvePathEnvKey(): "Path" | "PATH" | null {
if (process.env["Path"] !== undefined) return "Path"; 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 { interface ClaudeOptionsLogSummary {
cwd: string | null; cwd: string | null;
permissionMode: string | null; permissionMode: string | null;
@@ -1966,7 +1973,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null; this.persistence = null;
} }
async setThinkingOption(thinkingOptionId: string | null): Promise<void> { async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
const normalizedThinkingOptionId = const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId ? thinkingOptionId
@@ -1974,12 +1981,15 @@ class ClaudeAgentSession implements AgentSession {
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") { if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
this.config.thinkingOptionId = undefined; this.config.thinkingOptionId = undefined;
} else if (isClaudeThinkingEffort(normalizedThinkingOptionId)) { } else if (isClaudeThinkingOption(normalizedThinkingOptionId)) {
this.config.thinkingOptionId = normalizedThinkingOptionId; this.config.thinkingOptionId = normalizedThinkingOptionId;
} else { } else {
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`); throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
} }
this.queryRestartNeeded = true; this.queryRestartNeeded = true;
if (this.activeForegroundTurnId || this.autonomousTurn) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
}
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async setFeature(featureId: string, value: unknown): Promise<void> {
@@ -2607,15 +2617,19 @@ class ClaudeAgentSession implements AgentSession {
private resolveThinkingConfig(): { private resolveThinkingConfig(): {
thinking: ClaudeOptions["thinking"]; thinking: ClaudeOptions["thinking"];
effort: ClaudeOptions["effort"]; effort: ClaudeOptions["effort"];
ultracode: boolean;
} { } {
const thinkingOptionId = const thinkingOptionId =
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default" this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId ? this.config.thinkingOptionId
: undefined; : undefined;
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { if (thinkingOptionId === "ultracode") {
return { thinking: { type: "adaptive" }, effort: thinkingOptionId }; 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 { private buildAppendedSystemPrompt(): string {
@@ -2641,10 +2655,10 @@ class ClaudeAgentSession implements AgentSession {
} }
private async buildOptions(): Promise<ClaudeOptions> { private async buildOptions(): Promise<ClaudeOptions> {
const { thinking, effort } = this.resolveThinkingConfig(); const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const appendedSystemPrompt = this.buildAppendedSystemPrompt();
const extraClaudeOptions = this.config.extra?.claude; const extraClaudeOptions = this.config.extra?.claude;
const fastModeOptions = this.buildFastModeOptions(extraClaudeOptions); const settingsOptions = this.buildSettingsOptions(extraClaudeOptions, { ultracode });
const sdkEnv = this.buildSdkEnv(extraClaudeOptions); const sdkEnv = this.buildSdkEnv(extraClaudeOptions);
assertClaudeAutoModeEligible(this.currentMode, sdkEnv); assertClaudeAutoModeEligible(this.currentMode, sdkEnv);
@@ -2697,7 +2711,7 @@ class ClaudeAgentSession implements AgentSession {
...(thinking ? { thinking } : {}), ...(thinking ? { thinking } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...extraClaudeOptions, ...extraClaudeOptions,
...fastModeOptions, ...settingsOptions,
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
env: sdkEnv, env: sdkEnv,
}; };
@@ -2722,14 +2736,20 @@ class ClaudeAgentSession implements AgentSession {
return base; return base;
} }
private buildFastModeOptions( private buildSettingsOptions(
extraClaudeOptions: Partial<ClaudeOptions> | undefined, extraClaudeOptions: Partial<ClaudeOptions> | undefined,
input: { ultracode: boolean },
): Pick<ClaudeOptions, "settings"> | Record<string, never> { ): Pick<ClaudeOptions, "settings"> | Record<string, never> {
const fastMode = this.resolveFastModeSetting(); const fastMode = this.resolveFastModeSetting();
if (fastMode === null) { if (fastMode === null && !input.ultracode) {
return {}; return {};
} }
return { settings: mergeClaudeSettings(extraClaudeOptions?.settings, { fastMode }) }; return {
settings: mergeClaudeSettings(extraClaudeOptions?.settings, {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
}),
};
} }
private resolveFastModeSetting(): boolean | null { private resolveFastModeSetting(): boolean | null {
@@ -20,20 +20,25 @@ const CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS = [
{ id: "max", label: "Max" }, { id: "max", label: "Max" },
] as const; ] as const;
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS,
{ id: "ultracode", label: "Ultracode" },
] as const;
const CLAUDE_MODELS: AgentModelDefinition[] = [ const CLAUDE_MODELS: AgentModelDefinition[] = [
{ {
provider: "claude", provider: "claude",
id: "claude-fable-5", id: "claude-fable-5",
label: "Fable 5", label: "Fable 5",
description: "Fable 5 · Most powerful model", description: "Fable 5 · Most powerful model",
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS], thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
}, },
{ {
provider: "claude", provider: "claude",
id: "claude-opus-4-8[1m]", id: "claude-opus-4-8[1m]",
label: "Opus 4.8 1M", label: "Opus 4.8 1M",
description: "Opus 4.8 with 1M context window", description: "Opus 4.8 with 1M context window",
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS], thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
}, },
{ {
provider: "claude", provider: "claude",
@@ -41,7 +46,7 @@ const CLAUDE_MODELS: AgentModelDefinition[] = [
label: "Opus 4.8", label: "Opus 4.8",
description: "Opus 4.8 · Latest release", description: "Opus 4.8 · Latest release",
isDefault: true, isDefault: true,
thinkingOptions: [...CLAUDE_OPUS_EXTENDED_THINKING_OPTIONS], thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
}, },
{ {
provider: "claude", provider: "claude",
@@ -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"])( test.each(["auto_review", "guardian_subagent"])(
"parses %s thread/start response as auto-review mode", "parses %s thread/start response as auto-review mode",
async (approvalsReviewer) => { async (approvalsReviewer) => {
@@ -13,6 +13,7 @@ import {
type AgentPermissionRequest, type AgentPermissionRequest,
type AgentPermissionResponse, type AgentPermissionResponse,
type AgentPermissionResult, type AgentPermissionResult,
type AgentProviderNotice,
type AgentPromptContentBlock, type AgentPromptContentBlock,
type AgentPromptInput, type AgentPromptInput,
type AgentRunOptions, type AgentRunOptions,
@@ -92,6 +93,7 @@ import {
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "./diagnostic-utils.js"; } from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.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"; import type { WorkspaceGitService } from "../../workspace-git-service.js";
function assertChildWithPipes( function assertChildWithPipes(
@@ -3654,10 +3656,13 @@ export class CodexAppServerAgentSession implements AgentSession {
return this.currentMode ?? null; return this.currentMode ?? null;
} }
async setMode(modeId: string): Promise<void> { async setMode(modeId: string): Promise<void | AgentProviderNotice> {
validateCodexMode(modeId); validateCodexMode(modeId);
this.currentMode = modeId; this.currentMode = modeId;
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
}
} }
async setModel(modelId: string | null): Promise<void> { async setModel(modelId: string | null): Promise<void> {
@@ -3669,10 +3674,13 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
} }
async setThinkingOption(thinkingOptionId: string | null): Promise<void> { async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
this.refreshResolvedCollaborationMode(); this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
if (this.activeForegroundTurnId) {
return SETTING_APPLIES_NEXT_TURN_NOTICE;
}
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async setFeature(featureId: string, value: unknown): Promise<void> {
+7 -4
View File
@@ -4672,14 +4672,17 @@ export class Session {
this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request"); this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request");
try { try {
await setAgentModeCommand({ agentManager: this.agentManager }, { agentId, modeId }); const result = await setAgentModeCommand(
{ agentManager: this.agentManager },
{ agentId, modeId },
);
this.sessionLogger.info( this.sessionLogger.info(
{ agentId, modeId, requestId }, { agentId, modeId, requestId },
"session: set_agent_mode_request success", "session: set_agent_mode_request success",
); );
this.emit({ this.emit({
type: "set_agent_mode_response", type: "set_agent_mode_response",
payload: { requestId, agentId, accepted: true, error: null }, payload: { requestId, agentId, accepted: true, error: null, notice: result.notice },
}); });
} catch (error) { } catch (error) {
this.sessionLogger.error( this.sessionLogger.error(
@@ -4808,14 +4811,14 @@ export class Session {
); );
try { try {
await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId); const notice = await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId);
this.sessionLogger.info( this.sessionLogger.info(
{ agentId, thinkingOptionId, requestId }, { agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request success", "session: set_agent_thinking_request success",
); );
this.emit({ this.emit({
type: "set_agent_thinking_response", type: "set_agent_thinking_response",
payload: { requestId, agentId, accepted: true, error: null }, payload: { requestId, agentId, accepted: true, error: null, notice },
}); });
} catch (error) { } catch (error) {
this.sessionLogger.error( this.sessionLogger.error(