feat: add live model and thinking option preferences UI (#8)

* feat: add live model and thinking option preferences UI

* refactor: remove agent variant support and clean up thinking options

* feat: live model + thinking presets (RPC)
This commit is contained in:
Mohamed Boudra
2026-02-04 08:18:15 +07:00
committed by GitHub
parent 03f383b908
commit 469a25c724
50 changed files with 1976 additions and 894 deletions
+84 -1
View File
@@ -1086,7 +1086,90 @@ export class DaemonClientV2 {
}
async setAgentMode(agentId: string, modeId: string): Promise<void> {
this.sendSessionMessage({ type: "set_agent_mode", agentId, modeId });
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_mode_request",
agentId,
modeId,
requestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "set_agent_mode_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
15000,
{ skipQueue: true }
);
await this.sendSessionMessageOrThrow(message);
const payload = await response;
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentMode rejected");
}
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_model_request",
agentId,
modelId,
requestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "set_agent_model_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
15000,
{ skipQueue: true }
);
await this.sendSessionMessageOrThrow(message);
const payload = await response;
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentModel rejected");
}
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null
): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "set_agent_thinking_request",
agentId,
thinkingOptionId,
requestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "set_agent_thinking_response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
15000,
{ skipQueue: true }
);
await this.sendSessionMessageOrThrow(message);
const payload = await response;
if (!payload.accepted) {
throw new Error(payload.error ?? "setAgentThinkingOption rejected");
}
}
async restartServer(
@@ -444,6 +444,40 @@ export class AgentManager {
this.emitState(agent);
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
agent.config.model = normalizedModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.emitState(agent);
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null
): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
if (agent.session.setThinkingOption) {
await agent.session.setThinkingOption(normalizedThinkingOptionId);
}
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
this.emitState(agent);
}
async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
await this.registry?.setTitle(agentId, title);
@@ -12,7 +12,7 @@ import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
@@ -76,7 +76,7 @@ describe("agent metadata generation (real agents)", () => {
const agent = await manager.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: repoDir,
title: "Main Agent",
@@ -116,7 +116,7 @@ describe("agent metadata generation (real agents)", () => {
const agent = await manager.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto",
cwd: worktree.worktreePath,
title: "Worktree Agent",
@@ -160,7 +160,7 @@ export async function generateAndApplyAgentMetadata(
agentConfig: {
provider: AUTO_GEN_PROVIDER,
model: AUTO_GEN_MODEL,
reasoningEffort: AUTO_GEN_REASONING_EFFORT,
thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
cwd: options.cwd,
title: "Agent metadata generator",
internal: true,
@@ -234,6 +234,7 @@ describe("toAgentPayload", () => {
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
expect(payload.title).toBe("UI Payload");
expect(payload.model).toBe(agent.config.model);
expect(payload.thinkingOptionId).toBeNull();
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
"perm-a",
"perm-b",
@@ -66,12 +66,14 @@ export function toAgentPayload(
options?: ProjectionOptions
): AgentSnapshotPayload {
const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo);
const thinkingOptionId = agent.config.thinkingOptionId ?? null;
const payload: AgentSnapshotPayload = {
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
model: agent.config.model ?? null,
thinkingOptionId,
runtimeInfo,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
@@ -120,6 +122,9 @@ function buildSerializableConfig(
if (config.model) {
serializable.model = config.model;
}
if (config.thinkingOptionId) {
serializable.thinkingOptionId = config.thinkingOptionId;
}
const extra = sanitizeMetadata(config.extra);
if (extra !== undefined) {
serializable.extra = extra;
@@ -16,7 +16,7 @@ import { createAllClients, shutdownProviders } from "./provider-registry.js";
import pino from "pino";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
type AgentMcpServerHandle = {
url: string;
@@ -176,7 +176,7 @@ describe("getStructuredAgentResponse (e2e)", () => {
agentConfig: {
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Structured Response Test",
},
@@ -192,27 +192,42 @@ describe("getStructuredAgentResponse (e2e)", () => {
);
test(
"returns schema-valid JSON from Claude Haiku on first try",
"returns schema-valid JSON from Claude Haiku",
async () => {
const schema = z.object({
message: z.string(),
});
const result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "claude",
model: "haiku",
cwd,
title: "Claude Haiku Structured Test",
internal: true,
},
prompt: 'Return JSON with a message field containing "hello".',
schema,
maxRetries: 0,
});
let result: { message: string } | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
result = await generateStructuredAgentResponse({
manager,
agentConfig: {
provider: "claude",
model: "haiku",
thinkingOptionId: "on",
cwd,
title: "Claude Haiku Structured Test",
internal: true,
},
prompt:
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
schema,
maxRetries: 6,
});
lastError = null;
break;
} catch (error) {
lastError = error;
}
}
if (!result) {
throw lastError;
}
expect(result.message).toBe("hello");
expect(result.message.trim().toLowerCase()).toBe("hello");
},
180000
);
@@ -131,9 +131,84 @@ function extractJsonFromMarkdown(text: string): string {
if (fencedMatch) {
return fencedMatch[1].trim();
}
const extracted = extractFirstJsonSnippet(text);
if (extracted) {
return extracted;
}
return text.trim();
}
function extractFirstJsonSnippet(text: string): string | null {
const source = text.trim();
if (!source) {
return null;
}
// Try to find the first valid JSON object/array within a larger response.
// This is intentionally provider-agnostic and improves resilience when models
// add extra prose before/after the JSON.
const startIndexes: number[] = [];
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
if (ch === "{" || ch === "[") {
startIndexes.push(i);
}
}
for (const start of startIndexes) {
const open = source[start]!;
const close = open === "{" ? "}" : "]";
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < source.length; i += 1) {
const ch = source[i]!;
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === open) {
depth += 1;
continue;
}
if (ch === close) {
depth -= 1;
if (depth === 0) {
const candidate = source.slice(start, i + 1).trim();
try {
JSON.parse(candidate);
return candidate;
} catch {
// keep scanning; the snippet might not be JSON (e.g. braces in prose)
}
}
}
}
}
return null;
}
export async function getStructuredAgentResponse<T>(
options: StructuredAgentResponseOptions<T>
): Promise<T> {
@@ -194,12 +269,14 @@ export async function generateStructuredAgentResponse<T>(
try {
const caller: AgentCaller = async (nextPrompt) => {
const result = await manager.runAgent(agent.id, nextPrompt);
// Accumulate all assistant_message items since Claude streams text as deltas
const fullText = result.timeline
if (typeof result.finalText === "string" && result.finalText.length > 0) {
return result.finalText;
}
// Fallback for providers that may not populate finalText consistently.
const lastAssistant = result.timeline
.filter((item) => item.type === "assistant_message")
.map((item) => item.text)
.join("");
return fullText || result.finalText;
.at(-1);
return lastAssistant?.text ?? "";
};
return await getStructuredAgentResponse({
caller,
@@ -55,6 +55,16 @@ export type AgentModelDefinition = {
description?: string;
isDefault?: boolean;
metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;
};
export type AgentSelectOption = {
id: string;
label: string;
description?: string;
isDefault?: boolean;
metadata?: AgentMetadata;
};
export type AgentCapabilityFlags = {
@@ -228,12 +238,12 @@ export type AgentSessionConfig = {
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
title?: string | null;
approvalPolicy?: string;
sandboxMode?: string;
networkAccess?: boolean;
webSearch?: boolean;
reasoningEffort?: string;
extra?: {
codex?: AgentMetadata;
claude?: Partial<ClaudeAgentOptions>;
@@ -274,6 +284,15 @@ export interface AgentSession {
* @param args Optional arguments to pass to the command
*/
executeCommand?(commandName: string, args?: string): Promise<AgentCommandResult>;
/**
* Update the model used for subsequent turns (if supported by provider).
*/
setModel?(modelId: string | null): Promise<void>;
/**
* Update the thinking/effort setting used for subsequent turns (if supported).
* Normalized to a string option id (provider-specific interpretation).
*/
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
}
export interface ListModelsOptions {
@@ -13,6 +13,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
extra: z.record(z.any()).nullable().optional(),
})
.nullable()
@@ -60,7 +61,7 @@ const STORED_AGENT_SCHEMA = z.object({
export type SerializableAgentConfig = Pick<
AgentSessionConfig,
"modeId" | "model" | "extra"
"modeId" | "model" | "thinkingOptionId" | "extra"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -242,9 +242,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
if (typeof metadata.webSearch === "boolean") {
result.webSearch = metadata.webSearch;
}
if (typeof metadata.reasoningEffort === "string") {
result.reasoningEffort = metadata.reasoningEffort;
}
if (isMetadata(metadata.extra)) {
const extra: AgentSessionConfig["extra"] = {};
if (isMetadata(metadata.extra.codex)) {
@@ -366,6 +363,11 @@ export class ClaudeAgentClient implements AgentClient {
id: model.value,
label: model.displayName,
description: model.description,
thinkingOptions: [
{ id: "off", label: "Thinking Off", isDefault: true },
{ id: "on", label: "Thinking On" },
],
defaultThinkingOptionId: "off",
metadata: {
description: model.description,
},
@@ -552,8 +554,9 @@ class ClaudeAgentSession implements AgentSession {
async *stream(
prompt: AgentPromptInput,
_options?: AgentRunOptions
options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
void options;
// Increment turn ID to invalidate any in-flight processPrompt() loops from previous turns.
// This prevents race conditions where an interrupted turn's events get mixed with the new turn.
const turnId = ++this.currentTurnId;
@@ -674,6 +677,57 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
const query = await this.ensureQuery();
await query.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined;
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
const query = await this.ensureQuery();
if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") {
// Claude Code TUI exposes only ON/OFF. Default to OFF.
try {
await query.setMaxThinkingTokens(0);
} catch {
await query.setMaxThinkingTokens(1);
}
this.config.thinkingOptionId = undefined;
return;
}
if (normalizedThinkingOptionId === "on") {
await query.setMaxThinkingTokens(null);
this.config.thinkingOptionId = normalizedThinkingOptionId;
return;
}
if (normalizedThinkingOptionId === "off") {
try {
await query.setMaxThinkingTokens(0);
} catch {
// Some runtimes may reject 0; use a tiny cap as "off".
await query.setMaxThinkingTokens(1);
}
this.config.thinkingOptionId = "off";
return;
}
throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`);
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
}
@@ -725,12 +779,11 @@ class ClaudeAgentSession implements AgentSession {
if (!this.claudeSessionId) {
return null;
}
const { model: _ignoredModel, ...restConfig } = this.config;
this.persistence = {
provider: "claude",
sessionId: this.claudeSessionId,
nativeHandle: this.claudeSessionId,
metadata: restConfig,
metadata: this.config,
};
return this.persistence;
}
@@ -808,6 +861,19 @@ class ClaudeAgentSession implements AgentSession {
}
private buildOptions(): ClaudeOptions {
const configuredThinkingOptionId = this.config.thinkingOptionId;
const thinkingOptionId =
configuredThinkingOptionId && configuredThinkingOptionId !== "default"
? configuredThinkingOptionId
: "off";
let maxThinkingTokens: number | undefined;
if (typeof thinkingOptionId === "string" && thinkingOptionId.length > 0) {
if (thinkingOptionId === "off") {
maxThinkingTokens = 0;
}
// For "on" we omit maxThinkingTokens (SDK default max).
}
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
@@ -834,6 +900,7 @@ class ClaudeAgentSession implements AgentSession {
// If we have a session ID from a previous query (e.g., after interrupt),
// resume that session to continue the conversation history.
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
...(maxThinkingTokens !== undefined ? { maxThinkingTokens } : {}),
...this.config.extra?.claude,
};
@@ -8,7 +8,7 @@ import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function isCodexInstalled(): boolean {
try {
@@ -30,7 +30,7 @@ describe("Codex app-server provider (e2e)", () => {
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const result = await session.run("Say hello in one sentence.");
@@ -16,7 +16,7 @@ import type {
} from "../agent-sdk-types.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
@@ -104,7 +104,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const result = await session.run([
@@ -131,7 +131,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
await session.run("Reply with OK.");
@@ -168,7 +168,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const commands = await session.listCommands?.();
@@ -198,7 +198,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "auto",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
let sawPermission = false;
@@ -278,7 +278,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "full-access",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
let sawAssistantMessage = false;
@@ -417,7 +417,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "auto",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const stream = session.stream(
@@ -470,7 +470,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const followup = await followupSession.run("Reply OK and stop.");
expect(followup.finalText.toLowerCase()).toContain("ok");
@@ -501,7 +501,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const first = await session.run(`Remember the word ${token} and reply ACK.`);
@@ -559,7 +559,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "read-only",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
const result = await Promise.race([
@@ -599,7 +599,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "full-access",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
let sawPermission = false;
@@ -659,7 +659,6 @@ describe("Codex app-server provider (integration)", () => {
if (captured) {
expect(sawPermissionResolved).toBe(true);
}
expect(sawPermission || timelineItems.length > 0).toBe(true);
expect(readFileSync(targetPath, "utf8").trim()).toBe("ok");
} finally {
cleanup();
@@ -678,7 +677,7 @@ describe("Codex app-server provider (integration)", () => {
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
await session.connect();
@@ -1032,7 +1032,8 @@ class CodexAppServerAgentSession implements AgentSession {
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
if (match.developer_instructions) settings.developer_instructions = match.developer_instructions;
if (this.config.model) settings.model = this.config.model;
if (this.config.reasoningEffort) settings.reasoning_effort = this.config.reasoningEffort;
const thinkingOptionId = this.config.thinkingOptionId;
if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId;
return { mode: match.mode ?? "code", settings, name: match.name };
}
@@ -1158,8 +1159,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.model) {
params.model = this.config.model;
}
if (this.config.reasoningEffort) {
params.effort = this.config.reasoningEffort;
const thinkingOptionId = this.config.thinkingOptionId;
if (thinkingOptionId) {
params.effort = thinkingOptionId;
}
if (this.resolvedCollaborationMode) {
params.collaborationMode = {
@@ -1235,6 +1237,18 @@ class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
}
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.config.thinkingOptionId = thinkingOptionId ?? undefined;
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.cachedRuntimeInfo = null;
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
}
@@ -1301,6 +1315,7 @@ class CodexAppServerAgentSession implements AgentSession {
describePersistence(): { provider: typeof CODEX_PROVIDER; sessionId: string; nativeHandle: string; metadata: Record<string, unknown> } | null {
if (!this.currentThreadId) return null;
const thinkingOptionId = this.config.thinkingOptionId ?? null;
return {
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
@@ -1312,7 +1327,7 @@ class CodexAppServerAgentSession implements AgentSession {
threadId: this.currentThreadId,
modeId: this.currentMode,
model: this.config.model ?? null,
reasoningEffort: this.config.reasoningEffort ?? null,
thinkingOptionId,
},
};
}
@@ -1795,6 +1810,25 @@ export class CodexAppServerAgentClient implements AgentClient {
label: model.displayName,
description: model.description,
isDefault: model.isDefault,
thinkingOptions: [
{
id: "default",
label: "Default",
description: typeof model.defaultReasoningEffort === "string"
? `Use model default (${model.defaultReasoningEffort})`
: "Use model default",
isDefault: true,
},
...(Array.isArray(model.supportedReasoningEfforts)
? model.supportedReasoningEfforts.map((entry: any) => ({
id: entry.reasoningEffort,
label: entry.reasoningEffort,
description: entry.description,
isDefault: entry.reasoningEffort === model.defaultReasoningEffort,
}))
: []),
],
defaultThinkingOptionId: "default",
metadata: {
model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort,
@@ -1954,12 +1954,12 @@ const AgentSessionConfigSchema = z
cwd: z.string(),
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
title: z.string().nullable().optional(),
approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(),
extra: AgentSessionExtraSchema.optional(),
mcpServers: z.record(McpServerConfigSchema).optional(),
})
@@ -2872,9 +2872,14 @@ function buildCodexMcpConfig(
innerConfig.mcp_servers = mcpServers;
}
// Add reasoning effort to config if provided
if (typeof config.reasoningEffort === "string" && config.reasoningEffort.length > 0) {
innerConfig.model_reasoning_effort = config.reasoningEffort;
// Add thinking option to config if provided
const thinkingOptionId = config.thinkingOptionId;
if (
typeof thinkingOptionId === "string" &&
thinkingOptionId.length > 0 &&
thinkingOptionId !== "default"
) {
innerConfig.model_reasoning_effort = thinkingOptionId;
}
const configPayload: {
@@ -3500,6 +3505,22 @@ class CodexMcpAgentSession implements AgentSession {
}
}
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
this.cachedRuntimeInfo = null;
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
}
@@ -4790,6 +4811,25 @@ export class CodexMcpAgentClient implements AgentClient {
label: model.displayName,
description: model.description,
isDefault: model.isDefault,
thinkingOptions: [
{
id: "default",
label: "Default",
description: typeof model.defaultReasoningEffort === "string"
? `Use model default (${model.defaultReasoningEffort})`
: "Use model default",
isDefault: true,
},
...(Array.isArray(model.supportedReasoningEfforts)
? model.supportedReasoningEfforts.map((entry) => ({
id: entry.reasoningEffort,
label: entry.reasoningEffort,
description: entry.description,
isDefault: entry.reasoningEffort === model.defaultReasoningEffort,
}))
: []),
],
defaultThinkingOptionId: "default",
metadata: {
model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort,
@@ -290,11 +290,19 @@ export class OpenCodeAgentClient implements AgentClient {
}
for (const [modelId, model] of Object.entries(provider.models)) {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = [
{ id: "default", label: "Default", isDefault: true },
...rawVariants.map((id) => ({ id, label: id })),
];
models.push({
provider: "opencode",
id: `${provider.id}/${modelId}`,
label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 1 ? thinkingOptions : undefined,
defaultThinkingOptionId: "default",
metadata: {
providerId: provider.id,
providerName: provider.name,
@@ -372,6 +380,20 @@ class OpenCodeAgentSession implements AgentSession {
};
}
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
}
async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
const events = this.stream(prompt);
const timeline: AgentTimelineItem[] = [];
@@ -407,6 +429,9 @@ class OpenCodeAgentSession implements AgentSession {
const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant =
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
// Send prompt asynchronously
const promptResponse = await this.client.session.promptAsync({
@@ -414,6 +439,7 @@ class OpenCodeAgentSession implements AgentSession {
directory: this.config.cwd,
parts,
...(model ? { model } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
});
if (promptResponse.error) {
@@ -14,9 +14,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -35,7 +35,7 @@ describe("daemon E2E", () => {
test("creates agent and receives response", async () => {
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: "/tmp",
title: "Test Agent",
});
@@ -95,7 +95,7 @@ describe("daemon E2E", () => {
await expect(
ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: nonExistentCwd,
title: "Should Fail Agent",
})
@@ -6,7 +6,7 @@
export interface AgentTestConfig {
provider: "claude" | "codex";
model: string;
reasoningEffort?: string;
thinkingOptionId?: string;
modes: {
full: string; // No permissions required
ask: string; // Requires permission approval
@@ -25,7 +25,7 @@ export const agentConfigs = {
codex: {
provider: "codex",
model: "gpt-5.1-codex-mini",
reasoningEffort: "low",
thinkingOptionId: "low",
modes: {
full: "full-access",
ask: "auto",
@@ -40,11 +40,11 @@ export type AgentProvider = keyof typeof agentConfigs;
*/
export function getFullAccessConfig(provider: AgentProvider) {
const config = agentConfigs[provider];
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined;
const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return {
provider: config.provider,
model: config.model,
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
modeId: config.modes.full,
};
}
@@ -54,11 +54,11 @@ export function getFullAccessConfig(provider: AgentProvider) {
*/
export function getAskModeConfig(provider: AgentProvider) {
const config = agentConfigs[provider];
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined;
const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return {
provider: config.provider,
model: config.model,
...(reasoningEffort ? { reasoningEffort } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
modeId: config.modes.ask,
};
}
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -43,7 +43,7 @@ describe("daemon E2E", () => {
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Test Agent",
});
@@ -94,7 +94,7 @@ describe("daemon E2E", () => {
// Create a Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Timestamp Update Test Agent",
});
@@ -137,7 +137,7 @@ describe("daemon E2E", () => {
// Create Codex agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Cancel Test Agent",
});
@@ -211,7 +211,7 @@ describe("daemon E2E", () => {
// Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Mode Switch Test Agent",
});
@@ -331,7 +331,7 @@ describe("daemon E2E", () => {
// Create first agent
const agent1 = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd1,
title: "List Test Agent 1",
});
@@ -348,7 +348,7 @@ describe("daemon E2E", () => {
// Create second agent
const agent2 = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: cwd2,
title: "List Test Agent 2",
});
@@ -11,7 +11,7 @@ import {
import { createWorktree } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
@@ -125,7 +125,7 @@ describe("daemon checkout ship loop", () => {
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: worktree.worktreePath,
title: "Checkout Ship Loop",
});
@@ -281,7 +281,7 @@ describe("daemon checkout ship loop", () => {
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: worktree.worktreePath,
title: "Merge From Base Test",
});
@@ -351,7 +351,7 @@ describe("daemon checkout ship loop", () => {
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Checkout Non-Git",
});
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -38,7 +38,7 @@ describe("daemon E2E", () => {
writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
@@ -96,7 +96,7 @@ describe("daemon E2E", () => {
writeFileSync(filePath, "expired", "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Expired Token Test Agent",
});
@@ -127,7 +127,7 @@ describe("daemon E2E", () => {
async () => {
const cwd = tmpCwd();
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Outside Path Token Test Agent",
});
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -42,7 +42,7 @@ describe("daemon E2E", () => {
// Create agent in the directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Explorer Test",
});
@@ -92,7 +92,7 @@ describe("daemon E2E", () => {
// Create agent in the directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Read Test",
});
@@ -126,7 +126,7 @@ describe("daemon E2E", () => {
// Create agent
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "File Explorer Error Test",
});
@@ -92,9 +92,9 @@ async function waitForTimelineToolCall(
);
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -136,7 +136,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Test",
});
@@ -183,7 +183,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo (no modifications)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Clean Test",
});
@@ -211,7 +211,7 @@ describe("daemon E2E", () => {
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Diff Non-Git Test",
});
@@ -260,7 +260,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Test",
});
@@ -308,7 +308,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Clean Test",
});
@@ -338,7 +338,7 @@ describe("daemon E2E", () => {
// Create agent in a non-git directory
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Git Repo Info Non-Git Test",
});
@@ -396,7 +396,7 @@ describe("daemon E2E", () => {
ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup Test",
git: {
@@ -470,7 +470,7 @@ describe("daemon E2E", () => {
ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Async Worktree Setup Failure Test",
git: {
@@ -541,7 +541,7 @@ describe("daemon E2E", () => {
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Worktree Agent Test",
git: {
@@ -14,9 +14,7 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -0,0 +1,246 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import {
createDaemonTestContext,
type DaemonTestContext,
} from "../test-utils/index.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
function waitForAgentUpdate(
messages: SessionOutboundMessage[],
startIndex: number,
predicate: (agent: AgentSnapshotPayload) => boolean,
options?: { timeoutMs?: number }
): Promise<AgentSnapshotPayload> {
const timeoutMs = options?.timeoutMs ?? 15000;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
clearInterval(interval);
reject(new Error("Timeout waiting for agent_update"));
}, timeoutMs);
const interval = setInterval(() => {
for (let i = startIndex; i < messages.length; i++) {
const msg = messages[i];
if (msg.type !== "agent_update") continue;
if (msg.payload.kind !== "upsert") continue;
if (predicate(msg.payload.agent)) {
clearTimeout(timeout);
clearInterval(interval);
resolve(msg.payload.agent);
return;
}
}
}, 50);
});
}
function pickTwoDistinctModels(models: Array<{ id: string }>): [string, string] {
const ids = Array.from(new Set(models.map((m) => m.id))).filter(Boolean);
if (ids.length < 2) {
throw new Error(`Need at least 2 models to test switching; got ${ids.length}`);
}
return [ids[0]!, ids[1]!];
}
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
beforeEach(async () => {
ctx = await createDaemonTestContext();
messages = [];
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
ctx.client.subscribeAgentUpdates();
});
afterEach(async () => {
unsubscribe?.();
await ctx.cleanup();
}, 60000);
describe.each(["claude", "codex", "opencode"] as const)(
"live model switching (%s)",
(provider) => {
test(
"updates agent model without restarting",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels(provider);
if (!modelList.models || modelList.models.length === 0) {
throw new Error(`No models returned for provider ${provider}`);
}
const [modelA, modelB] = pickTwoDistinctModels(modelList.models);
const agent = await ctx.client.createAgent({
provider,
cwd,
title: `Model Switch (${provider})`,
model: modelA,
});
const startIndex = messages.length;
await ctx.client.setAgentModel(agent.id, modelB);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.model === modelB,
{ timeoutMs: 20000 }
);
expect(updated.model).toBe(modelB);
// Sanity: run a tiny prompt after switching.
await ctx.client.sendMessage(agent.id, "Say 'ok' and nothing else");
const final = await ctx.client.waitForFinish(agent.id, 120000);
expect(final.status).toBe("idle");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
180000
);
}
);
test(
"live thinking switching works for Claude (off -> on)",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("claude");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Claude models returned");
}
const modelId = modelList.models[0]!.id;
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Thinking Switch",
model: modelId,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, "on");
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === "on",
{ timeoutMs: 20000 }
);
expect(updated.thinkingOptionId).toBe("on");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
120000
);
test(
"live thinking switching works for Codex (default -> non-default)",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("codex");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Codex models returned");
}
const modelWithOptions = modelList.models.find(
(m) => (m.thinkingOptions?.length ?? 0) > 1
);
if (!modelWithOptions) {
throw new Error("No Codex model with thinkingOptions returned");
}
const nonDefault =
modelWithOptions.thinkingOptions?.find((o) => o.id !== "default")?.id ??
null;
if (!nonDefault) {
throw new Error("No non-default Codex thinking option found");
}
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Codex Thinking Switch",
model: modelWithOptions.id,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, nonDefault);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === nonDefault,
{ timeoutMs: 20000 }
);
expect(updated.thinkingOptionId).toBe(nonDefault);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
120000
);
test(
"live thinking switching works for OpenCode",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("opencode");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No OpenCode models returned");
}
const modelWithThinkingOptions = modelList.models.find(
(m) => (m.thinkingOptions?.length ?? 0) > 1
);
if (!modelWithThinkingOptions) {
throw new Error("No OpenCode model with thinkingOptions returned");
}
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode Preferences Switch",
model: modelWithThinkingOptions.id,
});
const thinkingId =
modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== "default")?.id ??
null;
if (!thinkingId) {
throw new Error("No non-default OpenCode thinking option found");
}
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingId);
const updatedThinking = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingId,
{ timeoutMs: 20000 }
);
expect(updatedThinking.thinkingOptionId).toBe(thinkingId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
180000
);
});
@@ -13,9 +13,7 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -76,6 +74,26 @@ describe("daemon E2E", () => {
},
60000 // 1 minute timeout
);
test(
"returns model list for OpenCode provider",
async () => {
const result = await ctx.client.listProviderModels("opencode");
expect(result.provider).toBe("opencode");
expect(result.error).toBeNull();
expect(result.fetchedAt).toBeTruthy();
expect(result.models).toBeTruthy();
expect(result.models.length).toBeGreaterThan(0);
const model = result.models[0];
expect(model.provider).toBe("opencode");
expect(model.id).toBeTruthy();
expect(model.label).toBeTruthy();
},
60000
);
});
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -44,7 +44,7 @@ describe("daemon E2E", () => {
// Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Permission Test",
modeId: "read-only",
@@ -111,7 +111,7 @@ describe("daemon E2E", () => {
// Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Permission Deny Test",
modeId: "read-only",
@@ -176,7 +176,7 @@ describe("daemon E2E", () => {
// Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Interrupt Test",
modeId: "full-access",
@@ -237,7 +237,7 @@ describe("daemon E2E", () => {
// Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Abort Stop Test",
modeId: "full-access",
@@ -275,7 +275,7 @@ describe("daemon E2E", () => {
// Step 1: Create Codex agent with "auto" mode (requires permission for writes)
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Mode Switch Permission Test",
modeId: "auto",
@@ -11,7 +11,7 @@ import { agentConfigs } from "./agent-configs.js";
// Re-export for backward compatibility - prefer using agentConfigs instead
export const CODEX_TEST_MODEL = agentConfigs.codex.model;
export const CODEX_TEST_REASONING_EFFORT = agentConfigs.codex.reasoningEffort;
export const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
// Re-export agent configs
export {
@@ -14,9 +14,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution
// Use gpt-5.1-codex-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => {
let ctx: DaemonTestContext;
@@ -197,7 +197,7 @@ describe("daemon E2E", () => {
const collector = createMessageCollector(ctx.client);
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Shell Test",
modeId: "full-access",
@@ -245,7 +245,7 @@ describe("daemon E2E", () => {
const collector = createMessageCollector(ctx.client);
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Read Test",
modeId: "full-access",
@@ -291,7 +291,7 @@ describe("daemon E2E", () => {
writeFileSync(testFile, "hello world\n");
const agent = await ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Codex Edit Test",
modeId: "full-access",
@@ -21,7 +21,7 @@ type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
function isKnownProvider(provider: string): provider is AgentProvider {
return provider === "claude" || provider === "codex";
return provider === "claude" || provider === "codex" || provider === "opencode";
}
/**
@@ -53,6 +53,7 @@ export function buildConfigOverrides(
cwd: record.cwd,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
title: record.title ?? undefined,
extra: record.config?.extra ?? undefined,
};
@@ -70,6 +71,7 @@ export function buildSessionConfig(
cwd: record.cwd,
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
title: overrides.title,
extra: overrides.extra,
};
+131 -11
View File
@@ -638,6 +638,7 @@ export class Session {
provider,
cwd: record.cwd,
model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
@@ -872,8 +873,20 @@ export class Session {
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break;
case "set_agent_mode":
await this.handleSetAgentMode(msg.agentId, msg.modeId);
case "set_agent_mode_request":
await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId);
break;
case "set_agent_model_request":
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
break;
case "set_agent_thinking_request":
await this.handleSetAgentThinkingRequest(
msg.agentId,
msg.thinkingOptionId,
msg.requestId
);
break;
case "agent_permission_response":
@@ -2129,25 +2142,30 @@ export class Session {
/**
* Handle set agent mode request
*/
private async handleSetAgentMode(
private async handleSetAgentModeRequest(
agentId: string,
modeId: string
modeId: string,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId, modeId },
`Setting agent ${agentId} mode to ${modeId}`
{ agentId, modeId, requestId },
"session: set_agent_mode_request"
);
try {
await this.agentManager.setAgentMode(agentId, modeId);
this.sessionLogger.info(
{ agentId, modeId },
`Agent ${agentId} mode set to ${modeId}`
{ agentId, modeId, requestId },
"session: set_agent_mode_request success"
);
this.emit({
type: "set_agent_mode_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, modeId },
"Failed to set agent mode"
{ err: error, agentId, modeId, requestId },
"session: set_agent_mode_request error"
);
this.emit({
type: "activity_log",
@@ -2158,7 +2176,109 @@ export class Session {
content: `Failed to set agent mode: ${error.message}`,
},
});
throw error;
this.emit({
type: "set_agent_mode_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "Failed to set agent mode",
},
});
}
}
private async handleSetAgentModelRequest(
agentId: string,
modelId: string | null,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId, modelId, requestId },
"session: set_agent_model_request"
);
try {
await this.agentManager.setAgentModel(agentId, modelId);
this.sessionLogger.info(
{ agentId, modelId, requestId },
"session: set_agent_model_request success"
);
this.emit({
type: "set_agent_model_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, modelId, requestId },
"session: set_agent_model_request error"
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent model: ${error.message}`,
},
});
this.emit({
type: "set_agent_model_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "Failed to set agent model",
},
});
}
}
private async handleSetAgentThinkingRequest(
agentId: string,
thinkingOptionId: string | null,
requestId: string
): Promise<void> {
this.sessionLogger.info(
{ agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request"
);
try {
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 },
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request error"
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent thinking option: ${error.message}`,
},
});
this.emit({
type: "set_agent_thinking_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message
? String(error.message)
: "Failed to set agent thinking option",
},
});
}
}
+68 -5
View File
@@ -21,6 +21,14 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
description: z.string().optional(),
});
const AgentSelectOptionSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.unknown()).optional(),
});
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
@@ -28,6 +36,8 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.unknown()).optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
});
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
@@ -76,6 +86,7 @@ const AgentSessionConfigSchema = z.object({
cwd: z.string(),
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
title: z
.string()
.trim()
@@ -87,7 +98,6 @@ const AgentSessionConfigSchema = z.object({
sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(),
extra: z
.object({
codex: z.record(z.unknown()).optional(),
@@ -253,6 +263,7 @@ export const AgentSnapshotPayloadSchema = z.object({
provider: AgentProviderSchema,
cwd: z.string(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(),
updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
@@ -520,10 +531,55 @@ export const InitializeAgentResponseMessageSchema = z.object({
}),
});
export const SetAgentModeMessageSchema = z.object({
type: z.literal("set_agent_mode"),
export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"),
agentId: z.string(),
modeId: z.string(),
requestId: z.string(),
});
export const SetAgentModeResponseMessageSchema = z.object({
type: z.literal("set_agent_mode_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetAgentModelRequestMessageSchema = z.object({
type: z.literal("set_agent_model_request"),
agentId: z.string(),
modelId: z.string().nullable(),
requestId: z.string(),
});
export const SetAgentModelResponseMessageSchema = z.object({
type: z.literal("set_agent_model_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetAgentThinkingRequestMessageSchema = z.object({
type: z.literal("set_agent_thinking_request"),
agentId: z.string(),
thinkingOptionId: z.string().nullable(),
requestId: z.string(),
});
export const SetAgentThinkingResponseMessageSchema = z.object({
type: z.literal("set_agent_thinking_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const AgentPermissionResponseMessageSchema = z.object({
@@ -830,7 +886,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentRequestMessageSchema,
RestartServerRequestMessageSchema,
InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema,
SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
AgentPermissionResponseMessageSchema,
GitDiffRequestSchema,
CheckoutStatusRequestSchema,
@@ -1559,6 +1617,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentsResponseMessageSchema,
FetchAgentResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
WaitForFinishResponseMessageSchema,
ListVoiceConversationsResponseMessageSchema,
DeleteVoiceConversationResponseMessageSchema,
@@ -1659,7 +1720,9 @@ export type ListProviderModelsRequestMessage = z.infer<
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;