Normalize thinking option IDs and update display logic

- Remove synthetic "default" thinking option ID, use concrete option IDs from providers
- Normalize thinking option IDs to undefined when "default" or empty string
- Update app UI to display "Model default" label for fallback thinking option
- Fix Codex thinking option round-trip through app-server turn context
- Add test for thinking option ID persistence across Codex sessions
- Update test helpers to use actual default thinking option IDs
- Update OpenCode label consistency to match app display logic
This commit is contained in:
Mohamed Boudra
2026-02-07 18:39:19 +07:00
parent 5b77cd4805
commit 05c150dab8
6 changed files with 269 additions and 59 deletions

View File

@@ -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 (
<View style={styles.container}>
@@ -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"
>
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
onSelect={() => {
if (!client) {
return;
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
onSelect={() => {
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);
});

View File

@@ -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<ReturnType<CodexAppServerAgentClient["createSession"]>> | 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<unknown>;
};
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-");

View File

@@ -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<void> {
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<string, unknown> } | 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<any> };
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<string, { id: string; label: string; description?: string }>();
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();
}

View File

@@ -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 })),
];

View File

@@ -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");

View File

@@ -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<T>(
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,
},
})