mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 })),
|
||||
];
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user