diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 8de83d230..e3c51e590 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -71,12 +71,16 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { : agent.model ?? "default"; const thinkingOptions = selectedModel?.thinkingOptions ?? null; + const explicitThinkingId = + agent.thinkingOptionId && agent.thinkingOptionId !== "default" + ? agent.thinkingOptionId + : null; const selectedThinkingId = - agent.thinkingOptionId ?? - selectedModel?.defaultThinkingOptionId ?? - "default"; + explicitThinkingId ?? selectedModel?.defaultThinkingOptionId ?? null; const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null; - const displayThinking = selectedThinking?.label ?? selectedThinkingId ?? "default"; + const displayThinking = + selectedThinking?.label ?? + (selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto"); return ( @@ -201,7 +205,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { return; } void client - .setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id) + .setAgentThinkingOption(agentId, opt.id) .catch((error) => { console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); }); @@ -289,22 +293,22 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { accessibilityLabel="Select thinking option" testID="agent-preferences-thinking" > - {displayThinking} - - - - {thinkingOptions.map((opt) => { - const isActive = opt.id === selectedThinkingId; - return ( - { - if (!client) { - return; + {displayThinking} + + + + {thinkingOptions.map((opt) => { + const isActive = opt.id === selectedThinkingId; + return ( + { + if (!client) { + return; } void client - .setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id) + .setAgentThinkingOption(agentId, opt.id) .catch((error) => { console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index fd0cf8d3c..e275dd982 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -90,6 +90,49 @@ async function waitForFileToContainText( return null; } +function readRolloutTurnContextEfforts(rolloutPath: string): string[] { + const lines = readFileSync(rolloutPath, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const efforts: string[] = []; + for (const line of lines) { + let parsed: { + type?: string; + payload?: { + effort?: string; + reasoning_effort?: string; + collaboration_mode?: { settings?: { reasoning_effort?: string } }; + }; + } | null = null; + try { + parsed = JSON.parse(line) as { + type?: string; + payload?: { + effort?: string; + reasoning_effort?: string; + collaboration_mode?: { settings?: { reasoning_effort?: string } }; + }; + }; + } catch { + continue; + } + + if (parsed?.type !== "turn_context") continue; + const effort = + parsed.payload?.effort ?? + parsed.payload?.reasoning_effort ?? + parsed.payload?.collaboration_mode?.settings?.reasoning_effort ?? + null; + if (typeof effort === "string" && effort.length > 0) { + efforts.push(effort); + } + } + + return efforts; +} + describe("Codex app-server provider (integration)", () => { const logger = createTestLogger(); @@ -117,6 +160,28 @@ describe("Codex app-server provider (integration)", () => { expect(models.some((model) => model.id.includes("gpt-5.1-codex"))).toBe(true); }, 30000); + test.runIf(isCodexInstalled())( + "listModels exposes concrete thinking options (no synthetic default id)", + async () => { + const client = new CodexAppServerAgentClient(logger); + const models = await client.listModels(); + + for (const model of models) { + const options = model.thinkingOptions ?? []; + for (const option of options) { + expect(option.id).not.toBe("default"); + } + + if (options.length > 0) { + const defaultThinkingId = model.defaultThinkingOptionId; + expect(typeof defaultThinkingId).toBe("string"); + expect(options.some((option) => option.id === defaultThinkingId)).toBe(true); + } + } + }, + 30000 + ); + test.runIf(isCodexInstalled())("accepts image prompt blocks without request validation errors", async () => { const cleanup = useTempCodexSessionDir(); const cwd = tmpCwd("codex-image-prompt-"); @@ -170,6 +235,78 @@ describe("Codex app-server provider (integration)", () => { } }, 120000); + test.runIf(isCodexInstalled())( + "thinking option changes round-trip through Codex app-server turn context", + async () => { + const cleanup = useTempCodexSessionDir(); + const cwd = tmpCwd("codex-thinking-roundtrip-"); + let session: Awaited> | null = null; + + try { + const client = new CodexAppServerAgentClient(logger); + const models = await client.listModels(); + const modelWithThinking = models.find((m) => (m.thinkingOptions?.length ?? 0) > 1); + if (!modelWithThinking) { + throw new Error("No Codex model with at least two non-default thinking options"); + } + + const defaultThinkingId = modelWithThinking.defaultThinkingOptionId ?? null; + const thinkingIds = (modelWithThinking.thinkingOptions ?? []).map((opt) => opt.id); + if (thinkingIds.length < 2) { + throw new Error("No Codex model with at least two non-default thinking options"); + } + const initialThinkingId = defaultThinkingId ?? thinkingIds[0]!; + const switchedThinkingId = + thinkingIds.find((id) => id !== initialThinkingId) ?? thinkingIds[0]!; + + session = await client.createSession({ + provider: "codex", + cwd, + modeId: "auto", + model: modelWithThinking.id, + thinkingOptionId: initialThinkingId, + }); + + await session.run("Reply with exactly OK."); + await session.setThinkingOption?.(switchedThinkingId); + await session.run("Reply with exactly OK."); + + const internal = session as unknown as { + client?: { + request: (method: string, params: unknown) => Promise; + }; + currentThreadId?: string | null; + }; + const threadId = internal.currentThreadId; + const codexClient = internal.client; + if (!threadId || !codexClient) { + throw new Error("Codex session did not initialize app-server client/thread"); + } + + const threadRead = (await codexClient.request("thread/read", { + threadId, + includeTurns: true, + })) as { thread?: { path?: string } }; + const rolloutPath = threadRead.thread?.path; + if (!rolloutPath) { + throw new Error("Codex app-server did not return rollout path"); + } + + const efforts = readRolloutTurnContextEfforts(rolloutPath); + const initialIndex = efforts.lastIndexOf(initialThinkingId); + const switchedIndex = efforts.lastIndexOf(switchedThinkingId); + + expect(initialIndex).toBeGreaterThanOrEqual(0); + expect(switchedIndex).toBeGreaterThan(initialIndex); + } finally { + await session?.close().catch(() => undefined); + cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120000 + ); + test.runIf(isCodexInstalled())("round-trips a stdio MCP tool call", async () => { const cleanup = useTempCodexSessionDir(); const cwd = tmpCwd("codex-mcp-roundtrip-"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 78d278841..9dd26b336 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -101,6 +101,19 @@ function validateCodexMode(modeId: string): void { } } +function normalizeCodexThinkingOptionId( + thinkingOptionId: string | null | undefined +): string | undefined { + if (typeof thinkingOptionId !== "string") { + return undefined; + } + const normalized = thinkingOptionId.trim(); + if (!normalized || normalized === "default") { + return undefined; + } + return normalized; +} + function resolveCodexBinary(): string { try { const codexPath = execSync("which codex", { encoding: "utf8" }).trim(); @@ -1162,6 +1175,7 @@ class CodexAppServerAgentSession implements AgentSession { validateCodexMode(config.modeId); this.currentMode = config.modeId; this.config = config; + this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; @@ -1277,7 +1291,7 @@ class CodexAppServerAgentSession implements AgentSession { .join("\n\n"); if (developerInstructions) settings.developer_instructions = developerInstructions; if (this.config.model) settings.model = this.config.model; - const thinkingOptionId = this.config.thinkingOptionId; + const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId; return { mode: match.mode ?? "code", settings, name: match.name }; } @@ -1430,7 +1444,7 @@ class CodexAppServerAgentSession implements AgentSession { if (this.config.model) { params.model = this.config.model; } - const thinkingOptionId = this.config.thinkingOptionId; + const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (thinkingOptionId) { params.effort = thinkingOptionId; } @@ -1528,7 +1542,7 @@ class CodexAppServerAgentSession implements AgentSession { } async setThinkingOption(thinkingOptionId: string | null): Promise { - this.config.thinkingOptionId = thinkingOptionId ?? undefined; + this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId); this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); this.cachedRuntimeInfo = null; } @@ -1599,7 +1613,7 @@ class CodexAppServerAgentSession implements AgentSession { describePersistence(): { provider: typeof CODEX_PROVIDER; sessionId: string; nativeHandle: string; metadata: Record } | null { if (!this.currentThreadId) return null; - const thinkingOptionId = this.config.thinkingOptionId ?? null; + const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null; return { provider: CODEX_PROVIDER, sessionId: this.currentThreadId, @@ -2123,37 +2137,58 @@ export class CodexAppServerAgentClient implements AgentClient { const response = (await client.request("model/list", {})) as { data?: Array }; const models = Array.isArray(response?.data) ? response.data : []; - return models.map((model) => ({ - provider: CODEX_PROVIDER, - id: model.id, - 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, + return models.map((model) => { + const defaultReasoningEffort = normalizeCodexThinkingOptionId( + typeof model.defaultReasoningEffort === "string" + ? model.defaultReasoningEffort + : null + ); + + const thinkingById = new Map(); + if (Array.isArray(model.supportedReasoningEfforts)) { + for (const entry of model.supportedReasoningEfforts) { + const id = normalizeCodexThinkingOptionId( + typeof entry?.reasoningEffort === "string" ? entry.reasoningEffort : null + ); + if (!id) continue; + const description = + typeof entry?.description === "string" && entry.description.trim().length > 0 + ? entry.description + : undefined; + thinkingById.set(id, { id, label: id, description }); + } + } + + if (defaultReasoningEffort && !thinkingById.has(defaultReasoningEffort)) { + thinkingById.set(defaultReasoningEffort, { + id: defaultReasoningEffort, + label: defaultReasoningEffort, + description: "Model default reasoning effort", + }); + } + + const thinkingOptions = Array.from(thinkingById.values()).map((option) => ({ + ...option, + isDefault: option.id === defaultReasoningEffort, + })); + const defaultThinkingOptionId = + defaultReasoningEffort ?? thinkingOptions.find((option) => option.isDefault)?.id ?? thinkingOptions[0]?.id; + + return { + provider: CODEX_PROVIDER, + id: model.id, + label: model.displayName, + description: model.description, + isDefault: model.isDefault, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId, + metadata: { + model: model.model, + defaultReasoningEffort: model.defaultReasoningEffort, + supportedReasoningEfforts: model.supportedReasoningEfforts, }, - ...(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, - supportedReasoningEfforts: model.supportedReasoningEfforts, - }, - })); + }; + }); } finally { await client.dispose(); } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ccc769a69..75d9e5435 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -292,7 +292,7 @@ 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 }, + { id: "default", label: "Model default", isDefault: true }, ...rawVariants.map((id) => ({ id, label: id })), ]; diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts index 7013b48e4..fd7750088 100644 --- a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -184,8 +184,10 @@ describe("daemon E2E", () => { if (!modelWithOptions) { throw new Error("No Codex model with thinkingOptions returned"); } + const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default"; const nonDefault = - modelWithOptions.thinkingOptions?.find((o) => o.id !== "default")?.id ?? + modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? + modelWithOptions.thinkingOptions?.[0]?.id ?? null; if (!nonDefault) { throw new Error("No non-default Codex thinking option found"); @@ -240,8 +242,10 @@ describe("daemon E2E", () => { model: modelWithThinkingOptions.id, }); + const defaultThinkingId = modelWithThinkingOptions.defaultThinkingOptionId ?? "default"; const thinkingId = - modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== "default")?.id ?? + modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? + modelWithThinkingOptions.thinkingOptions?.[0]?.id ?? null; if (!thinkingId) { throw new Error("No non-default OpenCode thinking option found"); diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index 40378e5e2..cf09aa6a4 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -9,7 +9,6 @@ import { createDaemonTestContext, type DaemonTestContext, } from "./test-utils/index.js"; -import { getFullAccessConfig, type AgentProvider } from "./daemon-e2e/agent-configs.js"; import { OpenAITTS } from "./speech/providers/openai/tts.js"; import { OpenAISTT } from "./speech/providers/openai/stt.js"; import { STTManager } from "./agent/stt-manager.js"; @@ -18,6 +17,37 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null; const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey); const speechTest = shouldRun ? test : test.skip; +type VoiceRoundtripProvider = "claude" | "codex" | "opencode"; + +function getVoiceRoundtripConfig(provider: VoiceRoundtripProvider): { + provider: VoiceRoundtripProvider; + model: string; + modeId: string; + thinkingOptionId?: string; +} { + switch (provider) { + case "claude": + return { + provider: "claude", + model: "haiku", + modeId: "bypassPermissions", + }; + case "codex": + return { + provider: "codex", + model: "gpt-5.1-codex-mini", + modeId: "full-access", + thinkingOptionId: "low", + }; + case "opencode": + return { + provider: "opencode", + model: "opencode/gpt-5-nano", + modeId: "default", + }; + } +} + function waitForSignal( timeoutMs: number, setup: ( @@ -98,7 +128,7 @@ describe("voice roundtrip e2e", () => { await ctx.cleanup(); }, 60000); - for (const targetProvider of ["claude", "codex"] as const satisfies AgentProvider[]) { + for (const targetProvider of ["claude", "codex", "opencode"] as const satisfies VoiceRoundtripProvider[]) { speechTest( `full roundtrip (${targetProvider}): voice input audio -> voice agent -> output audio -> transcribed output`, async () => { @@ -128,7 +158,7 @@ describe("voice roundtrip e2e", () => { 30000, ctx.client.createAgent({ config: { - ...getFullAccessConfig(targetProvider), + ...getVoiceRoundtripConfig(targetProvider), cwd: voiceCwd, }, })