From 2b0740ff84927216f21637f451dbcc7389183ade Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 20 Jun 2026 12:15:04 +0800 Subject: [PATCH] Add Claude Ultracode with setting-change notices (#1625) * Add Claude Ultracode mode notices * Share provider notice definitions * Test Claude Ultracode through public turn API --- docs/providers.md | 6 +- .../app/src/composer/agent-controls/index.tsx | 12 ++- .../composer/agent-controls/mode-control.tsx | 12 ++- packages/app/src/contexts/session-context.tsx | 23 +++-- .../app/src/utils/provider-notice-toast.ts | 19 ++++ packages/client/src/daemon-client.ts | 10 +- packages/protocol/src/agent-types.ts | 5 + packages/protocol/src/messages.test.ts | 41 +++++++++ packages/protocol/src/messages.ts | 8 ++ .../server/src/server/agent/agent-manager.ts | 15 ++- .../src/server/agent/agent-sdk-types.ts | 7 +- .../server/agent/lifecycle-command.test.ts | 5 +- .../src/server/agent/lifecycle-command.ts | 9 +- .../src/server/agent/provider-notices.ts | 6 ++ .../agent/providers/claude/agent.test.ts | 91 ++++++++++++++++++- .../server/agent/providers/claude/agent.ts | 42 ++++++--- .../server/agent/providers/claude/models.ts | 11 ++- .../providers/codex-app-server-agent.test.ts | 18 ++++ .../agent/providers/codex-app-server-agent.ts | 12 ++- packages/server/src/server/session.ts | 11 ++- 20 files changed, 310 insertions(+), 53 deletions(-) create mode 100644 packages/app/src/utils/provider-notice-toast.ts create mode 100644 packages/server/src/server/agent/provider-notices.ts diff --git a/docs/providers.md b/docs/providers.md index 16c0ea14f..a517314be 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -343,7 +343,7 @@ interface AgentSession { getRuntimeInfo(): Promise; getAvailableModes(): Promise; getCurrentMode(): Promise; - setMode(modeId: string): Promise; + setMode(modeId: string): Promise; getPendingPermissions(): AgentPermissionRequest[]; respondToPermission( requestId: string, @@ -355,7 +355,7 @@ interface AgentSession { // Optional: listCommands?(): Promise; setModel?(modelId: string | null): Promise; - setThinkingOption?(thinkingOptionId: string | null): Promise; + setThinkingOption?(thinkingOptionId: string | null): Promise; setFeature?(featureId: string, value: unknown): Promise; tryHandleOutOfBand?(prompt: AgentPromptInput): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise; @@ -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 diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index f771df5c1..4fafa19a4 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -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], ); diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 8c0adf1c1..c4daf344a 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -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], ); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 750a1e6bf..eb8ae0314 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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], ); diff --git a/packages/app/src/utils/provider-notice-toast.ts b/packages/app/src/utils/provider-notice-toast.ts new file mode 100644 index 000000000..478f929f8 --- /dev/null +++ b/packages/app/src/utils/provider-notice-toast.ts @@ -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, + }); +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a1212c3b5..0c9ac89f4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 { + async setAgentMode(agentId: string, modeId: string): Promise { 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 { @@ -2529,7 +2531,10 @@ export class DaemonClient { } } - async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise { + async setAgentThinkingOption( + agentId: string, + thinkingOptionId: string | null, + ): Promise { 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 { diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 2c5cf7224..9f90cc65e 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -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). */ diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 038155f6e..828b14d39 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -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( diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 284d32278..965241fbc 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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 = 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({ diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 6b2e2468c..9842d4715 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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 { + async setAgentMode(agentId: string, modeId: string): Promise { 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 { @@ -1307,15 +1309,19 @@ export class AgentManager { this.emitState(agent); } - async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise { + async setAgentThinkingOption( + agentId: string, + thinkingOptionId: string | null, + ): Promise { 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 { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index f6fd50d20..ed91b3bbf 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; getAvailableModes(): Promise; getCurrentMode(): Promise; - setMode(modeId: string): Promise; + setMode(modeId: string): Promise; getPendingPermissions(): AgentPermissionRequest[]; respondToPermission( requestId: string, @@ -615,7 +618,7 @@ export interface AgentSession { close(): Promise; listCommands?(): Promise; setModel?(modelId: string | null): Promise; - setThinkingOption?(thinkingOptionId: string | null): Promise; + setThinkingOption?(thinkingOptionId: string | null): Promise; setFeature?(featureId: string, value: unknown): Promise; revertConversation?(input: { messageId: string }): Promise; revertFiles?(input: { messageId: string }): Promise; diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index d00f944cc..9a3da4aa6 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -134,8 +134,9 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { this.notifiedAgentIds.push(agentId); } - async setAgentMode(agentId: string, modeId: string): Promise { + 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" }]); }); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index 5e91329c6..b6318c383 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -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; @@ -20,7 +21,7 @@ export interface LifecycleAgentManager { previousParentAgentId: string | null; }>; notifyAgentState(agentId: string): void; - setAgentMode(agentId: string, modeId: string): Promise; + setAgentMode(agentId: string, modeId: string): Promise; 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( diff --git a/packages/server/src/server/agent/provider-notices.ts b/packages/server/src/server/agent/provider-notices.ts new file mode 100644 index 000000000..921f70a50 --- /dev/null +++ b/packages/server/src/server/agent/provider-notices.ts @@ -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.", +}; diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index b35ad47ea..b48493e1a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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((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 { + 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({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 62493f28e..2dc8e6745 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 { + async setThinkingOption(thinkingOptionId: string | null): Promise { 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 { @@ -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 { - 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 | undefined, + input: { ultracode: boolean }, ): Pick | Record { 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 { diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index d14a43e1d..7c8ea10e8 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -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", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index c809f3474..879a8651b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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) => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 99f9686a9..af411a644 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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 { + async setMode(modeId: string): Promise { validateCodexMode(modeId); this.currentMode = modeId; this.cachedRuntimeInfo = null; + if (this.activeForegroundTurnId) { + return SETTING_APPLIES_NEXT_TURN_NOTICE; + } } async setModel(modelId: string | null): Promise { @@ -3669,10 +3674,13 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } - async setThinkingOption(thinkingOptionId: string | null): Promise { + async setThinkingOption(thinkingOptionId: string | null): Promise { 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 { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a67ea5563..12a5b1d92 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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(