mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
19
packages/app/src/utils/provider-notice-toast.ts
Normal file
19
packages/app/src/utils/provider-notice-toast.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
6
packages/server/src/server/agent/provider-notices.ts
Normal file
6
packages/server/src/server/agent/provider-notices.ts
Normal 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.",
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user